import JsBarcode from 'jsbarcode'; import QRCode from 'qrcode'; import { TemplateElement, RecordRow, LabelTemplate } from './types'; import { resolveElementValue, isGraphicContentEmpty, shouldRenderElement, ContentResolveMode, } from './utils'; import { resolveCornerRadiiPx, resolveBorderSides, resolvePaddingMm, getPaddedRectPx, traceRoundedRect, drawElementFill, drawElementBorders, drawElementChrome, } from './elementBox'; import { iterTableCells, getCellAnchor, getCellData, getEffectiveCellStyle, getTableRows, getTableCols, buildTableGridBoundariesPx, getCellRectPx, } from './tableUtils'; const GENERATOR_TRANSPARENT_BG = '#00000000'; function measureLineWidth(ctx: CanvasRenderingContext2D, line: string): number { return ctx.measureText(line).width; } function getTextLineSpan( textX: number, lineWidth: number, textAlign: CanvasTextAlign ): { left: number; right: number } { if (textAlign === 'center') { return { left: textX - lineWidth / 2, right: textX + lineWidth / 2 }; } if (textAlign === 'right') { return { left: textX - lineWidth, right: textX }; } return { left: textX, right: textX + lineWidth }; } function drawTextLineDecorations( ctx: CanvasRenderingContext2D, elem: TemplateElement, textX: number, lineY: number, line: string, fontSizePx: number, textAlign: CanvasTextAlign ) { if (!elem.textUnderline && !elem.textLineThrough) return; const lineWidth = measureLineWidth(ctx, line); const { left, right } = getTextLineSpan(textX, lineWidth, textAlign); const color = elem.textColor || '#111827'; ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = Math.max(1, fontSizePx * 0.06); ctx.beginPath(); if (elem.textUnderline) { const y = lineY + fontSizePx * 0.35; ctx.moveTo(left, y); ctx.lineTo(right, y); } if (elem.textLineThrough) { const y = lineY; ctx.moveTo(left, y); ctx.lineTo(right, y); } ctx.stroke(); ctx.restore(); } function loadImage(src: string): Promise { return new Promise((resolve, reject) => { const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => resolve(img); img.onerror = () => reject(new Error('Image load failed')); img.src = src; }); } function loadImageOrNull(src: string): Promise { const trimmed = src.trim(); if (!trimmed) return Promise.resolve(null); return loadImage(trimmed).catch(() => null); } async function resolveElementImage( elem: TemplateElement, primarySrc: string, mode: ContentResolveMode ): Promise<{ img: HTMLImageElement | null; showPlaceholder: boolean }> { const primary = primarySrc.trim(); const fallback = (elem.defaultImage ?? '').trim(); let img = await loadImageOrNull(primary); if (!img && fallback) { img = await loadImageOrNull(fallback); } if (img) return { img, showPlaceholder: false }; if (mode === 'design' && !primary && !fallback) { return { img: null, showPlaceholder: true }; } return { img: null, showPlaceholder: false }; } function drawImageWithFit( ctx: CanvasRenderingContext2D, img: HTMLImageElement, x: number, y: number, w: number, h: number, fit: 'contain' | 'cover' | 'fill' = 'contain' ) { if (fit === 'fill') { ctx.drawImage(img, x, y, w, h); return; } const imgRatio = img.width / img.height; const boxRatio = w / h; if (fit === 'contain') { let dw = w; let dh = h; let dx = x; let dy = y; if (imgRatio > boxRatio) { dh = w / imgRatio; dy = y + (h - dh) / 2; } else { dw = h * imgRatio; dx = x + (w - dw) / 2; } ctx.drawImage(img, dx, dy, dw, dh); return; } let sx = 0; let sy = 0; let sw = img.width; let sh = img.height; if (imgRatio > boxRatio) { sw = img.height * boxRatio; sx = (img.width - sw) / 2; } else { sh = img.width / boxRatio; sy = (img.height - sh) / 2; } ctx.drawImage(img, sx, sy, sw, sh, x, y, w, h); } function applyElementRotation( ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, rotationDeg: number | undefined ) { const rotation = rotationDeg ?? 0; if (rotation === 0) return; const cx = x + w / 2; const cy = y + h / 2; ctx.translate(cx, cy); ctx.rotate((rotation * Math.PI) / 180); ctx.translate(-cx, -cy); } function layoutHorizontalTextLines( ctx: CanvasRenderingContext2D, value: string, writableW: number, wordWrap: boolean ): string[] { const lines: string[] = []; const paragraphs = String(value).split(/\r?\n/); for (const paragraph of paragraphs) { if (!wordWrap) { lines.push(paragraph); continue; } let currentLine = ''; for (let i = 0; i < paragraph.length; i++) { const char = paragraph[i]; const testLine = currentLine + char; const metrics = ctx.measureText(testLine); if (metrics.width > writableW - 4 && i > 0 && currentLine.length > 0) { lines.push(currentLine); currentLine = char; } else { currentLine = testLine; } } if (currentLine || lines.length === 0) { lines.push(currentLine); } } return lines; } /** 竖排:按列组织字符,列高受限时换列 */ function layoutVerticalTextColumns( value: string, writableH: number, lineHeight: number, wordWrap: boolean ): string[][] { const columns: string[][] = []; const paragraphs = String(value).split(/\r?\n/); for (let p = 0; p < paragraphs.length; p++) { const paragraph = paragraphs[p]; if (!wordWrap) { columns.push([...paragraph]); } else { let currentColumn: string[] = []; for (const char of paragraph) { const colHeight = (currentColumn.length + 1) * lineHeight; if (colHeight > writableH - 4 && currentColumn.length > 0) { columns.push(currentColumn); currentColumn = [char]; } else { currentColumn.push(char); } } if (currentColumn.length > 0) { columns.push(currentColumn); } } if (p < paragraphs.length - 1) { columns.push([]); } } if (columns.length === 0) { columns.push([]); } return columns; } function drawTextContentInBox( ctx: CanvasRenderingContext2D, elem: TemplateElement, boxX: number, boxY: number, boxW: number, boxH: number, value: string, scale: number ) { const pad = resolvePaddingMm(elem); const padPx = { top: Math.round(pad.top * scale), right: Math.round(pad.right * scale), bottom: Math.round(pad.bottom * scale), left: Math.round(pad.left * scale), }; const writableW = Math.max(1, boxW - padPx.left - padPx.right); const writableH = Math.max(1, boxH - padPx.top - padPx.bottom); const fontStyle = elem.fontStyle === 'italic' ? 'italic ' : ''; const fontWeight = elem.fontWeight === 'bold' ? 'bold ' : ''; const fontSizePx = Math.max(6, Math.max(1, Math.round(elem.fontSize * 0.352777 * scale))); const fontFamily = elem.fontFamily === 'mono' ? 'JetBrains Mono, SFMono-Regular, Consolas, Courier New, monospace' : elem.fontFamily === 'serif' ? 'Georgia, Times New Roman, serif' : 'Inter, system-ui, -apple-system, Arial, sans-serif'; ctx.font = `${fontStyle}${fontWeight}${fontSizePx}px ${fontFamily}`.trim(); ctx.fillStyle = elem.textColor || '#111827'; const letterSpacingPx = Math.max(0, (elem.letterSpacing ?? 0) * scale); if (letterSpacingPx > 0 && 'letterSpacing' in ctx) { (ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${letterSpacingPx}px`; } const wordWrap = elem.wordWrap !== false; const lineHeight = fontSizePx * 1.25; const columnWidth = lineHeight; const isVertical = elem.textWritingMode === 'vertical'; ctx.textBaseline = 'middle'; const textScaleX = Math.max(0.1, Math.min(5, elem.textScaleX ?? 1)); const textScaleY = Math.max(0.1, Math.min(5, elem.textScaleY ?? 1)); const transformOriginX = boxX + padPx.left + writableW / 2; const transformOriginY = boxY + padPx.top + writableH / 2; ctx.save(); if (textScaleX !== 1 || textScaleY !== 1) { ctx.translate(transformOriginX, transformOriginY); ctx.scale(textScaleX, textScaleY); ctx.translate(-transformOriginX, -transformOriginY); } const vAlign = elem.verticalAlign ?? 'middle'; if (isVertical) { const columns = layoutVerticalTextColumns(String(value), writableH, lineHeight, wordWrap); const totalColumnsWidth = columns.length * columnWidth; let startX: number; if (elem.textAlign === 'center') { startX = boxX + padPx.left + Math.max(0, (writableW - totalColumnsWidth) / 2) + columnWidth / 2; } else if (elem.textAlign === 'right') { startX = boxX + padPx.left + Math.max(0, writableW - totalColumnsWidth) + columnWidth / 2; } else { startX = boxX + padPx.left + columnWidth / 2; } ctx.textAlign = 'center'; for (let col = 0; col < columns.length; col++) { const column = columns[col]; const colTextHeight = column.length * lineHeight; let colStartY: number; if (vAlign === 'top') { colStartY = boxY + padPx.top + lineHeight / 2; } else if (vAlign === 'bottom') { colStartY = boxY + boxH - padPx.bottom - colTextHeight + lineHeight / 2; } else { colStartY = boxY + padPx.top + Math.max(0, (writableH - colTextHeight) / 2) + lineHeight / 2; } const colX = startX + col * columnWidth; for (let i = 0; i < column.length; i++) { const charY = colStartY + i * lineHeight; if (charY - lineHeight / 2 <= boxY + boxH - padPx.bottom) { ctx.fillText(column[i], colX, charY); } } } } else { const lines = layoutHorizontalTextLines(ctx, String(value), writableW, wordWrap); const totalTextHeight = lines.length * lineHeight; let textX = boxX + padPx.left; let textAlign: CanvasTextAlign = 'left'; if (elem.textAlign === 'center') { textX = boxX + padPx.left + writableW / 2; textAlign = 'center'; } else if (elem.textAlign === 'right') { textX = boxX + padPx.left + (writableW - 2); textAlign = 'right'; } else { textX = boxX + padPx.left + 2; } ctx.textAlign = textAlign; let startY: number; if (vAlign === 'top') { startY = boxY + padPx.top + lineHeight / 2; } else if (vAlign === 'bottom') { startY = boxY + boxH - padPx.bottom - totalTextHeight + lineHeight / 2; } else { startY = boxY + padPx.top + Math.max(0, (writableH - totalTextHeight) / 2) + lineHeight / 2; } for (let i = 0; i < lines.length; i++) { const lineY = startY + i * lineHeight; if (lineY - lineHeight / 2 <= boxY + boxH - padPx.bottom) { const line = lines[i]; ctx.fillText(line, textX, lineY); drawTextLineDecorations(ctx, elem, textX, lineY, line, fontSizePx, textAlign); } } } ctx.restore(); if (letterSpacingPx > 0 && 'letterSpacing' in ctx) { (ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = '0px'; } } function alignStrokeCoord(v: number): number { return Math.round(v) + 0.5; } /** 统一绘制表格网格线,避免相邻单元格各画一条边造成双线 */ function drawTableGridLines( ctx: CanvasRenderingContext2D, elem: TemplateElement, ex: number, ey: number, scale: number ) { const borderMm = elem.tableBorderWidth ?? 0.2; if (borderMm <= 0) return; const borderW = borderMm * scale; if (borderW <= 0) return; const borderColor = elem.tableBorderColor ?? '#374151'; const rows = getTableRows(elem); const cols = getTableCols(elem); const { rowBounds, colBounds } = buildTableGridBoundariesPx(elem, scale, ex, ey); const left = colBounds[0]; const top = rowBounds[0]; const width = colBounds[colBounds.length - 1] - left; const height = rowBounds[rowBounds.length - 1] - top; ctx.strokeStyle = borderColor; ctx.lineWidth = borderW; ctx.beginPath(); const inset = borderW / 2; ctx.rect( alignStrokeCoord(left + inset), alignStrokeCoord(top + inset), Math.max(0, width - borderW), Math.max(0, height - borderW) ); for (let r = 1; r < rows; r++) { const y = alignStrokeCoord(rowBounds[r]); for (let c = 0; c < cols; c++) { const anchorAbove = getCellAnchor(elem, r - 1, c); const anchorBelow = getCellAnchor(elem, r, c); const sameMerge = anchorAbove.row === anchorBelow.row && anchorAbove.col === anchorBelow.col; if (!sameMerge) { ctx.moveTo(alignStrokeCoord(colBounds[c]), y); ctx.lineTo(alignStrokeCoord(colBounds[c + 1]), y); } } } for (let c = 1; c < cols; c++) { const x = alignStrokeCoord(colBounds[c]); for (let r = 0; r < rows; r++) { const anchorLeft = getCellAnchor(elem, r, c - 1); const anchorRight = getCellAnchor(elem, r, c); const sameMerge = anchorLeft.row === anchorRight.row && anchorLeft.col === anchorRight.col; if (!sameMerge) { ctx.moveTo(x, alignStrokeCoord(rowBounds[r])); ctx.lineTo(x, alignStrokeCoord(rowBounds[r + 1])); } } } ctx.stroke(); } async function drawTableElement( ctx: CanvasRenderingContext2D, elem: TemplateElement, ex: number, ey: number, ew: number, eh: number, scale: number, rowData: RecordRow | null, rowIndex: number, variableDefaults: Record | null | undefined, mode: ContentResolveMode ) { const radii = resolveCornerRadiiPx(elem, scale, ew, eh); const sides = resolveBorderSides(elem); drawElementFill(ctx, ex, ey, ew, eh, elem, radii); drawElementBorders(ctx, ex, ey, ew, eh, radii, sides, elem, scale); const { rowBounds, colBounds } = buildTableGridBoundariesPx(elem, scale, ex, ey); const gridX = colBounds[0]; const gridY = rowBounds[0]; const gridW = colBounds[colBounds.length - 1] - gridX; const gridH = rowBounds[rowBounds.length - 1] - gridY; ctx.save(); ctx.beginPath(); ctx.rect(gridX, gridY, gridW, gridH); ctx.clip(); const anchors = iterTableCells(elem); for (const { row, col } of anchors) { const { x: cellX, y: cellY, width: cellW, height: cellH } = getCellRectPx( elem, row, col, scale, ex, ey ); const cellData = getCellData(elem, row, col); if (cellData?.backgroundColor && cellData.backgroundColor !== 'transparent') { ctx.fillStyle = cellData.backgroundColor; ctx.fillRect(cellX, cellY, cellW, cellH); } const textElem = getEffectiveCellStyle(elem, row, col); if (!textElem.content.trim()) continue; const cellValue = resolveElementValue(textElem, rowData, rowIndex, variableDefaults, mode); const displayText = String(cellValue ?? '').trim(); if (!displayText) continue; drawTextContentInBox( ctx, { ...textElem, padding: 0, paddingTop: 0, paddingRight: 0, paddingBottom: 0, paddingLeft: 0, }, cellX, cellY, cellW, cellH, displayText, scale ); } drawTableGridLines(ctx, elem, ex, ey, scale); ctx.restore(); } function drawImagePlaceholder( ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, message: string ) { ctx.fillStyle = '#2a2a2a'; ctx.fillRect(x, y, w, h); ctx.strokeStyle = '#555'; ctx.lineWidth = 1; ctx.setLineDash([4, 4]); ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1); ctx.setLineDash([]); ctx.fillStyle = '#888'; ctx.font = '10px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(message, x + w / 2, y + h / 2); } export interface RenderLabelOptions { widthMm: number; heightMm: number; elements: TemplateElement[]; rowData: RecordRow | null; rowIndex: number; showBorder?: boolean; transparentBackground?: boolean; variableDefaults?: Record | null; mode?: ContentResolveMode; /** 输出参考背景(生成/打印),绘制在元素下层 */ referenceBackground?: string; referenceBackgroundOpacity?: number; referenceBackgroundFit?: 'contain' | 'cover' | 'fill'; /** 渲染倍率,默认 16;PDF 导出可用较低倍率以减轻内存与耗时 */ scale?: number; /** 输出格式,默认 png */ imageFormat?: 'png' | 'jpeg'; jpegQuality?: number; /** 为 false 时不裁剪到画布边界(用于拖动浮层完整显示元素) */ clipToCanvas?: boolean; } export type ReferenceBackgroundRenderOptions = Pick< RenderLabelOptions, 'referenceBackground' | 'referenceBackgroundOpacity' | 'referenceBackgroundFit' >; type ReferenceBackgroundTemplate = Pick< LabelTemplate, 'previewReferenceBackground' | 'previewReferenceBackgroundOpacity' | 'previewReferenceBackgroundFit' >; function buildReferenceBackgroundRenderOptions( template: ReferenceBackgroundTemplate ): ReferenceBackgroundRenderOptions { if (!template.previewReferenceBackground) { return {}; } return { referenceBackground: template.previewReferenceBackground, referenceBackgroundOpacity: template.previewReferenceBackgroundOpacity ?? 100, referenceBackgroundFit: template.previewReferenceBackgroundFit ?? 'cover', }; } /** 模板列表 / 设计预览等:有参考图即绘制 */ export function resolveReferenceBackgroundForPreview( template: ReferenceBackgroundTemplate ): ReferenceBackgroundRenderOptions { return buildReferenceBackgroundRenderOptions(template); } /** 模板开启「输出参考背景」时,供 PDF / 打印 / 排版预览使用的渲染参数 */ export function resolveReferenceBackgroundForOutput( template: ReferenceBackgroundTemplate & Pick ): ReferenceBackgroundRenderOptions { if (!template.includeReferenceBackgroundInOutput) { return {}; } return buildReferenceBackgroundRenderOptions(template); } /** 离屏渲染标签为 PNG data URL */ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise { const { widthMm, heightMm, elements, rowData, rowIndex, showBorder = false, transparentBackground = false, variableDefaults = null, mode = 'design', referenceBackground, referenceBackgroundOpacity = 100, referenceBackgroundFit = 'cover', scale: scaleOption = 16, imageFormat = 'png', jpegQuality = 0.92, clipToCanvas = true, } = options; const scale = scaleOption; const canvas = document.createElement('canvas'); canvas.width = Math.max(1, Math.round(widthMm * scale)); canvas.height = Math.max(1, Math.round(heightMm * scale)); const ctx = canvas.getContext('2d'); if (!ctx) return ''; if (!transparentBackground) { ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, canvas.width, canvas.height); } else { ctx.clearRect(0, 0, canvas.width, canvas.height); } if (showBorder) { ctx.strokeStyle = '#cccccc'; ctx.lineWidth = 1; ctx.setLineDash([4, 4]); ctx.strokeRect(0, 0, canvas.width, canvas.height); ctx.setLineDash([]); } if (referenceBackground) { const refImg = await loadImageOrNull(referenceBackground); if (refImg) { ctx.save(); ctx.globalAlpha = Math.min(1, Math.max(0, referenceBackgroundOpacity / 100)); drawImageWithFit(ctx, refImg, 0, 0, canvas.width, canvas.height, referenceBackgroundFit); ctx.restore(); } } ctx.save(); if (clipToCanvas) { ctx.beginPath(); ctx.rect(0, 0, canvas.width, canvas.height); ctx.clip(); } for (const elem of elements) { const ex = Math.round(elem.x * scale); const ey = Math.round(elem.y * scale); const ew = Math.round(elem.width * scale); const eh = Math.round(elem.height * scale); if (!shouldRenderElement(elem, rowData, rowIndex, variableDefaults, mode)) { continue; } const value = resolveElementValue(elem, rowData, rowIndex, variableDefaults, mode); if (elem.type !== 'table' && isGraphicContentEmpty(elem, value, mode)) { continue; } ctx.save(); applyElementRotation(ctx, ex, ey, ew, eh, elem.rotation); if (elem.type === 'text') { const radii = resolveCornerRadiiPx(elem, scale, ew, eh); const sides = resolveBorderSides(elem); ctx.save(); traceRoundedRect(ctx, ex, ey, ew, eh, radii); ctx.clip(); drawElementFill(ctx, ex, ey, ew, eh, elem, radii); drawTextContentInBox(ctx, elem, ex, ey, ew, eh, String(value), scale); ctx.restore(); drawElementBorders(ctx, ex, ey, ew, eh, radii, sides, elem, scale); } else if (elem.type === 'barcode') { try { drawElementChrome(ctx, ex, ey, ew, eh, elem, scale); const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale); const tempCanvas = document.createElement('canvas'); const barcodeValue = value ? String(value).trim() : mode === 'design' ? 'SAMPLE' : ''; if (barcodeValue) { JsBarcode(tempCanvas, barcodeValue, { format: elem.barcodeFormat || 'CODE128', width: 2, height: 40, displayValue: elem.showText ?? false, fontSize: 14, margin: 2, background: GENERATOR_TRANSPARENT_BG, }); ctx.drawImage(tempCanvas, inner.x, inner.y, inner.w, inner.h); } } catch (error) { console.warn('Barcode drawing failed', error); if (mode === 'design') { const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale); ctx.fillStyle = '#f3f4f6'; ctx.fillRect(inner.x, inner.y, inner.w, inner.h); ctx.strokeStyle = '#ef4444'; ctx.lineWidth = 2; ctx.strokeRect(inner.x, inner.y, inner.w, inner.h); ctx.fillStyle = '#b91c1c'; ctx.font = '10px Courier New'; ctx.textAlign = 'center'; ctx.fillText('ERR BARCODE', inner.x + inner.w / 2, inner.y + inner.h / 2); } } } else if (elem.type === 'qrcode') { try { drawElementChrome(ctx, ex, ey, ew, eh, elem, scale); const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale); const qrValue = value ? String(value).trim() : mode === 'design' ? 'SAMPLE QR' : ''; if (qrValue) { const tempCanvas = document.createElement('canvas'); await QRCode.toCanvas(tempCanvas, qrValue, { width: Math.max(64, inner.w), margin: 0, color: { dark: '#000000', light: GENERATOR_TRANSPARENT_BG, }, }); ctx.drawImage(tempCanvas, inner.x, inner.y, inner.w, inner.h); } } catch (error) { console.warn('QR code drawing failed', error); if (mode === 'design') { const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale); ctx.fillStyle = '#f3f4f6'; ctx.fillRect(inner.x, inner.y, inner.w, inner.h); ctx.strokeStyle = '#ef4444'; ctx.lineWidth = 1; ctx.strokeRect(inner.x, inner.y, inner.w, inner.h); } } } else if (elem.type === 'image') { const primarySrc = value ? String(value).trim() : ''; const { img, showPlaceholder } = await resolveElementImage(elem, primarySrc, mode); if (!img && !showPlaceholder) { ctx.restore(); continue; } drawElementChrome(ctx, ex, ey, ew, eh, elem, scale); const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale); if (img) { drawImageWithFit(ctx, img, inner.x, inner.y, inner.w, inner.h, elem.imageFit ?? 'contain'); } else { drawImagePlaceholder(ctx, inner.x, inner.y, inner.w, inner.h, '图片'); } } else if (elem.type === 'table') { await drawTableElement( ctx, elem, ex, ey, ew, eh, scale, rowData, rowIndex, variableDefaults, mode ); } ctx.restore(); } ctx.restore(); return imageFormat === 'jpeg' ? canvas.toDataURL('image/jpeg', jpegQuality) : canvas.toDataURL('image/png'); }