From 222f3e6a9712aa705f9e82685fcdb5bb19b26adf Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Fri, 11 Sep 2026 17:08:02 +0800 Subject: [PATCH] feat(barcode-print): add size template and keep package codes on one line --- .../barcode-print/barcodePrintLodop.test.ts | 34 ++- .../intake/barcode-print/barcodePrintLodop.ts | 53 +--- .../barcodePrintSplitCode.test.ts | 233 ++++++++++++++++++ .../barcode-print/barcodePrintSplitCode.ts | 77 ++++++ .../barcodePrintThirdTemplate.test.tsx | 185 ++++++++++++++ .../barcodePrintThirdTemplate.ts | 50 ++++ 6 files changed, 571 insertions(+), 61 deletions(-) create mode 100644 furniture-3d-system/src/app/intake/barcode-print/barcodePrintSplitCode.test.ts create mode 100644 furniture-3d-system/src/app/intake/barcode-print/barcodePrintSplitCode.ts create mode 100644 furniture-3d-system/src/app/intake/barcode-print/barcodePrintThirdTemplate.test.tsx create mode 100644 furniture-3d-system/src/app/intake/barcode-print/barcodePrintThirdTemplate.ts diff --git a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintLodop.test.ts b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintLodop.test.ts index ba87ec58..cbf01671 100644 --- a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintLodop.test.ts +++ b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintLodop.test.ts @@ -49,12 +49,22 @@ describe("splitBarcodeParts", () => { }); describe("second barcode template", () => { - it("uses the legacy fixed layout with whole-page scaling", async () => { + beforeEach(() => { + jest.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({ + scale: jest.fn(), fillText: jest.fn(), measureText: (text: string) => ({ width: text.length * 10 }), + } as never); + jest.spyOn(HTMLCanvasElement.prototype, "toDataURL").mockReturnValue("data:image/png;base64,dGVzdA=="); + }); + + afterEach(() => jest.restoreAllMocks()); + + it("prints ordinary segments as one image while retaining whole-page scaling", async () => { const lodop = { PRINT_INIT: jest.fn(), SET_PRINT_MODE: jest.fn(), NEWPAGE: jest.fn(), ADD_PRINT_BARCODE: jest.fn(), + ADD_PRINT_IMAGE: jest.fn(), ADD_PRINT_TEXT: jest.fn(), ADD_PRINT_RECT: jest.fn(), SET_PRINT_STYLEA: jest.fn(), @@ -82,24 +92,10 @@ describe("second barcode template", () => { } expect(lodop.SET_PRINT_MODE).toHaveBeenCalledWith("PRINT_PAGE_PERCENT", "Full-Page"); - expect(lodop.ADD_PRINT_TEXT).toHaveBeenCalledWith(203, 191, 150, 25, "CJ"); - expect(lodop.ADD_PRINT_TEXT).toHaveBeenCalledWith(186, 274, 230, 52, "026601"); - expect(lodop.ADD_PRINT_TEXT).toHaveBeenCalledWith(203, 474, 141, 25, "N1280V01"); - - const prefixCallIndex = lodop.ADD_PRINT_TEXT.mock.calls.findIndex( - (call) => call[4] === "CJ", - ); - const prefixCallOrder = lodop.ADD_PRINT_TEXT.mock.invocationCallOrder[prefixCallIndex]; - const prefixAlignmentCallIndex = lodop.SET_PRINT_STYLEA.mock.calls.findIndex( - (call, index) => - lodop.SET_PRINT_STYLEA.mock.invocationCallOrder[index] > prefixCallOrder && - call[1] === "Alignment", - ); - expect(lodop.SET_PRINT_STYLEA.mock.calls[prefixAlignmentCallIndex]).toEqual([ - 0, - "Alignment", - 2, - ]); + expect(lodop.ADD_PRINT_IMAGE).toHaveBeenCalledWith(186, 211, 387, 52, + ''); + expect(lodop.SET_PRINT_STYLEA).toHaveBeenCalledWith(0, "Stretch", 2); + expect(lodop.ADD_PRINT_TEXT.mock.calls.some(call => ["CJ", "026601", "N1280V01"].includes(call[4]))).toBe(false); }); it("prints modular suffixes on one row with the final five digits in the small style", async () => { diff --git a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintLodop.ts b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintLodop.ts index 18aac025..ccd9aa1b 100644 --- a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintLodop.ts +++ b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintLodop.ts @@ -3,6 +3,8 @@ import { pinyin } from "pinyin-pro"; import type { BarcodePrintRow } from "./barcodePrintRows"; import { splitGujiaBarcodeParts } from "./barcodePrintGujia"; +import { BARCODE_PRINT_THIRD_TEMPLATE } from "./barcodePrintThirdTemplate"; +import { printSplitBarcodeText } from "./barcodePrintSplitCode"; import { SOFIA_CERTIFICATE_FIELD_FONT_SIZE, SOFIA_CERTIFICATE_FIELD_LEFT, @@ -12,7 +14,7 @@ import { type LodopValue = string | number; -type Lodop = { +export type Lodop = { PRINT_INIT(title: string): void; SET_PRINT_PAGESIZE?(orientation: number, width: string, height: string, name: string): void; NEWPAGE(): void; @@ -59,10 +61,11 @@ declare global { } } -type BarcodeLodopRecord = { +export type BarcodeLodopRecord = { barcode: string; factoryNumber: string; skuName1: string; + size: string; productCategory: string; hangtagMaterial: string; executionStandard: string; @@ -85,7 +88,7 @@ type CertificateLodopRecord = { supplierName: string; }; -type BarcodePrintTemplate = { +export type BarcodePrintTemplate = { title: string; useFullPagePercent?: boolean; printFunc: ( @@ -243,6 +246,7 @@ function buildLodopRecord(row: BarcodePrintRow): BarcodeLodopRecord { barcode, factoryNumber: trimText(row.factoryModel), skuName1: `${buildColorCode(row.color)}${size}`, + size, productCategory: readLeafCategory(row.productCategory), hangtagMaterial: trimText(row.tagMaterial || row.material), executionStandard: trimText(row.executeStandard), @@ -350,16 +354,6 @@ function printCommonLayout( barcodeTextHeight?: number; barcodeFontSize?: number; productionBatchLeft?: number; - splitCodePrefixLeft?: number; - splitCodePrefixWidth?: number; - splitCodeCenterTop?: number; - splitCodeCenterLeft?: number; - splitCodeCenterWidth?: number; - splitCodeCenterHeight?: number; - splitCodeCenterFontSize?: number; - splitCodeSuffixLeft?: number; - splitCodeSuffixWidth?: number; - splitCodeSuffixFontSize?: number; }, ) { lodop.PRINT_INIT("条码打印"); @@ -380,35 +374,7 @@ function printCommonLayout( setFont(lodop, 15); if (options?.splitCode) { - const isModular13DigitSplit = - Boolean(record.catcode) && /^\d{8}$/.test(record.bar2) && /^\d{5}$/.test(record.bar3); - const splitCodePrefixLeft = options?.splitCodePrefixLeft ?? (isModular13DigitSplit ? 207 : 191); - const splitCodePrefixWidth = options?.splitCodePrefixWidth ?? (isModular13DigitSplit ? 120 : 150); - const splitCodePrefixAlignment = isModular13DigitSplit ? 1 : 2; - const splitCodeCenterTop = options?.splitCodeCenterTop ?? 186; - const splitCodeCenterLeft = options?.splitCodeCenterLeft ?? (isModular13DigitSplit ? 290 : 274); - const splitCodeCenterWidth = options?.splitCodeCenterWidth ?? 230; - const splitCodeCenterHeight = options?.splitCodeCenterHeight ?? 52; - const splitCodeCenterFontSize = options?.splitCodeCenterFontSize ?? 28; - const splitCodeSuffixLeft = options?.splitCodeSuffixLeft ?? (isModular13DigitSplit ? 482 : 474); - const splitCodeSuffixWidth = options?.splitCodeSuffixWidth ?? 141; - const splitCodeSuffixFontSize = options?.splitCodeSuffixFontSize ?? 15; - - lodop.ADD_PRINT_TEXT(203, splitCodePrefixLeft, splitCodePrefixWidth, 25, record.catcode); - setFont(lodop, 15); - lodop.SET_PRINT_STYLEA(0, "Alignment", splitCodePrefixAlignment); - lodop.ADD_PRINT_TEXT( - splitCodeCenterTop, - splitCodeCenterLeft, - splitCodeCenterWidth, - splitCodeCenterHeight, - record.bar2, - ); - setFont(lodop, splitCodeCenterFontSize, true); - lodop.SET_PRINT_STYLEA(0, "Alignment", 2); - lodop.ADD_PRINT_TEXT(203, splitCodeSuffixLeft, splitCodeSuffixWidth, 25, record.bar3); - setFont(lodop, splitCodeSuffixFontSize); - lodop.SET_PRINT_STYLEA(0, "Alignment", 2); + printSplitBarcodeText(record, lodop); } else { // Keep the full barcode on a single row and shrink text when needed. const barcodeTextHeight = options?.barcodeTextHeight ?? 25; @@ -446,6 +412,9 @@ const BARCODE_PRINT_TEMPLATES: BarcodePrintTemplate[] = [ }); }, }, + { + ...BARCODE_PRINT_THIRD_TEMPLATE, + }, { title: "外贸条码打印", printFunc(record, lodop) { diff --git a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintSplitCode.test.ts b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintSplitCode.test.ts new file mode 100644 index 00000000..110ebb52 --- /dev/null +++ b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintSplitCode.test.ts @@ -0,0 +1,233 @@ +import { __barcodePrintTestUtils, printBarcodeRow, type Lodop } from "./barcodePrintLodop"; +import type { BarcodePrintRow } from "./barcodePrintRows"; +import { printSplitBarcodeText } from "./barcodePrintSplitCode"; + +type CanvasText = { text: string; font: string; x: number; y: number; alignment: string; baseline: string }; +type PrintedText = { top: number; left: number; width: number; height: number; value: string }; +const PNG_DATA_URL = "data:image/png;base64,c2luZ2xlLWxpbmU="; +const TEMPLATES = ["条码打印_第二套模板", "条码打印-第三套模板(尺寸信息)"]; +const COLUMNS = [{ left: 0, width: 114 }, { left: 120, width: 160 }, { left: 286, width: 101 }]; + +function fontSizePt(font: string) { + return Number(font.match(/([\d.]+)px/)?.[1]) * 72 / 96; +} + +function measureWidth(text: string, font: string) { + const emWidth = Array.from(text).reduce((sum, char) => sum + (/[MW]/.test(char) ? 0.95 : 0.62), 0); + return emWidth * fontSizePt(font) * 96 / 72 * (font.startsWith("bold") ? 1.05 : 1); +} + +function createCanvasRecorder() { + const canvases: HTMLCanvasElement[] = []; + const draws: CanvasText[] = []; + const measurements: Array<{ text: string; font: string }> = []; + const context = { + font: "", textAlign: "start", textBaseline: "alphabetic", fillStyle: "#000000", + scale: jest.fn(), setTransform: jest.fn(), clearRect: jest.fn(), fillRect: jest.fn(), + measureText(text: string) { + measurements.push({ text, font: this.font }); + const width = measureWidth(text, this.font); + return { + width, + actualBoundingBoxLeft: this.textAlign === "center" ? width / 2 : 0, + actualBoundingBoxRight: this.textAlign === "center" ? width / 2 : width, + }; + }, + fillText(text: string, x: number, y: number) { + draws.push({ text, font: this.font, x, y, alignment: this.textAlign, baseline: this.textBaseline }); + }, + }; + const getContext = jest.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(function ( + this: HTMLCanvasElement, + ) { + canvases.push(this); + return context as never; + }); + const toDataURL = jest.spyOn(HTMLCanvasElement.prototype, "toDataURL").mockReturnValue(PNG_DATA_URL); + return { canvases, draws, measurements, context, getContext, toDataURL }; +} + +function createLodopRecorder() { + const texts: PrintedText[] = []; + const calls: Array<{ method: string; args: unknown[] }> = []; + const record = (method: string) => jest.fn((...args: unknown[]) => { calls.push({ method, args }); }); + const lodop: Lodop = { + PRINT_INIT: record("PRINT_INIT"), SET_PRINT_MODE: record("SET_PRINT_MODE"), NEWPAGE: record("NEWPAGE"), + ADD_PRINT_BARCODE: record("ADD_PRINT_BARCODE"), ADD_PRINT_RECT: record("ADD_PRINT_RECT"), + ADD_PRINT_IMAGE: record("ADD_PRINT_IMAGE"), PREVIEW: record("PREVIEW"), + ADD_PRINT_TEXT: jest.fn((top, left, width, height, value) => { + texts.push({ top: Number(top), left: Number(left), width: Number(width), height: Number(height), value }); + calls.push({ method: "ADD_PRINT_TEXT", args: [top, left, width, height, value] }); + }), + SET_PRINT_STYLEA: record("SET_PRINT_STYLEA"), + }; + return { texts, calls, lodop }; +} + +function createRow(barcode: string): BarcodePrintRow { + return { + barcode, supplierName: "", size: "120/60/75", color: "", productCategory: "电脑桌", + factoryModel: "ZWJ0249N1407", tagMaterial: "实木", executeStandard: "GB/T 3324", + } as BarcodePrintRow; +} + +describe("single-line barcode image", () => { + let canvas: ReturnType; + let savedLodop: Pick; + + beforeEach(() => { + savedLodop = { getLodop: window.getLodop, getCLodop: window.getCLodop, LODOP: window.LODOP }; + canvas = createCanvasRecorder(); + }); + + afterEach(() => { + Object.assign(window, savedLodop); + jest.restoreAllMocks(); + }); + + describe.each(TEMPLATES)("%s", (templateTitle) => { + it.each([ + "GBGJ-ZWJ024903AN1407V01", "GBGJ-ZWJ024903BN1407V01", + "GBGJ-ZWJ024901AN1407V01", "GBGJ-ZWJ024901BN1407V01", "GBGJ-ZWJ024901CN1407V01", + "GBSFY-LRSF009001N1280V01", "CJ026601N1280V01", "DNZ040717P396V01", + "GBGJ-ZWJ024903BN140712345V01", + ])("renders %s as one image without handing segments to LODOP text layout", async (barcode) => { + const { lodop, texts, calls } = createLodopRecorder(); + window.getLodop = () => lodop; + window.getCLodop = () => lodop; + await printBarcodeRow(createRow(barcode), { templateTitle }); + + const parts = __barcodePrintTestUtils.splitBarcodeParts(barcode); + expect(canvas.draws.map(({ text }) => text)).toEqual([parts.catcode, parts.bar2, parts.bar3]); + expect(canvas.draws.map(({ text }) => text).join("")).toBe(barcode); + expect(canvas.draws.map(({ y, baseline }) => [y, baseline])).toEqual([ + [38, "alphabetic"], [38, "alphabetic"], [38, "alphabetic"], + ]); + expect(canvas.getContext).toHaveBeenCalledTimes(1); + expect(canvas.canvases[0].width).toBe(3096); + expect(canvas.canvases[0].height).toBe(416); + expect(canvas.context.scale).toHaveBeenCalledWith(8, 8); + expect(canvas.context.fillRect).not.toHaveBeenCalled(); + expect(canvas.toDataURL).toHaveBeenCalledWith("image/png"); + + for (const [index, draw] of canvas.draws.entries()) { + const fontPt = fontSizePt(draw.font); + const width = measureWidth(draw.text, draw.font); + const column = COLUMNS[index]; + const left = draw.alignment === "center" ? draw.x - width / 2 : draw.x; + expect(draw.font).toContain("微软雅黑"); + expect(fontPt).toBeGreaterThanOrEqual(8); + expect(fontPt).toBeLessThanOrEqual(index === 1 ? 28 : 15); + expect(fontPt * 2).toBeCloseTo(Math.round(fontPt * 2), 8); + expect(width).toBeLessThanOrEqual(column.width - 8); + expect(left).toBeGreaterThanOrEqual(column.left); + expect(left + width).toBeLessThanOrEqual(column.left + column.width); + expect(canvas.measurements).toContainEqual({ text: draw.text, font: draw.font }); + } + expect(canvas.draws[1].font).toMatch(/^bold /); + expect(lodop.ADD_PRINT_IMAGE).toHaveBeenCalledTimes(1); + expect(lodop.ADD_PRINT_IMAGE).toHaveBeenCalledWith(186, 211, 387, 52, expect.stringContaining(PNG_DATA_URL)); + const imageIndex = calls.findIndex(({ method }) => method === "ADD_PRINT_IMAGE"); + expect(calls[imageIndex + 1]).toEqual({ method: "SET_PRINT_STYLEA", args: [0, "Stretch", 2] }); + expect(texts.some(({ value }) => [parts.catcode, parts.bar2, parts.bar3].includes(value))).toBe(false); + expect(lodop.ADD_PRINT_BARCODE).toHaveBeenCalledWith(15, 22, "100.01mm", "45.01mm", "QRCode", barcode); + expect(lodop.SET_PRINT_MODE).toHaveBeenCalledWith("PRINT_PAGE_PERCENT", "Full-Page"); + expect(lodop.PREVIEW).toHaveBeenCalledTimes(1); + expect(texts).toContainEqual({ top: 26, left: 181, width: 304, height: 25, value: "型号:ZWJ0249N1407" }); + expect(texts).toContainEqual({ top: 203, left: 14, width: 195, height: 25, value: "GB/T 3324" }); + if (templateTitle.includes("第三套")) { + expect(texts).toContainEqual({ top: 145, left: 429, width: 166, height: 25, value: "120.60.75" }); + expect(lodop.ADD_PRINT_RECT).toHaveBeenCalledWith(136, 426, 1, 40, 0, 1); + } + }); + + it.each([ + `${"W".repeat(100)}024903BN1407V01`, + `CJ${"0".repeat(100)}BN1407V01`, + `CJ024903BN${"1".repeat(100)}V01`, + ])("refuses unreadably long %s before preview rather than wrapping or truncating it", async (barcode) => { + const { lodop } = createLodopRecorder(); + window.getLodop = () => lodop; + window.getCLodop = () => lodop; + await expect(printBarcodeRow(createRow(barcode), { templateTitle })) + .rejects.toThrow(/单行|过长|无法/); + expect(lodop.ADD_PRINT_IMAGE).not.toHaveBeenCalled(); + expect(lodop.PREVIEW).not.toHaveBeenCalled(); + }); + + it("refuses missing canvas support rather than returning to wrapping text", async () => { + canvas.getContext.mockReturnValue(null); + const { lodop } = createLodopRecorder(); + window.getLodop = () => lodop; + window.getCLodop = () => lodop; + await expect(printBarcodeRow(createRow("GBGJ-ZWJ024903BN1407V01"), { templateTitle })) + .rejects.toThrow(); + expect(lodop.ADD_PRINT_IMAGE).not.toHaveBeenCalled(); + expect(lodop.PREVIEW).not.toHaveBeenCalled(); + }); + + it("refuses invalid image data before preview", async () => { + canvas.toDataURL.mockReturnValue("data:,"); + const { lodop } = createLodopRecorder(); + window.getLodop = () => lodop; + window.getCLodop = () => lodop; + await expect(printBarcodeRow(createRow("GBGJ-ZWJ024903BN1407V01"), { templateTitle })) + .rejects.toThrow(/图像|单行/); + expect(lodop.ADD_PRINT_IMAGE).not.toHaveBeenCalled(); + expect(lodop.PREVIEW).not.toHaveBeenCalled(); + }); + }); + + it("keeps the maximum sizes for short segments", () => { + const { lodop } = createLodopRecorder(); + printSplitBarcodeText({ catcode: "CJ", bar2: "026601", bar3: "N851V01" }, lodop); + canvas.draws.forEach(({ font }, index) => { + expect(fontSizePt(font)).toBeCloseTo(index === 1 ? 28 : 15, 8); + }); + }); + + it("fits a long prefix at the readable 8pt lower limit", () => { + const { lodop } = createLodopRecorder(); + printSplitBarcodeText({ catcode: "W".repeat(10), bar2: "026601", bar3: "N851V01" }, lodop); + expect(fontSizePt(canvas.draws[0].font)).toBe(8); + expect(canvas.draws[0].text).toBe("W".repeat(10)); + }); + + it("keeps glyph overhang inside each column as well as the advance width", () => { + const measure = canvas.context.measureText.bind(canvas.context); + jest.spyOn(canvas.context, "measureText").mockImplementation((text) => { + const metrics = measure(text); + return { ...metrics, actualBoundingBoxLeft: 5, actualBoundingBoxRight: metrics.width + 3 }; + }); + const { lodop } = createLodopRecorder(); + printSplitBarcodeText({ catcode: "GBGJ-ZWJ", bar2: "024903B", bar3: "N1407V01" }, lodop); + canvas.draws.forEach((draw, index) => { + const column = COLUMNS[index]; + expect(draw.x - 5).toBeGreaterThanOrEqual(column.left + 4); + expect(draw.x + measureWidth(draw.text, draw.font) + 3).toBeLessThanOrEqual(column.left + column.width - 4); + }); + }); + + it("preserves every modular 8+5 print instruction without requiring canvas", () => { + canvas.getContext.mockReturnValue(null); + const { lodop, calls } = createLodopRecorder(); + printSplitBarcodeText({ catcode: "MKHDNZ", bar2: "04092606", bar3: "00047" }, lodop); + expect(calls).toEqual([ + { method: "ADD_PRINT_TEXT", args: [203, 207, 120, 25, "MKHDNZ"] }, + { method: "SET_PRINT_STYLEA", args: [0, "FontName", "微软雅黑"] }, + { method: "SET_PRINT_STYLEA", args: [0, "FontSize", 15] }, + { method: "SET_PRINT_STYLEA", args: [0, "Alignment", 1] }, + { method: "ADD_PRINT_TEXT", args: [186, 290, 230, 52, "04092606"] }, + { method: "SET_PRINT_STYLEA", args: [0, "FontName", "微软雅黑"] }, + { method: "SET_PRINT_STYLEA", args: [0, "FontSize", 28] }, + { method: "SET_PRINT_STYLEA", args: [0, "Bold", 1] }, + { method: "SET_PRINT_STYLEA", args: [0, "Alignment", 2] }, + { method: "ADD_PRINT_TEXT", args: [203, 482, 141, 25, "00047"] }, + { method: "SET_PRINT_STYLEA", args: [0, "FontName", "微软雅黑"] }, + { method: "SET_PRINT_STYLEA", args: [0, "FontSize", 15] }, + { method: "SET_PRINT_STYLEA", args: [0, "Alignment", 2] }, + ]); + expect(canvas.getContext).not.toHaveBeenCalled(); + expect(canvas.toDataURL).not.toHaveBeenCalled(); + }); +}); diff --git a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintSplitCode.ts b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintSplitCode.ts new file mode 100644 index 00000000..8e5da68c --- /dev/null +++ b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintSplitCode.ts @@ -0,0 +1,77 @@ +import type { BarcodeLodopRecord, Lodop } from "./barcodePrintLodop"; + +type BarcodeParts = Pick; + +const PRINT_FONT = "微软雅黑"; +const PX_PER_PT = 96 / 72; +const TEXT_INSET = 8; +const MIN_FONT_PT = 8; +const IMAGE_SCALE = 8; +const IMAGE_WIDTH = 387; +const IMAGE_HEIGHT = 52; + +function fitText(context: CanvasRenderingContext2D, text: string, width: number, maximumPt: number, bold: boolean) { + for (let size = maximumPt; size >= MIN_FONT_PT; size -= 0.5) { + context.font = `${bold ? "bold" : "normal"} ${size * PX_PER_PT}px "${PRINT_FONT}", sans-serif`; + const measurement = context.measureText(text); + const left = Math.max(0, measurement.actualBoundingBoxLeft || 0); + const right = Math.max(measurement.width, measurement.actualBoundingBoxRight || 0); + if (left + right <= width - TEXT_INSET) { + return { left, right }; + } + } + throw new Error("条码文字过长,无法清晰地单行打印,请检查条码内容。"); +} + +function buildSingleLineImage(record: BarcodeParts) { + const canvas = document.createElement("canvas"); + canvas.width = IMAGE_WIDTH * IMAGE_SCALE; + canvas.height = IMAGE_HEIGHT * IMAGE_SCALE; + const context = canvas.getContext("2d"); + if (!context) throw new Error("无法生成单行条码图像,请刷新页面后重试。"); + + // Measure and draw the same glyphs; LODOP must not reflow the three text segments. + context.scale(IMAGE_SCALE, IMAGE_SCALE); + context.fillStyle = "#000000"; + context.textAlign = "left"; + context.textBaseline = "alphabetic"; + const segments = [ + { text: record.catcode, left: 0, width: 114, size: 15, bold: false }, + { text: record.bar2, left: 120, width: 160, size: 28, bold: true }, + { text: record.bar3, left: 286, width: 101, size: 15, bold: false }, + ]; + for (const segment of segments) { + const bounds = fitText(context, segment.text, segment.width, segment.size, segment.bold); + const x = segment.left + (segment.width - bounds.left - bounds.right) / 2 + bounds.left; + context.fillText(segment.text, x, 38); + } + const image = canvas.toDataURL("image/png"); + if (!image.startsWith("data:image/png;base64,")) { + throw new Error("无法生成单行条码图像,请刷新页面后重试。"); + } + return image; +} + +export function printSplitBarcodeText(record: BarcodeParts, lodop: Lodop) { + const modular = Boolean(record.catcode) && /^\d{8}$/.test(record.bar2) && /^\d{5}$/.test(record.bar3); + if (!modular) { + const image = buildSingleLineImage(record); + lodop.ADD_PRINT_IMAGE(186, 211, IMAGE_WIDTH, IMAGE_HEIGHT, ``); + lodop.SET_PRINT_STYLEA(0, "Stretch", 2); + return; + } + + // Preserve the separately calibrated modular layout and its original instructions. + const segments = [ + { text: record.catcode, top: 203, left: 207, width: 120, height: 25, size: 15, bold: false, alignment: 1 }, + { text: record.bar2, top: 186, left: 290, width: 230, height: 52, size: 28, bold: true, alignment: 2 }, + { text: record.bar3, top: 203, left: 482, width: 141, height: 25, size: 15, bold: false, alignment: 2 }, + ]; + for (const segment of segments) { + lodop.ADD_PRINT_TEXT(segment.top, segment.left, segment.width, segment.height, segment.text); + lodop.SET_PRINT_STYLEA(0, "FontName", PRINT_FONT); + lodop.SET_PRINT_STYLEA(0, "FontSize", segment.size); + if (segment.bold) lodop.SET_PRINT_STYLEA(0, "Bold", 1); + lodop.SET_PRINT_STYLEA(0, "Alignment", segment.alignment); + } +} diff --git a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintThirdTemplate.test.tsx b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintThirdTemplate.test.tsx new file mode 100644 index 00000000..19cdca0a --- /dev/null +++ b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintThirdTemplate.test.tsx @@ -0,0 +1,185 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { BarcodePrintTemplateDialog } from "./BarcodePrintTemplateDialog"; +import { + BARCODE_PRINT_TEMPLATE_OPTIONS, + getDefaultBarcodePrintTemplateTitle, + printBarcodeRow, + type Lodop, +} from "./barcodePrintLodop"; +import type { BarcodePrintRow } from "./barcodePrintRows"; + +const SECOND_TITLE = "条码打印_第二套模板"; +const THIRD_TITLE = "条码打印-第三套模板(尺寸信息)"; + +function createRow(barcode: string): BarcodePrintRow { + return { + barcode, + factoryModel: "ZWJ0276K01N1392", + productCategory: "家具>电脑桌", + color: "胡桃色", + size: "140/76/74.5", + tagMaterial: "人造板+铁架", + material: "人造板+铁架", + executeStandard: "T/CZFA 0002-2025", + supplierName: "测试供应商", + } as BarcodePrintRow; +} + +function createRecorder() { + const calls: Array<{ method: string; args: unknown[] }> = []; + const record = (method: string) => (...args: unknown[]) => { + calls.push({ method, args }); + }; + const lodop: Lodop = { + PRINT_INIT: record("PRINT_INIT"), + SET_PRINT_PAGESIZE: record("SET_PRINT_PAGESIZE"), + SET_PRINT_MODE: record("SET_PRINT_MODE"), + NEWPAGE: record("NEWPAGE"), + ADD_PRINT_BARCODE: record("ADD_PRINT_BARCODE"), + ADD_PRINT_TEXT: record("ADD_PRINT_TEXT"), + ADD_PRINT_RECT: record("ADD_PRINT_RECT"), + ADD_PRINT_IMAGE: record("ADD_PRINT_IMAGE"), + SET_PRINT_STYLEA: record("SET_PRINT_STYLEA"), + PREVIEW: record("PREVIEW"), + }; + return { calls, lodop }; +} + +describe("third barcode template with size column", () => { + beforeEach(() => { + jest.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({ + scale: jest.fn(), fillText: jest.fn(), measureText: (text: string) => ({ width: text.length * 5 }), + } as never); + jest.spyOn(HTMLCanvasElement.prototype, "toDataURL").mockReturnValue("data:image/png;base64,dGVzdA=="); + }); + + afterEach(() => jest.restoreAllMocks()); + + it("adds one selectable template after the second without changing old options", () => { + const titles = BARCODE_PRINT_TEMPLATE_OPTIONS.map(({ title }) => title); + expect(titles).toEqual([ + "条码打印_第一套编码(数字开头)&一次性采买条码", + SECOND_TITLE, + THIRD_TITLE, + "外贸条码打印", + "顾家", + ]); + expect(new Set(titles).size).toBe(titles.length); + expect(getDefaultBarcodePrintTemplateTitle(createRow("CJ026601N1280V01"))).toBe(SECOND_TITLE); + expect(getDefaultBarcodePrintTemplateTitle(createRow("1234567890123"))).toBe(titles[0]); + }); + + it.each([ + "CJ026601N1280V01", + "DNZ040717P396V01", + "GBSFY-LRSF009001N1280V01", + "GBGJ-ZWJ024903AN1407V01", + "GBGJ-ZWJ024903BN1407V01", + "MKHDNZ0409260600047", + "1234567890123", + ])("changes only the material row from template two for %s", async (barcode) => { + jest.useFakeTimers().setSystemTime(new Date("2026-09-11T06:00:00.000Z")); + const originalGetLodop = window.getLodop; + const originalGetCLodop = window.getCLodop; + const originalLodop = window.LODOP; + const second = createRecorder(); + const third = createRecorder(); + try { + window.getLodop = () => second.lodop; + window.getCLodop = () => second.lodop; + expect(await printBarcodeRow(createRow(barcode), { templateTitle: SECOND_TITLE })).toBe(SECOND_TITLE); + window.getLodop = () => third.lodop; + window.getCLodop = () => third.lodop; + expect(await printBarcodeRow(createRow(barcode), { templateTitle: THIRD_TITLE })).toBe(THIRD_TITLE); + const expectedCalls = second.calls.map((call) => ({ ...call, args: [...call.args] })); + const materialIndex = expectedCalls.findIndex( + (call) => call.method === "ADD_PRINT_TEXT" && call.args[0] === 145, + ); + expect(expectedCalls[materialIndex]).toEqual({ + method: "ADD_PRINT_TEXT", args: [145, 181, 407, 25, "材质:人造板+铁架"], + }); + expectedCalls[materialIndex].args[2] = 242; + expectedCalls.splice(materialIndex + 3, 0, + { method: "ADD_PRINT_TEXT", args: [145, 429, 166, 25, "140.76.74.5"] }, + { method: "SET_PRINT_STYLEA", args: [0, "FontName", "微软雅黑"] }, + { method: "SET_PRINT_STYLEA", args: [0, "FontSize", 15] }, + ); + expectedCalls.splice(-1, 0, { + method: "ADD_PRINT_RECT", args: [136, 426, 1, 40, 0, 1], + }); + expect(third.calls).toEqual(expectedCalls); + expect(third.calls).toContainEqual({ + method: "SET_PRINT_MODE", args: ["PRINT_PAGE_PERCENT", "Full-Page"], + }); + expect(third.calls.at(-1)).toEqual({ method: "PREVIEW", args: [] }); + } finally { + window.getLodop = originalGetLodop; + window.getCLodop = originalGetCLodop; + window.LODOP = originalLodop; + jest.useRealTimers(); + } + }); + + it.each([ + ["120/60/75", "120.60.75"], + ["140/76/74.5", "140.76.74.5"], + ["51/31.5/116", "51.31.5.116"], + ["120.60.75", "120.60.75"], + [" 120/60/75 ", "120.60.75"], + ["", ""], + [null, ""], + [undefined, ""], + ])("prints size %s without a label or changing source data", async (size, expected) => { + const originalGetLodop = window.getLodop; + const originalGetCLodop = window.getCLodop; + const originalLodop = window.LODOP; + const { calls, lodop } = createRecorder(); + const row = { + ...createRow("HJ006701N717V01"), + size: size as string, + lengthCm: "999", + widthCm: "888", + heightCm: "777", + }; + const originalRow = { ...row }; + try { + window.getLodop = () => lodop; + window.getCLodop = () => lodop; + await printBarcodeRow(row, { templateTitle: THIRD_TITLE }); + const sizeCalls = calls.filter( + (call) => call.method === "ADD_PRINT_TEXT" && call.args[0] === 145 && call.args[1] === 429, + ); + expect(sizeCalls).toEqual([ + { method: "ADD_PRINT_TEXT", args: [145, 429, 166, 25, expected] }, + ]); + expect(row).toEqual(originalRow); + } finally { + window.getLodop = originalGetLodop; + window.getCLodop = originalGetCLodop; + window.LODOP = originalLodop; + } + }); + + it("submits the third template using the actual template options", async () => { + const actor = userEvent.setup(); + const onSubmit = jest.fn(); + render( + , + ); + const select = screen.getByLabelText("打印模板"); + expect(select).toHaveValue(SECOND_TITLE); + expect(screen.getByRole("option", { name: THIRD_TITLE })).not.toBeDisabled(); + await actor.selectOptions(select, THIRD_TITLE); + await actor.click(screen.getByRole("button", { name: "确定" })); + expect(onSubmit).toHaveBeenCalledWith({ + templateTitle: THIRD_TITLE, batchNumber: "", editMode: undefined, + }); + }); +}); diff --git a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintThirdTemplate.ts b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintThirdTemplate.ts new file mode 100644 index 00000000..0a0e95ca --- /dev/null +++ b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintThirdTemplate.ts @@ -0,0 +1,50 @@ +import type { + BarcodeLodopRecord, + BarcodePrintTemplate, + Lodop, +} from "./barcodePrintLodop"; +import { printSplitBarcodeText } from "./barcodePrintSplitCode"; + +function setFont(lodop: Lodop, size: number, bold = false) { + lodop.SET_PRINT_STYLEA(0, "FontName", "微软雅黑"); + lodop.SET_PRINT_STYLEA(0, "FontSize", size); + if (bold) lodop.SET_PRINT_STYLEA(0, "Bold", 1); +} + +// Independent layout so size-specific changes do not affect template two. +function printThirdTemplate(record: BarcodeLodopRecord, lodop: Lodop) { + lodop.PRINT_INIT("条码打印"); + lodop.ADD_PRINT_BARCODE(15, 22, "100.01mm", "45.01mm", "QRCode", record.barcode); + lodop.SET_PRINT_STYLEA(0, "ShowBarText", 0); + lodop.ADD_PRINT_TEXT(26, 181, 304, 25, `型号:${record.factoryNumber}`); + setFont(lodop, 12); + lodop.ADD_PRINT_TEXT(59, 181, 415, 75, `编码:${record.skuName1}`); + setFont(lodop, 15); + lodop.SET_PRINT_STYLEA(0, "TextNeatRow", 1); + lodop.ADD_PRINT_TEXT(26, 429, 198, 25, `类目:${record.productCategory}`); + setFont(lodop, 15); + lodop.ADD_PRINT_TEXT(145, 181, 242, 25, `材质:${record.hangtagMaterial}`); + setFont(lodop, 15); + lodop.ADD_PRINT_TEXT(145, 429, 166, 25, record.size); + setFont(lodop, 15); + lodop.ADD_PRINT_TEXT(179, 26, 160, 25, "执行标准"); + setFont(lodop, 15); + lodop.ADD_PRINT_TEXT(203, 14, 195, 25, record.executionStandard); + setFont(lodop, 15); + + printSplitBarcodeText(record, lodop); + + lodop.ADD_PRINT_TEXT(182, 458, 172, 25, `生产批号:${record.printDate}`); + lodop.SET_PRINT_STYLEA(0, "FontName", "微软雅黑"); + lodop.ADD_PRINT_RECT(20, 178, 420, 157, 0, 1); + lodop.ADD_PRINT_RECT(56, 178, 420, 1, 0, 1); + lodop.ADD_PRINT_RECT(135, 178, 420, 1, 0, 1); + lodop.ADD_PRINT_RECT(21, 426, 1, 34, 0, 1); + lodop.ADD_PRINT_RECT(136, 426, 1, 40, 0, 1); +} + +export const BARCODE_PRINT_THIRD_TEMPLATE: BarcodePrintTemplate = { + title: "条码打印-第三套模板(尺寸信息)", + useFullPagePercent: true, + printFunc: printThirdTemplate, +};