export interface ImageMark { rect: { x: number y: number width: number height: number } number: number } export function loadImage(url: string) { return new Promise((resolve, reject) => { const image = new Image() image.crossOrigin = 'anonymous' image.onload = () => resolve(image) image.onerror = reject image.src = url }) } export function createCanvas(width: number, height: number) { if ('OffscreenCanvas' in window) { return new OffscreenCanvas(width, height) } const canvas = document.createElement('canvas') canvas.style.width = `${width}px` canvas.style.height = `${height}px` canvas.width = width canvas.height = height return canvas } export function blob2File(blob: Blob, fileName: string) { return new File([blob], fileName, { type: blob.type }) } export interface ImageMarkParams { image: HTMLImageElement | HTMLCanvasElement | OffscreenCanvas marks: ImageMark[] blobFileType?: string } export async function canvas2Blob( canvas: OffscreenCanvas | HTMLCanvasElement, fileType?: string, ) { if ('convertToBlob' in canvas) { return await canvas.convertToBlob({ type: fileType }) } return new Promise(resolve => { canvas.toBlob(blob => { if (!blob) return resolve(null) canvas.remove() resolve(blob) }, fileType) }) } export function image2Blob(image: HTMLImageElement) { const canvas = createCanvas(image.width, image.height) const ctx = canvas.getContext('2d') as | CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D if (!ctx) return null ctx.drawImage(image, 0, 0, image.width, image.height) return canvas2Blob(canvas) } export async function markImage(params: ImageMarkParams): Promise { const { image, marks, blobFileType: fileType = 'image/png' } = params try { const canvas = createCanvas(image.width, image.height) const ctx = canvas.getContext('2d') as | CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D if (!ctx) return null ctx.drawImage(image, 0, 0, image.width, image.height) marks.forEach(mark => { const { rect, number: markNumber } = mark ctx.save() /** * 1. black-white dashed border */ // 1.1 black border ctx.save() ctx.strokeStyle = 'black' ctx.lineWidth = 4 ctx.lineJoin = 'round' ctx.beginPath() const round = 6 if (ctx instanceof OffscreenCanvasRenderingContext2D) { ctx.roundRect(rect.x, rect.y, rect.width, rect.height, round) } else { ctx.rect(rect.x, rect.y, rect.width, rect.height) } ctx.stroke() ctx.closePath() // 1.2 white dashed border ctx.strokeStyle = 'white' ctx.lineWidth = 2 ctx.setLineDash([8, 8]) ctx.lineJoin = 'round' ctx.beginPath() if (ctx instanceof OffscreenCanvasRenderingContext2D) { ctx.roundRect(rect.x, rect.y, rect.width, rect.height, round) } else { ctx.rect(rect.x, rect.y, rect.width, rect.height) } ctx.stroke() ctx.closePath() ctx.restore() /** * 2. number mark */ // 2.1 circle background const fontSize = 16 const radius = 16 let offsetX = 0 let offsetY = 0 if ( rect.x + rect.width + radius > image.width || rect.y + rect.height + radius > image.height ) { offsetX = -radius offsetY = -radius } ctx.beginPath() ctx.arc( rect.x + rect.width + offsetX, rect.y + rect.height + offsetY, radius, 0, Math.PI * 2, ) ctx.closePath() ctx.fillStyle = 'blue' ctx.fill() // 2.2 circle border ctx.strokeStyle = 'black' ctx.lineWidth = 3 ctx.stroke() ctx.strokeStyle = 'white' ctx.lineWidth = 2 ctx.stroke() // 2.3 number ctx.font = `${fontSize}px Arial` ctx.textAlign = 'center' ctx.textBaseline = 'middle' ctx.fillStyle = 'white' ctx.fillText( markNumber.toString(), rect.x + rect.width + offsetX, rect.y + rect.height + offsetY + 2, ) ctx.restore() }) if ('convertToBlob' in canvas) { return await (canvas as OffscreenCanvas).convertToBlob({ type: fileType, quality: 0.8, }) } return new Promise(resolve => { ;(canvas as HTMLCanvasElement).toBlob(blob => { if (!blob) return resolve(null) ;(canvas as HTMLCanvasElement).remove() resolve(blob) }, fileType) }) } catch (error) { console.error('Failed to mark image:', error) return null } }