From 68a3a1577ed3a9453055fb8d502f288be09bda00 Mon Sep 17 00:00:00 2001 From: Leo Yang Date: Fri, 18 Sep 2026 16:20:55 +0800 Subject: [PATCH] fix(barcode): use official JsBarcode SVG renderer --- .github/architecture/11-element-system.md | 10 ++ packages/builtin/src/basic.ts | 2 +- packages/builtin/src/viewer.ts | 2 +- packages/core/src/material-viewer.ts | 2 + packages/materials/barcode/package.json | 3 + .../materials/barcode/src/designer.test.ts | 136 +++++++++++++++ packages/materials/barcode/src/designer.ts | 138 ++++++++++------ packages/materials/barcode/src/render.test.ts | 71 ++++++++ packages/materials/barcode/src/render.ts | 156 ++++++++++-------- packages/materials/barcode/src/test-utils.ts | 16 ++ packages/materials/barcode/src/viewer.test.ts | 95 +++++++---- packages/materials/barcode/src/viewer.ts | 68 +++++--- .../materials/chart/custom/src/viewer.test.ts | 1 + .../materials/flow-row/src/viewer.test.ts | 1 + packages/materials/line/src/viewer.test.ts | 3 + packages/materials/rating/src/viewer.test.ts | 1 + packages/viewer/src/render-surface.ts | 1 + pnpm-lock.yaml | 14 ++ pnpm-workspace.yaml | 1 + 19 files changed, 548 insertions(+), 173 deletions(-) create mode 100644 packages/materials/barcode/src/designer.test.ts create mode 100644 packages/materials/barcode/src/render.test.ts create mode 100644 packages/materials/barcode/src/test-utils.ts diff --git a/.github/architecture/11-element-system.md b/.github/architecture/11-element-system.md index 887f179b..188a9481 100644 --- a/.github/architecture/11-element-system.md +++ b/.github/architecture/11-element-system.md @@ -656,6 +656,15 @@ CanvasWorkspace 遍历 elements 每个物料包可提供 Viewer 渲染扩展: ```typescript +interface ViewerRenderContext { + readonly document: Document + data: Record + resolvedProps: Record + pageIndex: number + unit: string + zoom: number +} + interface MaterialViewerExtension { render(node: MaterialNode, context: ViewerRenderContext): ViewerRenderOutput measure?(node: MaterialNode, context: ViewerMeasureContext): ViewerMeasureResult @@ -666,6 +675,7 @@ interface MaterialViewerExtension { 其中职责边界如下: - `render()` 只负责生成物料内容 DOM/SVG。 +- `context.document` 是当前 Viewer host 的文档;物料返回 DOM/SVG 节点时必须使用它创建节点,不能依赖全局 `document`,以兼容 iframe 和 custom host。 - `measure()` 负责参与运行时测量和回流,典型场景是 table-data 这类动态高度物料。 - `getRenderSize()` 负责声明最终渲染容器尺寸,适用于“运行态视觉尺寸不等于 schema 几何尺寸”的物料;例如线条物料可把 legacy `lineWidth` 或最小可见厚度提升为实际渲染高度。 - `viewer` 的 `RenderSurface` 不允许根据 `node.type` 推断这些差异,必须统一通过 `MaterialRendererRegistry -> MaterialViewerExtension` 获取。 diff --git a/packages/builtin/src/basic.ts b/packages/builtin/src/basic.ts index b07e2f0d..abf1f006 100644 --- a/packages/builtin/src/basic.ts +++ b/packages/builtin/src/basic.ts @@ -585,7 +585,7 @@ export const builtinViewerMaterialBundle: BuiltinViewerMaterialBundle = { }, }, { type: IMAGE_TYPE, binding: imageMaterialBinding, extension: { render: (node, ctx) => renderImage(node, ctx.unit) } }, - { type: BARCODE_TYPE, binding: barcodeMaterialBinding, extension: { render: node => renderBarcode(node) } }, + { type: BARCODE_TYPE, binding: barcodeMaterialBinding, extension: { render: (node, ctx) => renderBarcode(node, ctx) } }, { type: QRCODE_TYPE, binding: qrcodeMaterialBinding, extension: { render: node => renderQrcode(node) } }, { type: LINE_TYPE, binding: noMaterialBinding, extension: createLineViewerExtension() }, { type: RECT_TYPE, binding: noMaterialBinding, extension: { render: (node, ctx) => renderRect(node, ctx.unit) } }, diff --git a/packages/builtin/src/viewer.ts b/packages/builtin/src/viewer.ts index 0c4a3d4a..2fe25594 100644 --- a/packages/builtin/src/viewer.ts +++ b/packages/builtin/src/viewer.ts @@ -56,7 +56,7 @@ const ALL_BUILTIN_VIEWER_MATERIAL_BUNDLE: BuiltinViewerMaterialBundle = { }, }, { type: IMAGE_TYPE, binding: imageMaterialBinding, extension: { render: (node, ctx) => renderImage(node, ctx.unit) } }, - { type: BARCODE_TYPE, binding: barcodeMaterialBinding, extension: { render: node => renderBarcode(node) } }, + { type: BARCODE_TYPE, binding: barcodeMaterialBinding, extension: { render: (node, ctx) => renderBarcode(node, ctx) } }, { type: QRCODE_TYPE, binding: qrcodeMaterialBinding, extension: { render: node => renderQrcode(node) } }, { type: LINE_TYPE, binding: noMaterialBinding, extension: createLineViewerExtension() }, { type: RECT_TYPE, binding: noMaterialBinding, extension: { render: (node, ctx) => renderRect(node, ctx.unit) } }, diff --git a/packages/core/src/material-viewer.ts b/packages/core/src/material-viewer.ts index 8baa4183..9e4b8004 100644 --- a/packages/core/src/material-viewer.ts +++ b/packages/core/src/material-viewer.ts @@ -31,6 +31,8 @@ export function readTrustedViewerHtml(html: TrustedViewerHtml): string { * Provides document-level unit and zoom for physical unit calculations. */ export interface ViewerRenderContext { + /** Host document used to create material-owned DOM nodes. */ + readonly document: Document data: Record resolvedProps: Record pageIndex: number diff --git a/packages/materials/barcode/package.json b/packages/materials/barcode/package.json index dd256882..3dd3ad0c 100644 --- a/packages/materials/barcode/package.json +++ b/packages/materials/barcode/package.json @@ -27,5 +27,8 @@ "@easyink/schema": "workspace:*", "@easyink/shared": "workspace:*", "jsbarcode": "catalog:framework" + }, + "devDependencies": { + "@types/jsbarcode": "catalog:types" } } diff --git a/packages/materials/barcode/src/designer.test.ts b/packages/materials/barcode/src/designer.test.ts new file mode 100644 index 00000000..77807d25 --- /dev/null +++ b/packages/materials/barcode/src/designer.test.ts @@ -0,0 +1,136 @@ +import type { MaterialExtensionContext, NodeSignal } from '@easyink/core' +import type { MaterialNode } from '@easyink/schema' +import { createDefaultSchema } from '@easyink/schema' +import { describe, expect, it } from 'vitest' +import { createBarcodeExtension } from './designer' +import { createBarcodeNode } from './schema' +import { installCanvasTextMeasurement } from './test-utils' + +installCanvasTextMeasurement() + +function createContext(): MaterialExtensionContext { + const schema = createDefaultSchema() + return { + getSchema: () => schema, + getNode: () => undefined, + getSelection: () => ({ ids: [], count: 0, isEmpty: true }), + getBindingLabel: binding => binding.fieldLabel || binding.fieldPath, + commitCommand: () => {}, + tx: { + run: () => {}, + batch: fn => fn(), + }, + requestPropertyPanel: () => {}, + emit: () => {}, + on: () => () => {}, + getZoom: () => 1, + getPageEl: () => null, + t: key => key, + } +} + +function createMutableNodeSignal(initialNode: MaterialNode): { + signal: NodeSignal + update: (node: MaterialNode) => void +} { + let currentNode = initialNode + let subscriber: ((node: MaterialNode) => void) | undefined + return { + signal: { + get: () => currentNode, + subscribe: (callback) => { + subscriber = callback + return () => { + subscriber = undefined + } + }, + }, + update: (node) => { + currentNode = node + subscriber?.(node) + }, + } +} + +describe('createBarcodeExtension', () => { + it('renders and repaints the official EAN-13 layout', () => { + const container = document.createElement('div') + const extension = createBarcodeExtension(createContext()) + const nodeSignal = createMutableNodeSignal(createBarcodeNode({ + props: { + value: '5901234123457', + format: 'EAN13', + showText: true, + }, + })) + const cleanup = extension.renderContent(nodeSignal.signal, container) + + expect(Array.from(container.querySelectorAll('text')).map(text => text.textContent).filter(Boolean)) + .toEqual(['5', '901234', '123457']) + + nodeSignal.update(createBarcodeNode({ + props: { + value: '5901234123457', + format: 'EAN13', + showText: false, + }, + })) + + expect(container.querySelectorAll('text')).toHaveLength(0) + cleanup() + }) + + it('shows the configured format sample for an empty value', () => { + const container = document.createElement('div') + const extension = createBarcodeExtension(createContext()) + const nodeSignal = createMutableNodeSignal(createBarcodeNode({ + props: { + value: '', + format: 'EAN13', + }, + })) + + extension.renderContent(nodeSignal.signal, container) + + expect(container.querySelector('svg')).not.toBeNull() + expect(container.querySelectorAll('text')).toHaveLength(0) + expect(container.textContent).toContain('EAN13') + }) + + it('writes binding labels through textContent', () => { + const container = document.createElement('div') + const extension = createBarcodeExtension(createContext()) + const nodeSignal = createMutableNodeSignal(createBarcodeNode({ + binding: { + sourceId: 'product', + fieldPath: 'ean', + fieldLabel: '', + }, + props: { + value: 'EasyInk', + format: 'CODE128', + }, + })) + + extension.renderContent(nodeSignal.signal, container) + + expect(container.textContent).toContain('{#}') + expect(container.querySelector('img')).toBeNull() + }) + + it('shows an error state for invalid values', () => { + const container = document.createElement('div') + const extension = createBarcodeExtension(createContext()) + const nodeSignal = createMutableNodeSignal(createBarcodeNode({ + props: { + value: 'invalid', + format: 'EAN13', + }, + })) + + extension.renderContent(nodeSignal.signal, container) + + expect(container.querySelector('svg')).toBeNull() + expect(container.textContent).toBe('Invalid: invalid') + }) +}) diff --git a/packages/materials/barcode/src/designer.ts b/packages/materials/barcode/src/designer.ts index bf489b7d..58a41b78 100644 --- a/packages/materials/barcode/src/designer.ts +++ b/packages/materials/barcode/src/designer.ts @@ -2,84 +2,126 @@ import type { MaterialDesignerExtension, MaterialExtensionContext } from '@easyi import type { MaterialNode } from '@easyink/schema' import type { BarcodeProps } from './schema' import { getBindingRefs } from '@easyink/schema' -import { escapeHtml } from '@easyink/shared' -import { generateBarcodeSvg } from './render' +import { createBarcodeSvgElement } from './render' import { BARCODE_FORMATS, resolveBarcodeProps } from './schema' -function buildPlaceholder(p: BarcodeProps, label: string): string { - const sampleValue = BARCODE_FORMATS.find(format => format.value === p.format)?.sampleValue || 'EasyInk' +function applyFrameStyles(frame: HTMLDivElement, props: BarcodeProps, unit: string): void { + frame.style.position = 'relative' + frame.style.width = '100%' + frame.style.height = '100%' + frame.style.boxSizing = 'border-box' + if (props.borderWidth) { + frame.style.border = `${props.borderWidth}${unit} ${props.borderType} ${props.borderColor}` + } +} + +function createErrorPlaceholder(document: Document, props: BarcodeProps, value: string): HTMLDivElement { + const error = document.createElement('div') + error.style.width = '100%' + error.style.height = '100%' + error.style.display = 'flex' + error.style.alignItems = 'center' + error.style.justifyContent = 'center' + error.style.boxSizing = 'border-box' + error.style.background = props.backgroundColor + error.style.color = '#e53e3e' + error.style.fontSize = '11px' + error.style.border = '1px dashed #e53e3e' + error.textContent = `Invalid: ${value}` + return error +} + +function appendLabelOverlay(document: Document, frame: HTMLElement, label: string, color: string): void { + const overlay = document.createElement('div') + overlay.style.position = 'absolute' + overlay.style.inset = '0' + overlay.style.display = 'flex' + overlay.style.alignItems = 'center' + overlay.style.justifyContent = 'center' + + const text = document.createElement('span') + text.style.maxWidth = '90%' + text.style.overflow = 'hidden' + text.style.padding = '1px 4px' + text.style.borderRadius = '2px' + text.style.background = 'rgba(255, 255, 255, 0.8)' + text.style.color = color + text.style.fontSize = '10px' + text.style.textOverflow = 'ellipsis' + text.style.whiteSpace = 'nowrap' + text.textContent = label + + overlay.appendChild(text) + frame.appendChild(overlay) +} + +function createPlaceholder(document: Document, props: BarcodeProps, label: string): HTMLElement { + const placeholder = document.createElement('div') + placeholder.style.position = 'relative' + placeholder.style.width = '100%' + placeholder.style.height = '100%' + placeholder.style.opacity = '0.4' - let svg: string + const sampleValue = BARCODE_FORMATS.find(format => format.value === props.format)?.sampleValue || 'EasyInk' try { - svg = generateBarcodeSvg(sampleValue, { - format: p.format, - lineWidth: p.lineWidth, - lineColor: p.lineColor, - backgroundColor: p.backgroundColor, + placeholder.appendChild(createBarcodeSvgElement(sampleValue, { + format: props.format, + lineWidth: props.lineWidth, + lineColor: props.lineColor, + backgroundColor: props.backgroundColor, showText: false, - }) + }, document)) } catch { - return buildErrorPlaceholder(p, label) + return createErrorPlaceholder(document, props, label) } - return `
${svg}
${label}
` + appendLabelOverlay(document, placeholder, label, props.lineColor) + return placeholder } -function buildErrorPlaceholder(p: BarcodeProps, value: string): string { - return `
Invalid: ${escapeHtml(value)}
` -} +function buildElement(node: MaterialNode, context: MaterialExtensionContext, document: Document): HTMLElement { + const props = resolveBarcodeProps(node) + const frame = document.createElement('div') + applyFrameStyles(frame, props, context.getSchema().unit) -function buildHtml(node: MaterialNode, context: MaterialExtensionContext): string { - const p = resolveBarcodeProps(node) - const unit = context.getSchema().unit - const DASH_MAP: Record = { dashed: 'dashed', dotted: 'dotted' } - const borderStyle = p.borderWidth ? `border:${p.borderWidth}${unit} ${DASH_MAP[p.borderType] || 'solid'} ${p.borderColor};box-sizing:border-box;` : '' - - let label: string | undefined - const b = getBindingRefs(node.binding)[0] - if (b) { - label = `{#${escapeHtml(context.getBindingLabel(b))}}` - } - - const value = p.value || '' + const binding = getBindingRefs(node.binding)[0] + const label = binding ? `{#${context.getBindingLabel(binding)}}` : undefined + const value = props.value == null ? '' : String(props.value) if (!value) { - const inner = buildPlaceholder(p, label || p.format) - return borderStyle ? `
${inner}
` : inner + frame.appendChild(createPlaceholder(document, props, label || props.format)) + return frame } - let svg: string try { - svg = generateBarcodeSvg(value, { - format: p.format, - lineWidth: p.lineWidth, - lineColor: p.lineColor, - backgroundColor: p.backgroundColor, - showText: p.showText, - }) + frame.appendChild(createBarcodeSvgElement(value, { + format: props.format, + lineWidth: props.lineWidth, + lineColor: props.lineColor, + backgroundColor: props.backgroundColor, + showText: props.showText, + }, document)) } catch { - const inner = buildErrorPlaceholder(p, value) - return borderStyle ? `
${inner}
` : inner + frame.appendChild(createErrorPlaceholder(document, props, value)) + return frame } - if (label) { - return `
${svg}
${label}
` - } + if (label) + appendLabelOverlay(document, frame, label, props.lineColor) - return borderStyle ? `
${svg}
` : svg + return frame } export function createBarcodeExtension(context: MaterialExtensionContext): MaterialDesignerExtension { return { renderContent(nodeSignal, container) { function render() { - container.innerHTML = buildHtml(nodeSignal.get(), context) + container.replaceChildren(buildElement(nodeSignal.get(), context, container.ownerDocument)) } render() - const unsub = nodeSignal.subscribe(render) - return unsub + return nodeSignal.subscribe(render) }, } } diff --git a/packages/materials/barcode/src/render.test.ts b/packages/materials/barcode/src/render.test.ts new file mode 100644 index 00000000..7e2a7ddb --- /dev/null +++ b/packages/materials/barcode/src/render.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { createBarcodeSvgElement, generateBarcodeSvg } from './render' +import { installCanvasTextMeasurement } from './test-utils' + +installCanvasTextMeasurement() + +function createEan13(value: string, showText = true): SVGSVGElement { + return createBarcodeSvgElement(value, { + format: 'EAN13', + lineWidth: 2, + lineColor: '#000000', + backgroundColor: '#ffffff', + showText, + }, document) +} + +describe('barcode SVG rendering', () => { + it('preserves the official EAN-13 text groups and guard bars', () => { + const svg = createEan13('5901234123457') + const textGroups = Array.from(svg.querySelectorAll('text')) + .map(text => text.textContent) + .filter(Boolean) + const barHeights = new Set(Array.from(svg.querySelectorAll('g rect')) + .map(rect => rect.getAttribute('height'))) + + expect(textGroups).toEqual(['5', '901234', '123457']) + expect(barHeights).toContain('100') + expect(barHeights).toContain('112') + expect(svg.getAttribute('viewBox')).toBe('0 0 234 142') + }) + + it('adds the EAN-13 checksum for a valid 12-digit value', () => { + const svg = createEan13('590123412345') + const textGroups = Array.from(svg.querySelectorAll('text')) + .map(text => text.textContent) + .filter(Boolean) + + expect(textGroups).toEqual(['5', '901234', '123457']) + }) + + it('keeps the 95-module EAN-13 symbol and guard bars when text is hidden', () => { + const svg = createEan13('5901234123457', false) + const barHeights = new Set(Array.from(svg.querySelectorAll('g rect')) + .map(rect => rect.getAttribute('height'))) + + expect(svg.querySelectorAll('text')).toHaveLength(0) + expect(svg.getAttribute('viewBox')).toBe('0 0 210 132') + expect((210 - 20) / 2).toBe(95) + expect(barHeights).toContain('100') + expect(barHeights).toContain('112') + }) + + it('keeps JsBarcode layout while applying responsive SVG attributes', () => { + const svg = createEan13('5901234123457') + const markup = generateBarcodeSvg('5901234123457', { + format: 'EAN13', + lineWidth: 2, + lineColor: '#000000', + backgroundColor: '#ffffff', + showText: true, + }, document) + + expect(svg.getAttribute('width')).toBe('100%') + expect(svg.getAttribute('height')).toBe('100%') + expect(svg.getAttribute('preserveAspectRatio')).toBe('xMidYMid meet') + expect(svg.getAttribute('shape-rendering')).toBe('crispEdges') + expect(markup).toContain('viewBox="0 0 234 142"') + expect(markup).toContain('>901234') + expect(markup).toContain('>123457') + }) +}) diff --git a/packages/materials/barcode/src/render.ts b/packages/materials/barcode/src/render.ts index da678e93..c92d0f04 100644 --- a/packages/materials/barcode/src/render.ts +++ b/packages/materials/barcode/src/render.ts @@ -1,6 +1,4 @@ -import { escapeHtml } from '@easyink/shared' -// @ts-expect-error -- JsBarcode internal encoders have no type declarations -import encoders from 'jsbarcode/bin/barcodes/index' +import JsBarcode from 'jsbarcode' export interface BarcodeSvgOptions { format: string @@ -10,83 +8,105 @@ export interface BarcodeSvgOptions { showText: boolean } -interface BarcodeEncoding { - data: string - text: string -} +const SVG_NAMESPACE = 'http://www.w3.org/2000/svg' +const EMPTY_BAR_RECTS = [ + [8, 2], + [13, 1], + [18, 4], + [27, 2], + [33, 1], + [38, 3], + [47, 2], + [53, 4], + [62, 1], + [67, 3], + [75, 2], + [82, 1], + [88, 4], + [97, 2], + [103, 3], + [112, 1], +] as const -function encode(value: string, format: string): BarcodeEncoding[] { - const Encoder = (encoders.default || encoders)[format] - if (!Encoder) { - throw new Error(`Unknown barcode format: ${format}`) - } - const instance = new Encoder(value, {}) - if (typeof instance.valid === 'function' && !instance.valid()) { - throw new Error(`Invalid value "${value}" for format ${format}`) - } - const encoded = instance.encode() - // encode() may return a single object or an array - return Array.isArray(encoded) ? encoded : [encoded] +function resolveDocument(xmlDocument?: Document): Document { + if (xmlDocument) + return xmlDocument + if (typeof document !== 'undefined') + return document + throw new Error('Barcode SVG rendering requires a DOM Document') } -function escapeSvgAttr(value: string): string { - return value - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(//g, '>') +function makeResponsive(svg: SVGSVGElement): SVGSVGElement { + svg.setAttribute('width', '100%') + svg.setAttribute('height', '100%') + svg.setAttribute('preserveAspectRatio', 'xMidYMid meet') + svg.setAttribute('shape-rendering', 'crispEdges') + svg.style.display = 'block' + return svg } -/** - * Generate a real barcode as an inline SVG string. - * Uses JsBarcode internal encoders to get the binary bar pattern, - * then renders it as SVG rects. - */ -export function generateBarcodeSvg(value: string, options: Partial & { format: string }): string { - const lineWidth = options.lineWidth || 2 - const lineColor = escapeSvgAttr(options.lineColor || '#000000') - const backgroundColor = escapeSvgAttr(options.backgroundColor || '#ffffff') - const showText = options.showText ?? true +export function createBarcodeSvgElement( + value: string, + options: Partial & { format: string }, + xmlDocument: Document, +): SVGSVGElement { + const svg = xmlDocument.createElementNS(SVG_NAMESPACE, 'svg') + const barcodeOptions: NonNullable[2]> & { xmlDocument: Document } = { + format: options.format, + width: options.lineWidth || 2, + lineColor: options.lineColor || '#000000', + background: options.backgroundColor || '#ffffff', + displayValue: options.showText ?? true, + xmlDocument, + } - const encodings = encode(value, options.format) + JsBarcode(svg, value, barcodeOptions) - // Merge all binary data segments - let binaryStr = '' - let text = '' - for (const enc of encodings) { - binaryStr += enc.data - if (enc.text) - text = enc.text - } + return makeResponsive(svg) +} - const barCount = binaryStr.length - const totalWidth = barCount * lineWidth - const textHeight = showText ? 16 : 0 - const barHeight = 60 - const svgHeight = barHeight + textHeight - const padding = 4 +export function generateBarcodeSvg( + value: string, + options: Partial & { format: string }, + xmlDocument?: Document, +): string { + return createBarcodeSvgElement(value, options, resolveDocument(xmlDocument)).outerHTML +} - const svgWidth = totalWidth + padding * 2 - const fullHeight = svgHeight + padding * 2 +export function createBarcodeEmptySvgElement( + options: Partial>, + xmlDocument: Document, +): SVGSVGElement { + const svg = xmlDocument.createElementNS(SVG_NAMESPACE, 'svg') + svg.setAttribute('xmlns', SVG_NAMESPACE) + svg.setAttribute('viewBox', '0 0 120 48') + svg.setAttribute('aria-hidden', 'true') - // Build bar rects - const rects: string[] = [] - for (let i = 0; i < barCount; i++) { - if (binaryStr[i] === '1') { - rects.push(``) - } - } + const background = xmlDocument.createElementNS(SVG_NAMESPACE, 'rect') + background.setAttribute('width', '120') + background.setAttribute('height', '48') + background.setAttribute('fill', options.backgroundColor || '#ffffff') + svg.appendChild(background) - const textEl = showText - ? `${escapeHtml(text || value)}` - : '' + const bars = xmlDocument.createElementNS(SVG_NAMESPACE, 'g') + bars.setAttribute('fill', options.lineColor || '#000000') + bars.setAttribute('opacity', '0.18') + for (const [x, width] of EMPTY_BAR_RECTS) { + const rect = xmlDocument.createElementNS(SVG_NAMESPACE, 'rect') + rect.setAttribute('x', String(x)) + rect.setAttribute('y', '8') + rect.setAttribute('width', String(width)) + rect.setAttribute('height', '32') + bars.appendChild(rect) + } + svg.appendChild(bars) - return `${rects.join('')}${textEl}` + return makeResponsive(svg) } -export function generateBarcodeEmptySvg(options: Partial>): string { - const lineColor = escapeSvgAttr(options.lineColor || '#000000') - const backgroundColor = escapeSvgAttr(options.backgroundColor || '#ffffff') - - return `` +export function generateBarcodeEmptySvg( + options: Partial>, + xmlDocument?: Document, +): string { + return createBarcodeEmptySvgElement(options, resolveDocument(xmlDocument)).outerHTML } diff --git a/packages/materials/barcode/src/test-utils.ts b/packages/materials/barcode/src/test-utils.ts new file mode 100644 index 00000000..c8445d47 --- /dev/null +++ b/packages/materials/barcode/src/test-utils.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll } from 'vitest' + +export function installCanvasTextMeasurement(): void { + const getContext = HTMLCanvasElement.prototype.getContext + + beforeAll(() => { + HTMLCanvasElement.prototype.getContext = (() => ({ + font: '', + measureText: (value: string) => ({ width: value.length * 10 }), + } as unknown as CanvasRenderingContext2D)) as unknown as typeof HTMLCanvasElement.prototype.getContext + }) + + afterAll(() => { + HTMLCanvasElement.prototype.getContext = getContext + }) +} diff --git a/packages/materials/barcode/src/viewer.test.ts b/packages/materials/barcode/src/viewer.test.ts index 6feee7af..80df74b7 100644 --- a/packages/materials/barcode/src/viewer.test.ts +++ b/packages/materials/barcode/src/viewer.test.ts @@ -1,29 +1,34 @@ -import { readTrustedViewerHtml } from '@easyink/core' +import type { MaterialNode } from '@easyink/schema' import { describe, expect, it } from 'vitest' import { BARCODE_FORMATS, createBarcodeNode } from './schema' +import { installCanvasTextMeasurement } from './test-utils' import { renderBarcode } from './viewer' +installCanvasTextMeasurement() + +function renderElement(node: MaterialNode, ownerDocument: Document = document, unit = 'mm'): HTMLElement { + return renderBarcode(node, { document: ownerDocument, unit }).element! +} + describe('renderBarcode', () => { it('renders an empty-state svg without legacy placeholder text or border', () => { - const node = createBarcodeNode({ + const element = renderElement(createBarcodeNode({ props: { value: '', lineColor: '#123456', backgroundColor: '#ffffff', }, - }) - - const html = readTrustedViewerHtml(renderBarcode(node).html!) + })) + const svg = element.querySelector('svg')! - expect(html).toContain(' { - const node = createBarcodeNode({ + it('renders a real barcode with configured colors and responsive sizing', () => { + const element = renderElement(createBarcodeNode({ props: { value: 'EasyInk', format: 'CODE128', @@ -32,43 +37,67 @@ describe('renderBarcode', () => { backgroundColor: '#eeeeee', showText: true, }, - }) + })) + const svg = element.querySelector('svg')! - const html = readTrustedViewerHtml(renderBarcode(node).html!) - - expect(html).toContain(' { + it('renders every supported format through JsBarcode', () => { for (const format of BARCODE_FORMATS) { - const node = createBarcodeNode({ + const element = renderElement(createBarcodeNode({ props: { value: format.sampleValue, format: format.value, }, - }) - - const html = readTrustedViewerHtml(renderBarcode(node).html!) + })) - expect(html, format.value).toContain(' { - const node = createBarcodeNode({ + it('renders invalid values as DOM text', () => { + const element = renderElement(createBarcodeNode({ + props: { + value: '', + format: 'EAN13', + }, + })) + + expect(element.querySelector('svg')).toBeNull() + expect(element.textContent).toBe('Invalid: ') + expect(element.querySelector('invalid')).toBeNull() + }) + + it('applies viewer borders in the document unit', () => { + const element = renderElement(createBarcodeNode({ props: { value: 'EasyInk', - lineColor: '', + borderWidth: 1, + borderType: 'dashed', + borderColor: '#123456', }, - }) + }), document, 'pt') + + expect(element.style.borderWidth).toBe('1pt') + expect(element.style.borderStyle).toBe('dashed') + expect(element.style.borderColor).toBe('#123456') + }) - const html = readTrustedViewerHtml(renderBarcode(node).html!) + it('creates output in the Viewer host document', () => { + const ownerDocument = document.implementation.createHTMLDocument('barcode-host') + const element = renderElement(createBarcodeNode({ + props: { value: 'EasyInk' }, + }), ownerDocument) - expect(html).toContain('fill="#000000"') + expect(element.ownerDocument).toBe(ownerDocument) + expect(element.querySelector('svg')?.ownerDocument).toBe(ownerDocument) }) }) diff --git a/packages/materials/barcode/src/viewer.ts b/packages/materials/barcode/src/viewer.ts index 44e8b1e8..6a4b7fa4 100644 --- a/packages/materials/barcode/src/viewer.ts +++ b/packages/materials/barcode/src/viewer.ts @@ -1,36 +1,60 @@ +import type { ViewerRenderContext, ViewerRenderOutput } from '@easyink/core' import type { MaterialNode } from '@easyink/schema' -import { trustedViewerHtml } from '@easyink/core' -import { escapeHtml } from '@easyink/shared' -import { generateBarcodeEmptySvg, generateBarcodeSvg } from './render' +import { createBarcodeEmptySvgElement, createBarcodeSvgElement } from './render' import { resolveBarcodeProps } from './schema' -export function renderBarcode(node: MaterialNode) { +function createErrorPlaceholder(document: Document, backgroundColor: string, value: string): HTMLDivElement { + const error = document.createElement('div') + error.style.width = '100%' + error.style.height = '100%' + error.style.display = 'flex' + error.style.alignItems = 'center' + error.style.justifyContent = 'center' + error.style.boxSizing = 'border-box' + error.style.background = backgroundColor + error.style.color = '#e53e3e' + error.style.fontSize = '12px' + error.style.border = '1px dashed #e53e3e' + error.textContent = `Invalid: ${value}` + return error +} + +export function renderBarcode( + node: MaterialNode, + context: Pick, +): ViewerRenderOutput { const props = resolveBarcodeProps(node) const value = props.value == null ? '' : String(props.value) + const { document } = context + const frame = document.createElement('div') + frame.style.width = '100%' + frame.style.height = '100%' + frame.style.boxSizing = 'border-box' + + if (props.borderWidth) { + frame.style.border = `${props.borderWidth}${context.unit} ${props.borderType} ${props.borderColor}` + } if (!value) { - return { - html: trustedViewerHtml(generateBarcodeEmptySvg({ - lineColor: props.lineColor, - backgroundColor: props.backgroundColor, - })), - } + frame.appendChild(createBarcodeEmptySvgElement({ + lineColor: props.lineColor, + backgroundColor: props.backgroundColor, + }, document)) + return { element: frame } } try { - return { - html: trustedViewerHtml(generateBarcodeSvg(value, { - format: props.format, - lineWidth: props.lineWidth, - lineColor: props.lineColor, - backgroundColor: props.backgroundColor, - showText: props.showText, - })), - } + frame.appendChild(createBarcodeSvgElement(value, { + format: props.format, + lineWidth: props.lineWidth, + lineColor: props.lineColor, + backgroundColor: props.backgroundColor, + showText: props.showText, + }, document)) } catch { - return { - html: trustedViewerHtml(`
Invalid: ${escapeHtml(value)}
`), - } + frame.appendChild(createErrorPlaceholder(document, props.backgroundColor, value)) } + + return { element: frame } } diff --git a/packages/materials/chart/custom/src/viewer.test.ts b/packages/materials/chart/custom/src/viewer.test.ts index 46e391fe..f3d19321 100644 --- a/packages/materials/chart/custom/src/viewer.test.ts +++ b/packages/materials/chart/custom/src/viewer.test.ts @@ -12,6 +12,7 @@ describe('chart custom viewer', () => { }, }) const output = renderChartCustom(node, { + document, data: {}, resolvedProps: node.props, pageIndex: 0, diff --git a/packages/materials/flow-row/src/viewer.test.ts b/packages/materials/flow-row/src/viewer.test.ts index 78291dbb..d7ea3559 100644 --- a/packages/materials/flow-row/src/viewer.test.ts +++ b/packages/materials/flow-row/src/viewer.test.ts @@ -6,6 +6,7 @@ import { createFlowRowNode, FLOW_ROW_DEFAULTS } from './schema' import { measureFlowRow, renderFlowRow } from './viewer' const viewerContext = { + document, data: {}, resolvedProps: {}, pageIndex: 0, diff --git a/packages/materials/line/src/viewer.test.ts b/packages/materials/line/src/viewer.test.ts index 7d67ceb8..41ac38fc 100644 --- a/packages/materials/line/src/viewer.test.ts +++ b/packages/materials/line/src/viewer.test.ts @@ -14,6 +14,7 @@ describe('renderLine', () => { }) const output = renderLine(node, { + document, data: {}, resolvedProps: node.props, pageIndex: 0, @@ -47,6 +48,7 @@ describe('renderLine', () => { }) const dashedOutput = renderLine(dashed, { + document, data: {}, resolvedProps: dashed.props, pageIndex: 0, @@ -54,6 +56,7 @@ describe('renderLine', () => { zoom: 1, }) const dottedOutput = renderLine(dotted, { + document, data: {}, resolvedProps: dotted.props, pageIndex: 0, diff --git a/packages/materials/rating/src/viewer.test.ts b/packages/materials/rating/src/viewer.test.ts index 223f588d..325e4177 100644 --- a/packages/materials/rating/src/viewer.test.ts +++ b/packages/materials/rating/src/viewer.test.ts @@ -20,6 +20,7 @@ describe('renderRating', () => { it('projects resolved bound values over preset props', () => { const node = createRatingNode({ props: { value: 20 } }) const html = readTrustedViewerHtml(renderRating(node, { + document, data: {}, resolvedProps: { value: 80 }, pageIndex: 0, diff --git a/packages/viewer/src/render-surface.ts b/packages/viewer/src/render-surface.ts index 124606a7..c62fd20f 100644 --- a/packages/viewer/src/render-surface.ts +++ b/packages/viewer/src/render-surface.ts @@ -48,6 +48,7 @@ export function renderPages( pageEl.appendChild(contentLayer) const context: ViewerRenderContext = { + document, data, resolvedProps: {}, pageIndex: page.index, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95a5a1d0..3e0c030c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -226,6 +226,9 @@ catalogs: specifier: ^2.8.1 version: 2.8.1 types: + '@types/jsbarcode': + specifier: ^3.11.4 + version: 3.11.4 '@types/lodash-es': specifier: ^4.17.12 version: 4.17.12 @@ -877,6 +880,10 @@ importers: jsbarcode: specifier: catalog:framework version: 3.12.3 + devDependencies: + '@types/jsbarcode': + specifier: catalog:types + version: 3.11.4 packages/materials/chart/bar: dependencies: @@ -2945,6 +2952,9 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/jsbarcode@3.11.4': + resolution: {integrity: sha512-VBcpTAnEMH0Gbh8JpV14CgOtJjCYjsvR2FoDRyoYPE0gUxtApf8N4c+HKEOyz/iiIZkMzqrzBA3XX7+KgKxxsA==} + '@types/jsesc@2.5.1': resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} @@ -7115,6 +7125,10 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/jsbarcode@3.11.4': + dependencies: + '@types/node': 24.10.0 + '@types/jsesc@2.5.1': {} '@types/json-schema@7.0.15': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f52f7199..8bc179d0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -99,6 +99,7 @@ catalogs: vue-plugin-hiprint: ^0.0.60 yaml: ^2.8.1 types: + '@types/jsbarcode': ^3.11.4 '@types/lodash-es': ^4.17.12 '@types/node': ^24.10.0 ignoreWorkspaceRootCheck: true