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,
+};