From 5f4adeabbb233ea6af57924e47f699872401f5c3 Mon Sep 17 00:00:00 2001 From: u8array Date: Wed, 29 Jul 2026 14:27:38 +0200 Subject: [PATCH 1/4] fix(parser): barcode handlers read the live field state across page resets The destructured alias went stale at each page reset, so barcodes past the first block imported as text. --- .../src/lib/zplParser/handlers/barcodes.ts | 123 +++++++++--------- src/lib/zplParser.test.ts | 12 ++ 2 files changed, 75 insertions(+), 60 deletions(-) diff --git a/packages/core/src/lib/zplParser/handlers/barcodes.ts b/packages/core/src/lib/zplParser/handlers/barcodes.ts index 6061fcfe..6be25733 100644 --- a/packages/core/src/lib/zplParser/handlers/barcodes.ts +++ b/packages/core/src/lib/zplParser/handlers/barcodes.ts @@ -10,7 +10,10 @@ import type { Handler } from "../types"; /** ^B* barcode commands + shared ^BY defaults. Touches `field` and `defaults`. */ export function createBarcodeHandlers(s: ParserState): Record { - const { field, defaults } = s; + // Read s.field live: resetFormatScopedState reassigns it at each page close, so + // a destructured alias goes stale and later blocks write a dead object (barcode + // degrades to text). `defaults` is only mutated in place, so aliasing it is safe. + const { defaults } = s; const { dots } = dotsFor(s); // Factory for 1D barcodes: hIdx/iIdx/cIdx = param indices for height/interp/check. @@ -23,19 +26,19 @@ export function createBarcodeHandlers(s: ParserState): Record { cIdx = -1, ): Handler => (p) => { - field.fieldType = type; - field.bcRotation = readRotation(p[0]); - field.bcHeight = dots(p[hIdx], defaults.byHeight || 100); - field.bcInterp = (p[iIdx] ?? iDefault) === "Y"; + s.field.fieldType = type; + s.field.bcRotation = readRotation(p[0]); + s.field.bcHeight = dots(p[hIdx], defaults.byHeight || 100); + s.field.bcInterp = (p[iIdx] ?? iDefault) === "Y"; // The g-param (interpretation line above) always sits right after f. - field.bcInterpAbove = (p[iIdx + 1] ?? "N") === "Y"; - field.bcCheck = cIdx >= 0 ? (p[cIdx] ?? "N") === "Y" : false; + s.field.bcInterpAbove = (p[iIdx + 1] ?? "N") === "Y"; + s.field.bcCheck = cIdx >= 0 ? (p[cIdx] ?? "N") === "Y" : false; }; const handleAztec: Handler = (p) => { - field.fieldType = "aztec"; - field.bcRotation = readRotation(p[0]); - field.aztecMag = int(p[1], 4); + s.field.fieldType = "aztec"; + s.field.bcRotation = readRotation(p[0]); + s.field.aztecMag = int(p[1], 4); }; return { @@ -51,7 +54,7 @@ export function createBarcodeHandlers(s: ParserState): Record { // plain ^BC clears it. BC: (p, rest, cmd) => { mkBarcode("code128", 1, 2, "Y", 4)(p, rest, cmd); - field.bcGs1 = (p[5] ?? "").toUpperCase() === "D"; + s.field.bcGs1 = (p[5] ?? "").toUpperCase() === "D"; }, B3: mkBarcode("code39", 2, 3, "Y", 1), // ^B3N,c,h,i,N BE: mkBarcode("ean13", 1, 2), // ^BEN,h,i,N @@ -72,12 +75,12 @@ export function createBarcodeHandlers(s: ParserState): Record { // ^B4o,h,f,m; Code 49. Custom handler for the extra mode parameter. B4(p) { - field.fieldType = "code49"; - field.bcRotation = readRotation(p[0]); - field.bcHeight = dots(p[1], defaults.byHeight || 20); - field.bcInterp = (p[2] ?? "N") === "Y"; + s.field.fieldType = "code49"; + s.field.bcRotation = readRotation(p[0]); + s.field.bcHeight = dots(p[1], defaults.byHeight || 20); + s.field.bcInterp = (p[2] ?? "N") === "Y"; const m = (p[3] ?? "A").toUpperCase(); - field.bcCode49Mode = /^[A0-5]$/.test(m) + s.field.bcCode49Mode = /^[A0-5]$/.test(m) ? (m as Code49Props["mode"]) : "A"; }, @@ -85,25 +88,25 @@ export function createBarcodeHandlers(s: ParserState): Record { // MSI: check logic is "any letter except N" (not simple "Y"); keep inline. // ^BMN,{checkType},{height},{interp},N (checkType: A/B/C/D=enabled, N=none) BM(p) { - field.fieldType = "msi"; - field.bcRotation = readRotation(p[0]); - field.bcCheck = (p[1] ?? "N") !== "N"; - field.bcHeight = dots(p[2], defaults.byHeight || 100); - field.bcInterp = (p[3] ?? "Y") === "Y"; - field.bcInterpAbove = (p[4] ?? "N") === "Y"; + s.field.fieldType = "msi"; + s.field.bcRotation = readRotation(p[0]); + s.field.bcCheck = (p[1] ?? "N") !== "N"; + s.field.bcHeight = dots(p[2], defaults.byHeight || 100); + s.field.bcInterp = (p[3] ?? "Y") === "Y"; + s.field.bcInterpAbove = (p[4] ?? "N") === "Y"; }, // GS1 Databar: different param layout, also updates defaults.byModuleWidth. // ^BRo,{symbology},{magnification},{separator},{height},{segments} BR(p) { - field.fieldType = "gs1databar"; - field.bcRotation = readRotation(p[0]); + s.field.fieldType = "gs1databar"; + s.field.bcRotation = readRotation(p[0]); // p[2] is the ^BR magnification multiplier (1-10), not a dot // quantity. Out-of-range falls back to ^BY at flush time. const mag = int(p[2]); - field.gsMagnification = mag >= 1 && mag <= 10 ? mag : undefined; - field.gsSymbology = (int(p[1], 1) as Gs1DatabarProps["symbology"]) || 1; - field.gsSegments = + s.field.gsMagnification = mag >= 1 && mag <= 10 ? mag : undefined; + s.field.gsSymbology = (int(p[1], 1) as Gs1DatabarProps["symbology"]) || 1; + s.field.gsSegments = p[5] !== undefined ? int(p[5], GS1_DATABAR_DEFAULT_SEGMENTS) : undefined; @@ -111,9 +114,9 @@ export function createBarcodeHandlers(s: ParserState): Record { // ^BQ orientation slot is decorative; canonicalized to N on emit. BQ(p) { - field.fieldType = "qrcode"; - field.qrModel = int(p[1], 2); - field.qrMag = int(p[2], 4); + s.field.fieldType = "qrcode"; + s.field.qrModel = int(p[1], 2); + s.field.qrMag = int(p[2], 4); }, // ^BXo,h,s,c,r,f,g,a; DataMatrix. p[6] = GS1 escape char, p[7] = aspect @@ -121,32 +124,32 @@ export function createBarcodeHandlers(s: ParserState): Record { // (format ID, quality 0-140 only) is intentionally dropped and // canonicalized away on emit. BX(p) { - field.fieldType = "datamatrix"; - field.bcRotation = readRotation(p[0]); - field.dmDim = dots(p[1], 5); - field.dmQuality = int(p[2], 200) as DataMatrixProps["quality"]; + s.field.fieldType = "datamatrix"; + s.field.bcRotation = readRotation(p[0]); + s.field.dmDim = dots(p[1], 5); + s.field.dmQuality = int(p[2], 200) as DataMatrixProps["quality"]; // c/r and the a param are ECC-200 features; below that the firmware // auto-sizes a square symbol, so drop them to keep the model consistent // with the preview (dmVersionString ignores them there anyway). - const q200 = field.dmQuality === 200; + const q200 = s.field.dmQuality === 200; const cols = q200 ? int(p[3], 0) || undefined : undefined; const rows = q200 ? int(p[4], 0) || undefined : undefined; - field.dmCols = cols; - field.dmRows = rows; - field.dmEscape = p[6] || undefined; + s.field.dmCols = cols; + s.field.dmRows = rows; + s.field.dmEscape = p[6] || undefined; // A forced c/r pair decides the shape — the firmware honors it over the // a param; only an auto-sized symbol takes its shape from a. const rect = cols && rows ? isDmRectPair(rows, cols) : int(p[7], 1) === 2; - field.dmAspect = q200 && rect ? 2 : undefined; + s.field.dmAspect = q200 && rect ? 2 : undefined; }, // ^B7N,{rowHeight},{securityLevel},{columns},,,; PDF417 B7(p) { - field.fieldType = "pdf417"; - field.bcRotation = readRotation(p[0]); - field.pdfRowHeight = dots(p[1], 10); - field.pdfSecurity = int(p[2], 0); - field.pdfColumns = int(p[3], 0); + s.field.fieldType = "pdf417"; + s.field.bcRotation = readRotation(p[0]); + s.field.pdfRowHeight = dots(p[1], 10); + s.field.pdfSecurity = int(p[2], 0); + s.field.pdfColumns = int(p[3], 0); }, // ^B0N,{magnification},... / ^BON,...; Aztec (^B0 and ^BO are synonyms) @@ -157,45 +160,45 @@ export function createBarcodeHandlers(s: ParserState): Record { // physical size, no orientation slot). Structured-append params are // read but pinned to (1,1) on emit. BD(p) { - field.fieldType = "maxicode"; + s.field.fieldType = "maxicode"; const m = int(p[0], 4); - field.maxicodeMode = (m >= 2 && m <= 6 ? m : 4) as MaxicodeProps["mode"]; + s.field.maxicodeMode = (m >= 2 && m <= 6 ? m : 4) as MaxicodeProps["mode"]; }, // ^BFN,{rowHeight}: MicroPDF417 BF(p) { - field.fieldType = "micropdf417"; - field.bcRotation = readRotation(p[0]); - field.mpdfRowHeight = dots(p[1], 10); + s.field.fieldType = "micropdf417"; + s.field.bcRotation = readRotation(p[0]); + s.field.mpdfRowHeight = dots(p[1], 10); }, // ^BBN,{rowHeight},{security},{numCharsPerRow},{numRows},{mode}: CODABLOCK. // c (p[3]) is the stacking control we model; r (p[4]) and mode (p[5]) are // canonicalized on emit (r left to the firmware, mode pinned to F). BB(p) { - field.fieldType = "codablock"; - field.bcRotation = readRotation(p[0]); - field.cbRowHeight = dots(p[1], 10); - field.cbSecurity = (p[2] ?? "Y") === "N" ? "N" : "Y"; + s.field.fieldType = "codablock"; + s.field.bcRotation = readRotation(p[0]); + s.field.cbRowHeight = dots(p[1], 10); + s.field.cbSecurity = (p[2] ?? "Y") === "N" ? "N" : "Y"; // int(p[3], 0) || undefined: a missing OR empty c both fall back to the // default (matches the dm cols/rows idiom above), not to the clamp floor. - field.cbColumns = clampCodablockColumns(int(p[3], 0) || undefined); + s.field.cbColumns = clampCodablockColumns(int(p[3], 0) || undefined); }, // ^BTo,w1,r1,h1,w2,h2: TLC39 (Code 39 + optional MicroPDF417 stack) BT(p) { - field.fieldType = "tlc39"; - field.bcRotation = readRotation(p[0]); + s.field.fieldType = "tlc39"; + s.field.bcRotation = readRotation(p[0]); // w1 (Code 39 narrow bar) overrides ^BY when present; undefined // means fall back to defaults.byModuleWidth at flush time. - field.tlcModuleWidth = dots(p[1], 0) || undefined; + s.field.tlcModuleWidth = dots(p[1], 0) || undefined; // p[2] (r1) intentionally dropped, canonicalized on emit. - field.tlcHeight = dots(p[3], defaults.byHeight || 40); - field.tlcMicroPdfRowHeight = dots(p[4], 4); + s.field.tlcHeight = dots(p[3], defaults.byHeight || 40); + s.field.tlcMicroPdfRowHeight = dots(p[4], 4); // Zebra ^BT h2 range is 1-10; firmware snaps to a valid linked // MicroPDF417 row count {4,6,8,10} on print. const rows = int(p[5], 4); - field.tlcMicroPdfRows = rows >= 1 && rows <= 10 ? rows : 4; + s.field.tlcMicroPdfRows = rows >= 1 && rows <= 10 ? rows : 4; }, }; } diff --git a/src/lib/zplParser.test.ts b/src/lib/zplParser.test.ts index f54da793..7d60ed7a 100644 --- a/src/lib/zplParser.test.ts +++ b/src/lib/zplParser.test.ts @@ -403,6 +403,18 @@ describe('parseZPL — ^BC Code 128', () => { expect(props(objects[0]).height).toBe(270); expect(props(objects[0]).moduleWidth).toBe(5); }); + + it('keeps barcodes on later blocks after a page reset (live field state)', () => { + // resetFormatScopedState reassigns s.field at each page close; a stale + // destructured alias made every ^B* handler from block 2 on write a dead + // object, degrading the barcode to text. + const r = parseZPL( + '^XA^FO10,10^BY2^BCN,100,N,N,N^FD123^FS^XZ^XA^FO20,20^BY2^BCN,80,N,N,N^FD456^FS^XZ', + 8, + ); + expect(r.pages[0]?.objects[0]?.type).toBe('code128'); + expect(r.pages[1]?.objects[0]?.type).toBe('code128'); + }); }); describe('parseZPL — ^BR GS1 Databar', () => { From 3a01d2b230044712ec0f3ff661f2e34d086c28c9 Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 30 Jul 2026 23:23:54 +0200 Subject: [PATCH 2/4] feat(zpl): model ^JM density with a format-head lookahead ^PW/^LL/^ML stay physical head dots (ZD230-verified); pages carry their own ^JM through editor, preview and export. --- packages/core/src/lib/barcodeScannability.ts | 5 +- packages/core/src/lib/designFile.ts | 40 +- packages/core/src/lib/objectBounds.ts | 6 +- packages/core/src/lib/resolveDefaultSize.ts | 5 +- packages/core/src/lib/zplGenerator.ts | 192 +++- packages/core/src/lib/zplHeadScan.ts | 141 +++ packages/core/src/lib/zplImportService.ts | 78 +- packages/core/src/lib/zplOverlay/overlay.ts | 54 +- packages/core/src/lib/zplParser.ts | 83 +- packages/core/src/lib/zplParser/context.ts | 29 + .../core/src/lib/zplParser/handlers/fields.ts | 29 +- .../src/lib/zplParser/handlers/labelConfig.ts | 30 +- .../core/src/lib/zplParser/handlers/units.ts | 25 +- .../src/lib/zplParser/handlers/unsupported.ts | 1 - packages/core/src/lib/zplParser/helpers.ts | 19 +- packages/core/src/lib/zplParser/types.ts | 6 +- packages/core/src/registry/zplHelpers.ts | 8 +- packages/core/src/types/Group.ts | 12 + packages/core/src/types/LabelConfig.test.ts | 56 + packages/core/src/types/LabelConfig.ts | 167 ++- packages/mcp-server/src/tools.test.ts | 25 + packages/mcp-server/src/tools.ts | 27 +- src/components/Canvas/LabelCanvas.tsx | 68 +- .../Canvas/hooks/useKonvaDragController.ts | 6 +- src/components/Output/ZplImportModal.tsx | 9 +- src/components/Palette/ObjectPalette.tsx | 11 +- src/components/PrinterSettings/OutputTab.tsx | 4 + .../Properties/BlockTextSettings.tsx | 3 + .../Properties/DensityRescaleModal.tsx | 34 +- src/components/Properties/FpSettings.tsx | 1 + src/components/Properties/PropertiesPanel.tsx | 89 +- src/components/Properties/TextModeSection.tsx | 2 + .../Properties/UnitNumberInput.scope.test.tsx | 85 ++ src/components/Properties/UnitNumberInput.tsx | 12 +- src/hooks/useZplImportExport.ts | 8 +- src/lib/densityRescale.test.ts | 246 +++- src/lib/densityRescale.ts | 147 ++- src/lib/designFile.test.ts | 43 + src/lib/footprintMeasurer.ts | 8 +- src/lib/importReport.ts | 5 + src/lib/printPreview.test.ts | 5 + src/lib/printPreview.ts | 3 +- src/lib/safeArea.ts | 3 +- src/lib/zplCommandSupport.ts | 1 + src/lib/zplImportService.test.ts | 44 + src/lib/zplJmDensity.test.ts | 1023 +++++++++++++++++ src/lib/zplParser.test.ts | 6 +- src/lib/zplRoundtrip.integration.test.ts | 13 +- src/locales/ar.ts | 8 + src/locales/bg.ts | 8 + src/locales/cs.ts | 8 + src/locales/da.ts | 8 + src/locales/de.ts | 8 + src/locales/el.ts | 8 + src/locales/en.ts | 8 + src/locales/es.ts | 8 + src/locales/et.ts | 8 + src/locales/fa.ts | 8 + src/locales/fi.ts | 8 + src/locales/fr.ts | 8 + src/locales/he.ts | 8 + src/locales/hr.ts | 8 + src/locales/hu.ts | 8 + src/locales/it.ts | 8 + src/locales/ja.ts | 8 + src/locales/ko.ts | 8 + src/locales/lt.ts | 8 + src/locales/lv.ts | 8 + src/locales/nl.ts | 8 + src/locales/no.ts | 8 + src/locales/pl.ts | 8 + src/locales/pt.ts | 8 + src/locales/ro.ts | 8 + src/locales/sk.ts | 8 + src/locales/sl.ts | 8 + src/locales/sr.ts | 8 + src/locales/sv.ts | 8 + src/locales/tr.ts | 8 + src/locales/zh-hans.ts | 8 + src/locales/zh-hant.ts | 8 + src/registry/barcode1d.panel.tsx | 1 + src/registry/box.panel.tsx | 3 + src/registry/codablock.panel.tsx | 1 + src/registry/code49.panel.tsx | 1 + src/registry/ellipse.panel.tsx | 4 + src/registry/image.panel.tsx | 1 + src/registry/line.panel.tsx | 2 + src/registry/micropdf417.panel.tsx | 1 + src/registry/pdf417.panel.tsx | 1 + src/registry/symbol.panel.tsx | 2 + src/registry/text.panel.tsx | 8 +- src/registry/tlc39.panel.tsx | 2 + src/store/labelStore.internals.ts | 9 +- src/store/labelStore.selectors.ts | 27 +- src/store/labelStore.test.ts | 62 + src/store/labelStore.ts | 13 +- src/store/labelStoreEnv.test.ts | 3 + src/store/pageLabelSeam.test.ts | 62 + src/store/slices/labelConfigSlice.ts | 30 +- src/store/slices/objectSlice.ts | 10 +- src/store/slices/previewSlice.ts | 9 +- 101 files changed, 3162 insertions(+), 263 deletions(-) create mode 100644 packages/core/src/lib/zplHeadScan.ts create mode 100644 packages/core/src/types/LabelConfig.test.ts create mode 100644 src/components/Properties/UnitNumberInput.scope.test.tsx create mode 100644 src/lib/zplJmDensity.test.ts create mode 100644 src/store/pageLabelSeam.test.ts diff --git a/packages/core/src/lib/barcodeScannability.ts b/packages/core/src/lib/barcodeScannability.ts index 4d8f431e..83f9ef70 100644 --- a/packages/core/src/lib/barcodeScannability.ts +++ b/packages/core/src/lib/barcodeScannability.ts @@ -1,4 +1,5 @@ import type { PreflightCtx, PreflightProducerResult } from "../types/preflight"; +import { effectiveDpmm } from "../types/LabelConfig"; import { mmToUnitExact, unitLabel, type Unit } from "./units"; /** Recommended minimum barcode module / X-dimension in mm for reliable general @@ -34,7 +35,7 @@ export function moduleTooSmallFindings( export function moduleTooSmallPreflight

( prop: keyof P & string, ): (obj: { props: P }, ctx: PreflightCtx) => PreflightProducerResult[] { - return (obj, ctx) => moduleTooSmallFindings(obj.props[prop] as number, ctx.label.dpmm, ctx.unit); + return (obj, ctx) => moduleTooSmallFindings(obj.props[prop] as number, effectiveDpmm(ctx.label), ctx.unit); } /** Registry `preflight` for legacy/niche symbologies (Code 49, TLC39): the @@ -44,7 +45,7 @@ export function limitedSupportPreflight

( prop: keyof P & string, ): (obj: { props: P }, ctx: PreflightCtx) => PreflightProducerResult[] { return (obj, ctx) => [ - ...moduleTooSmallFindings(obj.props[prop] as number, ctx.label.dpmm, ctx.unit), + ...moduleTooSmallFindings(obj.props[prop] as number, effectiveDpmm(ctx.label), ctx.unit), { kind: "printerSupportLimited" }, ]; } diff --git a/packages/core/src/lib/designFile.ts b/packages/core/src/lib/designFile.ts index 9dc6d4bf..1e71f2b7 100644 --- a/packages/core/src/lib/designFile.ts +++ b/packages/core/src/lib/designFile.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { labelConfigSchema, type LabelConfig } from "../types/LabelConfig"; +import { JM_DENSITY_VALUES, labelConfigSchema, type JmDensity, type LabelConfig } from "../types/LabelConfig"; import { labelObjectBaseSchema } from "../types/LabelObject"; import { variableSchema, @@ -9,7 +9,8 @@ import { } from "../types/Variable"; import { dbSourceRefSchema, type DbSourceRef } from "../types/DataSource"; import type { LabelObject } from "../types/Group"; -import { blockOverlaySchema, type BlockOverlay } from "./zplOverlay/overlay"; +import { blockOverlaySchema, overlayText, type BlockOverlay } from "./zplOverlay/overlay"; +import { reconstructLegacyBlockHeads } from "./zplHeadScan"; import { visitLeavesInPages, foldSerialLeaf, bindSingleMarkerLeaf, sanitiseVariableNames, safeUniqueNameById } from "./objectTree"; import { insertReverseBackingBoxes, pageNeedsReverseBacking } from "./reverseBacking"; import { ok, err, type Result } from "./result"; @@ -19,10 +20,10 @@ import { ok, err, type Result } from "./result"; * migrator below and dispatch on `schemaVersion` in `parseDesignFile`. * The persist middleware in `labelStore` has its own independent * version for localStorage state; do not conflate. */ -export const CURRENT_DESIGN_SCHEMA_VERSION = 3; +export const CURRENT_DESIGN_SCHEMA_VERSION = 4; export type DesignFileError = "parse_error" | "invalid_schema"; -export interface DesignFilePage { objects: LabelObject[]; overlay?: BlockOverlay } +export interface DesignFilePage { objects: LabelObject[]; overlay?: BlockOverlay; jmDensity?: JmDensity } export interface DesignFile { label: LabelConfig; pages: DesignFilePage[]; @@ -64,10 +65,13 @@ const labelObjectSchema: z.ZodType = z.union([groupSchema, leafSchema]) const pageSchema = z.object({ objects: z.array(labelObjectSchema), overlay: blockOverlaySchema.optional().catch(undefined), + jmDensity: z.enum(JM_DENSITY_VALUES).optional(), }); const designFileSchema = z.object({ - schemaVersion: z.literal(3), + // v3 predates ^JM: it loads through the legacy JM reconstruction. v4 carries + // the persisted density fields; both are read, anything else is rejected. + schemaVersion: z.union([z.literal(3), z.literal(4)]), label: labelConfigSchema, pages: z.array(pageSchema), variables: z.array(variableSchema).optional(), @@ -93,6 +97,7 @@ export function parseDesignFile(text: string): Result (page.overlay ? overlayText(page.overlay) : undefined)), + ); + const anchorIndex = pages.findIndex((p) => p.objects.length > 0); + if (anchorIndex >= 0) label.jmDensity = heads[anchorIndex]?.density; + const designJm: JmDensity = label.jmDensity ?? "A"; + pages.forEach((page, i) => { + const { density, head } = heads[i] ?? {}; + const blockJm: JmDensity = density ?? "A"; + if (blockJm !== designJm) page.jmDensity = blockJm; + if (head && page.overlay) page.overlay.head = head; + }); +} + interface SerializedDesign { schemaVersion: number; label: LabelConfig; diff --git a/packages/core/src/lib/objectBounds.ts b/packages/core/src/lib/objectBounds.ts index ce23c9b3..5a7ae191 100644 --- a/packages/core/src/lib/objectBounds.ts +++ b/packages/core/src/lib/objectBounds.ts @@ -15,6 +15,7 @@ import { getAllLeaves, isGroup } from "../types/Group"; import type { LeafObject } from "../registry"; import { BARCODE_1D_TYPES, STACKED_2D_TYPES, getEntry } from "../registry"; import type { LabelConfig } from "../types/LabelConfig"; +import { effectiveDpmm, type JmDensity } from "../types/LabelConfig"; import { isAxisSwapped, objectRotation, type ZplRotation } from "../registry/rotation"; import { resolveTextMode } from "../registry/text"; import { blockBoundsDots, EMPTY_TEXT_PLACEHOLDER_GLYPHS, isBlankText, rotatedLineOffset, tbBoundsDots, zebraLineWidthDots } from "./zebraTextLayout"; @@ -344,14 +345,15 @@ export function printableRectDots(label: { widthMm: number; heightMm: number; dpmm: number; + jmDensity?: JmDensity; labelShift?: number; }): BoundingBoxDots { const shift = label.labelShift ?? 0; return { x: shift, y: 0, - width: mmToDots(label.widthMm, label.dpmm), - height: mmToDots(label.heightMm, label.dpmm), + width: mmToDots(label.widthMm, effectiveDpmm(label)), + height: mmToDots(label.heightMm, effectiveDpmm(label)), }; } diff --git a/packages/core/src/lib/resolveDefaultSize.ts b/packages/core/src/lib/resolveDefaultSize.ts index bf9b1f8e..c81974aa 100644 --- a/packages/core/src/lib/resolveDefaultSize.ts +++ b/packages/core/src/lib/resolveDefaultSize.ts @@ -1,5 +1,6 @@ import { mmToDots } from "./coordinates"; import type { LabelConfig } from "../types/LabelConfig"; +import { effectiveDpmm } from "../types/LabelConfig"; import type { ObjectTypeCore } from "../types/ObjectType"; /** Resolve a registry `defaultSize` declaration to dot units against * the active label config. Spec-fixed-physical-size symbols (e.g. @@ -13,8 +14,8 @@ export function resolveDefaultSizeDots( ): { width: number; height: number } { if ("widthMm" in defaultSize) { return { - width: mmToDots(defaultSize.widthMm, label.dpmm), - height: mmToDots(defaultSize.heightMm, label.dpmm), + width: mmToDots(defaultSize.widthMm, effectiveDpmm(label)), + height: mmToDots(defaultSize.heightMm, effectiveDpmm(label)), }; } // Shallow-copy the dots-branch so callers can't accidentally diff --git a/packages/core/src/lib/zplGenerator.ts b/packages/core/src/lib/zplGenerator.ts index 73950872..2681a888 100644 --- a/packages/core/src/lib/zplGenerator.ts +++ b/packages/core/src/lib/zplGenerator.ts @@ -12,11 +12,13 @@ import { classifyField } from './variableField'; import { escapeGs1FdValue } from './gs1'; import { gs1ModeDExclusiveFns } from './gs1ModeDFns'; import { formatLabelMetaComment } from './zplLabelMeta'; -import type { ClockOffset, CustomFontMapping, LabelConfig } from '../types/LabelConfig'; +import { jmDensityOf } from '../types/LabelConfig'; +import type { ClockOffset, CustomFontMapping, JmDensity, LabelConfig } from '../types/LabelConfig'; import type { ZplEmitContext } from '../types/ZplEmit'; import type { Variable } from '../types/Variable'; -import { exportableLeaves, isGroup, walkObjects, type LabelObject, type LeafObject, type Page } from '../types/Group'; -import { isOverlayConsistent } from './zplOverlay/overlay'; +import { exportableLeaves, isGroup, pageLabelConfig, walkObjects, type LabelObject, type LeafObject, type Page } from '../types/Group'; +import { isOverlayConsistent, MIN_JM_SPAN, type FormatHead, type JmSpan } from './zplOverlay/overlay'; +import { reconstructBlockHead } from './zplHeadScan'; import { objectBoundsDots, type ObjectBoundsCtx } from './objectBounds'; import { formatFontDownloadFromPath } from './customFonts'; import { imageEmitDims, type ImageProps } from '../registry/image'; @@ -156,6 +158,18 @@ function formatGraphicUpload(p: ImageProps): string | undefined { return `~DY${formatStoragePath(p.storedAs, false)},${format},G,${total},${bpr},${data}`; } +/** Head-less replay block, once a density decision is due: self-declares + * target -> keep, latch wire; contradiction, or due with no self-declaration + * -> regenerate; nothing due (target === wire) -> replay as-is. */ +function headlessAction( + selfDeclared: JmDensity | undefined, + target: JmDensity, + wireJm: JmDensity | undefined, +): 'keep' | 'regenerate' | 'replay' { + if (selfDeclared === target) return 'keep'; + return selfDeclared !== undefined || target !== wireJm ? 'regenerate' : 'replay'; +} + /** Each page becomes its own ^XA..^XZ block (separate labels to the printer). * A page imported with a source-patch overlay replays its original bytes * verbatim except for edited/added/removed objects; everything else @@ -166,17 +180,41 @@ export function generateMultiPageZPL( variables: readonly Variable[] = [], ): string { let out = ''; + // ^JM persists on the wire, so a block only declares a density the preceding + // blocks didn't set. `undefined` means nothing declared one yet, so a block + // inheriting its ^JM from outside this export gets the declaration back. + let wireJm: JmDensity | undefined; for (const p of pages) { - let block: string; + const pageLabel = pageLabelConfig(label, p); + let emitted: PageBlock; try { - block = emitOverlayPage(label, p, variables); + emitted = emitPageBlock(pageLabel, p, variables); } catch (err) { - // emitOverlayPage handles expected inconsistencies internally, so a throw - // here is an unexpected bug. Surface it (still degrading to regeneration) - // rather than silently disabling the overlay. - console.warn('emitOverlayPage failed, regenerating page from model', err); - block = generateZPL(label, p.objects, variables); + // A throw here is an unexpected bug, not an expected inconsistency (those + // are handled internally); warn instead of failing silently, then regenerate. + console.warn('emitPageBlock failed, regenerating page from model', err); + emitted = generateZplBlock(pageLabel, p.objects, variables); + } + // Unset means full density, so a page after a ^JMB page resets the wire; + // while nothing is declared yet there is nothing to reset. + const target = pageLabel.jmDensity ?? (wireJm ? 'A' : undefined); + // A replayed block whose head the parser never recorded (or whose bytes + // moved) has no place to splice a declaration; a density-only scan of its + // bytes (cold path) decides instead of always regenerating. + if (!emitted.head && target !== undefined) { + const action = headlessAction(reconstructBlockHead(emitted.block).density, target, wireJm); + if (action === 'regenerate') { + emitted = generateZplBlock(pageLabel, p.objects, variables); + } else if (action === 'keep') { + // applyJmDensity can't read a head-less block, so latch the wire here. + wireJm = target; + } } + const applied = applyJmDensity(emitted, pageLabel.jmDensity, target === wireJm ? undefined : target); + const block = applied.block; + // Track what the block actually carries, not the intent: an unreachable + // head would leave the next block believing a density the wire never got. + wireJm = applied.wireJm ?? wireJm; // An overlay page already carries the inter-block separator captured at // import (the splitter folds it into the preceding block). Only insert a // newline when the previous block didn't end with one (a fresh or @@ -188,20 +226,110 @@ export function generateMultiPageZPL( } +/** One page's emitted bytes plus the format head they carry (`undefined` when unknown). */ +interface PageBlock { + block: string; + head: FormatHead | undefined; +} + +/** Density the head declares: the last VALID `^JM` wins, mirroring the parser + * (which ignores an invalid one rather than letting it clear the density). + * Undefined when nothing readable is declared. */ +function declaredJm(block: string, head: FormatHead): JmDensity | undefined { + for (let i = head.jmSpans.length - 1; i >= 0; i--) { + const span = head.jmSpans[i]; + if (!span) continue; + const v = jmDensityOf(block.slice(span.start + MIN_JM_SPAN, span.end), span.delim); + if (v) return v; + } + return undefined; +} + +/** True while the head's offsets still land, in bounds and in order, on the + * commands they were recorded for; a shifted or malformed head fails so the + * caller regenerates instead of splicing bytes it never owned. */ +function headMatches(block: string, head: FormatHead): boolean { + const nameAt = (at: number): string => block.slice(at + 1, at + 3).toUpperCase(); + if (head.at > block.length) return false; + if (head.at > 0 && (block[head.at - 3] !== head.caret || nameAt(head.at - 3) !== 'XA')) { + return false; + } + let cursor = 0; + for (const s of head.jmSpans) { + if (s.start < cursor || s.end < s.start + MIN_JM_SPAN || s.end > block.length) return false; + if (block[s.start] !== (s.caret) || nameAt(s.start) !== 'JM') return false; + cursor = s.end; + } + return true; +} + +/** Rewrites a block's head to the target density; the model wins over any + * existing declaration. `target` folds in the running wire state (undefined = nothing to add). */ +function applyJmDensity( + emitted: PageBlock, + pageJm: JmDensity | undefined, + target: JmDensity | undefined, +): { block: string; wireJm: JmDensity | undefined } { + const { block, head } = emitted; + if (!head) return { block, wireJm: undefined }; + const declared = declaredJm(block, head); + if (declared !== undefined) { + const want = pageJm ?? 'A'; + if (declared === want) return { block, wireJm: want }; + // Every declaration in the head is rewritten, not just the last: leaving a + // stale one behind would make the density depend on emit order. Back to + // front so the earlier spans keep their offsets. + let rewritten = block; + for (const s of [...head.jmSpans].reverse()) { + const params = block.slice(s.start + MIN_JM_SPAN, s.end); + // ^JM takes one parameter; anything past the delimiter is unmodelled, so + // carry it rather than dropping bytes the source had. Per-span delimiter so + // a ^CD retarget mid-head splits at the right byte. + const tailAt = params.indexOf(s.delim); + const tail = tailAt < 0 ? '' : params.slice(tailAt); + rewritten = `${rewritten.slice(0, s.start)}${s.caret}JM${want}${tail}${rewritten.slice(s.end)}`; + } + return { block: rewritten, wireJm: want }; + } + if (!target) return { block, wireJm: undefined }; + // Insert after the head's last ^JM, not before, since an unreadable one would + // otherwise outrank a fresh declaration; with no spans, the ^XA caret is the + // injection point. + const last = head.jmSpans[head.jmSpans.length - 1]; + const at = last ? last.end : head.at; + const caret = last ? last.caret : head.caret; + return { + block: `${block.slice(0, at)}${caret}JM${target}${block.slice(at)}`, + wireJm: target, + }; +} + /** Emit one page from its overlay: verbatim segments for untouched objects, * in-place regeneration for dirty ones, appended fields for new ones, raw * segments (config/comments/unmodeled commands/whitespace) replayed as-is. * Falls back to full regeneration when the overlay is missing/inconsistent, * or when an edit exists in a block whose running state (^MU/prefix/^CI/^FE) - * would re-interpret a regenerated field. */ + * would re-interpret a regenerated field. + * Single-block only: skips the wire-state ^JM pass, so it won't declare an + * inherited or head-less density; real exports use generateMultiPageZPL. */ export function emitOverlayPage( - label: LabelConfig, + design: LabelConfig, page: Page, variables: readonly Variable[] = [], ): string { + return emitPageBlock(pageLabelConfig(design, page), page, variables).block; +} + +/** Overlay replay plus the head those bytes carry, so the ^JM pass patches a + * parser-recorded position, not an inferred one. Label is pre-resolved (^JM override folded in). */ +function emitPageBlock( + label: LabelConfig, + page: Page, + variables: readonly Variable[] = [], +): PageBlock { const overlay = page.overlay; if (!overlay || !isOverlayConsistent(overlay)) { - return generateZPL(label, page.objects, variables); + return generateZplBlock(label, page.objects, variables); } const exportable = exportableLeaves(page.objects); @@ -219,7 +347,7 @@ export function emitOverlayPage( const liveLinkedOrder = exportable.filter((l) => segmentIds.has(l.id)).map((l) => l.id); const segmentLiveOrder = segmentObjectOrder.filter((id) => exportableById.has(id)); if (liveLinkedOrder.some((id, i) => id !== segmentLiveOrder[i])) { - return generateZPL(label, page.objects, variables); + return generateZplBlock(label, page.objects, variables); } // New objects are appended after all segments, so they must sit at the model // tail. If a segment-linked object follows a new one in model order, appending @@ -227,7 +355,7 @@ export function emitOverlayPage( let sawNew = false; for (const l of exportable) { if (!segmentIds.has(l.id)) sawNew = true; - else if (sawNew) return generateZPL(label, page.objects, variables); + else if (sawNew) return generateZplBlock(label, page.objects, variables); } const dirtyLeaves = exportable.filter((l) => segmentIds.has(l.id) && l.dirty); @@ -237,7 +365,7 @@ export function emitOverlayPage( // non-regenSafe block is not, so fall back wholesale the moment an edit // (dirty or new) exists there. if ((dirtyLeaves.length > 0 || newLeaves.length > 0) && !overlay.regenSafe) { - return generateZPL(label, page.objects, variables); + return generateZplBlock(label, page.objects, variables); } const fx = overlay.frame?.homeX ?? 0; @@ -302,7 +430,8 @@ export function emitOverlayPage( idx >= 0 ? `${result.slice(0, idx)}${block}\n${result.slice(idx)}` : `${result}\n${block}`; } - return result; + const head = overlay.head; + return { block: result, head: head && headMatches(result, head) ? head : undefined }; } /** R: is volatile RAM, matches single-run batch scope. */ @@ -461,6 +590,20 @@ export function generateZPL( objects: LabelObject[], variables: readonly Variable[] = [], ): string { + return generateZplBlock(label, objects, variables).block; +} + +/** Model emit plus the format head it wrote. The head is exact by construction + * rather than searched for; the ^A@ aliasing at the end only rewrites body + * bytes, so the offsets survive it. */ +function generateZplBlock( + label: LabelConfig, + objects: LabelObject[], + variables: readonly Variable[] = [], +): PageBlock { + // ^PW/^LL are consumed in physical head dots (ZD230-verified), even under + // ^JMB: the body's object dots emit in the effective (halved) scale, but the + // print width/length stay physical. const widthDots = mmToDots(label.widthMm, label.dpmm); const heightDots = mmToDots(label.heightMm, label.dpmm); @@ -497,6 +640,16 @@ export function generateZPL( if (label.backfeedSequence) lines.push(`~JS${label.backfeedSequence}`); lines.push('^XA'); + // Offset just past the ^XA: every preceding line plus its newline. + const headAt = lines.reduce((n, l) => n + l.length + 1, 0) - 1; + // ^JM must precede the first ^FS (p269), and the sidecar comment below + // already closes with one; emit it first. + const jmSpans: JmSpan[] = []; + if (label.jmDensity) { + const jm = `^JM${label.jmDensity}`; + jmSpans.push({ start: headAt + 1, end: headAt + 1 + jm.length, delim: ',', caret: '^' }); + lines.push(jm); + } // Leading geometry sidecar: recovers exact width/height/dpmm on re-import, // which plain ^PW/^LL (dots, no dpmm) can't. A comment, so print is unaffected. lines.push(formatLabelMetaComment({ @@ -590,7 +743,10 @@ export function generateZPL( lines.push('^XZ'); - return aliasFontPaths(lines.join('\n'), label); + return { + block: aliasFontPaths(lines.join('\n'), label), + head: { caret: '^', at: headAt, jmSpans }, + }; } /** Rewrite `^A@…PATH` to `^A{alias}` for paths the user registered via `^CW`. diff --git a/packages/core/src/lib/zplHeadScan.ts b/packages/core/src/lib/zplHeadScan.ts new file mode 100644 index 00000000..68d9a404 --- /dev/null +++ b/packages/core/src/lib/zplHeadScan.ts @@ -0,0 +1,141 @@ +import { jmDensityOf, type JmDensity } from "../types/LabelConfig"; +import { acceptsPrefixRemap, tokenize } from "./zplParser/helpers"; +import { MIN_JM_SPAN, type FormatHead, type JmSpan } from "./zplOverlay/overlay"; + +interface HeadToken { + cmd: string; + rest: string; + /** Absolute offset in `zpl`. */ + start: number; + /** Opened with the live caret prefix (vs the tilde form). */ + isCaret: boolean; + /** Caret prefix live at this token, for a ^JM behind a ^CC remap. */ + caret: string; + /** Delimiter live at this token, for reading a ^JM value. */ + delim: string; +} + +/** Live prefix/delimiter chars a scan threads. `headTokens` mutates it on + * ^CC/^CT/^CD so a caller scanning several blocks in sequence sees the remaps + * persist; a caller wanting isolation passes a fresh object. */ +export interface PrefixState { + caretChar: string; + tildeChar: string; + delim: string; +} + +/** Tokenize from `fromOffset` with the live caret/delimiter per token, applying + * ^CC/^CT/^CD via the handlers' own acceptance so scans and the parser never + * diverge on remaps. `st` is mutated in place so a caller can thread it onward. */ +function* headTokens(zpl: string, fromOffset: number, st: PrefixState): Generator { + for (const t of tokenize(zpl.slice(fromOffset), st)) { + const arg = t.rest[0]; + if (t.cmd === "CC") { if (acceptsPrefixRemap(arg, st.tildeChar, st.delim)) st.caretChar = arg; continue; } + if (t.cmd === "CT") { if (acceptsPrefixRemap(arg, st.caretChar, st.delim)) st.tildeChar = arg; continue; } + if (t.cmd === "CD") { if (acceptsPrefixRemap(arg, st.caretChar, st.tildeChar)) st.delim = arg; continue; } + const start = fromOffset + t.start; + yield { cmd: t.cmd, rest: t.rest, start, isCaret: zpl[start] === st.caretChar, caret: st.caretChar, delim: st.delim }; + } +} + +/** Resolve a format's ^JM density (spec p269) by scanning its head from its own + * ^XA up to the first ^FS/^XZ or the next ^XA; the last valid caret ^JM wins. */ +export function lookaheadJmDensity( + zpl: string, + fromOffset: number, + chars: { caretChar: string; tildeChar: string }, + delimiter: string, +): JmDensity | undefined { + let density: JmDensity | undefined; + const st: PrefixState = { caretChar: chars.caretChar, tildeChar: chars.tildeChar, delim: delimiter }; + for (const t of headTokens(zpl, fromOffset, st)) { + if (t.cmd === "FS" || t.cmd === "XZ") break; + if (t.cmd === "XA" && t.start > fromOffset) break; + if (t.cmd !== "JM" || !t.isCaret) continue; + density = jmDensityOf(t.rest, t.delim) ?? density; + } + return density; +} + +/** Scan one block's format head (density, FormatHead); ^CC/^CT/^CD remaps mutate + * `st` in place. Density and ^JM spans count only up to the first ^FS/^XZ; + * `threadBody` keeps tokenizing past that so body remaps still reach `st`. */ +function scanBlockHead( + block: string, + st: PrefixState, + threadBody: boolean, +): { density: JmDensity | undefined; head: FormatHead | undefined } { + let seenXa = false; + let openerCaret = ""; + let at: number | null = null; + let inHead = false; + let density: JmDensity | undefined; + const jmSpans: JmSpan[] = []; + for (const t of headTokens(block, 0, st)) { + if (!seenXa) { + if (t.cmd === "XA" && t.isCaret) { + seenXa = true; + inHead = true; + openerCaret = t.caret; + at = t.start + 3; + } + continue; + } + if (inHead && (t.cmd === "FS" || t.cmd === "XZ" || t.cmd === "XA")) { + inHead = false; + if (!threadBody) break; + continue; + } + if (!inHead || t.cmd !== "JM" || !t.isCaret) continue; + density = jmDensityOf(t.rest, t.delim) ?? density; + jmSpans.push({ start: t.start, end: t.start + MIN_JM_SPAN + t.rest.trimEnd().length, delim: t.delim, caret: t.caret }); + } + const head = at === null ? undefined : { caret: openerCaret, at, jmSpans }; + return { density, head }; +} + +/** A legacy overlay block's head density and FormatHead, read from an isolated + * slice with default prefixes (no incoming state). A cross-block ^CC/^CT/^CD + * remap is unrecoverable from the slice alone; use `reconstructLegacyBlockHeads`. */ +export function reconstructBlockHead(block: string): { + density: JmDensity | undefined; + head: FormatHead | undefined; +} { + return scanBlockHead(block, { caretChar: "^", tildeChar: "~", delim: "," }, false); +} + +/** Per-block head density and FormatHead for a legacy stream, threading ^CC/^CT/^CD + * remaps and the latched ^JM density (unset at the start is full density A) across + * blocks in parser order; an `undefined` block inherits the running density, no head. */ +export function reconstructLegacyBlockHeads( + blocks: readonly (string | undefined)[], +): { density: JmDensity | undefined; head: FormatHead | undefined }[] { + const st: PrefixState = { caretChar: "^", tildeChar: "~", delim: "," }; + let carried: JmDensity | undefined; + return blocks.map((block) => { + if (block === undefined) return { density: carried, head: undefined }; + const { density: headDensity, head } = scanBlockHead(block, st, true); + if (headDensity !== undefined) carried = headDensity; + return { density: carried, head }; + }); +} + +/** Whether the stream ever opens a format, plus the density its leading ^JM + * declares when it never does: a ^JM latches only in a real wrapper-less body, + * with an ^XA anywhere ahead it is a preamble the lookahead never reads. */ +export function scanBareStream( + zpl: string, + chars: { caretChar: string; tildeChar: string }, + delimiter: string, +): { hasXa: boolean; density: JmDensity | undefined } { + let density: JmDensity | undefined; + let inHead = true; + const st: PrefixState = { caretChar: chars.caretChar, tildeChar: chars.tildeChar, delim: delimiter }; + for (const t of headTokens(zpl, 0, st)) { + if (t.cmd === "XA") return { hasXa: true, density: undefined }; + if (t.cmd === "FS" || t.cmd === "XZ") { inHead = false; continue; } + if (!inHead || t.cmd !== "JM" || !t.isCaret) continue; + density = jmDensityOf(t.rest, t.delim) ?? density; + } + return { hasXa: false, density }; +} diff --git a/packages/core/src/lib/zplImportService.ts b/packages/core/src/lib/zplImportService.ts index b1517ba6..62d0c9a5 100644 --- a/packages/core/src/lib/zplImportService.ts +++ b/packages/core/src/lib/zplImportService.ts @@ -3,7 +3,7 @@ import { replayRiskFindings, dedupCommandsByKind } from "./importReport"; import { dropPageOverlays } from "./pageOverlay"; import { stripDrivePrefix } from "./customFonts"; import { renameTemplateMarkers } from "./fnTemplate"; -import { PER_FORMAT_ZPL_FIELDS, type CustomFontMapping, type LabelConfig } from "../types/LabelConfig"; +import { PER_FORMAT_ZPL_FIELDS, effectiveDpmm, type CustomFontMapping, type JmDensity, type LabelConfig } from "../types/LabelConfig"; import type { PrinterProfile } from "../types/PrinterProfile"; import type { LabelObject, Page } from "../types/Group"; import { nextFreeFnNumber, uniqueVariableName, type Variable } from "../types/Variable"; @@ -24,9 +24,9 @@ export interface ZplImportResult { pages: Page[]; variables: Variable[]; report: ImportReport; - /** True when ^XA blocks set different ^PW/^LL: the single-label design keeps - * only block 0's size, so later pages would render/preflight at the wrong - * size. Interactive import ignores it; the MCP tools reject on it. */ + /** True when ^XA blocks diverge in ^PW/^LL or ^JM density, which the + * single-label design shows only once, so other pages render/preflight + * wrong. Interactive import ignores it; the MCP tools reject on it. */ mixedPageGeometry: boolean; } @@ -102,8 +102,11 @@ export function importZplText(zpl: string, dpmm: number): ZplImportResult { for (const o of page.objects) { if (!BARCODE_1D_TYPES.has(o.type) || !verifiedZJustifyCombo(o)) continue; const resolved = resolveForMeasure(o, variables, clockCtxFromLabel(page.labelConfig)); - // Sidecar density wins: the stream's dot values live in ITS dpmm. - const fp = measureFootprintDots(resolved, r.labelConfig.dpmm ?? dpmm); + // Sidecar density wins; under ^JMB the stream dots live halved. + const fp = measureFootprintDots( + resolved, + effectiveDpmm({ dpmm: r.labelConfig.dpmm ?? dpmm, jmDensity: page.labelConfig.jmDensity }), + ); if (fp) { o.x -= fp.w; zNormalized = true; @@ -113,10 +116,11 @@ export function importZplText(zpl: string, dpmm: number): ZplImportResult { // wrapper-less paste of real fields also lands here; import those as a page // so they aren't silently dropped (no overlay: the wrapper-less source has // nothing to replay byte-for-byte, and re-export adds the ^XA/^XZ wrapper). + const jmDensity = page.labelConfig.jmDensity; if (!page.bare) { - pages.push({ objects: page.objects, overlay: page.overlay }); + pages.push({ objects: page.objects, overlay: page.overlay, jmDensity }); } else if (page.objects.length > 0) { - pages.push({ objects: page.objects }); + pages.push({ objects: page.objects, jmDensity }); } for (const f of page.findings) { // A bare page replays nothing, so its lossyEdit caveat is moot. @@ -205,7 +209,33 @@ export function importZplText(zpl: string, dpmm: number): ZplImportResult { .map((sz) => `${sz.widthMm ?? ''}x${sz.heightMm ?? ''}`), ), ]; - findings.push({ kind: 'mixedPageGeometry', command: sizes.join(', '), pageIndex: 0 }); + findings.push({ kind: 'mixedPageGeometry', command: sizes.join(', '), pageIndex: 0, cause: 'size' }); + } + + // ^JM is per-format on the wire; the single-label model folds it to the + // first object-bearing block's density (unset there means full density). + let jmDiverges = false; + const anchorIndex = r.pages.findIndex((p) => p.objects.length > 0); + if (anchorIndex >= 0) { + const anchorJm = r.pages[anchorIndex]?.labelConfig.jmDensity; + if (anchorJm === undefined) delete labelConfig.jmDensity; + else labelConfig.jmDensity = anchorJm; + } + // Pages carry their density only as an override from the design (unset = A), + // so a block that prints at A under a B-folded design is pinned to 'A'. + const designJm: JmDensity = labelConfig.jmDensity ?? 'A'; + for (const page of pages) { + const pageJm: JmDensity = page.jmDensity ?? 'A'; + if (pageJm === designJm) delete page.jmDensity; + else page.jmDensity = pageJm; + } + const keptHalf = labelConfig.jmDensity === 'B'; + for (const [i, page] of r.pages.entries()) { + if (i <= anchorIndex || page.objects.length === 0) continue; + if ((page.labelConfig.jmDensity === 'B') !== keptHalf) { + findings.push({ kind: 'mixedPageGeometry', command: '^JM', pageIndex: i, cause: 'jm' }); + jmDiverges = true; + } } // Bucket views deduplicate by command code to match the JSDoc contract on @@ -228,10 +258,38 @@ export function importZplText(zpl: string, dpmm: number): ZplImportResult { pages, variables, report, - mixedPageGeometry: r.mixedPageGeometry, + mixedPageGeometry: r.mixedPageGeometry || jmDiverges, }; } +/** Current label patched with the imported stream, except ^JM, which the + * import always sets (absent means full density): inheriting the open + * document's mode would reinterpret every imported dot at the wrong scale. */ +export function replaceImportLabel( + current: LabelConfig, + imported: Partial, +): LabelConfig { + const merged = { ...current, ...imported }; + if (imported.jmDensity === undefined) delete merged.jmDensity; + return merged; +} + +/** Re-pin appended pages to their true ^JM density against the joined design. + * Append drops the import's own design density, so a page whose folded + * density differs from the target must carry it as an explicit override. */ +export function rebaseAppendedPageDensity( + pages: Page[], + importJmDensity: JmDensity | undefined, + designJmDensity: JmDensity | undefined, +): Page[] { + const designJm = designJmDensity ?? 'A'; + const importJm = importJmDensity ?? 'A'; + return pages.map((p) => { + const pageJm = p.jmDensity ?? importJm; + return pageJm === designJm ? { ...p, jmDensity: undefined } : { ...p, jmDensity: pageJm }; + }); +} + /** Additive setup-font merge (dedupe by normalized path): a stream lists only * its own uploads and expresses no deletion. */ export function mergeSetupFonts( diff --git a/packages/core/src/lib/zplOverlay/overlay.ts b/packages/core/src/lib/zplOverlay/overlay.ts index 4f5ffea8..550d7828 100644 --- a/packages/core/src/lib/zplOverlay/overlay.ts +++ b/packages/core/src/lib/zplOverlay/overlay.ts @@ -26,6 +26,33 @@ export interface OverlayFrame { top: number; } +/** The block's format head, block-relative: where a `^JM` belongs and which + * bytes already declare one. A ^CC/^CT/^CD remap makes both unrecoverable from + * the bytes alone, so export reads them from here. */ +export interface FormatHead { + /** Command prefix char in effect at the opener. */ + caret: string; + /** Offset just past the `^XA`; 0 when the block has no wrapper. */ + at: number; + /** Spans of the head's own `^JM` commands, in source order. Invalid values + * are included: export must place its declaration behind them, since the + * printer may still read the trailing one. */ + jmSpans: readonly JmSpan[]; +} + +/** `delim`/`caret` are the chars live at this `^JM`: a ^CD/^CC retarget + * mid-head makes them differ from the opener's, and match/rewrite must use + * the span's own chars or splice unreadable bytes. */ +export interface JmSpan { + start: number; + end: number; + delim: string; + caret: string; +} + +/** Byte length of the shortest `^JM` (prefix + name, value optional). */ +export const MIN_JM_SPAN = 3; + export interface BlockOverlay { /** Ordered segments covering the whole block; their texts joined reproduce * the original source byte-for-byte (incl. ^XA/^XZ and whitespace). */ @@ -39,6 +66,10 @@ export interface BlockOverlay { regenSafe: boolean; /** Present only when ^LH/^LT moved the origin; absent means no shift. */ frame?: OverlayFrame; + /** Absent on overlays predating the ^JM pass; export then regenerates the + * block rather than guessing where a declaration goes, but only once a + * declaration is actually due. */ + head?: FormatHead; } /** Overlay schema version; bump when segmentation changes so a stale persisted @@ -59,7 +90,7 @@ export interface LinkedSpan { export function buildBlockOverlay( source: string, spans: readonly LinkedSpan[], - opts: { regenSafe: boolean; frame?: OverlayFrame }, + opts: { regenSafe: boolean; frame?: OverlayFrame; head?: FormatHead }, ): BlockOverlay { const sorted = [...spans].sort((a, b) => a.start - b.start); const segments: OverlaySegment[] = []; @@ -80,6 +111,7 @@ export function buildBlockOverlay( if (cursor < source.length) segments.push({ kind: "raw", text: source.slice(cursor) }); const overlay: BlockOverlay = { segments, v: OVERLAY_VERSION, regenSafe: opts.regenSafe }; if (opts.frame) overlay.frame = opts.frame; + if (opts.head) overlay.head = opts.head; return overlay; } @@ -111,5 +143,25 @@ export const blockOverlaySchema = z frame: z .object({ homeX: z.number(), homeY: z.number(), top: z.number() }) .optional(), + head: z + .object({ + caret: z.string().length(1), + at: z.number().int().min(0), + jmSpans: z.array(z.object({ start: z.number().int().min(0), end: z.number().int().min(0), delim: z.string().length(1), caret: z.string().length(1) })), + }) + .superRefine((head, ctx) => { + // Export patches these offsets into the block, so a span that is + // reversed, too short for a `^JM`, or out of order would splice bytes + // the head never owned. In-bounds is checked at emit against the block. + let cursor = 0; + for (const s of head.jmSpans) { + if (s.start < cursor || s.end < s.start + MIN_JM_SPAN) { + ctx.addIssue({ code: "custom", message: "invalid ^JM span in head" }); + return; + } + cursor = s.end; + } + }) + .optional(), }) .refine(isOverlayConsistent); diff --git a/packages/core/src/lib/zplParser.ts b/packages/core/src/lib/zplParser.ts index eed11fbd..f32b7c43 100644 --- a/packages/core/src/lib/zplParser.ts +++ b/packages/core/src/lib/zplParser.ts @@ -7,7 +7,8 @@ import { markerOf } from "../types/Variable"; import { getObjectStringContent } from "./variableBinding"; import { parseLabelMetaComment, type LabelMeta } from "./zplLabelMeta"; import { tokenize } from "./zplParser/helpers"; -import { createParserState, resetFormatScopedState } from "./zplParser/context"; +import { lookaheadJmDensity, scanBareStream } from "./zplHeadScan"; +import { createParserState, deriveUnitScale, resetFormatScopedState } from "./zplParser/context"; import { createFlushField } from "./zplParser/flushField"; import { createBarcodeHandlers } from "./zplParser/handlers/barcodes"; import { createDynamicFontAWildcard, createFieldHandlers } from "./zplParser/handlers/fields"; @@ -16,7 +17,7 @@ import { createLabelConfigHandlers } from "./zplParser/handlers/labelConfig"; import { createSetupScriptHandlers } from "./zplParser/handlers/setupScript"; import { createUnitsHandler } from "./zplParser/handlers/units"; import { createUnsupportedHandlers } from "./zplParser/handlers/unsupported"; -import { buildBlockOverlay, type BlockOverlay, type LinkedSpan } from "./zplOverlay/overlay"; +import { buildBlockOverlay, type BlockOverlay, type FormatHead, type JmSpan, type LinkedSpan } from "./zplOverlay/overlay"; import type { Handler, ImportFinding, @@ -72,8 +73,9 @@ export const BY_CONSUMING_BARCODE_TYPES = new Set([ /** Commands defining persistent state a later field may consume implicitly; * in-span they make single-field regen unsafe (the span replace drops the - * definition). ^BY absent: consumers self-flag via sawBareBarcode. */ -const PERSISTENT_DEF_CODES = new Set(["CF", "FW", "CW", "SO", "LH", "LT"]); + * definition). ^BY absent: consumers self-flag via sawBareBarcode; ^JM is + * legal pre-^FS, so it can sit in a field span too. */ +const PERSISTENT_DEF_CODES = new Set(["CF", "FW", "CW", "SO", "LH", "LT", "JM"]); /** Parse a ZPL II byte stream into an editable design model. `captureOverlay` * builds a source-patch overlay (segments linking each object to its bytes; @@ -134,10 +136,14 @@ export function parseZPL( commitPendingReverseBg(); s.format.embedChar = "#"; s.format.clockChars = { ...DEFAULT_CLOCK_CHARS }; + s.format.inFormatHead = true; resetComment(p, rest, cmd); }, XZ(p, rest, cmd) { commitPendingReverseBg(); + // Between formats there is no head: a ^JM out here is neither read by the + // next format's lookahead nor rewritable, so it must not be taken for one. + s.format.inFormatHead = false; resetComment(p, rest, cmd); }, }; @@ -152,12 +158,13 @@ export function parseZPL( // ^DY font uploads are intentionally not flagged. const replayRiskCodes = new Set(Object.keys(setupScriptHandlers)); const deviceActionCodes = new Set([ - "JA", "JM", "JC", "JD", "JE", "JI", "JR", + "JA", "JC", "JD", "JE", "JI", "JR", ]); // ^PH/^PP are modelled per-format settings, but their tilde twins are // immediate device controls; the handler map keys have no prefix, so the - // split happens at dispatch via the token's source char. - const tildeDeviceCodes = new Set(["PH", "PP"]); + // split happens at dispatch via the token's source char. ~JM is not a real + // command (only caret ^JM sets density), so it routes here as a noop too. + const tildeDeviceCodes = new Set(["PH", "PP", "JM"]); Object.assign(handlers, setupScriptHandlers); Object.assign(handlers, createLabelConfigHandlers(s, dpmm)); Object.assign(handlers, createUnitsHandler(s, dpmm)); @@ -179,7 +186,6 @@ export function parseZPL( // replay relies on). Page 0 opens at offset 0 and owns any preamble. const pages: ParsedPage[] = []; let mixedPageGeometry = false; - let sawXA = false; let lastPageW: number | undefined; let lastPageH: number | undefined; @@ -208,12 +214,30 @@ export function parseZPL( // Format state that would re-interpret a regenerated object's bytes on // replay; drives the overlay's regenSafe flag (verbatim replay unaffected). regenHostileFormat: false, + // Format head for the export-time ^JM pass: injection point and the head's + // own ^JM spans, block-relative. Re-armed at this page's ^XA; a block + // without one keeps at 0, where a ^JM is equally valid. + head: { + caret: s.format.caretChar, + at: 0, + jmSpans: [] as JmSpan[], + } satisfies FormatHead, sawNonUtf8Ci: false, sawBareBarcode: false, sawFnDeclaration: false, }); let pg = freshPageScope(0); + // A wrapper-less stream has no ^XA for the lookahead to hang off, so its own + // leading ^JM is resolved here instead; a ^JM ahead of a real ^XA is not. + const bare = scanBareStream(zpl, s.format, s.format.delimiterChar); + s.format.inFormatHead = !bare.hasXa; + if (bare.density) { + s.format.jmDensity = bare.density; + labelConfig.jmDensity = bare.density; + s.format.unitScale = deriveUnitScale(s.format, dpmm); + } + const bucketFindings = ( pageIndex: number, partial: readonly string[], @@ -276,7 +300,7 @@ export function parseZPL( overlaySpans .slice(pg.span) .map((sp) => ({ ...sp, start: sp.start - pg.start, end: sp.end - pg.start })), - { regenSafe: pageRegenSafe, frame }, + { regenSafe: pageRegenSafe, frame, head: pg.head }, ); } catch (err) { console.warn("buildBlockOverlay failed, dropping overlay for this page", err); @@ -305,7 +329,7 @@ export function parseZPL( labelConfig: { ...labelConfig }, }; if (pageOverlay) page.overlay = pageOverlay; - if (!sawXA) page.bare = true; + if (!s.sawXa) page.bare = true; pages.push(page); // ^PW/^LL persist across ^XA, so only a value CHANGE between page closes is // a real divergence the single-label model cannot represent. @@ -349,6 +373,18 @@ export function parseZPL( ? { x: s.label.lhX, y: s.label.lhY, t: s.label.ltY } : null; handler(p, rest, cmd); + if (cmd === "FS") s.format.inFormatHead = false; + // Every ^JM in the head is recorded, invalid values included: export + // rewrites the valid ones and must place a new declaration behind the + // last of them either way. + if (cmd === "JM" && s.format.inFormatHead) { + // trimEnd: `rest` runs to the next command, so it drags the line break + // of multi-line ZPL into the span. + const from = start - pg.start; + // A ^CC/^CD inside the head retargets the prefix/value read, so each span + // carries the caret and delimiter live at its own ^JM, not the opener's. + pg.head.jmSpans.push({ start: from, end: from + 3 + rest.trimEnd().length, delim: s.format.delimiterChar, caret: s.format.caretChar }); + } if (opts.captureOverlay) { if ( s.format.caretChar !== "^" || @@ -376,7 +412,13 @@ export function parseZPL( } // A persistent definition inside a field span: regen replaces the // span and drops the definition a later verbatim field may consume. - if (PERSISTENT_DEF_CODES.has(cmd) && pg.ovStart !== null) { + // A post-^FS ^JM is a no-op (the lookahead already applied the density), + // so only a pre-FS in-span ^JM stays regen-hostile. + if ( + PERSISTENT_DEF_CODES.has(cmd) && + pg.ovStart !== null && + (cmd !== "JM" || s.format.inFormatHead) + ) { pg.regenHostileFormat = true; } // A home change after this page already linked fields: earlier fields @@ -465,8 +507,23 @@ export function parseZPL( // this token's capture block so a boundary-committed reverse-bg span // still lands in the closing page. if (cmd === "XA") { - if (sawXA) closePage(start); - sawXA = true; + if (s.sawXa) closePage(start); + s.sawXa = true; + // Drops any ^JM span from a pre-^XA preamble: those sit outside the + // head the lookahead reads, so export must not rewrite them either. + pg.head = { + caret: s.format.caretChar, + at: start - pg.start + 3, + jmSpans: [], + }; + // Resolve this format's ^JM density up front so ^MU-scaled reads see + // the final density; absent ^JM leaves the persistent density. + const jm = lookaheadJmDensity(zpl, start, s.format, s.format.delimiterChar); + if (jm) { + s.format.jmDensity = jm; + labelConfig.jmDensity = jm; + } + s.format.unitScale = deriveUnitScale(s.format, dpmm); } continue; } diff --git a/packages/core/src/lib/zplParser/context.ts b/packages/core/src/lib/zplParser/context.ts index e4cd0e6b..d5a4dbdb 100644 --- a/packages/core/src/lib/zplParser/context.ts +++ b/packages/core/src/lib/zplParser/context.ts @@ -11,6 +11,7 @@ import type { MaxicodeProps } from "../../registry/maxicode"; import { CODABLOCK_DEFAULT_COLUMNS, type CodablockProps } from "../../registry/codablock"; import type { ZplRotation } from "../../registry/rotation"; import { DEFAULT_CLOCK_CHARS, type ClockChars } from "../fcTemplate"; +import { effectiveDpmm } from "../../types/LabelConfig"; import { getDecoder } from "./helpers"; import type { UploadedGraphic } from "./types"; @@ -94,6 +95,27 @@ export interface FormatState { * Internal model is dots-canonical; I/M sources get scaled on read. * Survives ^XA per spec (^MU carries field-by-field until overridden). */ unitScale: number; + /** ^MU a-slot mode, kept so ^JM can re-derive unitScale (both persist). */ + muMode: 'D' | 'I' | 'M'; + /** ^JM density; persistent across formats (p269). Resolved at each ^XA by a + * format-head lookahead (last ^JM before the first ^FS wins), so ^MU-scaled + * reads already see the final density and never need a late-^JM replay. */ + jmDensity?: 'A' | 'B'; + /** True while the stream sits in a format head (^XA up to the first ^FS), the + * only place a ^JM declares a density (p269). A wrapper-less body counts as + * its own head; between ^XZ and the next ^XA nothing does. */ + inFormatHead: boolean; +} + +/** ^MU a-slot scale for object/body dot reads, at the EFFECTIVE (^JM-adjusted) + * density: I = eff·25.4, M = eff, D = 1. ^PW/^LL bypass this and read at the + * PHYSICAL density instead (ZD230-verified, ^JM-independent). */ +export function deriveUnitScale( + format: Pick, + dpmm: number, +): number { + const eff = effectiveDpmm({ dpmm, jmDensity: format.jmDensity }); + return format.muMode === "I" ? eff * 25.4 : format.muMode === "M" ? eff : 1; } /** Persistent defaults for following fields (^CF, ^FW, ^FB, ^BY). */ @@ -229,6 +251,10 @@ export interface ParserState { * ^FN is per-format scoped, so find-or-reuse must not reach into an earlier * page's variables; advanced at each page close. */ varScopeStart: number; + /** True once the stream's first ^XA opened a format. A ^JM before it has no + * head for the lookahead to latch onto, so the ^JM handler reports it partial + * instead of silently dropping it. */ + sawXa: boolean; } /** trimEnd: `token` carries `rest` up to the next command, which in multi-line @@ -317,6 +343,8 @@ export function createParserState(): ParserState { tildeChar: "~", delimiterChar: ",", unitScale: 1, + muMode: "D", + inFormatHead: false, }, defaults: { cfHeight: 0, @@ -343,6 +371,7 @@ export function createParserState(): ParserState { bareDeclaredFns: new Set(), serialStrippedFns: new Set(), varScopeStart: 0, + sawXa: false, field: freshFieldState(), }; } diff --git a/packages/core/src/lib/zplParser/handlers/fields.ts b/packages/core/src/lib/zplParser/handlers/fields.ts index 7c66513e..fab8cb82 100644 --- a/packages/core/src/lib/zplParser/handlers/fields.ts +++ b/packages/core/src/lib/zplParser/handlers/fields.ts @@ -10,7 +10,7 @@ import { resetFieldBlockDefaults, type ParserState, } from "../context"; -import { ciToEncoding, dotsFor, getDecoder, int, readJustify, readRotation } from "../helpers"; +import { acceptsPrefixRemap, ciToEncoding, dotsFor, getDecoder, int, readJustify, readRotation } from "../helpers"; import type { Handler, Wildcard } from "../types"; /** flushField + appendComment are shared with the orchestrator. */ @@ -104,20 +104,21 @@ export function createFieldHandlers( // ^CF{font},{height},{width} → sets default for fields without ^A CF(p) { const fontId = (p[0] ?? "").trim(); - const explicitHeight = dotsOrUndef(p[1]); - const explicitWidth = dotsOrUndef(p[2]); - if (explicitHeight !== undefined) s.defaults.cfHeight = explicitHeight; - if (explicitWidth !== undefined) s.defaults.cfWidth = explicitWidth; + // No fallback: a missing height/width leaves the current default. + const cfH = dotsOrUndef(p[1]); + if (cfH !== undefined) { + s.defaults.cfHeight = cfH; + if (cfH > 0) labelConfig.defaultFontHeight = cfH; + } + const cfW = dotsOrUndef(p[2]); + if (cfW !== undefined) { + s.defaults.cfWidth = cfW; + if (cfW >= 0) labelConfig.defaultFontWidth = cfW; + } if (fontId) { labelConfig.defaultFontId = fontId; s.defaults.cfFontId = fontId; } - if (explicitHeight !== undefined && explicitHeight > 0) { - labelConfig.defaultFontHeight = explicitHeight; - } - if (explicitWidth !== undefined && explicitWidth >= 0) { - labelConfig.defaultFontWidth = explicitWidth; - } }, // ── Field-wide defaults ─────────────────────────────────────────────── @@ -425,7 +426,7 @@ export function createFieldHandlers( // a role boundary (tilde, delimiter) and end up parsing nothing. CC(_, rest) { const c = rest[0]; - if (!c || c <= " " || c === "\x7F" || c === s.format.tildeChar || c === s.format.delimiterChar) { + if (!acceptsPrefixRemap(c, s.format.tildeChar, s.format.delimiterChar)) { s.result.partialCmds.add("^CC"); return; } @@ -434,7 +435,7 @@ export function createFieldHandlers( // ^CT / ~CT: change the tilde-form prefix (default ~). CT(_, rest) { const c = rest[0]; - if (!c || c <= " " || c === "\x7F" || c === s.format.caretChar || c === s.format.delimiterChar) { + if (!acceptsPrefixRemap(c, s.format.caretChar, s.format.delimiterChar)) { s.result.partialCmds.add("^CT"); return; } @@ -443,7 +444,7 @@ export function createFieldHandlers( // ^CD / ~CD: change the parameter delimiter (default ,). CD(_, rest) { const c = rest[0]; - if (!c || c <= " " || c === "\x7F" || c === s.format.caretChar || c === s.format.tildeChar) { + if (!acceptsPrefixRemap(c, s.format.caretChar, s.format.tildeChar)) { s.result.partialCmds.add("^CD"); return; } diff --git a/packages/core/src/lib/zplParser/handlers/labelConfig.ts b/packages/core/src/lib/zplParser/handlers/labelConfig.ts index 09e2397a..863efcc5 100644 --- a/packages/core/src/lib/zplParser/handlers/labelConfig.ts +++ b/packages/core/src/lib/zplParser/handlers/labelConfig.ts @@ -1,8 +1,9 @@ import { DARKNESS_INSTANT_RANGE, DARKNESS_PERMANENT_RANGE, MAX_LABEL_LENGTH_RANGE, SLEW_DOT_ROWS_RANGE, SPEED_RANGE, isBackfeedPercent, isBackfeedSequence, isMediaFeedMode, isMediaMode, isMediaTracking, isMediaType, isPrintOrientation } from "../../../types/LabelConfig"; import { parseIntOrUndef } from "../../inputParse"; import { isYesNo } from "../../../types/typeHelpers"; -import type { ParserState } from "../context"; -import { dotsFor, firstChar, inRange, int, strParam } from "../helpers"; +import { dotsToMm } from "../../coordinates"; +import { deriveUnitScale, type ParserState } from "../context"; +import { dotsFor, firstChar, inRange, int, intDotsOrUndef, strParam } from "../helpers"; import type { Handler } from "../types"; /** ^PQ extended params (pauseCount, replicates); Zebra spec caps at @@ -15,15 +16,19 @@ export function createLabelConfigHandlers( dpmm: number, ): Record { const labelConfig = s.result.labelConfig; - const { dots, dotsOrUndef } = dotsFor(s); + const { dotsOrUndef } = dotsFor(s); + // ^PW/^LL/^ML are physical head dots, ^JM-independent (ZD230-verified): omit + // jmDensity from the scale so the un-halved ^MU multiplier applies. + const physDots = (raw: string | undefined): number | undefined => + intDotsOrUndef(raw, deriveUnitScale({ muMode: s.format.muMode }, dpmm)); return { PW(_, rest) { - const w = dots(rest); - if (w > 0) labelConfig.widthMm = Math.round((w / dpmm) * 10) / 10; + const w = physDots(rest); + if (w !== undefined && w > 0) labelConfig.widthMm = dotsToMm(w, dpmm); }, LL(_, rest) { - const h = dots(rest); - if (h > 0) labelConfig.heightMm = Math.round((h / dpmm) * 10) / 10; + const h = physDots(rest); + if (h !== undefined && h > 0) labelConfig.heightMm = dotsToMm(h, dpmm); }, PQ(p) { const qty = int(p[0], 0); @@ -47,8 +52,8 @@ export function createLabelConfigHandlers( if (isMediaMode(mode)) labelConfig.mediaMode = mode; }, LS(_, rest) { - const shift = dots(rest); - if (shift !== 0) labelConfig.labelShift = shift; + const d = dotsOrUndef(rest); + if (d !== undefined && d !== 0) labelConfig.labelShift = d; }, PR(p) { const print = inRange(parseIntOrUndef(p[0]), SPEED_RANGE); @@ -70,8 +75,9 @@ export function createLabelConfigHandlers( const v = strParam(p[0]); if (isYesNo(v)) labelConfig.mapClear = v; }, + // Dot rows, so ^MU-scaled and ^JM-effective like ^LS/^LT (not physical). PF(p) { - const v = inRange(parseIntOrUndef(p[0]), SLEW_DOT_ROWS_RANGE); + const v = inRange(dotsOrUndef(p[0]), SLEW_DOT_ROWS_RANGE); if (v !== undefined) labelConfig.slewDotRows = v; }, // ^PH/^PP take no parameters; the tilde forms never reach these handlers @@ -91,7 +97,9 @@ export function createLabelConfigHandlers( if (isMediaTracking(v)) labelConfig.mediaTracking = v; }, ML(p) { - const v = inRange(dotsOrUndef(p[0]), MAX_LABEL_LENGTH_RANGE); + const d = physDots(p[0]); + if (d === undefined) return; + const v = inRange(d, MAX_LABEL_LENGTH_RANGE); if (v !== undefined) labelConfig.maxLabelLength = v; }, MF(p) { diff --git a/packages/core/src/lib/zplParser/handlers/units.ts b/packages/core/src/lib/zplParser/handlers/units.ts index 01097c4e..2b8b27f2 100644 --- a/packages/core/src/lib/zplParser/handlers/units.ts +++ b/packages/core/src/lib/zplParser/handlers/units.ts @@ -1,10 +1,10 @@ -import { isMuDpi } from "../../../types/LabelConfig"; -import type { ParserState } from "../context"; +import { isMuDpi, jmDensityOf } from "../../../types/LabelConfig"; +import { deriveUnitScale, type ParserState } from "../context"; import type { Handler } from "../types"; -/** ^MU units-of-measure handler. Owns one format-state slice - * (`unitScale`) and one labelConfig slice (`muResampling`); kept - * together because both express the same command's intent. */ +/** ^MU / ^JM handlers. ^MU owns the dot-scale of body values; the ^JM density + * is resolved by the ^XA format-head lookahead (spec p269), so this ^JM handler + * only validates and surfaces the values the lookahead ignores. */ export function createUnitsHandler(s: ParserState, dpmm: number): Record { const labelConfig = s.result.labelConfig; const markPartial = () => s.result.partialCmds.add("^MU"); @@ -15,10 +15,10 @@ export function createUnitsHandler(s: ParserState, dpmm: number): Record " " && char !== "\x7F" && char !== roleA && char !== roleB; +} + /** Live command-prefix chars; the tokenizer reads these on every char * scan so ^CC/^CT mutations take effect on the very next command. */ export interface TokenizerChars { @@ -152,10 +163,10 @@ export function intDots( return intDotsOrUndef(s, unitScale) ?? fallback; } -/** Bind `intDots`/`intDotsOrUndef` to a parser state's live unit - * scale, so handler factories don't each rebuild the same closures. - * Returns helpers that read `state.format.unitScale` at call time - * (load-bearing: ^MU mid-format mutates it). */ +/** Bind `intDots`/`intDotsOrUndef` to a parser state's live unit scale, so + * handler factories don't each rebuild the same closures. Reads + * `state.format.unitScale` at call time: load-bearing, since ^MU mid-format + * mutation and the ^XA ^JM lookahead both land before any read. */ export function dotsFor(state: { format: { unitScale: number } }): { dots: (raw: string | undefined, fb?: number) => number; dotsOrUndef: (raw: string | undefined) => number | undefined; diff --git a/packages/core/src/lib/zplParser/types.ts b/packages/core/src/lib/zplParser/types.ts index ee46401b..0f339f1d 100644 --- a/packages/core/src/lib/zplParser/types.ts +++ b/packages/core/src/lib/zplParser/types.ts @@ -29,6 +29,9 @@ export interface ImportFinding { /** Page index (^XA block) this finding originated from, stamped by the * single-pass parser. */ pageIndex: number; + /** Which divergence a 'mixedPageGeometry' finding reports, so consumers + * pick their message without sniffing `command`. */ + cause?: 'size' | 'jm'; } export interface ImportReport { @@ -77,7 +80,8 @@ export interface ParsedZPL { * ^MU/^CC/^CW carries across pages). At least one page, possibly empty. */ pages: ParsedPage[]; /** ^XA blocks set different explicit ^PW/^LL: a single-label design keeps - * only one size, so callers reject or warn. */ + * only one size, so callers reject or warn. Diverging ^JM densities are + * folded into the import service's flag of the same name, not this one. */ mixedPageGeometry: boolean; labelConfig: Partial; /** EEPROM-persistent printer-state extracted from any Setup-Script diff --git a/packages/core/src/registry/zplHelpers.ts b/packages/core/src/registry/zplHelpers.ts index b92f5120..3dadfc2e 100644 --- a/packages/core/src/registry/zplHelpers.ts +++ b/packages/core/src/registry/zplHelpers.ts @@ -1,4 +1,5 @@ import type { LabelObjectBase } from "../types/LabelObject"; +import { effectiveDpmm, type JmDensity } from "../types/LabelConfig"; import type { ZplEmitContext } from "../types/ZplEmit"; import { hasTemplateMarkers, markersToEmbeds } from "../lib/fnTemplate"; import { hasClockMarkers, markersToTokens } from "../lib/fcTemplate"; @@ -33,10 +34,13 @@ export function verifiedZJustifyCombo(obj: LabelObjectBase & { type: string }): * (importing BARCODE_1D_TYPES here would cycle the registry). */ export function printerAnchoredX( obj: LabelObjectBase & { type: string }, - label: { emit1dZJustify?: boolean; dpmm?: number }, + label: { emit1dZJustify?: boolean; dpmm?: number; jmDensity?: JmDensity }, ): number | null { if (!label.emit1dZJustify || !verifiedZJustifyCombo(obj)) return null; - const fp = measureFootprintDots(obj as LabelObject, label.dpmm); + const fp = measureFootprintDots( + obj as LabelObject, + label.dpmm === undefined ? undefined : effectiveDpmm({ dpmm: label.dpmm, jmDensity: label.jmDensity }), + ); return fp ? obj.x + fp.w : null; } diff --git a/packages/core/src/types/Group.ts b/packages/core/src/types/Group.ts index 877ebb13..2030f435 100644 --- a/packages/core/src/types/Group.ts +++ b/packages/core/src/types/Group.ts @@ -1,6 +1,7 @@ import type { LeafObject } from '../registry/leafObject'; import type { LabelObjectBase } from './LabelObject'; import type { BlockOverlay } from '../lib/zplOverlay/overlay'; +import { withJmDensity, type JmDensity, type LabelConfig } from './LabelConfig'; export type { LeafObject }; /** Non-leaf container; cascades lock/visibility/inclusion. Intentionally * outside the registry (no toZPL/defaultSize/PropertiesPanel). */ @@ -15,6 +16,10 @@ export type LabelObject = LeafObject | GroupObject; export interface Page { objects: LabelObject[]; + /** ^JM override for this page, set only where the imported block's density + * diverges from the design's; export falls back to `label.jmDensity`, so a + * density change in the UI still reaches every non-diverging page. */ + jmDensity?: JmDensity; /** Source-patch overlay of the ^XA…^XZ block this page was imported from, * letting export replay untouched bytes verbatim. Absent on fresh designs * and on pages the parser couldn't fully link; those regenerate from the @@ -22,6 +27,13 @@ export interface Page { overlay?: BlockOverlay; } +/** The label as this page prints it: its own ^JM wins, so a block imported at + * a diverging density keeps that density through export (coordinates included, + * since every dots<->mm boundary reads it through effectiveDpmm). */ +export function pageLabelConfig(label: LabelConfig, page: Pick): LabelConfig { + return withJmDensity(label, page.jmDensity); +} + export function isGroup(obj: LabelObject): obj is GroupObject { return obj.type === 'group'; } diff --git a/packages/core/src/types/LabelConfig.test.ts b/packages/core/src/types/LabelConfig.test.ts new file mode 100644 index 00000000..dedff6c9 --- /dev/null +++ b/packages/core/src/types/LabelConfig.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { + LABEL_CONFIG_FIELDS, + NON_EMITTING_CONFIG_FIELDS, + PER_FORMAT_ZPL_FIELDS, + PER_LABEL_ZPL_FIELDS, + labelConfigSchema, + scaledLabelConfigFields, +} from './LabelConfig'; + +const sorted = (xs: readonly string[]) => [...xs].sort(); + +// A single typo in LABEL_CONFIG_FIELDS would silently drift all four derived lists apart. +describe('LABEL_CONFIG_FIELDS derivations', () => { + it('covers exactly the schema keys', () => { + expect(sorted(Object.keys(LABEL_CONFIG_FIELDS))).toEqual( + sorted(Object.keys(labelConfigSchema.shape)), + ); + }); + + it('derives PER_LABEL_ZPL_FIELDS', () => { + expect(sorted(PER_LABEL_ZPL_FIELDS)).toEqual( + sorted([ + 'mediaMode', 'mediaType', 'mediaTracking', 'maxLabelLength', + 'mediaFeedPowerUp', 'mediaFeedHeadClose', 'suppressBackfeed', 'backfeedSequence', + 'printOrientation', 'mirror', 'printSpeed', 'slewSpeed', 'backfeedSpeed', + 'darkness', 'instantDarkness', + 'labelHomeX', 'labelHomeY', 'labelTop', 'labelShift', + 'printQuantity', 'pauseCount', 'replicates', 'overridePauseCount', + 'mapClear', 'slewDotRows', 'slewToHome', 'programmablePause', + ]), + ); + }); + + it('derives PER_FORMAT_ZPL_FIELDS', () => { + expect(sorted(PER_FORMAT_ZPL_FIELDS)).toEqual( + sorted([ + 'printQuantity', 'pauseCount', 'replicates', 'overridePauseCount', + 'slewDotRows', 'slewToHome', 'programmablePause', 'mapClear', 'jmDensity', + ]), + ); + }); + + it('derives NON_EMITTING_CONFIG_FIELDS', () => { + expect(sorted(NON_EMITTING_CONFIG_FIELDS)).toEqual(['safeAreaMm']); + }); + + it('derives the scaled field sets', () => { + expect(sorted(scaledLabelConfigFields('always'))).toEqual( + sorted(['labelHomeX', 'labelHomeY', 'defaultFontHeight', 'defaultFontWidth']), + ); + expect(sorted(scaledLabelConfigFields('jmOnly'))).toEqual( + sorted(['labelShift', 'labelTop', 'slewDotRows']), + ); + }); +}); diff --git a/packages/core/src/types/LabelConfig.ts b/packages/core/src/types/LabelConfig.ts index 93559516..f8265aae 100644 --- a/packages/core/src/types/LabelConfig.ts +++ b/packages/core/src/types/LabelConfig.ts @@ -54,6 +54,36 @@ export const PRINT_ORIENTATION_VALUES = ['N', 'I'] as const; export type PrintOrientation = (typeof PRINT_ORIENTATION_VALUES)[number]; export const isPrintOrientation = makeEnumGuard(PRINT_ORIENTATION_VALUES); +/** ^JM: B halves the print density, so every stream dot value lives in the + * halved scale; A restores full density. Model dots stay as-stream, the + * effectiveDpmm seam applies the interpretation. */ +export const JM_DENSITY_VALUES = ['A', 'B'] as const; +export type JmDensity = (typeof JM_DENSITY_VALUES)[number]; +export const isJmDensity = makeEnumGuard(JM_DENSITY_VALUES); + +/** Density a `^JM` parameter tail declares, or undefined if invalid. Shared by + * parser and export so both agree where the value ends; `raw` runs to the next + * command, carrying further slots and the line break of multi-line ZPL. */ +export function jmDensityOf(raw: string, delimiter: string): JmDensity | undefined { + const v = (raw.split(delimiter)[0] ?? '').trim().toUpperCase(); + // A bare ^JM is full density (spec p269). + if (v === '') return 'A'; + return isJmDensity(v) ? v : undefined; +} + +/** The density dot values are interpreted in: head density, halved under ^JMB. + * Every mm<->dots and dots<->px boundary must use this instead of raw dpmm; + * exceptions are physical head selectors (Labelary URL, rescale) and the printer-preview raster. */ +export function effectiveDpmm(label: { dpmm: number; jmDensity?: JmDensity }): number { + return label.jmDensity === 'B' ? label.dpmm / 2 : label.dpmm; +} + +/** The label under a per-page density override. Returns `label` itself when the + * override is absent or already the label's, so callers can memo on identity. */ +export function withJmDensity(label: LabelConfig, jmDensity: JmDensity | undefined): LabelConfig { + return jmDensity === undefined || jmDensity === label.jmDensity ? label : { ...label, jmDensity }; +} + /** Print densities Zebra ships (152/203/300/600 dpi). The label settings UI * offers exactly these; the ZPL geometry sidecar validates against the set. */ export const DPMM_VALUES = [6, 8, 12, 24] as const; @@ -68,6 +98,8 @@ export const DARKNESS_INSTANT_RANGE = { min: 0, max: 30 } as const; /** ^ML: maximum label length, in dots. Zebra spec accepts 1..32000. */ export const MAX_LABEL_LENGTH_RANGE = { min: 1, max: 32000 } as const; export const SLEW_DOT_ROWS_RANGE = { min: 0, max: 32000 } as const; +/** ^LT y range (Zebra -120..+120); shared with the density rescale clamp. */ +export const LABEL_TOP_RANGE = { min: -120, max: 120 } as const; /** ^MU b,c dpi tokens; 200 = 203 dpi; ratio drives resampling. */ export const MU_DPI_VALUES = [150, 200, 300, 600] as const; @@ -165,7 +197,7 @@ export const labelConfigSchema = z.object({ /** ^LH y; see labelHomeX. */ labelHomeY: z.number().int().min(0).optional(), /** ^LT y; Zebra -120..+120. */ - labelTop: z.number().int().min(-120).max(120).optional(), + labelTop: intInRange(LABEL_TOP_RANGE).optional(), printSpeed: intInRange(SPEED_RANGE).optional(), /** ^PR p2: slew (inter-label) speed. */ slewSpeed: intInRange(SPEED_RANGE).optional(), @@ -200,6 +232,8 @@ export const labelConfigSchema = z.object({ .optional(), /** ^MU b,c; set only when both slots arrived valid. */ muResampling: muResamplingSchema.optional(), + /** ^JM: dot-density interpretation of this design's dot values. */ + jmDensity: z.enum(JM_DENSITY_VALUES).optional(), /** ^SO2: secondary clock offset (`«clock2:T»` markers resolve through this). */ secondaryClockOffset: z.preprocess(coerceEmptyOffset, clockOffsetSchema.optional()), /** ^SO3: tertiary clock offset (`«clock3:T»` markers resolve through this). */ @@ -216,25 +250,120 @@ export const labelConfigSchema = z.object({ export type LabelConfig = z.infer; +/** Fields holding a plain number, the only ones a density rescale may scale. */ +export type NumericLabelConfigField = { + [K in keyof LabelConfig]-?: NonNullable extends number ? K : never; +}[keyof LabelConfig]; + +export interface LabelConfigFieldSpec { + /** perLabel = print override `resetPerLabelConfig` clears back to unset; + * design = geometry or document state a reset must keep. */ + scope: 'design' | 'perLabel'; + /** A change reaches the emitted ZPL. false = design-time editor aid, so a + * patch touching only such fields keeps a page overlay's bytes valid. */ + emits: boolean; + /** `always` = layout dots, scaled on a dpmm swap and on ^JM; `jmOnly` = + * printer-persistent dots that keep their value across a head swap but are + * reinterpreted by ^JM; `never` = physical head dots and non-dot values. */ + scales: 'never' | 'always' | 'jmOnly'; + /** The import fold keeps this inside its own ^XA block instead of letting it + * fall through to later blocks. Orthogonal to `scope`. */ + perFormat?: true; + /** Spec bounds the scaled value is clamped into. */ + clamp?: { readonly min: number; readonly max: number }; + /** Post-scale floor. */ + floor?: number; +} + +/** Non-numeric fields cannot scale, so the table may not claim they do. */ +type SpecFor = K extends NumericLabelConfigField + ? LabelConfigFieldSpec + : LabelConfigFieldSpec & { scales: 'never' }; + +/** One row per LabelConfig field (scope, emits, scale); every consumer derives + * from here. The satisfies `-?` is load-bearing: LabelConfig's keys are + * optional, so without it the mapped type would accept a table omitting a field. */ +export const LABEL_CONFIG_FIELDS = { + widthMm: { scope: 'design', emits: true, scales: 'never' }, + heightMm: { scope: 'design', emits: true, scales: 'never' }, + // Design geometry: clearing or rescaling it would reinterpret every stored + // dot value, so it is neither a per-label override nor itself scaled. + dpmm: { scope: 'design', emits: true, scales: 'never' }, + safeAreaMm: { scope: 'design', emits: false, scales: 'never' }, + emit1dZJustify: { scope: 'design', emits: true, scales: 'never' }, + printQuantity: { scope: 'perLabel', emits: true, scales: 'never', perFormat: true }, + pauseCount: { scope: 'perLabel', emits: true, scales: 'never', perFormat: true }, + replicates: { scope: 'perLabel', emits: true, scales: 'never', perFormat: true }, + overridePauseCount: { scope: 'perLabel', emits: true, scales: 'never', perFormat: true }, + mediaMode: { scope: 'perLabel', emits: true, scales: 'never' }, + labelShift: { scope: 'perLabel', emits: true, scales: 'jmOnly' }, + labelHomeX: { scope: 'perLabel', emits: true, scales: 'always', floor: 0 }, + labelHomeY: { scope: 'perLabel', emits: true, scales: 'always', floor: 0 }, + labelTop: { scope: 'perLabel', emits: true, scales: 'jmOnly', clamp: LABEL_TOP_RANGE }, + printSpeed: { scope: 'perLabel', emits: true, scales: 'never' }, + slewSpeed: { scope: 'perLabel', emits: true, scales: 'never' }, + backfeedSpeed: { scope: 'perLabel', emits: true, scales: 'never' }, + darkness: { scope: 'perLabel', emits: true, scales: 'never' }, + instantDarkness: { scope: 'perLabel', emits: true, scales: 'never' }, + mediaType: { scope: 'perLabel', emits: true, scales: 'never' }, + printOrientation: { scope: 'perLabel', emits: true, scales: 'never' }, + mirror: { scope: 'perLabel', emits: true, scales: 'never' }, + defaultFontId: { scope: 'design', emits: true, scales: 'never' }, + defaultFontHeight: { scope: 'design', emits: true, scales: 'always', floor: 1 }, + defaultFontWidth: { scope: 'design', emits: true, scales: 'always', floor: 0 }, + customFonts: { scope: 'design', emits: true, scales: 'never' }, + mediaTracking: { scope: 'perLabel', emits: true, scales: 'never' }, + // ZD230-verified physical head dots, so a density change leaves them alone. + maxLabelLength: { scope: 'perLabel', emits: true, scales: 'never' }, + mediaFeedPowerUp: { scope: 'perLabel', emits: true, scales: 'never' }, + mediaFeedHeadClose: { scope: 'perLabel', emits: true, scales: 'never' }, + suppressBackfeed: { scope: 'perLabel', emits: true, scales: 'never' }, + backfeedSequence: { scope: 'perLabel', emits: true, scales: 'never' }, + muResampling: { scope: 'design', emits: true, scales: 'never' }, + // Persists on the wire, but folding it back would retroactively rescale + // earlier pages, so each page's own labelConfig carries it. + jmDensity: { scope: 'design', emits: true, scales: 'never', perFormat: true }, + secondaryClockOffset: { scope: 'design', emits: true, scales: 'never' }, + tertiaryClockOffset: { scope: 'design', emits: true, scales: 'never' }, + // Persists on the wire; folding it back would bleed the bitmap into earlier + // pages under ^MCN. + mapClear: { scope: 'perLabel', emits: true, scales: 'never', perFormat: true }, + slewDotRows: { scope: 'perLabel', emits: true, scales: 'jmOnly', perFormat: true, clamp: SLEW_DOT_ROWS_RANGE }, + slewToHome: { scope: 'perLabel', emits: true, scales: 'never', perFormat: true }, + programmablePause: { scope: 'perLabel', emits: true, scales: 'never', perFormat: true }, +} as const satisfies { [K in keyof LabelConfig]-?: SpecFor }; + +const FIELD_ENTRIES = Object.entries(LABEL_CONFIG_FIELDS) as [ + keyof LabelConfig, + LabelConfigFieldSpec, +][]; + +const fieldsWhere = ( + pred: (spec: LabelConfigFieldSpec) => boolean, +): readonly (keyof LabelConfig)[] => + FIELD_ENTRIES.filter(([, spec]) => pred(spec)).map(([key]) => key); + +/** Widened row lookup; the literal-typed table itself cannot be indexed by a + * key union because the optional axes differ per row. */ +export const labelConfigSpec = (key: keyof LabelConfig): LabelConfigFieldSpec => + LABEL_CONFIG_FIELDS[key]; + +/** Numeric fields a density rescale scales in the given mode. */ +export const scaledLabelConfigFields = ( + mode: 'always' | 'jmOnly', +): readonly NumericLabelConfigField[] => + FIELD_ENTRIES.filter(([, spec]) => spec.scales === mode).map( + ([key]) => key as NumericLabelConfigField, + ); + /** Per-label ZPL overrides the printer-modal perLabel tabs edit; the store's * `resetPerLabelConfig` clears exactly these back to unset (= printer default, - * not emitted). Design-time fields (size, dpmm, fonts) are not print overrides. - * Lives here so the `keyof LabelConfig` check sits next to the schema, and a - * leaf-module home keeps the slice and selectors that read it cycle-free. */ -export const PER_LABEL_ZPL_FIELDS = [ - 'mediaMode', 'mediaType', 'mediaTracking', 'maxLabelLength', - 'mediaFeedPowerUp', 'mediaFeedHeadClose', 'suppressBackfeed', 'backfeedSequence', - 'printOrientation', 'mirror', 'printSpeed', 'slewSpeed', 'backfeedSpeed', - 'darkness', 'instantDarkness', - 'labelHomeX', 'labelHomeY', 'labelTop', 'labelShift', - 'printQuantity', 'pauseCount', 'replicates', 'overridePauseCount', - 'mapClear', 'slewDotRows', 'slewToHome', 'programmablePause', -] as const satisfies readonly (keyof LabelConfig)[]; + * not emitted). */ +export const PER_LABEL_ZPL_FIELDS = fieldsWhere((s) => s.scope === 'perLabel'); /** Fields the import fold scopes to their own ^XA block; the rest is - * persistent state a later block may extend. ^MC persists on the wire - * (p300), but folding a later ^MCN back would leak its bitmap into page two. */ -export const PER_FORMAT_ZPL_FIELDS = [ - 'printQuantity', 'pauseCount', 'replicates', 'overridePauseCount', - 'slewDotRows', 'slewToHome', 'programmablePause', 'mapClear', -] as const satisfies readonly (keyof LabelConfig)[]; + * persistent state a later block may extend. */ +export const PER_FORMAT_ZPL_FIELDS = fieldsWhere((s) => s.perFormat === true); + +/** Config keys that never reach emitted ZPL. */ +export const NON_EMITTING_CONFIG_FIELDS = fieldsWhere((s) => !s.emits); diff --git a/packages/mcp-server/src/tools.test.ts b/packages/mcp-server/src/tools.test.ts index f11c155b..00154874 100644 --- a/packages/mcp-server/src/tools.test.ts +++ b/packages/mcp-server/src/tools.test.ts @@ -314,6 +314,22 @@ describe("mcp-server tools", () => { expect(r.errors[0]).toMatch(/object limit/); }); + it("resolves per-page ^JM density in preflight and geometry", () => { + // 100mm at dpmm 12: printable width 1200 dots at density A, 600 at B. + // x=700 is inside the design-density rect but outside the page's B rect. + const df = { + schemaVersion: 3, + label: { widthMm: 100, heightMm: 50, dpmm: 12 }, + pages: [ + { objects: [textObject("t1", "A")] }, + { jmDensity: "B", objects: [{ ...textObject("t2", "B"), x: 700, y: 50 }] }, + ], + }; + const r = ok(validateDraft(df)); + const page1Kinds = r.warnings.filter((w) => w.pageIndex === 1).map((w) => w.kind); + expect(page1Kinds).toContain("offLabelOutside"); + }); + it("rejects a design file past the page limit", () => { const pages = Array.from({ length: 1001 }, () => ({ objects: [] as never[] })); const df = { schemaVersion: 3, label: { widthMm: 100, heightMm: 50, dpmm: 8 }, pages }; @@ -358,6 +374,15 @@ describe("mcp-server tools", () => { expect(importZpl(mixed).ok).toBe(false); }); + it("rejects a multi-^XA stream whose blocks diverge in ^JM at equal size", () => { + const mixed = "^XA^PW800^LL400^FO10,10^FDA^FS^XZ^XA^JMB^PW800^LL400^FO10,10^FDB^FS^XZ"; + const v = validateZpl(mixed); + expect(v.ok).toBe(false); + if (v.ok) return; + expect(v.errors[0]).toMatch(/\^JM density modes/); + expect(importZpl(mixed).ok).toBe(false); + }); + it("accepts multi-^XA blocks that share one explicit size", () => { const same = "^XA^PW800^LL400^FO10,10^FDA^FS^XZ^XA^FO10,10^FDB^FS^XZ"; const v = validateZpl(same); diff --git a/packages/mcp-server/src/tools.ts b/packages/mcp-server/src/tools.ts index 7ddec62a..93e2580e 100644 --- a/packages/mcp-server/src/tools.ts +++ b/packages/mcp-server/src/tools.ts @@ -15,8 +15,8 @@ import type { BoundingBoxDots, ObjectBoundsCtx } from "@zplab/core/lib/objectBou import type { DesignResponse } from "./appBridge.js"; import { computeOverlaps, leafBoxesDots, MAX_OVERLAPS, type OverlapDots } from "@zplab/core/lib/objectOverlap"; import { getEntry, ObjectRegistry } from "@zplab/core/registry"; -import { exportableLeaves, type LabelObject, type Page } from "@zplab/core/types/Group"; -import { DPMM_VALUES, isDpmm, type Dpmm, type LabelConfig } from "@zplab/core/types/LabelConfig"; +import { exportableLeaves, pageLabelConfig, type LabelObject, type Page } from "@zplab/core/types/Group"; +import { DPMM_VALUES, isDpmm, type Dpmm, type JmDensity, type LabelConfig } from "@zplab/core/types/LabelConfig"; import type { PreflightKind, PreflightSeverity } from "@zplab/core/lib/preflight"; import type { Variable } from "@zplab/core/types/Variable"; @@ -185,15 +185,17 @@ export interface PreflightWarning { interface PageLike { objects: LabelObject[]; + jmDensity?: JmDensity; } -/** Run a per-page report over every page of a design. */ +/** Run a per-page report over every page of a design. Pages with a ^JM + * override get their density folded into the label they are judged against. */ function perPage( pages: PageLike[], label: LabelConfig, fn: (objects: LabelObject[], label: LabelConfig, pageIndex: number) => T[], ): T[] { - return pages.flatMap((page, i) => fn(page.objects, label, i)); + return pages.flatMap((page, i) => fn(page.objects, pageLabelConfig(label, page), i)); } function preflightOf( @@ -266,7 +268,7 @@ function geometryFor( truncated = true; return; } - const boxes = leafBoxesDots(leaves, { label, measured }); + const boxes = leafBoxesDots(leaves, { label: pageLabelConfig(label, page), measured }); for (const b of boxes) { bounds.push({ pageIndex, objectId: b.id, ...roundRect(b.box), approx: b.approx }); } @@ -413,14 +415,23 @@ function oversizeError(zpl: string): ToolError | null { } /** Reject a parsed stream the single-label draft model can't represent: - * divergent per-block ^PW/^LL, or too many objects/pages. */ + * divergent per-block ^PW/^LL or ^JM, or too many objects/pages. */ function importRejection(imported: ZplImportResult): ToolError | null { if (imported.mixedPageGeometry) { + const geo = imported.report.findings.filter((f) => f.kind === "mixedPageGeometry"); + const hasJm = geo.some((f) => f.cause === "jm"); + const hasSize = geo.some((f) => f.cause === "size"); + const cause = + hasJm && !hasSize + ? "set different ^JM density modes" + : hasSize && !hasJm + ? "set different ^PW/^LL sizes" + : "set different ^PW/^LL sizes or ^JM density modes"; return { ok: false, errors: [ - "^XA blocks set different ^PW/^LL sizes, which a single-label draft cannot " + - "represent; split the stream into one label per size.", + `^XA blocks ${cause}, which a single-label draft cannot represent; ` + + "split the stream into one label per block.", ], }; } diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index 7bafc740..216c53f3 100644 --- a/src/components/Canvas/LabelCanvas.tsx +++ b/src/components/Canvas/LabelCanvas.tsx @@ -14,9 +14,10 @@ import { CANVAS_DROPPABLE_ID } from "../../dnd/types"; import { paletteGhostHandlers } from "./paletteGhostMonitor"; import { Stage, Layer, Group, Image as KImage, Rect, Transformer } from "react-konva"; import type Konva from "konva"; -import { useLabelStore, useCurrentObjects, currentObjects, getCurrentObjects, selectPreviewLocksEditor } from "../../store/labelStore"; +import { useLabelStore, useCurrentObjects, currentObjects, currentPageLabel, getCurrentObjects, selectPreviewLocksEditor } from "../../store/labelStore"; import { isGroup, getAllLeaves, exportableLeaves, expandSelection, selectionTargetId, findObjectById, canDeleteSelection, canGroupSelection, canUngroupSelection, hasLockedAncestor, isSelectionLocked, type LabelObject } from "@zplab/core/types/Group"; import { pxToDots, dotsToPx, mmToDots, SCREEN_PX_PER_MM } from "@zplab/core/lib/coordinates"; +import { effectiveDpmm } from "@zplab/core/types/LabelConfig"; import { loadImage } from "@zplab/core/lib/loadImage"; import { SNAP_OPTIONS } from "@zplab/core/lib/units"; import type { Unit } from "@zplab/core/lib/units"; @@ -226,7 +227,6 @@ export const LabelCanvas = forwardRef(function LabelCa const t = useT(); const { - label, selectedIds, pristineEmptyIds, addObject, @@ -248,6 +248,11 @@ export const LabelCanvas = forwardRef(function LabelCa variables, pages, } = useLabelStore(); + // Everything on canvas is drawn in the current page's dot scale; only the + // whole-document emit below still needs the design label. + const label = useLabelStore(currentPageLabel); + const designLabel = useLabelStore((s) => s.label); + const effDpmm = effectiveDpmm(label); const objects = useCurrentObjects(); const previewBinding = usePreviewBinding(); // Raw dataset/mapping (not just the active row): markerValueFindings @@ -389,9 +394,9 @@ export const LabelCanvas = forwardRef(function LabelCa // shift = 10 mm, normal = snapSize when snap on, 1 dot when snap off const step = e.shiftKey - ? label.dpmm * 10 + ? effDpmm * 10 : snapEnabled - ? Math.round(snapSizeMm * label.dpmm) + ? Math.round(snapSizeMm * effDpmm) : 1; const screenDx = e.code === "ArrowRight" ? step : e.code === "ArrowLeft" ? -step : 0; const screenDy = e.code === "ArrowDown" ? step : e.code === "ArrowUp" ? -step : 0; @@ -410,7 +415,7 @@ export const LabelCanvas = forwardRef(function LabelCa }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [snapEnabled, snapSizeMm, label.dpmm, updateObjects, viewRotation]); + }, [snapEnabled, snapSizeMm, effDpmm, updateObjects, viewRotation]); // usable area after reserving space for the ruler const usableWidth = containerSize.width - RULER_SIZE; @@ -419,7 +424,7 @@ export const LabelCanvas = forwardRef(function LabelCa // ^LS shifts content LEFT by labelShift (spec/Labelary): model x=0 sits at the // viewport left, the physical label rect sits labelShift dots to its right. The // viewport is widened by labelShift so off-label-left content stays visible. - const labelShiftMm = (label.labelShift ?? 0) / label.dpmm; + const labelShiftMm = (label.labelShift ?? 0) / effDpmm; const effectiveWidthMm = label.widthMm + labelShiftMm; // zoom=1 = 100% (96 dpi CSS); fitZoom swaps axes at 90/270. @@ -463,16 +468,17 @@ export const LabelCanvas = forwardRef(function LabelCa const probe = (o: LabelObject) => { if (isGroup(o)) return null; const resolved = applyBindingToObject(o, vars, active, dataRenderMode, clock, objectResolvesCtrl(o)); - return measureBarcodeFootprintDots(resolved as LeafObject, scale, label.dpmm); + return measureBarcodeFootprintDots(resolved as LeafObject, scale, effDpmm); }; registerBarcodeWidthProber(probe); return () => unregisterBarcodeWidthProber(probe); - }, [scale, label.dpmm, previewBinding, dataRenderMode]); + }, [scale, effDpmm, previewBinding, dataRenderMode]); const labelWidthPx = effectiveWidthMm * scale; const physicalWidthPx = label.widthMm * scale; const labelHeightPx = label.heightMm * scale; - // Printer render reconciled against the label in dot space; Labelary fills - // the rect as-is. + // Printer bitmap is physical head-raster dots: the preview stream carries + // ^JMB, but ^PW/^LL stay physical (ZD230-verified), so this projection uses + // physical dpmm, not effective. const toPreviewPx = (dots: number) => dotsToPx(dots, scale, label.dpmm); const printerLayout = previewMode.status === 'active' && previewMode.printerDims @@ -520,13 +526,13 @@ export const LabelCanvas = forwardRef(function LabelCa // Safe-area guide rect in screen px (dots scaled, object-offset aligned). const safeAreaDots = safeAreaRectDots(label); const safeAreaPx = safeAreaDots && { - x: objectsOffsetX + (safeAreaDots.x / label.dpmm) * scale, - y: labelOffsetY + (safeAreaDots.y / label.dpmm) * scale, - width: (safeAreaDots.width / label.dpmm) * scale, - height: (safeAreaDots.height / label.dpmm) * scale, + x: objectsOffsetX + (safeAreaDots.x / effDpmm) * scale, + y: labelOffsetY + (safeAreaDots.y / effDpmm) * scale, + width: (safeAreaDots.width / effDpmm) * scale, + height: (safeAreaDots.height / effDpmm) * scale, }; - const snapUnit = Math.round(snapSizeMm * label.dpmm); + const snapUnit = Math.round(snapSizeMm * effDpmm); const snap = (dots: number) => snapEnabled ? Math.round(dots / snapUnit) * snapUnit : dots; @@ -598,10 +604,10 @@ export const LabelCanvas = forwardRef(function LabelCa const staticSelIds = visibleSelIds.filter((id) => !movableSelIds.includes(id)); const toFramePx = (b: { x: number; y: number; width: number; height: number } | null) => b && { - x: objectsOffsetX + dotsToPx(b.x, scale, label.dpmm), - y: labelOffsetY + dotsToPx(b.y, scale, label.dpmm), - width: dotsToPx(b.width, scale, label.dpmm), - height: dotsToPx(b.height, scale, label.dpmm), + x: objectsOffsetX + dotsToPx(b.x, scale, effDpmm), + y: labelOffsetY + dotsToPx(b.y, scale, effDpmm), + width: dotsToPx(b.width, scale, effDpmm), + height: dotsToPx(b.height, scale, effDpmm), }; // Frame Rect is multi-only; the movable/static bases also drive the action bar // (single drags too), so compute them for any selection. @@ -627,7 +633,7 @@ export const LabelCanvas = forwardRef(function LabelCa : suppressPristineEmpty( [ ...computePreflight(preflightLeaves, frameCtx, unit), - ...barcodeEncodeFindings(preflightLeaves, scale, label.dpmm, previewBinding), + ...barcodeEncodeFindings(preflightLeaves, scale, effDpmm, previewBinding), ...markerValueFindings(preflightLeaves, { variables: previewBinding.variables, dataset, @@ -728,7 +734,7 @@ export const LabelCanvas = forwardRef(function LabelCa stageRef, transformerRef, scale, - dpmm: label.dpmm, + dpmm: effDpmm, objectsOffsetX, labelOffsetY, snapEnabled, @@ -800,7 +806,7 @@ export const LabelCanvas = forwardRef(function LabelCa if (ids.length === 0) return null; const objs = currentObjects(state); const measured = measuredBoundsMap(); - const ctx = { label: state.label, measured }; + const ctx = { label: currentPageLabel(state), measured }; // Locked/hidden objects are non-participants (like drag/nudge/lasso). const movable = ids @@ -808,7 +814,7 @@ export const LabelCanvas = forwardRef(function LabelCa .filter((o): o is LabelObject => o !== undefined && !o.locked && o.visible !== false); if (movable.length === 0) return null; - const printable = printableRectDots(state.label); + const printable = printableRectDots(currentPageLabel(state)); // Exclude structural primitives (full-label frame, spanning dividers) so // they neither inflate the reference nor get rearranged; content only, // consistent with tidy. Falls back to all when fewer than 2 content. @@ -826,7 +832,7 @@ export const LabelCanvas = forwardRef(function LabelCa // Align-to-label pins to the safe-area inset when configured, so the // 6-edge buttons keep a uniform margin; otherwise the printable rect. - const labelBox = safeAreaRectDots(state.label) ?? printable; + const labelBox = safeAreaRectDots(currentPageLabel(state)) ?? printable; let refBox: ReturnType; // A single unit (one object or one group) has no meaningful "selection" // or "key" frame of its own, so it aligns to the label (Figma: a single @@ -896,7 +902,7 @@ export const LabelCanvas = forwardRef(function LabelCa const measured = measuredBoundsMap(); if (isBarcode(obj) && !measured.has(id)) return; const patch = convertPositionType(obj, target, { - label: state.label, + label: currentPageLabel(state), measured, }); if (patch) updateObject(id, patch); @@ -924,7 +930,7 @@ export const LabelCanvas = forwardRef(function LabelCa // transformer detaches (raw leaves would show dead resize handles). objects: visibleLeaves, scale, - dpmm: label.dpmm, + dpmm: effDpmm, objectsOffsetX, labelOffsetY, snap, @@ -1162,8 +1168,8 @@ export const LabelCanvas = forwardRef(function LabelCa const px = labelCenterX + rx; const py = labelCenterY + ry; return { - x: snap(pxToDots(px - objectsOffsetX, scale, label.dpmm)), - y: snap(pxToDots(py - labelOffsetY, scale, label.dpmm)), + x: snap(pxToDots(px - objectsOffsetX, scale, effDpmm)), + y: snap(pxToDots(py - labelOffsetY, scale, effDpmm)), }; }; @@ -1245,7 +1251,7 @@ export const LabelCanvas = forwardRef(function LabelCa void copyText(zplForSelection(label, objects, sel, variables)); }, copyZplLabel: () => { - void copyText(generateMultiPageZPL(label, pages, variables)); + void copyText(generateMultiPageZPL(designLabel, pages, variables)); }, copyImage: () => { // Call write synchronously with the pending blob so the user activation @@ -1572,7 +1578,7 @@ export const LabelCanvas = forwardRef(function LabelCa key={obj.id} obj={obj} scale={scale} - dpmm={label.dpmm} + dpmm={effDpmm} offsetX={objectsOffsetX} offsetY={labelOffsetY} isSelected={attachableIds.includes(obj.id)} @@ -1640,7 +1646,7 @@ export const LabelCanvas = forwardRef(function LabelCa onSelect={() => { /* ghost */ }} onChange={() => { /* ghost */ }} snap={snap} - dpmm={label.dpmm} + dpmm={effDpmm} /> )} diff --git a/src/components/Canvas/hooks/useKonvaDragController.ts b/src/components/Canvas/hooks/useKonvaDragController.ts index b442beca..e49503a1 100644 --- a/src/components/Canvas/hooks/useKonvaDragController.ts +++ b/src/components/Canvas/hooks/useKonvaDragController.ts @@ -11,7 +11,7 @@ import { } from "@zplab/core/lib/objectBounds"; import { measuredBoundsMap } from "../measuredBoundsCache"; import { expandSelection, findObjectById, getAllLeaves } from "@zplab/core/types/Group"; -import { useLabelStore, currentObjects, type ObjectChanges } from "../../../store/labelStore"; +import { useLabelStore, currentObjects, currentPageLabel, type ObjectChanges } from "../../../store/labelStore"; /** Everything the controller needs from LabelCanvas; mirrors the param style of * useKonvaTransformer so move and resize sit at the same layer. */ @@ -134,7 +134,7 @@ export function useKonvaDragController(args: DragControllerArgs): DragHandlers { const state = useLabelStore.getState(); const objs = currentObjects(state); if (!findObjectById(objs, primaryId)) return; - const ctx = { label: state.label, measured: measuredBoundsMap() }; + const ctx = { label: currentPageLabel(state), measured: measuredBoundsMap() }; // Drag the whole movable selection when the grabbed node is part of a 2+ // selection; otherwise just the grabbed object. @@ -165,7 +165,7 @@ export function useKonvaDragController(args: DragControllerArgs): DragHandlers { const b = objectBoundsDots(leaf, ctx); others.push({ id: leaf.id, ...b }); } - const labelDots = labelSnapRectDots(state.label); + const labelDots = labelSnapRectDots(currentPageLabel(state)); dragRef.current = { ids, diff --git a/src/components/Output/ZplImportModal.tsx b/src/components/Output/ZplImportModal.tsx index 7225fe55..b2d07dad 100644 --- a/src/components/Output/ZplImportModal.tsx +++ b/src/components/Output/ZplImportModal.tsx @@ -1,6 +1,6 @@ import { useRef, useState } from 'react'; import { XMarkIcon, ClipboardDocumentIcon, CheckIcon, FolderOpenIcon } from '@heroicons/react/16/solid'; -import { importZplText, routeSetupCommands, mergeSetupFonts, type ZplImportResult, type SetupCommandChoice } from '@zplab/core/lib/zplImportService'; +import { importZplText, routeSetupCommands, mergeSetupFonts, rebaseAppendedPageDensity, replaceImportLabel, type ZplImportResult, type SetupCommandChoice } from '@zplab/core/lib/zplImportService'; import { readFileAsText } from '../../lib/readFile'; import { useLabelStore } from '../../store/labelStore'; import type { Page } from '@zplab/core/types/Group'; @@ -57,9 +57,12 @@ export function ZplImportModal({ onClose }: Props) { // append-mode preserves the current Variables tab; merging here // would risk name/fnNumber collisions the user can't see in the // dialog. Round-trip from a saved design uses Save/Load, not Append. - appendPages(importedPages); + // The imported ^JM was folded into the discarded config, so re-pin each + // page's density against the design it joins or a diverging block reprints + // at the wrong density. + appendPages(rebaseAppendedPageDensity(importedPages, labelConfig.jmDensity, label.jmDensity)); } else { - loadDesign({ ...label, ...labelConfig }, importedPages, importedVariables); + loadDesign(replaceImportLabel(label, labelConfig), importedPages, importedVariables); } } // Profile fields are per-installation state, applied regardless of diff --git a/src/components/Palette/ObjectPalette.tsx b/src/components/Palette/ObjectPalette.tsx index ce85b79c..1364a806 100644 --- a/src/components/Palette/ObjectPalette.tsx +++ b/src/components/Palette/ObjectPalette.tsx @@ -10,7 +10,7 @@ import { useContextMenu } from '../../hooks/useContextMenu'; import { resolveAddable, typeLabelFor, type AddableEntry } from '../../registry/palettePresets'; import { getEntry } from '@zplab/core/registry'; import { useT } from '../../hooks/useT'; -import { useLabelStore } from '../../store/labelStore'; +import { useLabelStore, currentPageLabel } from '../../store/labelStore'; import { printableRectDots } from '@zplab/core/lib/objectBounds'; import { centeredSpawnAnchor } from '../../lib/spawn'; import { DragHandleIcon } from '../ui/DragHandleIcon'; @@ -43,11 +43,14 @@ function entryCategory(entry: AddableEntry, t: Translations): string { * centeredSpawnAnchor with the drag path, so both gestures land the same way * and honour the spawn rotation of a rotated canvas view. */ function spawnCentered(entry: AddableEntry) { - const { addObject, label, canvasSettings } = useLabelStore.getState(); + const state = useLabelStore.getState(); + // Current page's dot scale: addObject writes into it, so a jm-diverged page + // must not size/place against the design label. + const label = currentPageLabel(state); const r = printableRectDots(label); const at = { x: r.x + r.width / 2, y: r.y + r.height / 2 }; - const pos = centeredSpawnAnchor(entry.type, entry.propsOverride, at, label, canvasSettings.viewRotation); - if (pos) addObject(entry.type, pos, entry.propsOverride); + const pos = centeredSpawnAnchor(entry.type, entry.propsOverride, at, label, state.canvasSettings.viewRotation); + if (pos) state.addObject(entry.type, pos, entry.propsOverride); } const rowBodyCls = diff --git a/src/components/PrinterSettings/OutputTab.tsx b/src/components/PrinterSettings/OutputTab.tsx index e6724b62..c63a0b99 100644 --- a/src/components/PrinterSettings/OutputTab.tsx +++ b/src/components/PrinterSettings/OutputTab.tsx @@ -33,6 +33,7 @@ export function OutputTab() { minDots={0} allowUnset onChangeDots={(labelHomeX) => setLabelConfig({ labelHomeX })} + scope="design" zplCmd="^LH" className="contents" /> @@ -44,6 +45,7 @@ export function OutputTab() { minDots={0} allowUnset onChangeDots={(labelHomeY) => setLabelConfig({ labelHomeY })} + scope="design" zplCmd="^LH" className="contents" /> @@ -56,6 +58,7 @@ export function OutputTab() { maxDots={120} allowUnset onChangeDots={(labelTop) => setLabelConfig({ labelTop })} + scope="design" zplCmd="^LT" className="contents" /> @@ -73,6 +76,7 @@ export function OutputTab() { minDots={0} allowUnset onChangeDots={(labelShift) => setLabelConfig({ labelShift })} + scope="design" zplCmd="^LS" /> diff --git a/src/components/Properties/BlockTextSettings.tsx b/src/components/Properties/BlockTextSettings.tsx index 8339294e..c0251733 100644 --- a/src/components/Properties/BlockTextSettings.tsx +++ b/src/components/Properties/BlockTextSettings.tsx @@ -52,6 +52,7 @@ export function BlockTextSettings({ props: p, onChange }: Props) {

onChange({ blockLineSpacing })} @@ -76,6 +78,7 @@ export function BlockTextSettings({ props: p, onChange }: Props) { /> void; } /** Asks how to handle a print-density change: rescale every dot value to keep * the physical layout, or keep the dot values (physical size changes). The * warning count is previewed from the same pure transform the action commits. */ -export function DensityRescaleModal({ toDpmm, onClose }: Props) { +export function DensityRescaleModal({ pending, onClose }: Props) { const t = useT(); const td = t.densityRescale; const label = useLabelStore((s) => s.label); const pages = useLabelStore((s) => s.pages); const rescaleDensity = useLabelStore((s) => s.rescaleDensity); + const rescaleJmDensity = useLabelStore((s) => s.rescaleJmDensity); const setLabelConfig = useLabelStore((s) => s.setLabelConfig); - const { warnings } = rescaleDesign(pages, label, label.dpmm, toDpmm); + // The warning count previews the exact transform the action commits, so both + // read their arguments from the same rescaleParamsFor mapping. + const { fromEff, toEff, patch, includeCalibrationFields } = rescaleParamsFor(pending, label); + // A head swap is a physical dpmm change, so show the physical densities (like + // the selector); a ^JM change shifts only the effective density, so show that. + const displayFrom = pending.kind === "dpmm" ? label.dpmm : fromEff; + const displayTo = pending.kind === "dpmm" ? pending.toDpmm : toEff; + const { warnings } = rescaleDesign(pages, label, fromEff, toEff, patch, includeCalibrationFields); + + // ^JM shifts the effective density, not the head dpmm, so it gets its own copy. + const titleText = pending.kind === "jm" ? td.jmTitle : td.title; + const fromToText = pending.kind === "jm" ? td.jmFromToFmt : td.fromToFmt; const scale = () => { - rescaleDensity(toDpmm); + if (pending.kind === "dpmm") rescaleDensity(pending.toDpmm, pending.configPatch); + else rescaleJmDensity(pending.toJm); onClose(); }; + // Keep stamps the same fields the rescale would (dpmm plus any preset size), + // only without touching the dots, so both paths commit the identical change. const keep = () => { - setLabelConfig({ dpmm: toDpmm }); + setLabelConfig(patch); onClose(); }; @@ -40,10 +56,10 @@ export function DensityRescaleModal({ toDpmm, onClose }: Props) { >

- {td.title} + {titleText}

- {td.fromToFmt.split("{from}").join(String(label.dpmm)).split("{to}").join(String(toDpmm))} + {fromToText.split("{from}").join(String(displayFrom)).split("{to}").join(String(displayTo))}

{td.question}

{warnings.length > 0 && ( diff --git a/src/components/Properties/FpSettings.tsx b/src/components/Properties/FpSettings.tsx index 7d97b4b1..958fe184 100644 --- a/src/components/Properties/FpSettings.tsx +++ b/src/components/Properties/FpSettings.tsx @@ -85,6 +85,7 @@ export function FpSettings({ props: p, onChange }: Props) {
s.label); const objects = useCurrentObjects(); const unit = canvasSettings.unit; // Walk the tree: when the layers panel drills into a nested child, the @@ -180,7 +184,7 @@ export function PropertiesPanel({ canvasRef }: PropertiesPanelProps) { if (!obj) { return ( @@ -280,13 +284,13 @@ export function PropertiesPanel({ canvasRef }: PropertiesPanelProps) { updateObject(obj.id, { x: mmToDots( unitToMm(Number(e.target.value), unit), - label.dpmm, + effectiveDpmm(label), ), }) } @@ -299,13 +303,13 @@ export function PropertiesPanel({ canvasRef }: PropertiesPanelProps) { updateObject(obj.id, { y: mmToDots( unitToMm(Number(e.target.value), unit), - label.dpmm, + effectiveDpmm(label), ), }) } @@ -480,13 +484,20 @@ function LabelConfigPanel({ onUnitChange, }: LabelConfigPanelProps) { const t = useT(); - const [pendingDpmm, setPendingDpmm] = useState(null); - // Rescale is only meaningful when there is geometry to scale; an empty design - // just adopts the new density. Under the preview lock every label edit no-ops, - // so skip the prompt rather than open a modal that would do nothing. - const hasObjects = useLabelStore((s) => s.pages.some((p) => p.objects.length > 0)); + const [pendingDpmm, setPendingDpmm] = useState<{ toDpmm: number; configPatch?: Partial } | null>(null); + const [pendingJm, setPendingJm] = useState<{ to: JmDensity | undefined } | null>(null); + // Skip a dead rescale prompt: open only when rescaleDesign would actually + // change a field for the pending target. Read on demand rather than + // subscribed, so an unrelated pages-identity change can't re-render the panel. + const wouldRescale = (patch: Partial, includeCalibrationFields: boolean) => { + const s = useLabelStore.getState(); + return rescaleWouldChange(s.pages, s.label, includeCalibrationFields, patch); + }; const locked = useLabelStore(selectPreviewLocksEditor); const setPrinterSettingsTab = useLabelStore((s) => s.setPrinterSettingsTab); + // The select edits the design-wide mode; surface a page's persisted ^JM + // override so a diverging page is not an invisible no-op target. + const pageJm = useLabelStore((s) => currentPageLabel(s).jmDensity); const matchedPreset = PRESETS.find( (p) => p.widthMm === label.widthMm && @@ -501,7 +512,15 @@ function LabelConfigPanel({ if (value === "custom") return; const p = PRESETS[Number(value)]; if (!p) return; - onUpdate({ widthMm: p.widthMm, heightMm: p.heightMm, dpmm: p.dpmm }); + // A preset that changes dpmm reinterprets stored dots, same as the dpmm + // selector, so route it through the rescale prompt; the new size rides along + // as a configPatch (cancel then reverts size too, keep/scale stamp it). + const configPatch = { widthMm: p.widthMm, heightMm: p.heightMm }; + if (p.dpmm !== label.dpmm && wouldRescale({ dpmm: p.dpmm }, false)) { + setPendingDpmm({ toDpmm: p.dpmm, configPatch }); + } else { + onUpdate({ ...configPatch, dpmm: p.dpmm }); + } }; // ^CF / ^A suggestions: the shared resolver returns the same union @@ -538,6 +557,7 @@ function LabelConfigPanel({ value={presetValue} + disabled={locked} onChange={handlePreset} groups={[ { @@ -600,8 +620,11 @@ function LabelConfigPanel({ value={label.dpmm} + // Every commit path is a no-op under the preview lock, so the + // control has to read as unavailable instead of silently dropping. + disabled={locked} onChange={(value) => { - if (value !== label.dpmm && hasObjects && !locked) setPendingDpmm(value); + if (value !== label.dpmm && wouldRescale({ dpmm: value }, false)) setPendingDpmm({ toDpmm: value }); else onUpdate({ dpmm: value }); }} groups={[ @@ -616,8 +639,40 @@ function LabelConfigPanel({ ]} />
+
+ {t.label.jmDensity} + + value={label.jmDensity ?? ''} + disabled={locked} + onChange={(v) => { + const next = v === '' ? undefined : (v as JmDensity); + if (wouldRescale({ jmDensity: next }, true)) setPendingJm({ to: next }); + else onUpdate({ jmDensity: next }); + }} + groups={[ + { + options: [ + { value: '', label: t.label.jmDensityDefault }, + { value: 'A', label: t.label.jmDensityA }, + { value: 'B', label: t.label.jmDensityB }, + ], + }, + ]} + /> + {pageJm !== label.jmDensity && ( +

+ {t.label.jmPageOverrideHintFmt.split('{mode}').join(pageJm ?? 'A')} +

+ )} +
{pendingDpmm !== null && ( - setPendingDpmm(null)} /> + setPendingDpmm(null)} + /> + )} + {pendingJm !== null && ( + setPendingJm(null)} /> )}
@@ -681,6 +736,7 @@ function LabelConfigPanel({ minDots={1} allowUnset onChangeDots={(defaultFontHeight) => onUpdate({ defaultFontHeight })} + scope="design" zplCmd="^CF" className={fieldGridCell} /> @@ -690,6 +746,7 @@ function LabelConfigPanel({ minDots={0} allowUnset onChangeDots={(defaultFontWidth) => onUpdate({ defaultFontWidth })} + scope="design" zplCmd="^CF" className={fieldGridCell} /> diff --git a/src/components/Properties/TextModeSection.tsx b/src/components/Properties/TextModeSection.tsx index f5945f53..065be539 100644 --- a/src/components/Properties/TextModeSection.tsx +++ b/src/components/Properties/TextModeSection.tsx @@ -107,6 +107,7 @@ export function TextModeSection({
onChange({ blockWidth })} @@ -115,6 +116,7 @@ export function TextModeSection({ /> onChange({ blockHeight })} diff --git a/src/components/Properties/UnitNumberInput.scope.test.tsx b/src/components/Properties/UnitNumberInput.scope.test.tsx new file mode 100644 index 00000000..f3d978af --- /dev/null +++ b/src/components/Properties/UnitNumberInput.scope.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach, beforeEach } from "vitest"; +import { render, cleanup, act, fireEvent } from "@testing-library/react"; +import { UnitNumberInput } from "./UnitNumberInput"; +import { useLabelStore } from "../../store/labelStore"; + +afterEach(cleanup); + +// 8 dpmm design, current page halved by its own ^JM: 80 dots read as 10 mm at +// design scale and 20 mm at page scale. +const setPageDensity = (jmDensity: "A" | "B" | undefined) => + act(() => { + useLabelStore.setState({ + label: { widthMm: 100, heightMm: 50, dpmm: 8 }, + pages: [{ objects: [], jmDensity }], + currentPageIndex: 0, + canvasSettings: { ...useLabelStore.getState().canvasSettings, unit: "mm" }, + } as never); + }); + +const noop = (): void => undefined; + +const shown = (el: HTMLElement) => (el.querySelector("input") as HTMLInputElement).value; + +describe("UnitNumberInput density scope", () => { + beforeEach(() => setPageDensity(undefined)); + + it("shows a design field at the design density on a ^JMB page", () => { + const { container } = render( + , + ); + expect(shown(container)).toBe("10"); + setPageDensity("B"); + expect(shown(container)).toBe("10"); + }); + + it("shows an object prop at the page density on a ^JMB page", () => { + const { container } = render( + , + ); + expect(shown(container)).toBe("10"); + setPageDensity("B"); + expect(shown(container)).toBe("20"); + }); + + it("writes a design field back at the design density", () => { + setPageDensity("B"); + let written: number | undefined; + const { container } = render( + { + written = d; + }} + scope="design" + />, + ); + const input = container.querySelector("input") as HTMLInputElement; + act(() => { + fireEvent.change(input, { target: { value: "5" } }); + }); + expect(written).toBe(40); + }); + + it("writes an object prop back at the page density on a ^JMB page", () => { + setPageDensity("B"); + let written: number | undefined; + const { container } = render( + { + written = d; + }} + scope="page" + />, + ); + const input = container.querySelector("input") as HTMLInputElement; + act(() => { + fireEvent.change(input, { target: { value: "5" } }); + }); + expect(written).toBe(20); + }); +}); diff --git a/src/components/Properties/UnitNumberInput.tsx b/src/components/Properties/UnitNumberInput.tsx index 5e3ac498..94d6bda4 100644 --- a/src/components/Properties/UnitNumberInput.tsx +++ b/src/components/Properties/UnitNumberInput.tsx @@ -1,9 +1,10 @@ import { useState } from 'react'; -import { useLabelStore } from '../../store/labelStore'; +import { useLabelStore, currentPageLabel } from '../../store/labelStore'; import { inputCls } from './styles'; import { FieldLabel } from './ZplCmd'; import { mmToUnit, unitToMm, unitLabel, unitStep } from '@zplab/core/lib/units'; import { dotsToMm, mmToDots } from '@zplab/core/lib/coordinates'; +import { effectiveDpmm } from '@zplab/core/types/LabelConfig'; interface UnitNumberInputProps { label: string; @@ -20,6 +21,10 @@ interface UnitNumberInputProps { zplCmd?: string; /** Replaces the cell layout (e.g. fieldGridCell); omit for the default column. */ className?: string; + /** Density the dots are stored in: object props live in the current page's + * scale, label/design fields in the design's own (a page ^JM must not + * reinterpret them). Required so every call site decides explicitly. */ + scope: 'page' | 'design'; } /** @@ -39,9 +44,12 @@ export function UnitNumberInput({ disabled, zplCmd, className, + scope, }: UnitNumberInputProps) { const unit = useLabelStore((s) => s.canvasSettings.unit); - const dpmm = useLabelStore((s) => s.label.dpmm); + const dpmm = useLabelStore((s) => + effectiveDpmm(scope === 'design' ? s.label : currentPageLabel(s)), + ); const toUnit = (dots: number) => mmToUnit(dotsToMm(dots, dpmm), unit); // null = not editing; show the canonical store value. Otherwise show the raw // keystrokes verbatim so the input never fights the user mid-entry. diff --git a/src/hooks/useZplImportExport.ts b/src/hooks/useZplImportExport.ts index c81863e4..b8dae85c 100644 --- a/src/hooks/useZplImportExport.ts +++ b/src/hooks/useZplImportExport.ts @@ -10,7 +10,7 @@ import { generateSetupScript } from "../lib/zplSetupScript"; import { printLabel } from "../lib/printPreview"; import { saveTextFile, saveErrorMessage, ZPL_FILTER } from "../lib/fileDialogs"; import { labelaryErrorMessage } from "../lib/labelary"; -import { selectLabelaryEndpoint } from "../store/labelStore.selectors"; +import { currentPageLabel, selectLabelaryEndpoint } from "../store/labelStore.selectors"; import { buildActiveRow } from "@zplab/core/lib/variableBinding"; export function useZplImportExport() { @@ -49,7 +49,7 @@ export function useZplImportExport() { const batch = selectBatchInputs(s); if (!batch) return; const zpl = generateBatchZpl( - s.label, currentObjects(s), s.variables, batch.dataset, batch.mapping, + currentPageLabel(s), currentObjects(s), s.variables, batch.dataset, batch.mapping, ); void saveTextFile(zpl, { filename: "label-batch.zpl", @@ -75,7 +75,7 @@ export function useZplImportExport() { const s = useLabelStore.getState(); const active = buildActiveRow(s.dataset, s.columnMapping); const { host, apiKey } = selectLabelaryEndpoint(s); - await printLabel(s.label, currentObjects(s), host, apiKey, s.variables, active); + await printLabel(currentPageLabel(s), currentObjects(s), host, apiKey, s.variables, active); clearUserError(); } catch (e) { setUserError(labelaryErrorMessage(e), { retryExport: true }); @@ -95,7 +95,7 @@ export function useZplImportExport() { const batch = selectBatchInputs(s); return batch ? generateBatchZpl( - s.label, currentObjects(s), s.variables, batch.dataset, batch.mapping, + currentPageLabel(s), currentObjects(s), s.variables, batch.dataset, batch.mapping, ) : generateMultiPageZPL(s.label, s.pages, s.variables); }; diff --git a/src/lib/densityRescale.test.ts b/src/lib/densityRescale.test.ts index 58c9ecc4..3a387b63 100644 --- a/src/lib/densityRescale.test.ts +++ b/src/lib/densityRescale.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, beforeEach } from "vitest"; -import { rescaleDesign } from "./densityRescale"; +import { CALIBRATION_CLAMP, LAYOUT_LABEL_FIELDS, rescaleDesign, rescaleParamsFor, rescaleWouldChange } from "./densityRescale"; import { useLabelStore } from "../store/labelStore"; import type { LabelObject, Page } from "@zplab/core/types/Group"; import type { LeafObject } from "@zplab/core/registry"; @@ -23,7 +23,7 @@ const page = (...objects: LabelObject[]): Page[] => [{ objects }]; describe("rescaleDesign", () => { it("is a no-op when the density is unchanged (just stamps dpmm)", () => { const pages = page(leaf("b", "box", 10, 20, { width: 100, height: 50, thickness: 2, filled: false, color: "B", rounding: 0 })); - const r = rescaleDesign(pages, label, 8, 8); + const r = rescaleDesign(pages, label, 8, 8, { dpmm: 8 }); expect(r.pages).toBe(pages); expect(r.warnings).toEqual([]); expect(r.label.dpmm).toBe(8); @@ -31,7 +31,7 @@ describe("rescaleDesign", () => { it("scales position and box dimensions by the density ratio", () => { const box = leaf("b", "box", 10, 20, { width: 100, height: 50, thickness: 2, filled: false, color: "B", rounding: 4 }); - const r = rescaleDesign(page(box), label, 8, 12); // factor 1.5 + const r = rescaleDesign(page(box), label, 8, 12, { dpmm: 12 }); // factor 1.5 const out = r.pages[0]!.objects[0] as typeof box; expect(out.x).toBe(15); expect(out.y).toBe(30); @@ -44,14 +44,14 @@ describe("rescaleDesign", () => { }); it("scales label dimensions in mm are kept (physical size constant)", () => { - const r = rescaleDesign(page(), label, 8, 12); + const r = rescaleDesign(page(), label, 8, 12, { dpmm: 12 }); expect(r.label.widthMm).toBe(100); expect(r.label.heightMm).toBe(50); }); it("clamps barcode moduleWidth to 10 and warns", () => { const bc = leaf("c", "code128", 0, 0, { content: "X", height: 80, moduleWidth: 8, printInterpretation: false, checkDigit: false, rotation: "N" } as never); - const r = rescaleDesign(page(bc), label, 8, 24); // factor 3 -> 24 clamps to 10 + const r = rescaleDesign(page(bc), label, 8, 24, { dpmm: 24 }); // factor 3 -> 24 clamps to 10 const out = r.pages[0]!.objects[0] as typeof bc; expect((out.props as { moduleWidth: number }).moduleWidth).toBe(10); expect((out.props as { height: number }).height).toBe(240); @@ -60,7 +60,7 @@ describe("rescaleDesign", () => { it("does not warn when moduleWidth scales within range", () => { const bc = leaf("c", "code128", 0, 0, { content: "X", height: 80, moduleWidth: 2, printInterpretation: false, checkDigit: false, rotation: "N" } as never); - const r = rescaleDesign(page(bc), label, 8, 12); // factor 1.5 -> mw 3 + const r = rescaleDesign(page(bc), label, 8, 12, { dpmm: 12 }); // factor 1.5 -> mw 3 const out = r.pages[0]!.objects[0] as typeof bc; expect((out.props as { moduleWidth: number }).moduleWidth).toBe(3); expect(r.warnings).toEqual([]); @@ -68,7 +68,7 @@ describe("rescaleDesign", () => { it("clamps QR magnification to 10 and warns", () => { const qr = leaf("q", "qrcode", 0, 0, { content: "QA,hi", magnification: 5, errorCorrection: "Q", model: 2, rotation: "N" } as never); - const r = rescaleDesign(page(qr), label, 8, 24); // factor 3 -> 15 clamps to 10 + const r = rescaleDesign(page(qr), label, 8, 24, { dpmm: 24 }); // factor 3 -> 15 clamps to 10 const out = r.pages[0]!.objects[0] as typeof qr; expect((out.props as { magnification: number }).magnification).toBe(10); expect(r.warnings.some((w) => w.reason === "magnificationClamped")).toBe(true); @@ -76,7 +76,7 @@ describe("rescaleDesign", () => { it("enforces pdf417 module width minimum of 2", () => { const pdf = leaf("p", "pdf417", 0, 0, { content: "x", moduleWidth: 2, rowHeight: 6, securityLevel: 0, columns: 0, rotation: "N" } as never); - const r = rescaleDesign(page(pdf), label, 12, 8); // factor ~0.667 -> 1.33 rounds to 1, clamps to 2 + const r = rescaleDesign(page(pdf), label, 12, 8, { dpmm: 8 }); // factor ~0.667 -> 1.33 rounds to 1, clamps to 2 const out = r.pages[0]!.objects[0] as typeof pdf; expect((out.props as { moduleWidth: number }).moduleWidth).toBe(2); expect(r.warnings.some((w) => w.reason === "moduleClamped")).toBe(true); @@ -84,7 +84,7 @@ describe("rescaleDesign", () => { it("floors image widthDots at 8 and warns", () => { const img = leaf("i", "image", 0, 0, { imageId: "a", widthDots: 16, threshold: 128 } as never); - const r = rescaleDesign(page(img), label, 24, 8); // factor 1/3 -> 5.33 floors to 8 + const r = rescaleDesign(page(img), label, 24, 8, { dpmm: 8 }); // factor 1/3 -> 5.33 floors to 8 const out = r.pages[0]!.objects[0] as typeof img; expect((out.props as { widthDots: number }).widthDots).toBe(8); expect(r.warnings.some((w) => w.reason === "imageFloor")).toBe(true); @@ -92,7 +92,7 @@ describe("rescaleDesign", () => { it("drops the stale GFA cache when an editable image is rescaled", () => { const img = leaf("i", "image", 0, 0, { imageId: "a", widthDots: 100, threshold: 128, _gfaCache: "^GFA,old" } as never); - const r = rescaleDesign(page(img), label, 8, 16); // factor 2 + const r = rescaleDesign(page(img), label, 8, 16, { dpmm: 16 }); // factor 2 const out = r.pages[0]!.objects[0] as typeof img; expect((out.props as { widthDots: number }).widthDots).toBe(200); expect((out.props as { _gfaCache?: string })._gfaCache).toBeUndefined(); @@ -100,7 +100,7 @@ describe("rescaleDesign", () => { it("locks the footprint of a verbatim (rawGf) graphic and warns it cannot rescale", () => { const img = leaf("i", "image", 10, 0, { imageId: "", widthDots: 100, heightDots: 50, threshold: 128, rawGf: "^GFA,raw" } as never); - const r = rescaleDesign(page(img), label, 8, 16); // factor 2 + const r = rescaleDesign(page(img), label, 8, 16, { dpmm: 16 }); // factor 2 const out = r.pages[0]!.objects[0] as typeof img; expect((out.props as { widthDots: number }).widthDots).toBe(100); // box locked expect((out.props as { heightDots: number }).heightDots).toBe(50); @@ -110,7 +110,7 @@ describe("rescaleDesign", () => { it("locks the footprint of a recall (storedAs) image", () => { const img = leaf("i", "image", 0, 0, { imageId: "", widthDots: 100, threshold: 128, storedAs: { device: "R", name: "LOGO" } } as never); - const r = rescaleDesign(page(img), label, 8, 16); + const r = rescaleDesign(page(img), label, 8, 16, { dpmm: 16 }); const out = r.pages[0]!.objects[0] as typeof img; expect((out.props as { widthDots: number }).widthDots).toBe(100); expect(r.warnings.some((w) => w.reason === "imageFixed")).toBe(true); @@ -118,7 +118,7 @@ describe("rescaleDesign", () => { it("floors image heightDots at 8 and warns (symmetric with widthDots)", () => { const img = leaf("i", "image", 0, 0, { imageId: "a", widthDots: 90, heightDots: 18, threshold: 128 } as never); - const r = rescaleDesign(page(img), label, 24, 8); // factor 1/3 -> heightDots 6 floors to 8 + const r = rescaleDesign(page(img), label, 24, 8, { dpmm: 8 }); // factor 1/3 -> heightDots 6 floors to 8 const out = r.pages[0]!.objects[0] as typeof img; expect((out.props as { heightDots: number }).heightDots).toBe(8); expect(r.warnings.some((w) => w.prop === "heightDots" && w.reason === "imageFloor")).toBe(true); @@ -126,7 +126,7 @@ describe("rescaleDesign", () => { it("clamps DataMatrix dimension to 12 and warns", () => { const dm = leaf("d", "datamatrix", 0, 0, { content: "x", dimension: 6, quality: 200, rotation: "N", gs1: false } as never); - const r = rescaleDesign(page(dm), label, 8, 24); // factor 3 -> 18 clamps to 12 + const r = rescaleDesign(page(dm), label, 8, 24, { dpmm: 24 }); // factor 3 -> 18 clamps to 12 const out = r.pages[0]!.objects[0] as typeof dm; expect((out.props as { dimension: number }).dimension).toBe(12); expect(r.warnings.some((w) => w.reason === "dimensionClamped")).toBe(true); @@ -136,14 +136,14 @@ describe("rescaleDesign", () => { // mag 1 * 0.5 = 0.5 -> rounds to 1 (already the min), so no real clamp occurred. // Guards the off-by-one where the pre-round ideal (0.5 < min) falsely warned. const qr = leaf("q", "qrcode", 0, 0, { content: "QA,hi", magnification: 1, errorCorrection: "Q", model: 2, rotation: "N" } as never); - const r = rescaleDesign(page(qr), label, 24, 12); // factor 0.5 + const r = rescaleDesign(page(qr), label, 24, 12, { dpmm: 12 }); // factor 0.5 expect((r.pages[0]!.objects[0] as typeof qr).props as { magnification: number }).toMatchObject({ magnification: 1 }); expect(r.warnings).toEqual([]); }); it("scales tlc39 microPdfRowHeight along with the rest of the symbol", () => { const tlc = leaf("x", "tlc39", 0, 0, { content: "1,2", moduleWidth: 2, height: 40, microPdfRowHeight: 4, rotation: "N" } as never); - const r = rescaleDesign(page(tlc), label, 8, 16); // factor 2 + const r = rescaleDesign(page(tlc), label, 8, 16, { dpmm: 16 }); // factor 2 const out = r.pages[0]!.objects[0] as typeof tlc; expect((out.props as { microPdfRowHeight: number }).microPdfRowHeight).toBe(8); expect((out.props as { height: number }).height).toBe(80); @@ -152,20 +152,29 @@ describe("rescaleDesign", () => { it("warns on device-font snap for fonts A-H but not font 0", () => { const a = leaf("t1", "text", 0, 0, { content: "x", fontHeight: 30, fontWidth: 0, fontId: "A", rotation: "N" } as never); const z = leaf("t2", "text", 0, 0, { content: "x", fontHeight: 30, fontWidth: 0, fontId: "0", rotation: "N" } as never); - const r = rescaleDesign(page(a, z), label, 8, 12); + const r = rescaleDesign(page(a, z), label, 8, 12, { dpmm: 12 }); expect(r.warnings.filter((w) => w.reason === "deviceFontSnap").map((w) => w.id)).toEqual(["t1"]); }); it("scales negative ^FB line spacing preserving its sign", () => { const t = leaf("t", "text", 0, 0, { content: "x", fontHeight: 20, fontWidth: 0, fontId: "0", rotation: "N", textMode: "fb", blockWidth: 200, blockLines: 3, blockLineSpacing: -8 } as never); - const r = rescaleDesign(page(t), label, 8, 16); // factor 2 + const r = rescaleDesign(page(t), label, 8, 16, { dpmm: 16 }); // factor 2 const out = r.pages[0]!.objects[0] as typeof t; expect((out.props as { blockLineSpacing: number }).blockLineSpacing).toBe(-16); }); + it("rounds negative object coordinates and line spacing sign-symmetrically at factor 0.5", () => { + const t = leaf("t", "text", -25, -25, { content: "x", fontHeight: 20, fontWidth: 0, fontId: "0", rotation: "N", textMode: "fb", blockWidth: 200, blockLines: 3, blockLineSpacing: -25 } as never); + const r = rescaleDesign(page(t), label, 8, 4, { dpmm: 4 }); + const out = r.pages[0]!.objects[0] as typeof t; + expect(out.x).toBe(-13); + expect(out.y).toBe(-13); + expect((out.props as { blockLineSpacing: number }).blockLineSpacing).toBe(-13); + }); + it("keeps fontWidth 0 (auto) as 0 and scales fontHeight", () => { const t = leaf("t", "text", 0, 0, { content: "x", fontHeight: 20, fontWidth: 0, fontId: "0", rotation: "N" } as never); - const r = rescaleDesign(page(t), label, 8, 16); // factor 2 + const r = rescaleDesign(page(t), label, 8, 16, { dpmm: 16 }); // factor 2 const out = r.pages[0]!.objects[0] as typeof t; expect((out.props as { fontHeight: number }).fontHeight).toBe(40); expect((out.props as { fontWidth: number }).fontWidth).toBe(0); @@ -174,7 +183,7 @@ describe("rescaleDesign", () => { it("recurses into groups, scaling leaves but not group coordinates", () => { const child = leaf("b", "box", 10, 10, { width: 40, height: 20, thickness: 2, filled: false, color: "B", rounding: 0 }); const group = { id: "g", type: "group", x: 5, y: 5, rotation: 0, children: [child] } as unknown as LabelObject; - const r = rescaleDesign(page(group), label, 8, 16); // factor 2 + const r = rescaleDesign(page(group), label, 8, 16, { dpmm: 16 }); // factor 2 const outGroup = r.pages[0]!.objects[0] as { x: number; children: LabelObject[] }; expect(outGroup.x).toBe(5); // group coord untouched const outChild = outGroup.children[0] as typeof child; @@ -184,13 +193,109 @@ describe("rescaleDesign", () => { it("scales layout-affecting label dot fields but leaves calibration fields", () => { const cfg: LabelConfig = { widthMm: 100, heightMm: 50, dpmm: 8, labelHomeX: 10, labelHomeY: 20, defaultFontHeight: 30, defaultFontWidth: 0, labelTop: 100, maxLabelLength: 1200 }; - const r = rescaleDesign(page(), cfg, 8, 16); // factor 2 + const r = rescaleDesign(page(), cfg, 8, 16, { dpmm: 16 }); // factor 2 expect(r.label.labelHomeX).toBe(20); expect(r.label.labelHomeY).toBe(40); expect(r.label.defaultFontHeight).toBe(60); expect(r.label.labelTop).toBe(100); // calibration unchanged expect(r.label.maxLabelLength).toBe(1200); // calibration unchanged }); + + it("scales calibration fields when includeCalibrationFields is set (^JM path)", () => { + const cfg: LabelConfig = { widthMm: 100, heightMm: 50, dpmm: 8, labelShift: -12, labelTop: 40, maxLabelLength: 1200, slewDotRows: 30 }; + const r = rescaleDesign(page(), cfg, 8, 4, {}, true); + expect(r.label.labelShift).toBe(-6); + expect(r.label.labelTop).toBe(20); + expect(r.label.maxLabelLength).toBe(1200); // ^ML is physical (ZD230): a ^JM switch must not rescale it + expect(r.label.slewDotRows).toBe(15); + expect(r.warnings).toEqual([]); + }); + + it("rounds negative calibration values sign-symmetrically (^LS ±25 at factor 0.5)", () => { + const neg: LabelConfig = { widthMm: 100, heightMm: 50, dpmm: 8, labelShift: -25 }; + const pos: LabelConfig = { widthMm: 100, heightMm: 50, dpmm: 8, labelShift: 25 }; + // ±12.5 must round to ±13; Math.round(-12.5) = -12 would bias negatives up. + expect(rescaleDesign(page(), neg, 8, 4, {}, true).label.labelShift).toBe(-13); + expect(rescaleDesign(page(), pos, 8, 4, {}, true).label.labelShift).toBe(13); + }); + + it("clamps ^LT past its ±120 range and warns", () => { + const cfg: LabelConfig = { widthMm: 100, heightMm: 50, dpmm: 8, labelTop: 100 }; + const r = rescaleDesign(page(), cfg, 8, 16, {}, true); + expect(r.label.labelTop).toBe(120); + expect(r.warnings).toContainEqual({ id: "label", name: "labelTop", type: "label", prop: "labelTop", reason: "calibrationClamped" }); + }); + + const pinnedWarning = { id: "label", name: "", type: "label", prop: "", reason: "pinnedPageLabelFields" as const }; + + it("warns that a ^JM-pinned page's shared label dot fields shift its physical output", () => { + const cfg: LabelConfig = { widthMm: 100, heightMm: 50, dpmm: 8, labelHomeX: 10 }; + const box = leaf("p", "box", 100, 40, { width: 200, height: 80, thickness: 2, filled: false, color: "B", rounding: 0 }); + const pages: Page[] = [{ objects: [box], jmDensity: "B" }]; + const r = rescaleDesign(pages, cfg, 8, 4, { jmDensity: "B" }, true); + expect(r.warnings).toContainEqual(pinnedWarning); + }); + + it("does not warn about pinned label fields when no page is pinned", () => { + const cfg: LabelConfig = { widthMm: 100, heightMm: 50, dpmm: 8, labelHomeX: 10 }; + const r = rescaleDesign(page(), cfg, 8, 4, { jmDensity: "B" }, true); + expect(r.warnings).not.toContainEqual(pinnedWarning); + }); + + it("does not warn about pinned label fields when every set field is an explicit 0", () => { + const cfg: LabelConfig = { widthMm: 100, heightMm: 50, dpmm: 8, labelTop: 0, labelShift: 0 }; + const box = leaf("p", "box", 100, 40, { width: 200, height: 80, thickness: 2, filled: false, color: "B", rounding: 0 }); + const pages: Page[] = [{ objects: [box], jmDensity: "B" }]; + const r = rescaleDesign(pages, cfg, 8, 4, { jmDensity: "B" }, true); + expect(r.warnings).not.toContainEqual(pinnedWarning); + }); +}); + +describe("rescaleWouldChange", () => { + it("is true when a page has objects", () => { + const pages = page(leaf("b", "box", 0, 0, { width: 10, height: 10, thickness: 1, filled: false, color: "B", rounding: 0 })); + expect(rescaleWouldChange(pages, label, false, { dpmm: 12 })).toBe(true); + }); + + it("is false for an empty design with no scalable label fields", () => { + expect(rescaleWouldChange(page(), label, false, { dpmm: 12 })).toBe(false); + expect(rescaleWouldChange(page(), label, true, { jmDensity: 'B' })).toBe(false); + }); + + it("gates only the ^JM path on a calibration-only design", () => { + const cfg: LabelConfig = { widthMm: 100, heightMm: 50, dpmm: 8, labelTop: 40 }; + expect(rescaleWouldChange(page(), cfg, false, { dpmm: 12 })).toBe(false); + expect(rescaleWouldChange(page(), cfg, true, { jmDensity: 'B' })).toBe(true); + }); + + it("is false for explicitly zeroed fields (they scale to themselves)", () => { + const cfg: LabelConfig = { + widthMm: 100, heightMm: 50, dpmm: 8, + labelTop: 0, labelShift: 0, defaultFontWidth: 0, labelHomeX: 0, defaultFontHeight: 0, + }; + expect(rescaleWouldChange(page(), cfg, false, { dpmm: 12 })).toBe(false); + expect(rescaleWouldChange(page(), cfg, true, { jmDensity: 'B' })).toBe(false); + // and the transform agrees: no field moves off zero. + const r = rescaleDesign(page(), cfg, 8, 4, {}, true); + expect(r.label).toEqual(cfg); + }); + + it("is true on both paths when a layout label field is set", () => { + const cfg: LabelConfig = { widthMm: 100, heightMm: 50, dpmm: 8, labelHomeX: 10 }; + expect(rescaleWouldChange(page(), cfg, false, { dpmm: 12 })).toBe(true); + expect(rescaleWouldChange(page(), cfg, true, { jmDensity: 'B' })).toBe(true); + }); + + it("is false when the pending ^JM leaves the effective density alone", () => { + const cfg: LabelConfig = { widthMm: 100, heightMm: 50, dpmm: 8, labelHomeX: 10 }; + expect(rescaleWouldChange(page(), cfg, true, { jmDensity: "A" })).toBe(false); + }); + + it("is false when every object page is pinned by its own ^JM override", () => { + const box = leaf("b", "box", 10, 10, { width: 10, height: 10, thickness: 1, filled: false, color: "B", rounding: 0 }); + const pages: Page[] = [{ objects: [box], jmDensity: "B" }]; + expect(rescaleWouldChange(pages, label, true, { jmDensity: "B" })).toBe(false); + }); }); describe("rescaleDensity store action", () => { @@ -231,4 +336,103 @@ describe("rescaleDensity store action", () => { expect(useLabelStore.getState().label.dpmm).toBe(8); expect(firstBox().props.width).toBe(100); }); + + it("stamps a preset's size alongside the dpmm while rescaling dots", () => { + useLabelStore.getState().rescaleDensity(16, { widthMm: 50, heightMm: 25 }); + const l = useLabelStore.getState().label; + expect(l.dpmm).toBe(16); + expect(l.widthMm).toBe(50); + expect(l.heightMm).toBe(25); + expect(firstBox().props.width).toBe(200); + }); +}); + +describe("rescaleParamsFor with a preset configPatch", () => { + it("folds the extra size fields into the dpmm patch", () => { + const p = rescaleParamsFor( + { kind: "dpmm", toDpmm: 12, configPatch: { widthMm: 62, heightMm: 29 } }, + label, + ); + expect(p.patch).toEqual({ dpmm: 12, widthMm: 62, heightMm: 29 }); + expect(p.includeCalibrationFields).toBe(false); + }); + + it("leaves the plain dpmm patch untouched when no configPatch rides along", () => { + expect(rescaleParamsFor({ kind: "dpmm", toDpmm: 12 }, label).patch).toEqual({ dpmm: 12 }); + }); +}); + +describe("rescaleJmDensity (store)", () => { + beforeEach(() => { + useLabelStore.setState({ + previewMode: { status: 'idle' }, + label: { ...label }, + pages: page(leaf("b", "box", 100, 40, { width: 200, height: 80, thickness: 2, filled: false, color: "B", rounding: 0 })), + } as never); + }); + + it("halves dot values when switching to B (physical size preserved)", () => { + useLabelStore.getState().rescaleJmDensity("B"); + const s = useLabelStore.getState(); + expect(s.label.jmDensity).toBe("B"); + expect(s.label.dpmm).toBe(8); + const o = s.pages[0]!.objects[0] as { x: number; props: { width: number } }; + expect(o.x).toBe(50); + expect(o.props.width).toBe(100); + }); + + it("rescales label + objects in a single undo step", () => { + useLabelStore.temporal.getState().clear(); + useLabelStore.getState().rescaleJmDensity("B"); + expect(useLabelStore.temporal.getState().pastStates.length).toBe(1); + }); + + it("a no-ratio switch (unset to A) only stamps the mode", () => { + useLabelStore.getState().rescaleJmDensity("A"); + const s = useLabelStore.getState(); + expect(s.label.jmDensity).toBe("A"); + expect((s.pages[0]!.objects[0] as { x: number }).x).toBe(100); + }); + + it("leaves a page pinned by its own ^JM override unscaled", () => { + const pinned = leaf("p", "box", 100, 40, { width: 200, height: 80, thickness: 2, filled: false, color: "B", rounding: 0 }); + useLabelStore.setState({ + pages: [{ objects: [pinned], jmDensity: "B" }, ...useLabelStore.getState().pages], + } as never); + useLabelStore.getState().rescaleJmDensity("B"); + const s = useLabelStore.getState(); + // The pinned page already printed at B, so only the page that followed the + // design halves; scaling both would double-scale the override. + expect((s.pages[0]!.objects[0] as { x: number }).x).toBe(100); + expect((s.pages[1]!.objects[0] as { x: number }).x).toBe(50); + }); + + it("scales calibration fields on the ratio switch (same head reinterpreted)", () => { + useLabelStore.setState({ label: { ...label, labelTop: 40, maxLabelLength: 1200 } } as never); + useLabelStore.getState().rescaleJmDensity("B"); + const s = useLabelStore.getState(); + expect(s.label.labelTop).toBe(20); + expect(s.label.maxLabelLength).toBe(1200); // ^ML physical: unchanged by ^JM + }); +}); + +// Both lists derive from LABEL_CONFIG_FIELDS; pin the shape so a table typo +// cannot silently drop a field from the rescale or widen its bounds. +describe("derived label field sets", () => { + it("keeps the layout fields and their floors", () => { + expect(LAYOUT_LABEL_FIELDS).toEqual([ + { prop: "labelHomeX", min: 0 }, + { prop: "labelHomeY", min: 0 }, + { prop: "defaultFontHeight", min: 1 }, + { prop: "defaultFontWidth", min: 0 }, + ]); + }); + + it("keeps the calibration fields and their clamps", () => { + expect(CALIBRATION_CLAMP).toEqual([ + { prop: "labelShift", min: -Infinity, max: Infinity }, + { prop: "labelTop", min: -120, max: 120 }, + { prop: "slewDotRows", min: 0, max: 32000 }, + ]); + }); }); diff --git a/src/lib/densityRescale.ts b/src/lib/densityRescale.ts index b7f22205..770348c9 100644 --- a/src/lib/densityRescale.ts +++ b/src/lib/densityRescale.ts @@ -1,6 +1,40 @@ import { isGroup, type LabelObject, type LeafObject, type Page } from "@zplab/core/types/Group"; import { getEntry } from "@zplab/core/registry"; -import type { LabelConfig } from "@zplab/core/types/LabelConfig"; +import { effectiveDpmm, labelConfigSpec, scaledLabelConfigFields, type JmDensity, type LabelConfig } from "@zplab/core/types/LabelConfig"; + +/** Pending density change: a new head dpmm or a new ^JM mode, both reinterpreting + * stored dots under one rescale prompt. `configPatch` carries extra fields (e.g. a + * preset's physical size) so keep/scale commits the whole change together. */ +export type PendingDensity = + | { kind: "dpmm"; toDpmm: number; configPatch?: Partial } + | { kind: "jm"; toJm: JmDensity | undefined }; + +export interface RescaleParams { + fromEff: number; + toEff: number; + patch: Partial; + includeCalibrationFields: boolean; +} + +/** Single mapping from a pending density change to rescaleDesign arguments, shared + * by the preview and the committing action so they cannot diverge. ^JM keeps the + * same head, so only it rescales printer-persistent calibration fields. */ +export function rescaleParamsFor(pending: PendingDensity, label: LabelConfig): RescaleParams { + const fromEff = effectiveDpmm(label); + return pending.kind === "dpmm" + ? { + fromEff, + toEff: effectiveDpmm({ dpmm: pending.toDpmm, jmDensity: label.jmDensity }), + patch: { dpmm: pending.toDpmm, ...pending.configPatch }, + includeCalibrationFields: false, + } + : { + fromEff, + toEff: effectiveDpmm({ dpmm: label.dpmm, jmDensity: pending.toJm }), + patch: { jmDensity: pending.toJm }, + includeCalibrationFields: true, + }; +} /** A field whose scaled value had to be clamped or snapped, so the rescale is * not perfectly proportional and the user should know. */ @@ -9,7 +43,7 @@ export interface RescaleWarning { name: string; type: string; prop: string; - reason: "moduleClamped" | "magnificationClamped" | "dimensionClamped" | "imageFloor" | "imageFixed" | "deviceFontSnap"; + reason: "moduleClamped" | "magnificationClamped" | "dimensionClamped" | "imageFloor" | "imageFixed" | "deviceFontSnap" | "calibrationClamped" | "pinnedPageLabelFields"; } export interface RescaleResult { @@ -21,6 +55,22 @@ export interface RescaleResult { const MODULE_MAX = 10; // ^BY module-width ceiling (no per-type SSOT for the max). const IMAGE_MIN_DOTS = 8; +// Effective-space printer-persistent dot fields (^LS/^LT/^PF), scaled only on a +// ^JM reinterpretation (same head). An unbounded field keeps its scaled value. +export const CALIBRATION_CLAMP = scaledLabelConfigFields("jmOnly").map((prop) => ({ + prop, + min: labelConfigSpec(prop).clamp?.min ?? -Infinity, + max: labelConfigSpec(prop).clamp?.max ?? Infinity, +})); + +// Layout-affecting label dot fields (home origin, ^CF default font), always rescaled; `min` is the post-scale floor. +export const LAYOUT_LABEL_FIELDS = scaledLabelConfigFields("always").map((prop) => ({ + prop, + min: labelConfigSpec(prop).floor ?? 0, +})); + +const CALIBRATION_FIELDS: readonly (keyof LabelConfig)[] = CALIBRATION_CLAMP.map((c) => c.prop); + // Dot-valued props scaled proportionally; absent or 0 (unset block dims) stay 0. // `rounding` is excluded on purpose: ^GB's corner param is a 0-8 index, not // dots, and the radius already scales because width/height do. @@ -32,13 +82,16 @@ const SCALE_MIN0 = ["fontWidth", "blockHangingIndent", "fpCharGap"] as const; const SCALE_SIGNED = ["blockLineSpacing"] as const; const clamp = (min: number, max: number, v: number) => Math.min(max, Math.max(min, v)); +// Math.round(-12.5) = -12 biases negatives toward zero, so round the magnitude +// and restore the sign. Used wherever a signed dot value is scaled. +const roundSymmetric = (v: number) => Math.sign(v) * Math.round(Math.abs(v)); const labelOf = (leaf: LeafObject): string => (leaf.name && leaf.name.trim() ? leaf.name : leaf.type); /** Scale an integer prop and clamp to its spec bounds, reporting whether the * rounded value actually had to be pulled into range (a mere round is not a * clamp, so it is not flagged). */ function scaleClamped(raw: number, factor: number, min: number, max: number) { - const rounded = Math.round(raw * factor); + const rounded = roundSymmetric(raw * factor); const value = clamp(min, max, rounded); return { value, clamped: value !== rounded }; } @@ -59,7 +112,7 @@ function rescaleLeaf(leaf: LeafObject, factor: number, warnings: RescaleWarning[ } for (const k of SCALE_SIGNED) { const v = props[k]; - if (typeof v === "number") next[k] = Math.round(v * factor); + if (typeof v === "number") next[k] = roundSymmetric(v * factor); } // Editable bitmaps scale their box and drop the stale GFA cache so it @@ -105,7 +158,7 @@ function rescaleLeaf(leaf: LeafObject, factor: number, warnings: RescaleWarning[ if (typeof fontId === "string" && /^[A-H]$/.test(fontId)) warn("fontHeight", "deviceFontSnap"); } - return { ...leaf, x: Math.round(leaf.x * factor), y: Math.round(leaf.y * factor), props: next } as unknown as LeafObject; + return { ...leaf, x: roundSymmetric(leaf.x * factor), y: roundSymmetric(leaf.y * factor), props: next } as unknown as LeafObject; } function rescaleObjects(objects: LabelObject[], factor: number, warnings: RescaleWarning[]): LabelObject[] { @@ -116,31 +169,93 @@ function rescaleObjects(objects: LabelObject[], factor: number, warnings: Rescal ); } +/** A single page's ratio: the design's, corrected for a page pinned by its own + * ^JM. Such a page keeps its density across a design-level ^JM change, so + * applying the design ratio to it would scale dots that never moved. */ +function pageFactor(page: Page, before: LabelConfig, after: LabelConfig, designFactor: number): number { + const jmScale = (jm: LabelConfig["jmDensity"]) => effectiveDpmm({ dpmm: 1, jmDensity: jm }); + const design = jmScale(after.jmDensity) / jmScale(before.jmDensity); + const own = + jmScale(page.jmDensity ?? after.jmDensity) / jmScale(page.jmDensity ?? before.jmDensity); + return (designFactor * own) / design; +} + /** Rescale a whole design from `fromDpmm` to `toDpmm`, keeping the physical * (mm) size constant by scaling every dot-valued field by the density ratio. - * Pure: returns new pages + label + the list of fields that clamped/snapped. - * Group nodes carry no positional geometry (children are absolute), so only - * leaves are scaled. Printer-calibration label fields (^LT/^LS/^ML) are left - * alone; only layout-affecting label dots (home, default font) scale. */ + * Pure transform. `includeCalibrationFields` scales printer-persistent dot + * fields too, for a same-head ^JM reinterpretation (not a head swap). */ export function rescaleDesign( pages: Page[], label: LabelConfig, fromDpmm: number, toDpmm: number, + // Explicit, no default: the dpmm path patches { dpmm: toDpmm }, the ^JM path keeps + // the same head and patches { jmDensity }; a default here risks stamping dpmm on + // a ^JM reinterpretation. + configPatch: Partial, + includeCalibrationFields = false, ): RescaleResult { const warnings: RescaleWarning[] = []; + const nextLabel: LabelConfig = { ...label, ...configPatch }; if (fromDpmm === toDpmm || fromDpmm <= 0) { - return { pages, label: { ...label, dpmm: toDpmm }, warnings }; + return { pages, label: nextLabel, warnings }; } const factor = toDpmm / fromDpmm; - const nextPages = pages.map((p) => ({ ...p, objects: rescaleObjects(p.objects, factor, warnings) })); + const nextPages = pages.map((p) => { + const f = pageFactor(p, label, nextLabel, factor); + return f === 1 ? p : { ...p, objects: rescaleObjects(p.objects, f, warnings) }; + }); - const nextLabel: LabelConfig = { ...label, dpmm: toDpmm }; - if (typeof label.labelHomeX === "number") nextLabel.labelHomeX = Math.max(0, Math.round(label.labelHomeX * factor)); - if (typeof label.labelHomeY === "number") nextLabel.labelHomeY = Math.max(0, Math.round(label.labelHomeY * factor)); - if (typeof label.defaultFontHeight === "number") nextLabel.defaultFontHeight = Math.max(1, Math.round(label.defaultFontHeight * factor)); - if (typeof label.defaultFontWidth === "number") nextLabel.defaultFontWidth = Math.max(0, Math.round(label.defaultFontWidth * factor)); + for (const { prop, min } of LAYOUT_LABEL_FIELDS) { + const v = label[prop]; + // 0 means unset here (no home offset, ^CF default height): scaling it to + // the floor would invent a value the design never had. + if (typeof v === "number" && v !== 0) nextLabel[prop] = Math.max(min, Math.round(v * factor)); + } + + if (includeCalibrationFields) { + for (const { prop, min, max } of CALIBRATION_CLAMP) { + const v = label[prop]; + if (typeof v !== "number") continue; + const r = scaleClamped(v, factor, min, max); + nextLabel[prop] = r.value; + if (r.clamped) warnings.push({ id: "label", name: prop, type: "label", prop, reason: "calibrationClamped" }); + } + } + + // Shared label dot fields scale by the design factor even for a page pinned to + // its own ^JM, so that page's physical output shifts though its objects hold. + // Per-page config is the structural fix (deferred); warn until then. + const hasPinnedPage = pages.some((p) => pageFactor(p, label, nextLabel, factor) !== factor); + // Explicit 0 scales to 0: nothing physically moves, so it must not warn. + const scalesLabelField = + LAYOUT_LABEL_FIELDS.some(({ prop }) => typeof label[prop] === "number" && label[prop] !== 0) || + (includeCalibrationFields && CALIBRATION_CLAMP.some(({ prop }) => typeof label[prop] === "number" && label[prop] !== 0)); + if (hasPinnedPage && scalesLabelField) { + warnings.push({ id: "label", name: "", type: "label", prop: "", reason: "pinnedPageLabelFields" }); + } return { pages: nextPages, label: nextLabel, warnings }; } + +/** Whether rescaleDesign would actually change any dot-valued field, so the UI can + * skip a dead rescale prompt. Reads the same field sets as rescaleDesign; + * `configPatch` is the pending change, so pages pinned by their own ^JM don't count. */ +export function rescaleWouldChange( + pages: Page[], + label: LabelConfig, + includeCalibrationFields: boolean, + configPatch: Partial, +): boolean { + const next: LabelConfig = { ...label, ...configPatch }; + const { pages: nextPages, label: nextLabel } = rescaleDesign( + pages, label, effectiveDpmm(label), effectiveDpmm(next), configPatch, includeCalibrationFields, + ); + // rescaleDesign hands back the same page ref when its factor is 1, so a moved + // object page is a real change (an empty page's ref may change without one). + if (pages.some((p, i) => p.objects.length > 0 && nextPages[i] !== p)) return true; + const layout = LAYOUT_LABEL_FIELDS.map((f) => f.prop); + const fields = includeCalibrationFields ? [...layout, ...CALIBRATION_FIELDS] : layout; + return fields.some((f) => nextLabel[f] !== label[f]); +} diff --git a/src/lib/designFile.test.ts b/src/lib/designFile.test.ts index 8a9678a3..fa7b7c46 100644 --- a/src/lib/designFile.test.ts +++ b/src/lib/designFile.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { parseDesignFile, serializeDesign, CURRENT_DESIGN_SCHEMA_VERSION } from '@zplab/core/lib/designFile'; +import { importZplText } from '@zplab/core/lib/zplImportService'; import type { LabelObject } from '@zplab/core/types/Group'; import type { Variable } from '@zplab/core/types/Variable'; @@ -403,6 +404,48 @@ describe('parseDesignFile', () => { expect(result.error).toBe('invalid_schema'); }); + // A main-era v3 save predates the persisted ^JM fields; it loads through the + // legacy reconstruction, which reads the head density from the overlay bytes. + it('loads a v3 file and reconstructs its ^JM density from the overlay', () => { + const v3 = importZplText('^XA^JMB^FO10,10^A0N,30,30^FDa^FS^XZ', 8); + const legacyPages = v3.pages.map((p) => { + const overlay = p.overlay ? { ...p.overlay } : undefined; + if (overlay) delete overlay.head; + return { objects: p.objects, overlay }; + }); + const json = JSON.stringify({ + schemaVersion: 3, + label: { widthMm: 100, heightMm: 50, dpmm: 8 }, + pages: legacyPages, + }); + const result = parseDesignFile(json); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.label.jmDensity).toBe('B'); + expect(result.value.pages[0]?.jmDensity).toBeUndefined(); + expect(result.value.pages[0]?.overlay?.head).toBeDefined(); + }); + + // A v4 file already carries the density fields; it loads unchanged, the legacy + // reconstruction sees the label density and skips. + it('loads a v4 file and leaves its ^JM density untouched', () => { + const json = JSON.stringify({ + schemaVersion: 4, + label: { widthMm: 100, heightMm: 50, dpmm: 8, jmDensity: 'B' }, + pages: [{ objects: SAMPLE_OBJECTS, jmDensity: 'A' }], + }); + const result = parseDesignFile(json); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.label.jmDensity).toBe('B'); + expect(result.value.pages[0]?.jmDensity).toBe('A'); + }); + + it('writes schemaVersion 4 on save', () => { + const json = serializeDesign({ widthMm: 100, heightMm: 60, dpmm: 8 }, [{ objects: SAMPLE_OBJECTS }]); + expect((JSON.parse(json) as { schemaVersion: number }).schemaVersion).toBe(4); + }); + it('returns parse_error for invalid JSON', () => { const result = parseDesignFile('not json {'); expect(result.ok).toBe(false); diff --git a/src/lib/footprintMeasurer.ts b/src/lib/footprintMeasurer.ts index 6a3da41d..f6080cab 100644 --- a/src/lib/footprintMeasurer.ts +++ b/src/lib/footprintMeasurer.ts @@ -1,6 +1,7 @@ import { registerFootprintMeasurer } from "@zplab/core/lib/footprintProber"; import { clockCtxFromLabel } from "@zplab/core/lib/variableBinding"; import bwipjs from "bwip-js/browser"; +import { effectiveDpmm } from "@zplab/core/types/LabelConfig"; import { measureBarcodeFootprintDotsWith, resolveForMeasure, @@ -8,16 +9,17 @@ import { } from "@zplab/core/lib/barcodeDims"; import { type LeafObject } from "@zplab/core/registry"; import { isGroup, type LabelObject } from "@zplab/core/types/Group"; -import { useLabelStore } from "../store/labelStore"; +import { currentPageLabel, useLabelStore } from "../store/labelStore"; const engine = bwipjs as unknown as BwipEngine; const measure = (o: LabelObject, dpmm?: number) => { if (isGroup(o)) return null; const s = useLabelStore.getState(); + const label = currentPageLabel(s); // Defaults only: see resolveForMeasure (anchor must not track the preview row). - const resolved = resolveForMeasure(o, s.variables, clockCtxFromLabel(s.label)); - return measureBarcodeFootprintDotsWith(engine, resolved as LeafObject, dpmm ?? s.label.dpmm); + const resolved = resolveForMeasure(o, s.variables, clockCtxFromLabel(label)); + return measureBarcodeFootprintDotsWith(engine, resolved as LeafObject, dpmm ?? effectiveDpmm(label)); }; /** Registers the exact (dot-space, defaults-resolved) footprint measurer diff --git a/src/lib/importReport.ts b/src/lib/importReport.ts index 2580bebd..6eb71236 100644 --- a/src/lib/importReport.ts +++ b/src/lib/importReport.ts @@ -64,6 +64,11 @@ export function describeFinding( return { title: tr.fnDefaultDroppedTitleFmt.replace('{fn}', '^FN'), detail: f.command }; } if (f.kind === 'mixedPageGeometry') { + // ^JM divergence is a mode conflict, not a size one, so it gets its own + // headline; the command token alone is the detail (title carries meaning). + if (f.cause === 'jm') { + return { title: tr.mixedJmTitle, detail: f.command }; + } return { title: tr.mixedGeoTitle, detail: tr.mixedGeoDetailFmt.replace('{cmds}', '^PW/^LL').replace('{detail}', f.command), diff --git a/src/lib/printPreview.test.ts b/src/lib/printPreview.test.ts index e603d360..bbf85dfb 100644 --- a/src/lib/printPreview.test.ts +++ b/src/lib/printPreview.test.ts @@ -28,6 +28,11 @@ describe("buildPreviewZpl blank-field samples", () => { expect(zpl).not.toContain("^FD12345678^FS"); }); + it("emits the page's ^JM so a diverging page previews at its own density", () => { + const zpl = buildPreviewZpl({ ...label, jmDensity: "B" }, blankBarcode(), [], null); + expect(zpl).toContain("^JMB"); + }); + it("blank text has no sample and stays blank even with the opt-in", () => { const text = [ { diff --git a/src/lib/printPreview.ts b/src/lib/printPreview.ts index 0c4ce948..089be82a 100644 --- a/src/lib/printPreview.ts +++ b/src/lib/printPreview.ts @@ -27,7 +27,8 @@ function withBlankSamples(objects: LabelObject[]): LabelObject[] { * loaded). Shared by `printLabel` (new window with image) and * `enterPreviewMode` (canvas overlay) so the two stay in lockstep; * only the overlay opts into blank-field samples, printing must - * never put sample data on paper. */ + * never put sample data on paper. `label` is the page's label (its ^JM + * override included), since the emitted dots live in that density. */ export function buildPreviewZpl( label: LabelConfig, objects: LabelObject[], diff --git a/src/lib/safeArea.ts b/src/lib/safeArea.ts index 9890aa2f..aa31cba2 100644 --- a/src/lib/safeArea.ts +++ b/src/lib/safeArea.ts @@ -1,6 +1,7 @@ // Pure geometry for the configurable safe-area (margin) inset. A uniform // margin in mm, converted to dots, that align/pin and the canvas guide use // so elements keep a consistent distance to the label edge. +import { effectiveDpmm } from "@zplab/core/types/LabelConfig"; import { printableRectDots, type BoundingBoxDots } from "@zplab/core/lib/objectBounds"; import type { LabelConfig } from "@zplab/core/types/LabelConfig"; @@ -12,7 +13,7 @@ import { mmToDots } from "@zplab/core/lib/coordinates"; export function safeAreaRectDots(label: LabelConfig): BoundingBoxDots | null { const mm = label.safeAreaMm ?? 0; if (mm <= 0) return null; - const inset = mmToDots(mm, label.dpmm); + const inset = mmToDots(mm, effectiveDpmm(label)); if (inset <= 0) return null; const r = printableRectDots(label); const width = r.width - 2 * inset; diff --git a/src/lib/zplCommandSupport.ts b/src/lib/zplCommandSupport.ts index 685f6cfb..c22766e6 100644 --- a/src/lib/zplCommandSupport.ts +++ b/src/lib/zplCommandSupport.ts @@ -166,6 +166,7 @@ const ZPL_COMMANDS: readonly ZplCommandInfo[] = [ { cmd: 'PR', status: 'structural', description: 'Print rate: sets print speed (hardware)' }, { cmd: 'PS', status: 'structural', description: 'Print start: resumes printing after a pause (hardware)' }, { cmd: 'JS', status: 'supported', description: 'Change backfeed sequence (A/B/N/O or percent 10-90, rounded to tens like the printer)' }, + { cmd: 'JM', status: 'supported', description: 'Set dots per millimeter: B halves the density, dot values then count in the halved scale' }, // ── Printer storage & resources ─────────────────────────────────────────── { diff --git a/src/lib/zplImportService.test.ts b/src/lib/zplImportService.test.ts index b7a4ea1e..6184a907 100644 --- a/src/lib/zplImportService.test.ts +++ b/src/lib/zplImportService.test.ts @@ -201,6 +201,50 @@ describe('importZplText - multi-label', () => { const result = importZplText(zpl, 8); expect(result.pages).toHaveLength(2); }); + + it('flags and rejects a stream whose later block diverges in ^JM density', () => { + const zpl = [ + '^XA^FO10,10^A0N,30,30^FDx^FS^XZ', + '^XA^JMB^FO10,10^A0N,30,30^FDy^FS^XZ', + ].join('\n'); + const result = importZplText(zpl, 8); + expect(result.mixedPageGeometry).toBe(true); + expect(result.report.findings).toContainEqual({ kind: 'mixedPageGeometry', command: '^JM', pageIndex: 1, cause: 'jm' }); + const finding = result.report.findings.find((f) => f.command === '^JM'); + expect(describeFinding(finding!, fallbackTranslations.importReport).title).toBe( + 'Density mode differs between blocks: later pages keep their own mode, but only the first is editable', + ); + }); + + it('lets a settings-only block 0 inherit a later block ^JM (no divergence)', () => { + const zpl = ['^XA^MNY^XZ', '^XA^JMB^FO10,10^A0N,30,30^FDy^FS^XZ'].join('\n'); + const result = importZplText(zpl, 8); + expect(result.mixedPageGeometry).toBe(false); + expect(result.report.findings.some((f) => f.command === '^JM')).toBe(false); + expect(result.labelConfig.jmDensity).toBe('B'); + }); + + it('anchors on the first object block, not on a settings-only lead block', () => { + const zpl = [ + '^XA^MNY^XZ', + '^XA^FO10,10^GB50,50,2^FS^XZ', + '^XA^JMB^FO10,10^GB50,50,2^FS^XZ', + ].join('\n'); + const result = importZplText(zpl, 8); + expect(result.labelConfig.jmDensity).toBeUndefined(); + expect(result.mixedPageGeometry).toBe(true); + expect(result.report.findings).toContainEqual({ kind: 'mixedPageGeometry', command: '^JM', pageIndex: 2, cause: 'jm' }); + }); + + it('treats a later ^JMA block as full density (no divergence from an unset first block)', () => { + const zpl = [ + '^XA^FO10,10^A0N,30,30^FDx^FS^XZ', + '^XA^JMA^FO10,10^A0N,30,30^FDy^FS^XZ', + ].join('\n'); + const result = importZplText(zpl, 8); + expect(result.mixedPageGeometry).toBe(false); + expect(result.report.findings.some((f) => f.command === '^JM')).toBe(false); + }); }); describe('importZplText - empty / malformed', () => { diff --git a/src/lib/zplJmDensity.test.ts b/src/lib/zplJmDensity.test.ts new file mode 100644 index 00000000..b62ddef1 --- /dev/null +++ b/src/lib/zplJmDensity.test.ts @@ -0,0 +1,1023 @@ +import { describe, it, expect } from 'vitest'; +import { parseZPL } from '@zplab/core/lib/zplParser'; +import { emitOverlayPage, generateMultiPageZPL, generateZPL } from '@zplab/core/lib/zplGenerator'; +import { importZplText, rebaseAppendedPageDensity } from '@zplab/core/lib/zplImportService'; +import { parseDesignFile, serializeDesign } from '@zplab/core/lib/designFile'; +import { effectiveDpmm, type JmDensity, type LabelConfig } from "@zplab/core/types/LabelConfig"; +import { printableRectDots } from '@zplab/core/lib/objectBounds'; +import { blockOverlaySchema } from '@zplab/core/lib/zplOverlay/overlay'; + +const base: LabelConfig = { widthMm: 100, heightMm: 50, dpmm: 8 }; + +describe('^JM density', () => { + it('effectiveDpmm halves only under B', () => { + expect(effectiveDpmm({ dpmm: 8 })).toBe(8); + expect(effectiveDpmm({ dpmm: 8, jmDensity: 'A' })).toBe(8); + expect(effectiveDpmm({ dpmm: 8, jmDensity: 'B' })).toBe(4); + }); + + it('reads ^PW/^LL as physical head dots under ^JMB (ZD230-verified)', () => { + const { labelConfig } = parseZPL('^XA^JMB^PW400^LL200^XZ', 8); + expect(labelConfig.jmDensity).toBe('B'); + expect(labelConfig.widthMm).toBe(50); + expect(labelConfig.heightMm).toBe(25); + }); + + it('keeps ^PW/^LL physical when ^JM arrives after them (jm-independent)', () => { + const { labelConfig } = parseZPL('^XA^PW400^LL200^JMB^XZ', 8); + expect(labelConfig.widthMm).toBe(50); + expect(labelConfig.heightMm).toBe(25); + }); + + it('reads a bare ^JM as full density A (spec default p269)', () => { + expect(parseZPL('^XA^JM^PW400^XZ', 8).labelConfig.jmDensity).toBe('A'); + }); + + it('reads ^MU inch ^ML as physical dots, ^JM-independent and order-invariant (ZD230)', () => { + expect(parseZPL('^XA^JMB^MUI^ML2^XZ', 8).labelConfig.maxLabelLength).toBe( + parseZPL('^XA^MUI^JMB^ML2^XZ', 8).labelConfig.maxLabelLength, + ); + // ZD230: ^ML is physical, so 2 in = 2 * 8 * 25.4 = 406 dots under ^JMA or ^JMB. + expect(parseZPL('^XA^MUI^JMB^ML2^XZ', 8).labelConfig.maxLabelLength).toBe(406); + }); + + it('reads unit-converted ^ML physically whether ^JM leads or trails ^MU', () => { + expect(parseZPL('^XA^JMB^MUI^ML2^XZ', 8).labelConfig.maxLabelLength).toBe(406); + expect(parseZPL('^XA^MUI^ML2^JMB^XZ', 8).labelConfig.maxLabelLength).toBe(406); + }); + + it('rescales a unit-converted ^FO the same whether ^JM leads or trails ^MU', () => { + const lead = parseZPL('^XA^JMB^MUI^FO2,1^A0N,1,1^FDx^FS^XZ', 8); + const trail = parseZPL('^XA^MUI^FO2,1^JMB^A0N,1,1^FDx^FS^XZ', 8); + expect((lead.pages[0]?.objects[0] as { x: number }).x).toBe(203); + expect((trail.pages[0]?.objects[0] as { x: number }).x).toBe(203); + }); + + it('does not retroactively rescale a prior format across ^XA (persistent dots)', () => { + const r = parseZPL('^XA^MUI^ML2^XZ^XA^JMB^XZ', 8); + expect(r.pages[1]?.labelConfig.maxLabelLength).toBe(406); + }); + + it('round-trips: emits ^JMB plus physical ^PW and parses back', () => { + const zpl = generateZPL({ ...base, jmDensity: 'B' }, []); + expect(zpl).toContain('^JMB'); + expect(zpl).toContain('^PW800'); + expect(zpl.indexOf('^JMB')).toBeLessThan(zpl.indexOf('^FS') === -1 ? zpl.length : zpl.indexOf('^FS')); + const back = parseZPL(zpl, 8).labelConfig; + expect(back.jmDensity).toBe('B'); + expect(back.widthMm).toBe(100); + expect(back.heightMm).toBe(50); + }); + + it('an explicit ^JMA round-trips without rescaling', () => { + const { labelConfig } = parseZPL('^XA^JMA^PW400^XZ', 8); + expect(labelConfig.jmDensity).toBe('A'); + expect(labelConfig.widthMm).toBe(50); + expect(generateZPL({ ...base, jmDensity: 'A' }, [])).toContain('^JMA'); + }); + + it('drops an invalid ^JM value', () => { + expect(parseZPL('^XA^JMX^PW400^XZ', 8).labelConfig.jmDensity).toBeUndefined(); + }); + + it('keeps block 0 config; a later block ^JMB does not leak (retroactive rescale)', () => { + const r = importZplText( + '^XA^FO10,10^A0N,30,30^FDa^FS^XZ^XA^JMB^FO10,10^A0N,30,30^FDb^FS^XZ', + 8, + ); + expect(r.labelConfig.jmDensity).toBeUndefined(); + // A later block that diverges in density is flagged (single-label model + // keeps only block 0's). + expect( + r.report.findings.some( + (f) => f.kind === 'mixedPageGeometry' && f.command === '^JM' && f.pageIndex === 1, + ), + ).toBe(true); + }); + + it('round-trips a per-page ^JM override through save/load', () => { + const r = importZplText( + '^XA^FO10,10^A0N,30,30^FDa^FS^XZ\n^XA^JMB^FO10,10^A0N,30,30^FDb^FS^XZ', + 8, + ); + const label = { ...base, ...r.labelConfig }; + const loaded = parseDesignFile(serializeDesign(label, r.pages, r.variables)); + expect(loaded.ok).toBe(true); + if (!loaded.ok) return; + expect(loaded.value.pages[1]?.jmDensity).toBe('B'); + expect(generateMultiPageZPL(loaded.value.label, loaded.value.pages, loaded.value.variables)).toBe( + generateMultiPageZPL(label, r.pages, r.variables), + ); + }); + + // Main-era payloads (schemaVersion 3) never modelled ^JM: the head ^JMB rode + // only in the overlay bytes, with no overlay.head and no page.jmDensity. + // Load must reconstruct the override so regeneration keeps the density. + it('reconstructs a legacy overlay ^JMB (no head, no page override) on load', () => { + const r = importZplText( + '^XA^FO10,10^A0N,30,30^FDa^FS^XZ\n^XA^FO10,10^JMB^A0N,30,30^FDb^FS^XZ', + 8, + ); + const label = { ...base, ...r.labelConfig }; + expect(label.jmDensity).toBeUndefined(); + const legacyPages = r.pages.map((p) => { + const overlay = p.overlay ? { ...p.overlay } : undefined; + if (overlay) delete overlay.head; + return { objects: p.objects, overlay }; + }); + const loaded = parseDesignFile(serializeDesign(label, legacyPages, r.variables)); + expect(loaded.ok).toBe(true); + if (!loaded.ok) return; + expect(loaded.value.pages[1]?.jmDensity).toBe('B'); + // Force a full regeneration of page 1 (the in-span ^JMB block is regen- + // hostile, so a dirty edit falls back to model regen). + const pages = loaded.value.pages.map((p, i) => + i === 1 + ? { ...p, objects: p.objects.map((o) => ({ ...o, x: o.x + 5, dirty: true })) } + : p, + ); + const out = generateMultiPageZPL(loaded.value.label, pages, loaded.value.variables); + expect(out.split('^XZ')[1]).toContain('^JMB'); + }); + + it('leaves a legacy overlay without a diverging ^JM unpinned on load', () => { + const r = importZplText( + '^XA^FO10,10^A0N,30,30^FDa^FS^XZ\n^XA^FO10,10^A0N,30,30^FDb^FS^XZ', + 8, + ); + const label = { ...base, ...r.labelConfig }; + const legacyPages = r.pages.map((p) => { + const overlay = p.overlay ? { ...p.overlay } : undefined; + if (overlay) delete overlay.head; + return { objects: p.objects, overlay }; + }); + const loaded = parseDesignFile(serializeDesign(label, legacyPages, r.variables)); + expect(loaded.ok).toBe(true); + if (!loaded.ok) return; + expect(loaded.value.pages[0]?.jmDensity).toBeUndefined(); + expect(loaded.value.pages[1]?.jmDensity).toBeUndefined(); + }); + + // A main-era save carried a head ^JMB only in the overlay bytes (no head, no + // page override). Reconstruction must synthesize the head so a zero-edit + // export stays on the verbatim path instead of regenerating the block. + it('replays a legacy head ^JMB byte-identically on a zero-edit export', () => { + const src = '^XA^FO10,10^A0N,30,30^FDa^FS^XZ\n^XA^JMB^FO10,10^A0N,30,30^FDb^FS^XZ'; + const r = importZplText(src, 8); + const label = { ...base, ...r.labelConfig }; + expect(label.jmDensity).toBeUndefined(); + const legacyPages = r.pages.map((p) => { + const overlay = p.overlay ? { ...p.overlay } : undefined; + if (overlay) delete overlay.head; + return { objects: p.objects, overlay }; + }); + const loaded = parseDesignFile(serializeDesign(label, legacyPages, r.variables)); + expect(loaded.ok).toBe(true); + if (!loaded.ok) return; + expect(loaded.value.pages[1]?.jmDensity).toBe('B'); + const out = generateMultiPageZPL(loaded.value.label, loaded.value.pages, loaded.value.variables); + expect(out).toBe(src); + }); + + it('still carries ^JMB after an object edit on a reconstructed legacy head', () => { + const r = importZplText( + '^XA^FO10,10^A0N,30,30^FDa^FS^XZ\n^XA^JMB^FO10,10^A0N,30,30^FDb^FS^XZ', + 8, + ); + const label = { ...base, ...r.labelConfig }; + const legacyPages = r.pages.map((p) => { + const overlay = p.overlay ? { ...p.overlay } : undefined; + if (overlay) delete overlay.head; + return { objects: p.objects, overlay }; + }); + const loaded = parseDesignFile(serializeDesign(label, legacyPages, r.variables)); + expect(loaded.ok).toBe(true); + if (!loaded.ok) return; + const pages = loaded.value.pages.map((p, i) => + i === 1 + ? { ...p, objects: p.objects.map((o) => ({ ...o, x: o.x + 5, dirty: true })) } + : p, + ); + const out = generateMultiPageZPL(loaded.value.label, pages, loaded.value.variables); + expect(out.split('^XZ')[1]).toContain('^JMB'); + }); + + it('rewrites a reconstructed ^JMB when the model contradicts it (density A)', () => { + const r = importZplText( + '^XA^FO10,10^A0N,30,30^FDa^FS^XZ\n^XA^JMB^FO10,10^A0N,30,30^FDb^FS^XZ', + 8, + ); + const label = { ...base, ...r.labelConfig }; + const legacyPages = r.pages.map((p) => { + const overlay = p.overlay ? { ...p.overlay } : undefined; + if (overlay) delete overlay.head; + return { objects: p.objects, overlay }; + }); + const loaded = parseDesignFile(serializeDesign(label, legacyPages, r.variables)); + expect(loaded.ok).toBe(true); + if (!loaded.ok) return; + // rescaleJmDensity drops overlays on a density change, so construct the + // model/head contradiction directly: force page 1 back to A. + const pages = loaded.value.pages.map((p, i) => (i === 1 ? { ...p, jmDensity: 'A' as const } : p)); + const block1 = generateMultiPageZPL(loaded.value.label, pages, loaded.value.variables).split('^XZ')[1] ?? ''; + expect(block1).toContain('^JMA'); + expect(block1).not.toContain('^JMB'); + }); + + it('is idempotent: a second load leaves the reconstructed head untouched', () => { + const src = '^XA^FO10,10^A0N,30,30^FDa^FS^XZ\n^XA^JMB^FO10,10^A0N,30,30^FDb^FS^XZ'; + const r = importZplText(src, 8); + const label = { ...base, ...r.labelConfig }; + const legacyPages = r.pages.map((p) => { + const overlay = p.overlay ? { ...p.overlay } : undefined; + if (overlay) delete overlay.head; + return { objects: p.objects, overlay }; + }); + const first = parseDesignFile(serializeDesign(label, legacyPages, r.variables)); + expect(first.ok).toBe(true); + if (!first.ok) return; + expect(first.value.pages[1]?.overlay?.head).toBeDefined(); + const second = parseDesignFile( + serializeDesign(first.value.label, first.value.pages, first.value.variables), + ); + expect(second.ok).toBe(true); + if (!second.ok) return; + expect(second.value.pages).toEqual(first.value.pages); + expect( + generateMultiPageZPL(second.value.label, second.value.pages, second.value.variables), + ).toBe(src); + }); + + it('rescales a committed graphic the same whether ^JM leads or trails ^MU', () => { + const lead = parseZPL('^XA^JMB^MUM^FO2,2^GE4,2,1^FS^XZ', 8); + const trail = parseZPL('^XA^MUM^FO2,2^GE4,2,1^JMB^FS^XZ', 8); + const pick = (r: ReturnType) => { + const o = r.pages[0]?.objects[0] as { x: number; y: number; props: { width: number; height: number; thickness: number } }; + const { width, height, thickness } = o.props; + return { x: o.x, y: o.y, width, height, thickness }; + }; + expect(pick(trail)).toEqual({ x: 8, y: 8, width: 16, height: 8, thickness: 4 }); + expect(pick(lead)).toEqual(pick(trail)); + }); + + it('rescales ^CF default font dots the same whether ^JM leads or trails', () => { + const lead = parseZPL('^XA^JMB^MUM^CF0,4,2^XZ', 8).labelConfig; + const trail = parseZPL('^XA^MUM^CF0,4,2^JMB^XZ', 8).labelConfig; + expect(trail.defaultFontHeight).toBe(16); + expect(trail.defaultFontWidth).toBe(8); + expect(lead.defaultFontHeight).toBe(trail.defaultFontHeight); + expect(lead.defaultFontWidth).toBe(trail.defaultFontWidth); + }); + + + it('printableRectDots follows the effective density (drag bounds, align, spawn)', () => { + expect(printableRectDots(base).width).toBe(800); + expect(printableRectDots({ ...base, jmDensity: 'B' })).toMatchObject({ width: 400, height: 200 }); + }); + + it('^PW is jm-independent across blocks (physical, same mm both pages)', () => { + const r = parseZPL('^XA^JMB^PW400^XZ^XA^JMA^XZ', 8); + expect(r.pages[0]?.labelConfig.widthMm).toBe(50); + expect(r.pages[1]?.labelConfig.widthMm).toBe(50); + expect(r.pages[1]?.labelConfig.jmDensity).toBe('A'); + }); + + + it('ignores a ^JM after the format has seen its first ^FS (printer does too)', () => { + const { labelConfig } = parseZPL('^XA^FO10,10^GB10,10,1^FS^JMB^PW400^XZ', 8); + expect(labelConfig.jmDensity).toBeUndefined(); + expect(labelConfig.widthMm).toBe(50); + // A later format may still set it (fresh ^FS budget per ^XA). + const r = parseZPL('^XA^FO10,10^GB10,10,1^FS^XZ^XA^JMB^PW400^XZ', 8); + expect(r.pages[1]?.labelConfig.jmDensity).toBe('B'); + }); + + + it('an in-span ^JMB survives a dirty-object regen (persistent-def fallback)', () => { + const zpl = '^XA^FO10,10^JMB^BY2^BCN,100,N,N,N^FD123^FS^FO200,10^A0N,30,30^FDx^FS^XZ'; + const r = importZplText(zpl, 8); + const page = r.pages[0]!; + const first = page.objects[0]! as { x: number }; + const edited = { + ...page, + objects: page.objects.map((o, i) => (i === 0 ? { ...o, x: first.x + 5, dirty: true } : o)), + }; + const out = emitOverlayPage({ widthMm: 100, heightMm: 50, dpmm: 8, ...r.labelConfig }, edited, r.variables); + expect(out).toContain('^JMB'); + }); + + it('re-emits a later page ^JMB when a dirty edit forces its full regen', () => { + // The in-span ^JMB makes the block regen-hostile, so the dirty edit falls + // back to model regeneration; without the page override that drops ^JMB and + // the page silently prints at full density. + const r = importZplText( + '^XA^FO10,10^A0N,30,30^FDa^FS^XZ\n^XA^FO10,10^JMB^A0N,30,30^FDb^FS^XZ', + 8, + ); + expect(r.labelConfig.jmDensity).toBeUndefined(); + expect(r.pages[1]?.jmDensity).toBe('B'); + const pages = r.pages.map((p, i) => + i === 1 + ? { ...p, objects: p.objects.map((o) => ({ ...o, x: o.x + 5, dirty: true })) } + : p, + ); + const out = generateMultiPageZPL({ ...base, ...r.labelConfig }, pages, r.variables); + expect(out.split('^XZ')[1]).toContain('^JMB'); + }); + + it('declares an inherited ^JMB when the leading settings-only block is gone', () => { + const r = importZplText('^XA^JMB^XZ\n^XA^FO10,10^A0N,30,30^FDa^FS^XZ', 8); + const label = { ...base, ...r.labelConfig }; + // Full replay stays byte-identical: the leading block still carries ^JMB. + expect(generateMultiPageZPL(label, r.pages, r.variables)).toBe( + '^XA^JMB^XZ\n^XA^FO10,10^A0N,30,30^FDa^FS^XZ', + ); + expect(generateMultiPageZPL(label, r.pages.slice(1), r.variables)).toBe( + '^XA^JMB^FO10,10^A0N,30,30^FDa^FS^XZ', + ); + }); + + it('persists within the stream: block 2 inherits block 1 ^JMB', () => { + const r = parseZPL('^XA^JMB^PW400^XZ^XA^PW400^XZ', 8); + expect(r.pages[1]?.labelConfig.jmDensity).toBe('B'); + expect(r.pages[1]?.labelConfig.widthMm).toBe(50); + }); + + // ── Format-head lookahead: reads matching a prior block value ──────────── + it('keeps ^ML physical on a later ^JMB block (ZD230: ^JM-independent)', () => { + // ^ML reads at physical head dots, so ^JMB does not halve it; both blocks + // hold the same 2 in = 406. + const r = parseZPL('^XA^MUI^ML2^XZ^XA^MUI^ML2^JMB^XZ', 8); + expect(r.pages[0]?.labelConfig.maxLabelLength).toBe(406); + expect(r.pages[1]?.labelConfig.maxLabelLength).toBe(406); + }); + + it('rescales a field origin equal to a prior block value', () => { + const r = parseZPL( + '^XA^MUI^FO2,0^A0N,1,1^FDa^FS^XZ^XA^MUI^FO2,0^JMB^A0N,1,1^FDb^FS^XZ', + 8, + ); + expect((r.pages[0]?.objects[0] as { x: number }).x).toBe(406); + expect((r.pages[1]?.objects[0] as { x: number }).x).toBe(203); + }); + + it('scales only the ^FO dots, not a persistent ^LH offset (composite)', () => { + // ^LH set full-density in block 0 persists; block 1 adds it, then ^JMB. + // Only the ^FO 2in halves (406→203); the kept 203-dot home is not scaled. + const r = parseZPL('^XA^MUI^LH1,0^XZ^XA^MUI^FO2,0^JMB^A0N,1,1^FDx^FS^XZ', 8); + expect((r.pages[1]?.objects[0] as { x: number }).x).toBe(406); + }); + + it('scales a same-block ^LH and the ^FO that adds it together', () => { + const r = parseZPL('^XA^MUI^LH1,0^FO2,0^JMB^A0N,1,1^FDx^FS^XZ', 8); + // lhX 203→102, FO 406→203, field.x = 203 + 102 = 305. + expect((r.pages[0]?.objects[0] as { x: number }).x).toBe(305); + }); + + it('recomputes a fractional ^MU coordinate from raw (no double rounding), lead==trail', () => { + const lead = parseZPL('^XA^JMB^MUM^FO1.1,0^A0N,1,1^FDx^FS^XZ', 8); + const trail = parseZPL('^XA^MUM^FO1.1,0^JMB^A0N,1,1^FDx^FS^XZ', 8); + // 1.1mm @ 4 dots/mm = 4.4 → 4. Scaling the 9-dot full-density value would + // double-round to 5. + expect((lead.pages[0]?.objects[0] as { x: number }).x).toBe(4); + expect((trail.pages[0]?.objects[0] as { x: number }).x).toBe(4); + }); +}); + +// ── Format-head ^JM lookahead ──────────────────────────────────────────────── +// The lookahead resolves the format's density before any body token, so lead and +// trail placements of ^JM are identical by construction; each pair asserts that. +describe('^JM format-head lookahead', () => { + const x0 = (r: ReturnType) => (r.pages[0]?.objects[0] as { x: number }).x; + + it('reads ^ML at the physical I-scale despite trailing ^MU-mode churn', () => { + // ^MUI^ML2 = 2 in = 406 physical dots (ZD230); neither the later ^MUM nor + // ^JMB shifts it. + expect(parseZPL('^XA^MUI^ML2^MUM^JMB^XZ', 8).labelConfig.maxLabelLength).toBe(406); + }); + + it('leaves ^MUD reads invariant under ^JMB, lead==trail', () => { + expect(parseZPL('^XA^JMB^MUD^ML200^XZ', 8).labelConfig.maxLabelLength).toBe(200); + expect(parseZPL('^XA^MUD^ML200^JMB^XZ', 8).labelConfig.maxLabelLength).toBe(200); + }); + + it('applies a mid-field-flush ^JMB to the surviving field, lead==trail', () => { + // The second ^FO flushes the first (dataless) field; ^JMB still precedes the + // format's first ^FS, so it applies to the whole format. FO3in = 3*4*25.4=305. + expect(x0(parseZPL('^XA^JMB^MUI^FO2,0^FO3,0^A0N,1,1^FDx^FS^XZ', 8))).toBe(305); + expect(x0(parseZPL('^XA^MUI^FO2,0^FO3,0^JMB^A0N,1,1^FDx^FS^XZ', 8))).toBe(305); + }); + + it('rescales a stashed reverse-bg ^GB under ^JMB, lead==trail', () => { + const pick = (r: ReturnType) => { + const o = r.pages[0]?.objects[0] as { x: number; y: number }; + return { x: o.x, y: o.y }; + }; + const lead = parseZPL('^XA^JMB^MUM^FO2,2^GB4,4,4^FS^XZ', 8); + const trail = parseZPL('^XA^MUM^FO2,2^GB4,4,4^JMB^FS^XZ', 8); + expect(pick(trail)).toEqual({ x: 8, y: 8 }); + expect(pick(lead)).toEqual(pick(trail)); + }); + + it('rescales a ^GS symbol and its width fallback under ^JMB, lead==trail', () => { + const dims = (r: ReturnType) => { + const p = (r.pages[0]?.objects[0] as { props: { height: number; width: number } }).props; + return { height: p.height, width: p.width }; + }; + // ^GS,4 omits the width slot → it falls back to the (scaled) height. + const lead = parseZPL('^XA^JMB^MUM^FO0,0^GS,4^FDA^FS^XZ', 8); + const trail = parseZPL('^XA^MUM^FO0,0^GS,4^JMB^FDA^FS^XZ', 8); + expect(dims(trail)).toEqual({ height: 16, width: 16 }); + expect(dims(lead)).toEqual(dims(trail)); + }); + + it('rescales ^LS under ^JMB, lead==trail', () => { + expect(parseZPL('^XA^JMB^MUM^LS4^XZ', 8).labelConfig.labelShift).toBe(16); + expect(parseZPL('^XA^MUM^LS4^JMB^XZ', 8).labelConfig.labelShift).toBe(16); + }); + + it('rescales ^PF like the other dot-row commands, lead==trail', () => { + expect(parseZPL('^XA^MUI^PF1^XZ', 8).labelConfig.slewDotRows).toBe(203); + expect(parseZPL('^XA^JMB^MUM^PF4^XZ', 8).labelConfig.slewDotRows).toBe(16); + expect(parseZPL('^XA^MUM^PF4^JMB^XZ', 8).labelConfig.slewDotRows).toBe(16); + }); + + it('reads ^PW/^LL at physical density under ^JMB+^MUI (ZD230), order-independent', () => { + // ZD230-verified: 2in print width = 406 physical dots and 3in length = 609, + // both unchanged by ^JM and its placement (only object dots are halved). + for (const z of ['^XA^MUI^PW2^LL3^XZ', '^XA^JMB^MUI^PW2^LL3^XZ', '^XA^MUI^PW2^LL3^JMB^XZ']) { + const { labelConfig } = parseZPL(z, 8); + expect(labelConfig.widthMm).toBe(50.8); + expect(labelConfig.heightMm).toBe(76.3); + } + }); + + it('surfaces an invalid or post-^FS ^JM as a partial import', () => { + const invalid = parseZPL('^XA^JMX^XZ', 8); + expect(invalid.pages[0]?.findings.some((f) => f.kind === 'partial' && f.command === '^JM')).toBe(true); + const postFs = parseZPL('^XA^FO10,10^GB10,10,1^FS^JMB^XZ', 8); + expect(postFs.pages[0]?.findings.some((f) => f.kind === 'partial' && f.command === '^JM')).toBe(true); + }); + + it('ignores ~JM (not a real command): no density, routed as a device action', () => { + const r = parseZPL('^XA~JMB^PW400^XZ', 8); + expect(r.labelConfig.jmDensity).toBeUndefined(); + expect(r.pages[0]?.findings.some((f) => f.kind === 'deviceAction' && f.command === '~JM')).toBe(true); + }); + + it('a post-first-^FS in-span ^JM is a no-op, so it does not force a lossy regen', () => { + // ^JMB sits in the SECOND field span, after the format's first ^FS. It is + // ignored (no density change), so it defines nothing a regen would drop and + // must not mark the overlay lossy. + const r = parseZPL( + '^XA^FO10,10^A0N,30,30^FDa^FS^FO50,50^JMB^A0N,30,30^FDb^FS^XZ', + 8, + { captureOverlay: true }, + ); + expect(r.pages[0]?.overlay).toBeDefined(); + expect(r.pages[0]?.findings.some((f) => f.kind === 'lossyEdit')).toBe(false); + }); + + it('a pre-first-^FS in-span ^JM stays regen-hostile (definition lives in the span)', () => { + // ^JMB before the field's ^FS is applied by the lookahead; the span replace + // would drop it, so the overlay must be flagged lossy. + const r = parseZPL( + '^XA^FO10,10^JMB^A0N,30,30^FDa^FS^XZ', + 8, + { captureOverlay: true }, + ); + expect(r.pages[0]?.findings.some((f) => f.kind === 'lossyEdit')).toBe(true); + }); + + it('a headless format does not inherit the next format ^JM (lookahead stops at ^XA)', () => { + // Format 0 has no ^FS/^XZ before the next ^XA; its head must not scan into + // format 1 and pick up that block's ^JMB. Format 0 stays ^JMA, so ^MUI^ML2 + // reads full-density physical 406, not the halved 203. + const r = parseZPL('^XA^MUI^ML2^XA^JMB^ML2^XZ', 8); + expect(r.pages[0]?.labelConfig.jmDensity).toBeUndefined(); + expect(r.pages[0]?.labelConfig.maxLabelLength).toBe(406); + }); + + it('tracks a ^CC prefix remap in the head so a remapped ^JM still resolves', () => { + // ^CC/ remaps the caret to '/'; the head lookahead must follow it to see /JMB. + expect(parseZPL('^XA^CC//JMB/XZ', 8).labelConfig.jmDensity).toBe('B'); + }); + + it('reports a pre-^XA ^JM as partial without applying a density (no format head)', () => { + const r = parseZPL('^JMB^XA^PW400^XZ', 8); + expect(r.labelConfig.jmDensity).toBeUndefined(); + expect(r.pages[0]?.findings.some((f) => f.kind === 'partial' && f.command === '^JM')).toBe(true); + }); +}); + +// ── Wire transition matrix ─────────────────────────────────────────────────── +// ^JM is persistent (p269): what a block must declare depends on what the +// preceding blocks left on the wire. `[]` = declares nothing and inherits. +describe('^JM wire transitions across exported blocks', () => { + const declaredJm = (block: string) => [...block.matchAll(/\^JM(.)/g)].map((m) => m[1]); + const blocks = (zpl: string) => zpl.split('^XZ').slice(0, -1); + + const cases: { + name: string; + design?: JmDensity; + pages: (JmDensity | undefined)[]; + expected: string[][]; + }[] = [ + { name: 'unset -> unset', pages: [undefined, undefined], expected: [[], []] }, + { name: 'unset -> A', pages: [undefined, 'A'], expected: [[], ['A']] }, + { name: 'unset -> B', pages: [undefined, 'B'], expected: [[], ['B']] }, + { name: 'A -> B', pages: ['A', 'B'], expected: [['A'], ['B']] }, + { name: 'B -> A', pages: ['B', 'A'], expected: [['B'], ['A']] }, + { name: 'B -> unset resets the wire', pages: ['B', undefined], expected: [['B'], ['A']] }, + { name: 'A -> unset needs no reset', pages: ['A', undefined], expected: [['A'], []] }, + { name: 'B -> B stays on one density', pages: ['B', 'B'], expected: [['B'], ['B']] }, + { name: 'B -> unset -> B', pages: ['B', undefined, 'B'], expected: [['B'], ['A'], ['B']] }, + { + name: 'design B: an unset page inherits it', + design: 'B', + pages: [undefined, undefined], + expected: [['B'], ['B']], + }, + { + name: 'design B: a page overriding to A resets, the next inherits B again', + design: 'B', + pages: [undefined, 'A', undefined], + expected: [['B'], ['A'], ['B']], + }, + ]; + + for (const { name, design, pages, expected } of cases) { + it(name, () => { + const label: LabelConfig = design ? { ...base, jmDensity: design } : base; + const modelPages = pages.map((jm) => (jm ? { objects: [], jmDensity: jm } : { objects: [] })); + const out = generateMultiPageZPL(label, modelPages, []); + expect(blocks(out).map(declaredJm)).toEqual(expected); + }); + } + + it('lets the last ^JM in a format head win', () => { + expect(parseZPL('^XA^JMB^JMA^PW400^XZ', 8).labelConfig.jmDensity).toBe('A'); + expect(parseZPL('^XA^JMA^JMB^PW400^XZ', 8).labelConfig.jmDensity).toBe('B'); + }); + + it('resets an inherited ^JMB for a page added after it (empty new page)', () => { + const r = importZplText('^XA^JMB^FO10,10^A0N,30,30^FDa^FS^XZ', 8); + const label = { ...base, ...r.labelConfig, jmDensity: undefined }; + const pages = [{ ...r.pages[0]!, jmDensity: 'B' as const }, { objects: [] }]; + expect(blocks(generateMultiPageZPL(label, pages, r.variables)).map(declaredJm)).toEqual([ + ['B'], + ['A'], + ]); + }); + + it('declares an inherited density with the block own caret after a ^CC remap', () => { + // Block 0 remaps the caret to '/', so block 1 opens as /XA. Dropping block 0 + // takes its ^JMB with it; the declaration has to go back in as /JMB, since + // a literal ^JM would not reach a printer left on the remapped prefix. + const r = importZplText('^XA^JMB^CC/^XZ\n/XA/FO10,10/A0N,30,30/FDa/FS/XZ', 8); + const label = { ...base, ...r.labelConfig }; + expect(label.jmDensity).toBe('B'); + const out = generateMultiPageZPL(label, r.pages.slice(1), r.variables); + expect(out).toContain('/JMB'); + expect(out).not.toContain('^JM'); + }); + + it('lets a model ^JM change overrule the imported head bytes', () => { + const r = importZplText('^XA^JMA^FO10,10^A0N,30,30^FDa^FS^XZ', 8); + const out = generateMultiPageZPL({ ...base, ...r.labelConfig, jmDensity: 'B' }, r.pages, r.variables); + expect(declaredJm(out)).toEqual(['B']); + }); + + it('leaves the head alone when it already matches the model', () => { + const r = importZplText('^XA^JMB^FO10,10^A0N,30,30^FDa^FS^XZ', 8); + const label = { ...base, ...r.labelConfig }; + expect(label.jmDensity).toBe('B'); + expect(generateMultiPageZPL(label, r.pages, r.variables)).toBe( + generateMultiPageZPL(label, r.pages, r.variables), + ); + expect(declaredJm(generateMultiPageZPL(label, r.pages, r.variables))).toEqual(['B']); + }); + + it('does not credit the wire with a density a corrected head never carried', () => { + // Block 0's ^JMA is corrected to B, so block 1 (also B) must not re-declare, + // and a following unset page still needs its reset. + const r = importZplText( + '^XA^JMA^FO10,10^A0N,30,30^FDa^FS^XZ\n^XA^FO10,10^A0N,30,30^FDb^FS^XZ', + 8, + ); + const pages = [...r.pages, { objects: [], jmDensity: 'A' as const }]; + const out = generateMultiPageZPL({ ...base, ...r.labelConfig, jmDensity: 'B' }, pages, r.variables); + expect(blocks(out).map(declaredJm)).toEqual([['B'], [], ['A']]); + }); + + it('finds a remapped opener behind a ^CC preamble in the same block', () => { + // The caret is remapped inside the block, ahead of its own opener, so the + // declaration has to follow /XA; a literal ^JM would not reach the printer. + const r = importZplText('^CC/\n/XA/FO10,10/A0N,30,30/FDa/FS/XZ', 8); + const out = generateMultiPageZPL({ ...base, ...r.labelConfig, jmDensity: 'B' }, r.pages, r.variables); + expect(out).toContain('/JMB'); + expect(out).not.toContain('^JM'); + expect(out.indexOf('/JMB')).toBe(out.indexOf('/XA') + '/XA'.length); + }); + + it('keeps export idempotent across a re-import (no ^JM accretion)', () => { + const r = importZplText( + '^XA^FO10,10^A0N,30,30^FDa^FS^XZ\n^XA^JMB^FO10,10^A0N,30,30^FDb^FS^XZ\n^XA^FO10,10^A0N,30,30^FDc^FS^XZ', + 8, + ); + const label = { ...base, ...r.labelConfig }; + const first = generateMultiPageZPL(label, r.pages, r.variables); + // Block 3 declares nothing: ^JMB persists on the wire, so the import read + // it as B too and the export has nothing to change. + expect(blocks(first).map(declaredJm)).toEqual([[], ['B'], []]); + const back = importZplText(first, 8); + const second = generateMultiPageZPL({ ...base, ...back.labelConfig }, back.pages, back.variables); + expect(second).toBe(first); + }); +}); + +describe('^JM format opener from the parsed head', () => { + const declaredJm = (block: string) => [...block.matchAll(/.JM(.)/g)].map((m) => m[1]); + + it('injects at the block own opener, not at a literal ^XA in the bytes', () => { + const r = importZplText('~CC!\n!XA!FO10,10!A0N,30,30!FD^XA!FS!XZ', 8); + const out = generateMultiPageZPL({ ...base, ...r.labelConfig, jmDensity: 'B' }, r.pages, r.variables); + expect(out).toContain('!XA!JMB'); + expect(out).toContain('!FD^XA!FS'); + }); + + it('follows a ~CT tilde remap ahead of the prefix setter', () => { + const r = importZplText('~CT?\n?CC!\n!XA!FO10,10!A0N,30,30!FDa!FS!XZ', 8); + const out = generateMultiPageZPL({ ...base, ...r.labelConfig, jmDensity: 'B' }, r.pages, r.variables); + expect(out).toContain('!XA!JMB'); + expect(declaredJm(out)).toEqual(['B']); + }); + + it('rewrites the head ^JM behind a remapped prefix instead of appending one', () => { + const r = importZplText('~CC!\n!XA!JMA!FO10,10!A0N,30,30!FDa!FS!XZ', 8); + const out = generateMultiPageZPL({ ...base, ...r.labelConfig, jmDensity: 'B' }, r.pages, r.variables); + expect(declaredJm(out)).toEqual(['B']); + expect(out).toContain('!JMB'); + }); + + it('injects at position 0 of a wrapper-less block', () => { + const parsed = parseZPL('^FO10,10^A0N,30,30^FDa^FS', 8, { captureOverlay: true }); + const page = { ...parsed.pages[0]!, jmDensity: 'B' as const }; + const out = generateMultiPageZPL(base, [page], []); + expect(out.startsWith('^JMB^FO10,10')).toBe(true); + }); + + it('persists the head through save/load so a reloaded design still patches it', () => { + const r = importZplText('~CC!\n!XA!JMA!FO10,10!A0N,30,30!FDa!FS!XZ', 8); + const loaded = parseDesignFile(serializeDesign({ ...base, ...r.labelConfig }, r.pages, r.variables)); + expect(loaded.ok).toBe(true); + if (!loaded.ok) return; + const out = generateMultiPageZPL( + { ...loaded.value.label, jmDensity: 'B' }, + loaded.value.pages, + loaded.value.variables, + ); + expect(declaredJm(out)).toEqual(['B']); + expect(out).toContain('!JMB'); + }); + + it('never mistakes a ~DY payload for the format head', () => { + const src = '~DYR:L.GRF,B,G,4,2,^JMA\n^XA^FO10,10^A0N,30,30^FDa^FS^XZ'; + const r = importZplText(src, 8); + const out = generateMultiPageZPL({ ...base, ...r.labelConfig, jmDensity: 'B' }, r.pages, r.variables); + expect(out.indexOf('^JMB')).toBe(out.indexOf('^XA') + '^XA'.length); + }); +}); + +describe('^JM in a wrapper-less stream', () => { + it('applies a leading ^JM when no ^XA wraps the fields', () => { + const r = importZplText('^JMB^FO10,10^A0N,30,30^FDX^FS', 8); + expect(r.labelConfig.jmDensity).toBe('B'); + expect(r.pages[0]?.jmDensity).toBeUndefined(); + }); + + it('rescales wrapper-less field dots at the halved density', () => { + const bare = importZplText('^JMB^MUI^FO2,1^A0N,30,30^FDX^FS', 8); + const wrapped = importZplText('^XA^JMB^MUI^FO2,1^A0N,30,30^FDX^FS^XZ', 8); + expect((bare.pages[0]?.objects[0] as { x: number }).x).toBe( + (wrapped.pages[0]?.objects[0] as { x: number }).x, + ); + }); + + it('still only reports a ^JM sitting before a real ^XA', () => { + const r = importZplText('^JMB^XA^FO10,10^A0N,30,30^FDX^FS^XZ', 8); + expect(r.labelConfig.jmDensity).toBeUndefined(); + expect(r.report.partial).toContain('^JM'); + }); + + it('reports a preamble ^JM even behind a field of its own', () => { + const r = importZplText('^JMB^FO10,10^A0N,30,30^FDX^FS^XA^FO1,1^A0N,30,30^FDy^FS^XZ', 8); + expect(r.labelConfig.jmDensity).toBeUndefined(); + expect(r.report.partial).toContain('^JM'); + }); +}); + +// A head runs from ^XA to the format's first ^FS. Outside one, a ^JM neither +// declares a density nor may be rewritten; inside one, it does both even when +// the value or the byte layout is not what the model expects. +describe('^JM outside a format head', () => { + const declaredJm = (block: string) => [...block.matchAll(/\^JM(.)/g)].map((m) => m[1]); + + it('does not fold a ^JM between two blocks into the preceding head', () => { + const src = '^XA^MMT^XZ\n^JMB\n^XA^FO10,10^A0N,30,30^FDa^FS^XZ\n'; + const r = importZplText(src, 8); + expect(r.labelConfig.jmDensity).toBeUndefined(); + expect(r.report.partial).toContain('^JM'); + expect(generateMultiPageZPL({ ...base, ...r.labelConfig }, r.pages, r.variables)).toBe(src); + }); + + it('does not swallow a ^JM after the format own first ^FS', () => { + const r = importZplText('^XA^FO10,10^A0N,30,30^FDa^FS^JMB^XZ', 8); + expect(r.labelConfig.jmDensity).toBeUndefined(); + expect(r.report.partial).toContain('^JM'); + }); + + it('declares behind a head ^JM the printer may still read, not before it', () => { + const r = importZplText('^XA^JMZ^FO10,10^A0N,30,30^FDa^FS^XZ', 8); + expect(r.labelConfig.jmDensity).toBeUndefined(); + const out = generateMultiPageZPL({ ...base, ...r.labelConfig, jmDensity: 'B' }, r.pages, r.variables); + expect(out).toContain('^JMZ^JMB'); + expect(importZplText(out, 8).labelConfig.jmDensity).toBe('B'); + }); + + it('reads the head value the way the parser does, delimiter included', () => { + const r = importZplText('^XA^JMB,X^FO10,10^A0N,30,30^FDa^FS^XZ', 8); + expect(r.labelConfig.jmDensity).toBe('B'); + // Model and bytes agree, so nothing is rewritten and the extra slot stays. + const same = generateMultiPageZPL({ ...base, ...r.labelConfig }, r.pages, r.variables); + expect(same).toContain('^JMB,X'); + const flipped = generateMultiPageZPL({ ...base, jmDensity: 'A' }, r.pages, r.variables); + expect(flipped).toContain('^JMA,X'); + expect(importZplText(flipped, 8).labelConfig.jmDensity).toBe('A'); + }); + + it('reads the head value at a ^CD-remapped delimiter', () => { + const r = importZplText('^XA^CD;^JMB;X^FO10,10^A0N,30,30^FDa^FS^XZ', 8); + expect(r.labelConfig.jmDensity).toBe('B'); + const flipped = generateMultiPageZPL({ ...base, jmDensity: 'A' }, r.pages, r.variables); + expect(flipped).toContain('^JMA;X'); + }); + + it('regenerates instead of splicing when a head span left the block', () => { + const parsed = parseZPL('^XA^JMB^FO10,10^A0N,30,30^FDa^FS^XZ', 8, { captureOverlay: true }); + const page = parsed.pages[0]!; + const overlay = { ...page.overlay!, head: { ...page.overlay!.head!, jmSpans: [{ start: 3, end: 9999, delim: ',', caret: '^' }] } }; + const out = generateMultiPageZPL({ ...base, jmDensity: 'A' }, [{ ...page, overlay, jmDensity: 'A' }], []); + expect(declaredJm(out)).toEqual(['A']); + expect(out.endsWith('^XZ')).toBe(true); + }); + + it('rejects a persisted head whose spans overlap or run backwards', () => { + const parsed = parseZPL('^XA^JMB^FO10,10^A0N,30,30^FDa^FS^XZ', 8, { captureOverlay: true }); + const overlay = parsed.pages[0]!.overlay!; + expect(blockOverlaySchema.safeParse(overlay).success).toBe(true); + for (const jmSpans of [[{ start: 7, end: 3 }], [{ start: 3, end: 5 }], [{ start: 3, end: 7 }, { start: 5, end: 9 }]]) { + expect(blockOverlaySchema.safeParse({ ...overlay, head: { ...overlay.head, jmSpans } }).success).toBe(false); + } + }); +}); + +describe('^JM head offsets from the model emitter', () => { + const declaredJm = (block: string) => [...block.matchAll(/\^JM(.)/g)].map((m) => m[1]); + + it('declares inside the block when a ~SD/~DY preamble precedes the opener', () => { + const label: LabelConfig = { ...base, instantDarkness: 12, jmDensity: 'B' }; + const out = generateMultiPageZPL(label, [{ objects: [] }, { objects: [], jmDensity: 'A' }], []); + expect(out).toContain('~SD12\n^XA\n^JMB'); + expect(declaredJm(out)).toEqual(['B', 'A']); + expect(importZplText(out, 8).labelConfig.jmDensity).toBe('B'); + }); +}); + +describe('^JM import fold divergence', () => { + it('does not leak the design density onto a settings-only block that prints at A', () => { + const r = importZplText('^XA^MNY^XZ\n^XA^JMB^FO10,10^A0N,30,30^FDy^FS^XZ', 8); + expect(r.labelConfig.jmDensity).toBe('B'); + const out = generateMultiPageZPL({ ...base, ...r.labelConfig }, r.pages, r.variables); + const [block0, block1] = out.split('^XZ'); + expect(block0).not.toContain('^JMB'); + expect(block1).toContain('^JMB'); + }); + + it('keeps ^JMB after a ^CC arg the handler rejects (space)', () => { + const r = importZplText('^XA^CC ^JMB^FO10,10^A0N,30,30^FDy^FS^XZ', 8); + expect(r.labelConfig.jmDensity).toBe('B'); + const out = generateMultiPageZPL({ ...base, ...r.labelConfig }, r.pages, r.variables); + expect(out).toContain('^JMB'); + expect(out).not.toContain('^JMA'); + }); + + it('keeps ^JMB after a ^CD arg the handler rejects (caret)', () => { + const r = importZplText('^XA^CD^^JMB,x^FO10,10^A0N,30,30^FDy^FS^XZ', 8); + expect(r.labelConfig.jmDensity).toBe('B'); + const out = generateMultiPageZPL({ ...base, ...r.labelConfig }, r.pages, r.variables); + expect(out).not.toContain('^JMA'); + }); + + it('reads each head ^JM span with the delimiter live at that span', () => { + const r = importZplText('^XA^CD;^JMB;x^CD,^JMQ,y^FO10,10^A0N,30,30^FDa^FS^XZ', 8); + expect(r.labelConfig.jmDensity).toBe('B'); + const out = generateMultiPageZPL({ ...base, ...r.labelConfig }, r.pages, r.variables); + expect((out.match(/\^JM/g) ?? []).length).toBe(2); + }); + + it('rewrites a head ^JM behind an in-head ^CC remap with its live caret', () => { + // ^CC/ ahead of the ^JM makes the span open as /JMB, not ^JMB; a model flip + // must patch it in place as /JMA rather than fall back to regeneration. + const r = importZplText('^XA^CC//JMB/FO10,10/A0N,30,30/FDa/FS/XZ', 8); + expect(r.labelConfig.jmDensity).toBe('B'); + const out = generateMultiPageZPL({ ...base, ...r.labelConfig, jmDensity: 'A' }, r.pages, r.variables); + expect(out).toContain('/JMA'); + expect(out).not.toContain('/JMB'); + }); + + it('round-trips an in-head ^CC-before-^JM block byte-for-byte across re-import', () => { + // Block 0 remaps the caret before its ^JMB, so block 1 opens as /XA. Block 0 + // must replay verbatim (keeping the ^CC) or block 1 becomes unreadable. + const src = '^XA^CC//JMB/XZ\n/XA/FO10,10/A0N,30,30/FDa/FS/XZ'; + const r = importZplText(src, 8); + const out = generateMultiPageZPL({ ...base, ...r.labelConfig }, r.pages, r.variables); + expect(out).toBe(src); + const re = importZplText(out, 8); + expect(re.pages.length).toBe(2); + expect(re.pages[1]?.objects.length).toBe(1); + }); +}); + +// A main-era save carried no ^JM in the model: the density rode only in the +// overlay bytes. Loading such a payload must land on the exact fold a fresh +// import of the same bytes would produce, not a stale full-density A. +describe('^JM legacy reconstruction mirrors the import fold', () => { + const foldVsLegacyLoad = (src: string) => { + const imp = importZplText(src, 8); + const label = { ...base, ...imp.labelConfig }; + const legacyPages = imp.pages.map((p) => { + const overlay = p.overlay ? { ...p.overlay } : undefined; + if (overlay) delete overlay.head; + return { objects: p.objects, overlay }; + }); + const loaded = parseDesignFile( + serializeDesign({ ...label, jmDensity: undefined }, legacyPages, imp.variables), + ); + expect(loaded.ok).toBe(true); + return { imp, loaded }; + }; + + const cases = [ + { + name: 'single-page ^JMB lifts to the label, no page override', + src: '^XA^JMB^FO10,10^A0N,30,30^FDa^FS^XZ', + }, + { + name: 'anchor B, later A pins A as an override', + src: '^XA^JMB^FO10,10^A0N,30,30^FDa^FS^XZ\n^XA^JMA^FO50,50^A0N,30,30^FDb^FS^XZ', + }, + { + name: 'anchor A, later B pins B as an override', + src: '^XA^FO10,10^A0N,30,30^FDa^FS^XZ\n^XA^JMB^FO50,50^A0N,30,30^FDb^FS^XZ', + }, + { + name: 'settings-only block before an anchor B pins that block to A', + src: '^XA^MNY^XZ\n^XA^JMB^FO10,10^A0N,30,30^FDy^FS^XZ', + }, + { + name: 'both blocks at full density stay unset', + src: '^XA^FO10,10^A0N,30,30^FDa^FS^XZ\n^XA^FO50,50^A0N,30,30^FDb^FS^XZ', + }, + { + // A settings-only block declares ^JMB; the object anchor carries no ^JM of + // its own but inherits B on the wire, so the fold must lift B to the label + // instead of reading the anchor block alone as A. + name: 'a settings block ^JMB is inherited by the following object anchor', + src: '^XA^JMB^XZ^XA^FO0,0^A0N,30,30^FDX^FS^XZ', + }, + { + // Block 0 remaps the caret to '!' (persists past ^XZ); block 1 opens as + // !XA and declares !JMB. The fold must track the remap through the whole + // stream to see both the opener and the density. + name: 'a ^CC-remapped opener block still resolves its inherited-prefix ^JMB', + src: '^XA^CC!^XZ!XA!JMB!FO0,0!A0N,30,30!FDX!FS!XZ', + }, + ]; + + for (const { name, src } of cases) { + it(name, () => { + const { imp, loaded } = foldVsLegacyLoad(src); + if (!loaded.ok) return; + expect(loaded.value.label.jmDensity).toBe(imp.labelConfig.jmDensity); + expect(loaded.value.pages.map((p) => p.jmDensity)).toEqual(imp.pages.map((p) => p.jmDensity)); + }); + } + + // Reconstructing the head must make a zero-edit export replay the whole + // ^CC/!JMB chain byte-for-byte (matching a fresh import) and stay stable + // across a reload, or a re-export regenerates and drops the remap. + it('replays a threaded ^CC-remapped chain byte-identically and idempotently', () => { + const src = '^XA^CC!^XZ!XA!JMB!FO0,0!A0N,30,30!FDX!FS!XZ'; + const { imp, loaded } = foldVsLegacyLoad(src); + expect(loaded.ok).toBe(true); + if (!loaded.ok) return; + const freshOut = generateMultiPageZPL({ ...base, ...imp.labelConfig }, imp.pages, imp.variables); + const legacyOut = generateMultiPageZPL(loaded.value.label, loaded.value.pages, loaded.value.variables); + expect(legacyOut).toBe(freshOut); + const reloaded = parseDesignFile( + serializeDesign(loaded.value.label, loaded.value.pages, loaded.value.variables), + ); + expect(reloaded.ok).toBe(true); + if (!reloaded.ok) return; + expect(generateMultiPageZPL(reloaded.value.label, reloaded.value.pages, reloaded.value.variables)).toBe(legacyOut); + }); +}); + +describe('rebaseAppendedPageDensity', () => { + it('pins an appended B stream against a full-density design', () => { + const r = importZplText('^XA^JMB^FO10,10^A0N,30,30^FDy^FS^XZ', 8); + expect(r.pages[0]?.jmDensity).toBeUndefined(); + const rebased = rebaseAppendedPageDensity(r.pages, r.labelConfig.jmDensity, undefined); + expect(rebased[0]?.jmDensity).toBe('B'); + }); + + it('clears an override that already matches the target design', () => { + const r = importZplText('^XA^JMB^FO10,10^A0N,30,30^FDy^FS^XZ', 8); + const rebased = rebaseAppendedPageDensity(r.pages, r.labelConfig.jmDensity, 'B'); + expect(rebased[0]?.jmDensity).toBeUndefined(); + }); +}); + +// A main-era save never modelled ^JM; a remapped-opener block kept the density +// only in its bytes. `headless()` skips head reconstruction to exercise the +// generator's cold path, which must still replay the bytes verbatim. +describe('^JM headless remapped-opener replay', () => { + const declaredJm = (block: string) => [...block.matchAll(/.JM(.)/g)].map((m) => m[1]); + + // Fresh import, then drop overlay.head to mimic the headless legacy shape. + const headless = (src: string) => { + const r = importZplText(src, 8); + const pages = r.pages.map((p) => { + const overlay = p.overlay ? { ...p.overlay } : undefined; + if (overlay) delete overlay.head; + return { ...p, overlay }; + }); + return { label: { ...base, ...r.labelConfig }, pages, variables: r.variables }; + }; + + it('replays a self-declaring ^JMB byte-identically on a zero-edit export', () => { + const src = '^CC/\n/XA/JMB/FO10,10/A0N,30,30/FDa/FS/XZ'; + const r = importZplText(src, 8); + const legacyPages = r.pages.map((p) => { + const overlay = p.overlay ? { ...p.overlay } : undefined; + if (overlay) delete overlay.head; + return { objects: p.objects, overlay, jmDensity: p.jmDensity }; + }); + // Legacy design never carried the density; the threaded reconstruction + // latches it from the bytes onto the design default (single block = the + // fold's anchor) and recovers the head behind the remapped opener. + const loaded = parseDesignFile( + serializeDesign({ ...base, ...r.labelConfig, jmDensity: undefined }, legacyPages, r.variables), + ); + expect(loaded.ok).toBe(true); + if (!loaded.ok) return; + expect(loaded.value.label.jmDensity).toBe('B'); + expect(loaded.value.pages[0]?.jmDensity).toBeUndefined(); + expect(loaded.value.pages[0]?.overlay?.head?.caret).toBe('/'); + const out = generateMultiPageZPL(loaded.value.label, loaded.value.pages, loaded.value.variables); + expect(out).toBe(src); + expect(importZplText(out, 8).labelConfig.jmDensity).toBe('B'); + }); + + it('regenerates when the model contradicts the self-declared density', () => { + const { label, pages, variables } = headless('^CC/\n/XA/JMB/FO10,10/A0N,30,30/FDa/FS/XZ'); + const pagesA = pages.map((p, i) => (i === 0 ? { ...p, jmDensity: 'A' as const } : p)); + const out = generateMultiPageZPL(label, pagesA, variables); + expect(out).not.toContain('JMB'); + expect(out).toContain('^JMA'); + expect(importZplText(out, 8).labelConfig.jmDensity).toBe('A'); + }); + + it('replays the whole remap chain so an inheriting /XA block stays readable', () => { + const src = + '^CC/\n/XA/JMB/FO10,10/A0N,30,30/FDa/FS/XZ\n/XA/FO50,50/A0N,30,30/FDb/FS/XZ'; + const { label, pages, variables } = headless(src); + const out = generateMultiPageZPL(label, pages, variables); + expect(out).toBe(src); + const re = importZplText(out, 8); + expect(re.pages.length).toBe(2); + expect(re.pages.map((p) => p.objects.length)).toEqual([1, 1]); + }); + + it('marks the wire from the kept block so a following A page declares its reset', () => { + const src = '^CC/\n/XA/JMB/FO10,10/A0N,30,30/FDa/FS/XZ'; + const { label, pages, variables } = headless(src); + const withA = [...pages, { objects: [], jmDensity: 'A' as const }]; + const out = generateMultiPageZPL(label, withA, variables); + // Block 0 stays verbatim, not regenerated, and its B marks the wire so + // the appended A page emits an explicit reset. + expect(out.startsWith(`${src}\n`)).toBe(true); + expect(declaredJm(out)).toEqual(['B', 'A']); + }); +}); diff --git a/src/lib/zplParser.test.ts b/src/lib/zplParser.test.ts index 7d60ed7a..791759a3 100644 --- a/src/lib/zplParser.test.ts +++ b/src/lib/zplParser.test.ts @@ -405,9 +405,9 @@ describe('parseZPL — ^BC Code 128', () => { }); it('keeps barcodes on later blocks after a page reset (live field state)', () => { - // resetFormatScopedState reassigns s.field at each page close; a stale - // destructured alias made every ^B* handler from block 2 on write a dead - // object, degrading the barcode to text. + // resetFormatScopedState reassigns s.field at each page close; ^B* handlers + // must read the live field, not a destructured alias from before the reset, + // or blocks after the first render as bare text. const r = parseZPL( '^XA^FO10,10^BY2^BCN,100,N,N,N^FD123^FS^XZ^XA^FO20,20^BY2^BCN,80,N,N,N^FD456^FS^XZ', 8, diff --git a/src/lib/zplRoundtrip.integration.test.ts b/src/lib/zplRoundtrip.integration.test.ts index 01e5371c..168b98f3 100644 --- a/src/lib/zplRoundtrip.integration.test.ts +++ b/src/lib/zplRoundtrip.integration.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; import { useLabelStore } from "../store/labelStore"; -import { importZplText } from "@zplab/core/lib/zplImportService"; +import { importZplText, replaceImportLabel } from "@zplab/core/lib/zplImportService"; import { generateMultiPageZPL } from "@zplab/core/lib/zplGenerator"; import { serializeDesign, parseDesignFile } from "@zplab/core/lib/designFile"; import { getObjectStringContent } from "@zplab/core/lib/variableBinding"; @@ -32,7 +32,7 @@ beforeEach(() => { function importInto(zpl: string): void { const label = store().label; const r = importZplText(zpl, label.dpmm); - store().loadDesign({ ...label, ...r.labelConfig }, r.pages, r.variables); + store().loadDesign(replaceImportLabel(label, r.labelConfig), r.pages, r.variables); } const exportZpl = (): string => { const s = store(); @@ -68,6 +68,15 @@ describe("round-trip integration (real import -> store -> export)", () => { expect(exportZpl()).toBe(REAL_LABEL); }); + it("replace-import without ^JM clears the open document's density mode", () => { + // Inheriting the old ^JMB would reinterpret every imported dot at half + // density; an import without ^JM declares full density. + useLabelStore.setState({ label: { ...store().label, jmDensity: "B" } }); + importInto(REAL_LABEL); + expect(store().label.jmDensity).toBeUndefined(); + expect(exportZpl()).toBe(REAL_LABEL); + }); + it("a realistic foreign label: edit one field, the rest stays byte-identical", () => { importInto(REAL_LABEL); const addr = store().pages[0]!.objects.find( diff --git a/src/locales/ar.ts b/src/locales/ar.ts index a1db7f27..aaa52083 100644 --- a/src/locales/ar.ts +++ b/src/locales/ar.ts @@ -392,6 +392,11 @@ const ar = { dpmm8: '8 نقطة/مم (203 dpi)', dpmm12: '12 نقطة/مم (300 dpi)', dpmm24: '24 نقطة/مم (600 dpi)', + jmDensity: 'وضع الكثافة', + jmDensityDefault: 'افتراضي (كثافة كاملة)', + jmPageOverrideHintFmt: 'تحافظ هذه الصفحة على وضع الكثافة المستورد {mode}؛ يغيّر التحديد الوضع على مستوى التصميم بالكامل', + jmDensityA: 'كثافة كاملة (صريحة)', + jmDensityB: 'نصف الكثافة (يضاعف مقياس التنسيق)', safeArea: 'Safe area', safeAreaHint: 'Uniform margin inset from every edge. Shows a guide and pins "Align to label" to the margin.', emitZJustify: 'تثبيت الباركود من جهة الطابعة', @@ -426,7 +431,9 @@ const ar = { }, densityRescale: { title: 'تغيير كثافة الطباعة', + jmTitle: 'تغيير وضع الكثافة', fromToFmt: '{from} → {to} dpmm', + jmFromToFmt: 'الكثافة الفعلية: {from} إلى {to} dpmm', question: 'كيف ينبغي التعامل مع الكائنات الموجودة؟', scale: 'التحجيم للحفاظ على الحجم', scaleHint: 'يضبط كل قيمة نقطة بحيث يبقى التخطيط المادي كما هو.', @@ -853,6 +860,7 @@ const ar = { fnDefaultDroppedTitleFmt: 'جميع فتحات {fn} مشغولة: يحتفظ هذا الحقل بالقيمة الافتراضية للصفحة الأولى', mixedGeoTitle: 'أحجام ملصقات متعددة: تستخدم الصفحات اللاحقة الحجم الأول', mixedGeoDetailFmt: 'تختلف {cmds} بين الكتل ({detail})', + mixedJmTitle: 'يختلف وضع كثافة الطباعة بين الكتل: تحتفظ الصفحات اللاحقة بوضعها الخاص، لكن يمكن تعديل الكتلة الأولى فقط', unknownTitle: 'تم التخطي: أمر غير معروف', reportHeader: 'تقرير استيراد ZPL', reportObjectsFmt: 'العناصر المستوردة: {n}', diff --git a/src/locales/bg.ts b/src/locales/bg.ts index 0d42652e..9e7c4c0c 100644 --- a/src/locales/bg.ts +++ b/src/locales/bg.ts @@ -392,6 +392,11 @@ const bg = { dpmm8: '8 dpmm (203 dpi)', dpmm12: '12 dpmm (300 dpi)', dpmm24: '24 dpmm (600 dpi)', + jmDensity: 'Режим на плътност', + jmDensityDefault: 'По подразбиране (пълна плътност)', + jmPageOverrideHintFmt: 'Тази страница запазва своя импортиран режим на плътност {mode}; изборът променя режима за целия дизайн', + jmDensityA: 'Пълна плътност (изрично)', + jmDensityB: 'Половин плътност (удвоява мащаба на формата)', safeArea: 'Safe area', safeAreaHint: 'Uniform margin inset from every edge. Shows a guide and pins "Align to label" to the margin.', emitZJustify: 'Закотвяне на баркода от страна на принтера', @@ -426,7 +431,9 @@ const bg = { }, densityRescale: { title: 'Промяна на плътността на печат', + jmTitle: 'Промяна на режима на плътност', fromToFmt: '{from} → {to} dpmm', + jmFromToFmt: 'Ефективна плътност: {from} до {to} dpmm', question: 'Как да се обработят съществуващите обекти?', scale: 'Мащабиране за запазване на размера', scaleHint: 'Коригира всяка стойност в точки, така че физическото оформление да остане същото.', @@ -853,6 +860,7 @@ const bg = { fnDefaultDroppedTitleFmt: 'Всички слотове {fn} заети: това поле запазва стойността по подразбиране от първата страница', mixedGeoTitle: 'Няколко размера на етикета: следващите страници използват първия размер', mixedGeoDetailFmt: '{cmds} се различават между блоковете ({detail})', + mixedJmTitle: 'Режимът на плътност на печат се различава между блоковете: следващите страници запазват собствения си режим, но само първият е редактируем', unknownTitle: 'Пропуснато: командата не е разпозната', reportHeader: 'Отчет за импортиране на ZPL', reportObjectsFmt: 'Импортирани обекти: {n}', diff --git a/src/locales/cs.ts b/src/locales/cs.ts index d439b1d2..fe86da3c 100644 --- a/src/locales/cs.ts +++ b/src/locales/cs.ts @@ -392,6 +392,11 @@ const cs = { dpmm8: '8 dpmm (203 dpi)', dpmm12: '12 dpmm (300 dpi)', dpmm24: '24 dpmm (600 dpi)', + jmDensity: 'Režim hustoty', + jmDensityDefault: 'Výchozí (plná hustota)', + jmPageOverrideHintFmt: 'Tato stránka si zachovává svůj importovaný režim hustoty {mode}; výběr mění režim platný pro celý návrh', + jmDensityA: 'Plná hustota (explicitně)', + jmDensityB: 'Poloviční hustota (zdvojnásobí měřítko formátu)', safeArea: 'Safe area', safeAreaHint: 'Uniform margin inset from every edge. Shows a guide and pins "Align to label" to the margin.', emitZJustify: 'Ukotvení čárového kódu na straně tiskárny', @@ -426,7 +431,9 @@ const cs = { }, densityRescale: { title: 'Změnit hustotu tisku', + jmTitle: 'Změna režimu hustoty', fromToFmt: '{from} → {to} dpmm', + jmFromToFmt: 'Efektivní hustota: {from} až {to} dpmm', question: 'Jak se mají zpracovat stávající objekty?', scale: 'Škálovat pro zachování velikosti', scaleHint: 'Upraví každou hodnotu v bodech, aby fyzické rozvržení zůstalo stejné.', @@ -853,6 +860,7 @@ const cs = { fnDefaultDroppedTitleFmt: 'Všechny sloty {fn} obsazeny: toto pole si ponechává výchozí hodnotu první strany', mixedGeoTitle: 'Více velikostí štítku: pozdější strany používají první velikost', mixedGeoDetailFmt: '{cmds} se mezi bloky liší ({detail})', + mixedJmTitle: 'Režim hustoty tisku se mezi bloky liší: pozdější stránky si zachovávají svůj vlastní režim, ale upravitelný je pouze první', unknownTitle: 'Přeskočeno: příkaz nerozpoznán', reportHeader: 'Zpráva o importu ZPL', reportObjectsFmt: 'Importované objekty: {n}', diff --git a/src/locales/da.ts b/src/locales/da.ts index 29f60b03..97f610ec 100644 --- a/src/locales/da.ts +++ b/src/locales/da.ts @@ -392,6 +392,11 @@ const da = { dpmm8: '8 dpmm (203 dpi)', dpmm12: '12 dpmm (300 dpi)', dpmm24: '24 dpmm (600 dpi)', + jmDensity: 'Densitetstilstand', + jmDensityDefault: 'Standard (fuld densitet)', + jmPageOverrideHintFmt: 'Denne side beholder sin importerede densitetstilstand {mode}; valget ændrer den designbrede tilstand', + jmDensityA: 'Fuld densitet (eksplicit)', + jmDensityB: 'Halv densitet (fordobler formatets skala)', safeArea: 'Safe area', safeAreaHint: 'Uniform margin inset from every edge. Shows a guide and pins "Align to label" to the margin.', emitZJustify: 'Printersidet stregkodefastgørelse', @@ -426,7 +431,9 @@ const da = { }, densityRescale: { title: 'Skift printtæthed', + jmTitle: 'Ændring af tæthedstilstand', fromToFmt: '{from} → {to} dpmm', + jmFromToFmt: 'Effektiv tæthed: {from} til {to} dpmm', question: 'Hvordan skal eksisterende objekter håndteres?', scale: 'Skalér for at bevare størrelsen', scaleHint: 'Justerer hver dot-værdi, så det fysiske layout forbliver det samme.', @@ -853,6 +860,7 @@ const da = { fnDefaultDroppedTitleFmt: 'Alle {fn}-pladser optaget: dette felt beholder standardværdien fra den første side', mixedGeoTitle: 'Flere etiketstørrelser: senere sider bruger den første størrelse', mixedGeoDetailFmt: '{cmds} adskiller sig mellem blokke ({detail})', + mixedJmTitle: 'Udskriftstæthedstilstanden er forskellig mellem blokke: senere sider beholder deres egen tilstand, men kun den første kan redigeres', unknownTitle: 'Sprunget over: kommando ikke genkendt', reportHeader: 'ZPL-importrapport', reportObjectsFmt: 'Importerede objekter: {n}', diff --git a/src/locales/de.ts b/src/locales/de.ts index 9f21cb60..929f5acb 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -392,6 +392,11 @@ const de = { dpmm8: '8 dpmm (203 dpi)', dpmm12: '12 dpmm (300 dpi)', dpmm24: '24 dpmm (600 dpi)', + jmDensity: 'Dichtemodus', + jmDensityDefault: 'Standard (volle Dichte)', + jmPageOverrideHintFmt: 'Diese Seite behält ihren importierten Druckdichte-Modus {mode} bei; die Auswahl ändert den design-weiten Modus', + jmDensityA: 'Volle Dichte (explizit)', + jmDensityB: 'Halbe Dichte (verdoppelt den Format-Maßstab)', safeArea: 'Sicherheitsabstand', safeAreaHint: 'Gleichmäßiger Rand zu allen Kanten. Zeigt eine Hilfslinie und richtet "An Etikett ausrichten" am Rand aus.', emitZJustify: 'Druckerseitige Barcode-Verankerung', @@ -426,7 +431,9 @@ const de = { }, densityRescale: { title: 'Druckdichte ändern', + jmTitle: 'Dichtemodus-Wechsel', fromToFmt: '{from} → {to} dpmm', + jmFromToFmt: 'Effektive Dichte: {from} bis {to} dpmm', question: 'Wie sollen vorhandene Objekte behandelt werden?', scale: 'Skalieren, um Größe beizubehalten', scaleHint: 'Passt jeden Dot-Wert an, damit das physische Layout gleich bleibt.', @@ -853,6 +860,7 @@ const de = { fnDefaultDroppedTitleFmt: 'Alle {fn}-Slots belegt: Dieses Feld behält den Standardwert der ersten Seite', mixedGeoTitle: 'Mehrere Etikettengrößen: spätere Seiten übernehmen die erste Größe', mixedGeoDetailFmt: '{cmds} unterscheiden sich zwischen Blöcken ({detail})', + mixedJmTitle: 'Der Druckdichte-Modus unterscheidet sich zwischen den Blöcken: spätere Seiten behalten ihren eigenen Modus, aber nur der erste ist bearbeitbar', unknownTitle: 'Übersprungen: Befehl nicht erkannt', reportHeader: 'ZPL-Importbericht', reportObjectsFmt: 'Importierte Objekte: {n}', diff --git a/src/locales/el.ts b/src/locales/el.ts index 06f0f8b1..205a4f70 100644 --- a/src/locales/el.ts +++ b/src/locales/el.ts @@ -392,6 +392,11 @@ const el = { dpmm8: '8 dpmm (203 dpi)', dpmm12: '12 dpmm (300 dpi)', dpmm24: '24 dpmm (600 dpi)', + jmDensity: 'Λειτουργία πυκνότητας', + jmDensityDefault: 'Προεπιλογή (πλήρης πυκνότητα)', + jmPageOverrideHintFmt: 'Αυτή η σελίδα διατηρεί τον εισαγόμενο τρόπο πυκνότητας {mode}· η επιλογή αλλάζει τον τρόπο για ολόκληρο το σχέδιο', + jmDensityA: 'Πλήρης πυκνότητα (ρητά)', + jmDensityB: 'Μισή πυκνότητα (διπλασιάζει την κλίμακα της μορφής)', safeArea: 'Safe area', safeAreaHint: 'Uniform margin inset from every edge. Shows a guide and pins "Align to label" to the margin.', emitZJustify: 'Αγκύρωση γραμμωτού κώδικα από την πλευρά του εκτυπωτή', @@ -426,7 +431,9 @@ const el = { }, densityRescale: { title: 'Αλλαγή πυκνότητας εκτύπωσης', + jmTitle: 'Αλλαγή λειτουργίας πυκνότητας', fromToFmt: '{from} → {to} dpmm', + jmFromToFmt: 'Ενεργός πυκνότητα: {from} έως {to} dpmm', question: 'Πώς πρέπει να αντιμετωπιστούν τα υπάρχοντα αντικείμενα;', scale: 'Κλιμάκωση για διατήρηση μεγέθους', scaleHint: 'Προσαρμόζει κάθε τιμή κουκκίδας ώστε η φυσική διάταξη να παραμείνει ίδια.', @@ -853,6 +860,7 @@ const el = { fnDefaultDroppedTitleFmt: 'Όλες οι θέσεις {fn} κατειλημμένες: αυτό το πεδίο διατηρεί την προεπιλογή της πρώτης σελίδας', mixedGeoTitle: 'Πολλαπλά μεγέθη ετικέτας: οι επόμενες σελίδες χρησιμοποιούν το πρώτο μέγεθος', mixedGeoDetailFmt: 'Τα {cmds} διαφέρουν μεταξύ των μπλοκ ({detail})', + mixedJmTitle: 'Η λειτουργία πυκνότητας εκτύπωσης διαφέρει μεταξύ των μπλοκ: οι επόμενες σελίδες διατηρούν τη δική τους λειτουργία, αλλά μόνο η πρώτη είναι επεξεργάσιμη', unknownTitle: 'Παραλείφθηκε: μη αναγνωρίσιμη εντολή', reportHeader: 'Αναφορά εισαγωγής ZPL', reportObjectsFmt: 'Αντικείμενα που εισήχθησαν: {n}', diff --git a/src/locales/en.ts b/src/locales/en.ts index b8d45b4e..c074366e 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -392,6 +392,11 @@ const en = { dpmm8: '8 dpmm (203 dpi)', dpmm12: '12 dpmm (300 dpi)', dpmm24: '24 dpmm (600 dpi)', + jmDensity: 'Density mode', + jmDensityDefault: 'Default (full density)', + jmPageOverrideHintFmt: 'This page keeps its imported density mode {mode}; the selection changes the design-wide mode', + jmDensityA: 'Full density (explicit)', + jmDensityB: 'Half density (doubles the format scale)', safeArea: 'Safe area', safeAreaHint: 'Uniform margin inset from every edge. Shows a guide and pins "Align to label" to the margin.', emitZJustify: 'Printer-side barcode anchoring', @@ -426,7 +431,9 @@ const en = { }, densityRescale: { title: 'Change print density', + jmTitle: 'Density mode change', fromToFmt: '{from} → {to} dpmm', + jmFromToFmt: 'Effective density: {from} to {to} dpmm', question: 'How should existing objects be handled?', scale: 'Scale to keep size', scaleHint: 'Adjusts every dot value so the physical layout stays the same.', @@ -853,6 +860,7 @@ const en = { fnDefaultDroppedTitleFmt: "All {fn} slots taken: this field keeps the first page's default", mixedGeoTitle: 'Multiple label sizes: later pages use the first size', mixedGeoDetailFmt: '{cmds} differ between blocks ({detail})', + mixedJmTitle: 'Density mode differs between blocks: later pages keep their own mode, but only the first is editable', unknownTitle: 'Skipped: command not recognised', reportHeader: 'ZPL Import Report', reportObjectsFmt: 'Objects imported: {n}', diff --git a/src/locales/es.ts b/src/locales/es.ts index 326230bd..b66a135b 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -392,6 +392,11 @@ const es = { dpmm8: '8 dpmm (203 dpi)', dpmm12: '12 dpmm (300 dpi)', dpmm24: '24 dpmm (600 dpi)', + jmDensity: 'Modo de densidad', + jmDensityDefault: 'Predeterminado (densidad completa)', + jmPageOverrideHintFmt: 'Esta página mantiene su modo de densidad importado {mode}; la selección cambia el modo global del diseño', + jmDensityA: 'Densidad completa (explícita)', + jmDensityB: 'Media densidad (duplica la escala del formato)', safeArea: 'Safe area', safeAreaHint: 'Uniform margin inset from every edge. Shows a guide and pins "Align to label" to the margin.', emitZJustify: 'Anclaje del código de barras en la impresora', @@ -426,7 +431,9 @@ const es = { }, densityRescale: { title: 'Cambiar densidad de impresión', + jmTitle: 'Cambio de modo de densidad', fromToFmt: '{from} → {to} dpmm', + jmFromToFmt: 'Densidad efectiva: {from} a {to} dpmm', question: '¿Cómo se deben tratar los objetos existentes?', scale: 'Escalar para mantener el tamaño', scaleHint: 'Ajusta cada valor de punto para que el diseño físico se mantenga igual.', @@ -853,6 +860,7 @@ const es = { fnDefaultDroppedTitleFmt: 'Todas las ranuras {fn} ocupadas: este campo conserva el valor predeterminado de la primera página', mixedGeoTitle: 'Varios tamaños de etiqueta: las páginas posteriores usan el primer tamaño', mixedGeoDetailFmt: '{cmds} difieren entre bloques ({detail})', + mixedJmTitle: 'El modo de densidad de impresión difiere entre bloques: las páginas posteriores mantienen su propio modo, pero solo el primero es editable', unknownTitle: 'Omitido: comando no reconocido', reportHeader: 'Informe de importación ZPL', reportObjectsFmt: 'Objetos importados: {n}', diff --git a/src/locales/et.ts b/src/locales/et.ts index 09fd5d28..44ecbffb 100644 --- a/src/locales/et.ts +++ b/src/locales/et.ts @@ -392,6 +392,11 @@ const et = { dpmm8: '8 dpmm (203 dpi)', dpmm12: '12 dpmm (300 dpi)', dpmm24: '24 dpmm (600 dpi)', + jmDensity: 'Tiheduse režiim', + jmDensityDefault: 'Vaikimisi (täistihedus)', + jmPageOverrideHintFmt: 'See leht säilitab oma imporditud tiheduse režiimi {mode}; valik muudab kogu kujunduse režiimi', + jmDensityA: 'Täistihedus (selgesõnaline)', + jmDensityB: 'Pool tihedust (kahekordistab vormingu mõõtkava)', safeArea: 'Safe area', safeAreaHint: 'Uniform margin inset from every edge. Shows a guide and pins "Align to label" to the margin.', emitZJustify: 'Triipkoodi ankurdamine printeri poolel', @@ -426,7 +431,9 @@ const et = { }, densityRescale: { title: 'Muuda prindi tihedust', + jmTitle: 'Tiheusrežiimi muutus', fromToFmt: '{from} → {to} dpmm', + jmFromToFmt: 'Tegelik tihedus: {from}–{to} dpmm', question: 'Kuidas tuleks olemasolevaid objekte käsitleda?', scale: 'Skaleeri suuruse säilitamiseks', scaleHint: 'Kohandab iga punktiväärtust, et füüsiline paigutus jääks samaks.', @@ -853,6 +860,7 @@ const et = { fnDefaultDroppedTitleFmt: 'Kõik {fn}-pesad hõivatud: see väli säilitab esimese lehe vaikeväärtuse', mixedGeoTitle: 'Mitu etiketisuurust: hilisemad lehed kasutavad esimest suurust', mixedGeoDetailFmt: '{cmds} erinevad plokkide vahel ({detail})', + mixedJmTitle: 'Prindi tiheduse režiim erineb plokkide vahel: hilisemad leheküljed säilitavad oma režiimi, kuid muudetav on ainult esimene', unknownTitle: 'Vahele jäetud: käsku ei tuntud ära', reportHeader: 'ZPL-i importimise aruanne', reportObjectsFmt: 'Imporditud objektid: {n}', diff --git a/src/locales/fa.ts b/src/locales/fa.ts index ae24cd5e..6aba982d 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -392,6 +392,11 @@ const fa = { dpmm8: '8 نقطه/میلی‌متر (203 dpi)', dpmm12: '12 نقطه/میلی‌متر (300 dpi)', dpmm24: '24 نقطه/میلی‌متر (600 dpi)', + jmDensity: 'حالت تراکم', + jmDensityDefault: 'پیش‌فرض (تراکم کامل)', + jmPageOverrideHintFmt: 'این صفحه حالت تراکم وارد‌شده‌ی خود {mode} را حفظ می‌کند؛ انتخاب، حالت کل طرح را تغییر می‌دهد', + jmDensityA: 'تراکم کامل (صریح)', + jmDensityB: 'نصف تراکم (مقیاس فرمت را دو برابر می‌کند)', safeArea: 'Safe area', safeAreaHint: 'Uniform margin inset from every edge. Shows a guide and pins "Align to label" to the margin.', emitZJustify: 'لنگرسازی بارکد در سمت چاپگر', @@ -426,7 +431,9 @@ const fa = { }, densityRescale: { title: 'تغییر تراکم چاپ', + jmTitle: 'تغییر حالت تراکم', fromToFmt: '{from} → {to} dpmm', + jmFromToFmt: 'چگالی مؤثر: {from} تا {to} dpmm', question: 'اشیای موجود چگونه باید مدیریت شوند؟', scale: 'مقیاس‌بندی برای حفظ اندازه', scaleHint: 'هر مقدار نقطه را تنظیم می‌کند تا چیدمان فیزیکی ثابت بماند.', @@ -853,6 +860,7 @@ const fa = { fnDefaultDroppedTitleFmt: 'همه اسلات‌های {fn} پر است: این فیلد مقدار پیش‌فرض صفحه اول را حفظ می‌کند', mixedGeoTitle: 'اندازه‌های برچسب متعدد: صفحات بعدی از اندازه اول استفاده می‌کنند', mixedGeoDetailFmt: '{cmds} بین بلوک‌ها متفاوت است ({detail})', + mixedJmTitle: 'حالت تراکم چاپ بین بلوک‌ها متفاوت است: صفحات بعدی حالت خود را حفظ می‌کنند، اما فقط بلوک اول قابل ویرایش است', unknownTitle: 'رد شد: دستور شناسایی نشد', reportHeader: 'گزارش وارد کردن ZPL', reportObjectsFmt: 'اشیاء وارد‌شده: {n}', diff --git a/src/locales/fi.ts b/src/locales/fi.ts index 30669ddf..28586f36 100644 --- a/src/locales/fi.ts +++ b/src/locales/fi.ts @@ -392,6 +392,11 @@ const fi = { dpmm8: '8 dpmm (203 dpi)', dpmm12: '12 dpmm (300 dpi)', dpmm24: '24 dpmm (600 dpi)', + jmDensity: 'Tiheystila', + jmDensityDefault: 'Oletus (täysi tiheys)', + jmPageOverrideHintFmt: 'Tämä sivu säilyttää tuodun tiheystilansa {mode}; valinta muuttaa koko suunnitelman laajuista tilaa', + jmDensityA: 'Täysi tiheys (eksplisiittinen)', + jmDensityB: 'Puolitiheys (kaksinkertaistaa muodon mittakaavan)', safeArea: 'Safe area', safeAreaHint: 'Uniform margin inset from every edge. Shows a guide and pins "Align to label" to the margin.', emitZJustify: 'Tulostimen puoleinen viivakoodin ankkurointi', @@ -426,7 +431,9 @@ const fi = { }, densityRescale: { title: 'Muuta tulostustiheyttä', + jmTitle: 'Tiheystilan muutos', fromToFmt: '{from} → {to} dpmm', + jmFromToFmt: 'Todellinen tiheys: {from}–{to} dpmm', question: 'Miten olemassa olevia objekteja käsitellään?', scale: 'Skaalaa koon säilyttämiseksi', scaleHint: 'Säätää jokaista pistearvoa, jotta fyysinen asettelu pysyy samana.', @@ -853,6 +860,7 @@ const fi = { fnDefaultDroppedTitleFmt: 'Kaikki {fn}-paikat varattu: tämä kenttä säilyttää ensimmäisen sivun oletusarvon', mixedGeoTitle: 'Useita etikettikokoja: myöhemmät sivut käyttävät ensimmäistä kokoa', mixedGeoDetailFmt: '{cmds} eroavat lohkojen välillä ({detail})', + mixedJmTitle: 'Tulostustiheyden tila vaihtelee lohkojen välillä: myöhemmät sivut säilyttävät oman tilansa, mutta vain ensimmäistä voi muokata', unknownTitle: 'Ohitettu: komentoa ei tunnistettu', reportHeader: 'ZPL-tuontiraportti', reportObjectsFmt: 'Tuodut objektit: {n}', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 8edf004b..985bb591 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -392,6 +392,11 @@ const fr = { dpmm8: '8 dpmm (203 dpi)', dpmm12: '12 dpmm (300 dpi)', dpmm24: '24 dpmm (600 dpi)', + jmDensity: 'Mode de densité', + jmDensityDefault: 'Par défaut (densité complète)', + jmPageOverrideHintFmt: 'Cette page conserve son mode de densité importé {mode} ; la sélection modifie le mode global de la conception', + jmDensityA: 'Densité complète (explicite)', + jmDensityB: "Demi-densité (double l'échelle du format)", safeArea: 'Safe area', safeAreaHint: 'Uniform margin inset from every edge. Shows a guide and pins "Align to label" to the margin.', emitZJustify: 'Ancrage du code-barres côté imprimante', @@ -426,7 +431,9 @@ const fr = { }, densityRescale: { title: "Modifier la densité d'impression", + jmTitle: 'Changement de mode de densité', fromToFmt: '{from} → {to} dpmm', + jmFromToFmt: 'Densité effective : {from} à {to} dpmm', question: 'Comment traiter les objets existants ?', scale: "Mettre à l'échelle pour conserver la taille", scaleHint: 'Ajuste chaque valeur de point pour que la disposition physique reste identique.', @@ -853,6 +860,7 @@ const fr = { fnDefaultDroppedTitleFmt: 'Tous les emplacements {fn} occupés : ce champ conserve la valeur par défaut de la première page', mixedGeoTitle: "Plusieurs formats d'étiquette : les pages suivantes utilisent le premier format", mixedGeoDetailFmt: '{cmds} diffèrent entre les blocs ({detail})', + mixedJmTitle: "Le mode de densité d'impression diffère entre les blocs : les pages suivantes conservent leur propre mode, mais seul le premier est modifiable", unknownTitle: 'Ignoré : commande non reconnue', reportHeader: "Rapport d'import ZPL", reportObjectsFmt: 'Objets importés : {n}', diff --git a/src/locales/he.ts b/src/locales/he.ts index cfa0e091..468e177f 100644 --- a/src/locales/he.ts +++ b/src/locales/he.ts @@ -392,6 +392,11 @@ const he = { dpmm8: '8 נקודות/מ"מ (203 dpi)', dpmm12: '12 נקודות/מ"מ (300 dpi)', dpmm24: '24 נקודות/מ"מ (600 dpi)', + jmDensity: 'מצב צפיפות', + jmDensityDefault: 'ברירת מחדל (צפיפות מלאה)', + jmPageOverrideHintFmt: 'עמוד זה שומר על מצב הצפיפות המיובא שלו {mode}; הבחירה משנה את המצב עבור כל העיצוב', + jmDensityA: 'צפיפות מלאה (מפורש)', + jmDensityB: 'חצי צפיפות (מכפילה את קנה המידה של הפורמט)', safeArea: 'Safe area', safeAreaHint: 'Uniform margin inset from every edge. Shows a guide and pins "Align to label" to the margin.', emitZJustify: 'עיגון ברקוד בצד המדפסת', @@ -426,7 +431,9 @@ const he = { }, densityRescale: { title: 'שינוי צפיפות ההדפסה', + jmTitle: 'שינוי מצב צפיפות', fromToFmt: '{from} → {to} dpmm', + jmFromToFmt: 'צפיפות אפקטיבית: {from} עד {to} dpmm', question: 'כיצד יש לטפל באובייקטים הקיימים?', scale: 'שינוי קנה מידה לשמירה על הגודל', scaleHint: 'מתאים כל ערך נקודה כך שהפריסה הפיזית תישאר זהה.', @@ -853,6 +860,7 @@ const he = { fnDefaultDroppedTitleFmt: 'כל המשבצות {fn} תפוסות: שדה זה שומר על ברירת המחדל של העמוד הראשון', mixedGeoTitle: 'מספר גדלי תוויות: עמודים מאוחרים יותר משתמשים בגודל הראשון', mixedGeoDetailFmt: '{cmds} שונים בין הבלוקים ({detail})', + mixedJmTitle: 'מצב צפיפות ההדפסה שונה בין הבלוקים: העמודים המאוחרים שומרים על המצב שלהם, אך ניתן לערוך רק את הראשון', unknownTitle: 'דולג: הפקודה לא זוהתה', reportHeader: 'דוח ייבוא ZPL', reportObjectsFmt: 'אובייקטים שיובאו: {n}', diff --git a/src/locales/hr.ts b/src/locales/hr.ts index bd379477..3cf6b167 100644 --- a/src/locales/hr.ts +++ b/src/locales/hr.ts @@ -392,6 +392,11 @@ const hr = { dpmm8: '8 dpmm (203 dpi)', dpmm12: '12 dpmm (300 dpi)', dpmm24: '24 dpmm (600 dpi)', + jmDensity: 'Način gustoće', + jmDensityDefault: 'Zadano (puna gustoća)', + jmPageOverrideHintFmt: 'Ova stranica zadržava svoj uvezeni način gustoće {mode}; odabir mijenja način za cijeli dizajn', + jmDensityA: 'Puna gustoća (eksplicitno)', + jmDensityB: 'Polovična gustoća (udvostručuje mjerilo formata)', safeArea: 'Safe area', safeAreaHint: 'Uniform margin inset from every edge. Shows a guide and pins "Align to label" to the margin.', emitZJustify: 'Sidrenje crtičnog koda na strani pisača', @@ -426,7 +431,9 @@ const hr = { }, densityRescale: { title: 'Promijeni gustoću ispisa', + jmTitle: 'Promjena načina gustoće', fromToFmt: '{from} → {to} dpmm', + jmFromToFmt: 'Stvarna gustoća: {from} do {to} dpmm', question: 'Kako postupiti s postojećim objektima?', scale: 'Skaliraj za zadržavanje veličine', scaleHint: 'Prilagođava svaku vrijednost u točkama tako da fizički raspored ostane isti.', @@ -853,6 +860,7 @@ const hr = { fnDefaultDroppedTitleFmt: 'Svi utori {fn} zauzeti: ovo polje zadržava zadanu vrijednost prve stranice', mixedGeoTitle: 'Više veličina naljepnica: kasnije stranice koriste prvu veličinu', mixedGeoDetailFmt: '{cmds} razlikuju se između blokova ({detail})', + mixedJmTitle: 'Način gustoće ispisa razlikuje se između blokova: kasnije stranice zadržavaju svoj vlastiti način, ali samo je prvi uredljiv', unknownTitle: 'Preskočeno: naredba nije prepoznata', reportHeader: 'Izvještaj o uvozu ZPL-a', reportObjectsFmt: 'Uvezeni objekti: {n}', diff --git a/src/locales/hu.ts b/src/locales/hu.ts index ff2a5bea..a986c81d 100644 --- a/src/locales/hu.ts +++ b/src/locales/hu.ts @@ -392,6 +392,11 @@ const hu = { dpmm8: '8 dpmm (203 dpi)', dpmm12: '12 dpmm (300 dpi)', dpmm24: '24 dpmm (600 dpi)', + jmDensity: 'Sűrűség mód', + jmDensityDefault: 'Alapértelmezett (teljes sűrűség)', + jmPageOverrideHintFmt: 'Ez az oldal megtartja az importált sűrűség módját {mode}; a kiválasztás a teljes tervre vonatkozó módot módosítja', + jmDensityA: 'Teljes sűrűség (explicit)', + jmDensityB: 'Fél sűrűség (megduplázza a formátum méretarányát)', safeArea: 'Safe area', safeAreaHint: 'Uniform margin inset from every edge. Shows a guide and pins "Align to label" to the margin.', emitZJustify: 'Nyomtató oldali vonalkód-rögzítés', @@ -426,7 +431,9 @@ const hu = { }, densityRescale: { title: 'Nyomtatási sűrűség módosítása', + jmTitle: 'Sűrűségmód váltás', fromToFmt: '{from} → {to} dpmm', + jmFromToFmt: 'Tényleges sűrűség: {from}–{to} dpmm', question: 'Hogyan kezeljük a meglévő objektumokat?', scale: 'Méretezés a méret megtartásához', scaleHint: 'Minden pontértéket úgy állít be, hogy a fizikai elrendezés változatlan maradjon.', @@ -853,6 +860,7 @@ const hu = { fnDefaultDroppedTitleFmt: 'Minden {fn} hely foglalt: ez a mező megtartja az első oldal alapértelmezett értékét', mixedGeoTitle: 'Több címkeméret: a későbbi oldalak az első méretet használják', mixedGeoDetailFmt: '{cmds} eltér a blokkok között ({detail})', + mixedJmTitle: 'A nyomtatási sűrűség módja eltér a blokkok között: a későbbi oldalak megtartják saját módjukat, de csak az első szerkeszthető', unknownTitle: 'Kihagyva: ismeretlen parancs', reportHeader: 'ZPL importjelentés', reportObjectsFmt: 'Importált objektumok: {n}', diff --git a/src/locales/it.ts b/src/locales/it.ts index 9d2040fd..012b2c2b 100644 --- a/src/locales/it.ts +++ b/src/locales/it.ts @@ -392,6 +392,11 @@ const it = { dpmm8: '8 dpmm (203 dpi)', dpmm12: '12 dpmm (300 dpi)', dpmm24: '24 dpmm (600 dpi)', + jmDensity: 'Modalità densità', + jmDensityDefault: 'Predefinito (densità piena)', + jmPageOverrideHintFmt: "Questa pagina mantiene la propria modalità di densità importata {mode}; la selezione modifica la modalità a livello dell'intero progetto", + jmDensityA: 'Densità piena (esplicita)', + jmDensityB: 'Mezza densità (raddoppia la scala del formato)', safeArea: 'Safe area', safeAreaHint: 'Uniform margin inset from every edge. Shows a guide and pins "Align to label" to the margin.', emitZJustify: 'Ancoraggio del codice a barre lato stampante', @@ -426,7 +431,9 @@ const it = { }, densityRescale: { title: 'Cambia densità di stampa', + jmTitle: 'Cambio modalità densità', fromToFmt: '{from} → {to} dpmm', + jmFromToFmt: 'Densità effettiva: {from} a {to} dpmm', question: 'Come gestire gli oggetti esistenti?', scale: 'Ridimensiona per mantenere le dimensioni', scaleHint: 'Regola ogni valore in punti in modo che il layout fisico resti invariato.', @@ -853,6 +860,7 @@ const it = { fnDefaultDroppedTitleFmt: 'Tutti gli slot {fn} occupati: questo campo mantiene il valore predefinito della prima pagina', mixedGeoTitle: 'Più dimensioni di etichetta: le pagine successive usano la prima dimensione', mixedGeoDetailFmt: '{cmds} differiscono tra i blocchi ({detail})', + mixedJmTitle: 'La modalità di densità di stampa differisce tra i blocchi: le pagine successive mantengono la propria modalità, ma solo la prima è modificabile', unknownTitle: 'Saltato: comando non riconosciuto', reportHeader: 'Rapporto di importazione ZPL', reportObjectsFmt: 'Oggetti importati: {n}', diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 5e04fa73..31334351 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -392,6 +392,11 @@ const ja = { dpmm8: '8 ドット/mm (203 dpi)', dpmm12: '12 ドット/mm (300 dpi)', dpmm24: '24 ドット/mm (600 dpi)', + jmDensity: '密度モード', + jmDensityDefault: 'デフォルト(フル密度)', + jmPageOverrideHintFmt: 'このページはインポートされた密度モード {mode} を保持します。選択するとデザイン全体のモードが変更されます', + jmDensityA: 'フル密度(明示指定)', + jmDensityB: '半密度(フォーマットの縮尺が2倍になります)', safeArea: 'Safe area', safeAreaHint: 'Uniform margin inset from every edge. Shows a guide and pins "Align to label" to the margin.', emitZJustify: 'プリンター側でのバーコード位置固定', @@ -426,7 +431,9 @@ const ja = { }, densityRescale: { title: '印刷濃度を変更', + jmTitle: '密度モードの変更', fromToFmt: '{from} → {to} dpmm', + jmFromToFmt: '実効密度: {from} から {to} dpmm', question: '既存のオブジェクトをどう扱いますか?', scale: 'サイズを保つように拡大縮小', scaleHint: 'すべてのドット値を調整し、物理的なレイアウトを同じに保ちます。', @@ -853,6 +860,7 @@ const ja = { fnDefaultDroppedTitleFmt: 'すべての{fn}スロットが使用中です: このフィールドは最初のページのデフォルト値を保持します', mixedGeoTitle: '複数のラベルサイズ: 以降のページは最初のサイズを使用します', mixedGeoDetailFmt: 'ブロック間で{cmds}が異なります ({detail})', + mixedJmTitle: 'ブロック間で印字濃度モードが異なります。後続のページはそれぞれ自身のモードを保持しますが、編集できるのは最初のブロックのみです', unknownTitle: 'スキップ: コマンドが認識されません', reportHeader: 'ZPLインポートレポート', reportObjectsFmt: 'インポートされたオブジェクト: {n}', diff --git a/src/locales/ko.ts b/src/locales/ko.ts index f5dbf9bf..8e7e70a3 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -392,6 +392,11 @@ const ko = { dpmm8: '8 점/mm (203 dpi)', dpmm12: '12 점/mm (300 dpi)', dpmm24: '24 점/mm (600 dpi)', + jmDensity: '밀도 모드', + jmDensityDefault: '기본값(전체 밀도)', + jmPageOverrideHintFmt: '이 페이지는 가져온 밀도 모드 {mode}를 유지합니다. 선택하면 디자인 전체 모드가 변경됩니다', + jmDensityA: '전체 밀도(명시적)', + jmDensityB: '절반 밀도(포맷 배율을 두 배로 늘림)', safeArea: 'Safe area', safeAreaHint: 'Uniform margin inset from every edge. Shows a guide and pins "Align to label" to the margin.', emitZJustify: '프린터 측 바코드 고정', @@ -426,7 +431,9 @@ const ko = { }, densityRescale: { title: '인쇄 농도 변경', + jmTitle: '밀도 모드 변경', fromToFmt: '{from} → {to} dpmm', + jmFromToFmt: '유효 밀도: {from}에서 {to} dpmm', question: '기존 객체를 어떻게 처리할까요?', scale: '크기 유지를 위해 배율 조정', scaleHint: '모든 도트 값을 조정하여 물리적 레이아웃이 동일하게 유지됩니다.', @@ -853,6 +860,7 @@ const ko = { fnDefaultDroppedTitleFmt: '모든 {fn} 슬롯이 사용 중: 이 필드는 첫 페이지의 기본값을 유지합니다', mixedGeoTitle: '여러 라벨 크기: 이후 페이지는 첫 번째 크기를 사용합니다', mixedGeoDetailFmt: '블록 간에 {cmds}이(가) 다릅니다 ({detail})', + mixedJmTitle: '블록마다 인쇄 농도 모드가 다릅니다. 이후 페이지는 각자의 모드를 유지하지만, 편집할 수 있는 것은 첫 번째 블록뿐입니다', unknownTitle: '건너뜀: 인식되지 않은 명령', reportHeader: 'ZPL 가져오기 보고서', reportObjectsFmt: '가져온 객체: {n}개', diff --git a/src/locales/lt.ts b/src/locales/lt.ts index 7fccff43..9daa3476 100644 --- a/src/locales/lt.ts +++ b/src/locales/lt.ts @@ -392,6 +392,11 @@ const lt = { dpmm8: '8 dpmm (203 dpi)', dpmm12: '12 dpmm (300 dpi)', dpmm24: '24 dpmm (600 dpi)', + jmDensity: 'Tankio režimas', + jmDensityDefault: 'Numatytasis (pilnas tankis)', + jmPageOverrideHintFmt: 'Šis puslapis išlaiko savo importuotą tankio režimą {mode}; pasirinkimas keičia viso dizaino režimą', + jmDensityA: 'Pilnas tankis (aiškiai nurodytas)', + jmDensityB: 'Pusinis tankis (padvigubina formato mastelį)', safeArea: 'Safe area', safeAreaHint: 'Uniform margin inset from every edge. Shows a guide and pins "Align to label" to the margin.', emitZJustify: 'Brūkšninio kodo tvirtinimas spausdintuvo pusėje', @@ -426,7 +431,9 @@ const lt = { }, densityRescale: { title: 'Keisti spausdinimo tankį', + jmTitle: 'Tankio režimo pakeitimas', fromToFmt: '{from} → {to} dpmm', + jmFromToFmt: 'Efektyvus tankis: {from}–{to} dpmm', question: 'Kaip tvarkyti esamus objektus?', scale: 'Keisti mastelį siekiant išlaikyti dydį', scaleHint: 'Pakoreguoja kiekvieną taško reikšmę, kad fizinis išdėstymas liktų toks pat.', @@ -853,6 +860,7 @@ const lt = { fnDefaultDroppedTitleFmt: 'Visi {fn} lizdai užimti: šis laukas išlaiko pirmojo puslapio numatytąją reikšmę', mixedGeoTitle: 'Keli etikečių dydžiai: vėlesni puslapiai naudoja pirmąjį dydį', mixedGeoDetailFmt: '{cmds} skiriasi tarp blokų ({detail})', + mixedJmTitle: 'Spausdinimo tankio režimas skiriasi tarp blokų: vėlesni puslapiai išlaiko savo režimą, tačiau redaguoti galima tik pirmąjį', unknownTitle: 'Praleista: komanda neatpažinta', reportHeader: 'ZPL importo ataskaita', reportObjectsFmt: 'Importuoti objektai: {n}', diff --git a/src/locales/lv.ts b/src/locales/lv.ts index b917b758..30e3a988 100644 --- a/src/locales/lv.ts +++ b/src/locales/lv.ts @@ -392,6 +392,11 @@ const lv = { dpmm8: '8 dpmm (203 dpi)', dpmm12: '12 dpmm (300 dpi)', dpmm24: '24 dpmm (600 dpi)', + jmDensity: 'Blīvuma režīms', + jmDensityDefault: 'Noklusējums (pilns blīvums)', + jmPageOverrideHintFmt: 'Šī lapa saglabā savu importēto blīvuma režīmu {mode}; izvēle maina visam dizainam kopīgo režīmu', + jmDensityA: 'Pilns blīvums (skaidri norādīts)', + jmDensityB: 'Puse blīvuma (dubulto formāta mērogu)', safeArea: 'Safe area', safeAreaHint: 'Uniform margin inset from every edge. Shows a guide and pins "Align to label" to the margin.', emitZJustify: 'Svītrkoda enkurošana printera pusē', @@ -426,7 +431,9 @@ const lv = { }, densityRescale: { title: 'Mainīt drukas blīvumu', + jmTitle: 'Blīvuma režīma maiņa', fromToFmt: '{from} → {to} dpmm', + jmFromToFmt: 'Efektīvais blīvums: {from} līdz {to} dpmm', question: 'Kā rīkoties ar esošajiem objektiem?', scale: 'Mērogot, lai saglabātu izmēru', scaleHint: 'Pielāgo katru punktu vērtību, lai fiziskais izkārtojums paliktu nemainīgs.', @@ -853,6 +860,7 @@ const lv = { fnDefaultDroppedTitleFmt: 'Visi {fn} sloti aizņemti: šis lauks saglabā pirmās lapas noklusējuma vērtību', mixedGeoTitle: 'Vairāki etiķešu izmēri: vēlākās lapas izmanto pirmo izmēru', mixedGeoDetailFmt: '{cmds} atšķiras starp blokiem ({detail})', + mixedJmTitle: 'Drukas blīvuma režīms atšķiras starp blokiem: vēlākās lapas saglabā savu režīmu, bet rediģējams ir tikai pirmais', unknownTitle: 'Izlaists: komanda nav atpazīta', reportHeader: 'ZPL importēšanas atskaite', reportObjectsFmt: 'Importēti objekti: {n}', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 024011de..5d108182 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -392,6 +392,11 @@ const nl = { dpmm8: '8 dpmm (203 dpi)', dpmm12: '12 dpmm (300 dpi)', dpmm24: '24 dpmm (600 dpi)', + jmDensity: 'Dichtheidsmodus', + jmDensityDefault: 'Standaard (volledige dichtheid)', + jmPageOverrideHintFmt: 'Deze pagina behoudt zijn geïmporteerde dichtheidsmodus {mode}; de selectie wijzigt de ontwerpbrede modus', + jmDensityA: 'Volledige dichtheid (expliciet)', + jmDensityB: 'Halve dichtheid (verdubbelt de formaatschaal)', safeArea: 'Safe area', safeAreaHint: 'Uniform margin inset from every edge. Shows a guide and pins "Align to label" to the margin.', emitZJustify: 'Barcode-verankering aan printerzijde', @@ -426,7 +431,9 @@ const nl = { }, densityRescale: { title: 'Afdrukdichtheid wijzigen', + jmTitle: 'Wijziging dichtheidsmodus', fromToFmt: '{from} → {to} dpmm', + jmFromToFmt: 'Effectieve dichtheid: {from} tot {to} dpmm', question: 'Hoe moeten bestaande objecten worden behandeld?', scale: 'Schalen om grootte te behouden', scaleHint: 'Past elke dotwaarde aan zodat de fysieke lay-out hetzelfde blijft.', @@ -853,6 +860,7 @@ const nl = { fnDefaultDroppedTitleFmt: 'Alle {fn}-slots bezet: dit veld behoudt de standaardwaarde van de eerste pagina', mixedGeoTitle: "Meerdere labelformaten: latere pagina's gebruiken het eerste formaat", mixedGeoDetailFmt: '{cmds} verschillen tussen blokken ({detail})', + mixedJmTitle: "De afdrukdichtheidsmodus verschilt tussen blokken: latere pagina's behouden hun eigen modus, maar alleen de eerste is bewerkbaar", unknownTitle: 'Overgeslagen: opdracht niet herkend', reportHeader: 'ZPL-importrapport', reportObjectsFmt: 'Geïmporteerde objecten: {n}', diff --git a/src/locales/no.ts b/src/locales/no.ts index dc58cb09..bbbbc94b 100644 --- a/src/locales/no.ts +++ b/src/locales/no.ts @@ -392,6 +392,11 @@ const no = { dpmm8: '8 dpmm (203 dpi)', dpmm12: '12 dpmm (300 dpi)', dpmm24: '24 dpmm (600 dpi)', + jmDensity: 'Tetthetsmodus', + jmDensityDefault: 'Standard (full tetthet)', + jmPageOverrideHintFmt: 'Denne siden beholder sin importerte tetthetsmodus {mode}; valget endrer den designomfattende modusen', + jmDensityA: 'Full tetthet (eksplisitt)', + jmDensityB: 'Halv tetthet (dobler formatskalaen)', safeArea: 'Safe area', safeAreaHint: 'Uniform margin inset from every edge. Shows a guide and pins "Align to label" to the margin.', emitZJustify: 'Skriverbasert strekkodeforankring', @@ -426,7 +431,9 @@ const no = { }, densityRescale: { title: 'Endre utskriftstetthet', + jmTitle: 'Endring av tetthetsmodus', fromToFmt: '{from} → {to} dpmm', + jmFromToFmt: 'Effektiv tetthet: {from} til {to} dpmm', question: 'Hvordan skal eksisterende objekter håndteres?', scale: 'Skaler for å beholde størrelsen', scaleHint: 'Justerer hver punktverdi slik at det fysiske oppsettet forblir det samme.', @@ -853,6 +860,7 @@ const no = { fnDefaultDroppedTitleFmt: 'Alle {fn}-plasser opptatt: dette feltet beholder standardverdien fra den første siden', mixedGeoTitle: 'Flere etikettstørrelser: senere sider bruker den første størrelsen', mixedGeoDetailFmt: '{cmds} skiller seg mellom blokker ({detail})', + mixedJmTitle: 'Utskriftstetthetsmodus er forskjellig mellom blokker: senere sider beholder sin egen modus, men kun den første kan redigeres', unknownTitle: 'Hoppet over: kommandoen ble ikke gjenkjent', reportHeader: 'ZPL-importrapport', reportObjectsFmt: 'Importerte objekter: {n}', diff --git a/src/locales/pl.ts b/src/locales/pl.ts index 97162734..a5d03e3e 100644 --- a/src/locales/pl.ts +++ b/src/locales/pl.ts @@ -392,6 +392,11 @@ const pl = { dpmm8: '8 dpmm (203 dpi)', dpmm12: '12 dpmm (300 dpi)', dpmm24: '24 dpmm (600 dpi)', + jmDensity: 'Tryb gęstości', + jmDensityDefault: 'Domyślny (pełna gęstość)', + jmPageOverrideHintFmt: 'Ta strona zachowuje zaimportowany tryb gęstości {mode}; wybór zmienia tryb obowiązujący dla całego projektu', + jmDensityA: 'Pełna gęstość (jawnie)', + jmDensityB: 'Połowa gęstości (podwaja skalę formatu)', safeArea: 'Safe area', safeAreaHint: 'Uniform margin inset from every edge. Shows a guide and pins "Align to label" to the margin.', emitZJustify: 'Zakotwiczenie kodu kreskowego po stronie drukarki', @@ -426,7 +431,9 @@ const pl = { }, densityRescale: { title: 'Zmień gęstość druku', + jmTitle: 'Zmiana trybu gęstości', fromToFmt: '{from} → {to} dpmm', + jmFromToFmt: 'Efektywna gęstość: {from} do {to} dpmm', question: 'Jak potraktować istniejące obiekty?', scale: 'Skaluj, aby zachować rozmiar', scaleHint: 'Dostosowuje każdą wartość punktów, aby fizyczny układ pozostał taki sam.', @@ -853,6 +860,7 @@ const pl = { fnDefaultDroppedTitleFmt: 'Wszystkie sloty {fn} zajęte: to pole zachowuje wartość domyślną z pierwszej strony', mixedGeoTitle: 'Wiele rozmiarów etykiet: kolejne strony używają pierwszego rozmiaru', mixedGeoDetailFmt: '{cmds} różnią się między blokami ({detail})', + mixedJmTitle: 'Tryb gęstości wydruku różni się między blokami: kolejne strony zachowują własny tryb, ale tylko pierwszy jest edytowalny', unknownTitle: 'Pominięto: nierozpoznane polecenie', reportHeader: 'Raport importu ZPL', reportObjectsFmt: 'Zaimportowane obiekty: {n}', diff --git a/src/locales/pt.ts b/src/locales/pt.ts index b76e6047..e2c407ef 100644 --- a/src/locales/pt.ts +++ b/src/locales/pt.ts @@ -392,6 +392,11 @@ const pt = { dpmm8: '8 dpmm (203 dpi)', dpmm12: '12 dpmm (300 dpi)', dpmm24: '24 dpmm (600 dpi)', + jmDensity: 'Modo de densidade', + jmDensityDefault: 'Padrão (densidade total)', + jmPageOverrideHintFmt: 'Esta página mantém o seu modo de densidade importado {mode}; a seleção altera o modo a nível de todo o design', + jmDensityA: 'Densidade total (explícita)', + jmDensityB: 'Meia densidade (duplica a escala do formato)', safeArea: 'Safe area', safeAreaHint: 'Uniform margin inset from every edge. Shows a guide and pins "Align to label" to the margin.', emitZJustify: 'Ancoragem do código de barras no lado da impressora', @@ -426,7 +431,9 @@ const pt = { }, densityRescale: { title: 'Alterar densidade de impressão', + jmTitle: 'Mudança de modo de densidade', fromToFmt: '{from} → {to} dpmm', + jmFromToFmt: 'Densidade efetiva: {from} a {to} dpmm', question: 'Como tratar os objetos existentes?', scale: 'Dimensionar para manter o tamanho', scaleHint: 'Ajusta cada valor de pontos para que o layout físico permaneça igual.', @@ -853,6 +860,7 @@ const pt = { fnDefaultDroppedTitleFmt: 'Todos os slots {fn} ocupados: este campo mantém o padrão da primeira página', mixedGeoTitle: 'Vários tamanhos de etiqueta: páginas posteriores usam o primeiro tamanho', mixedGeoDetailFmt: '{cmds} diferem entre os blocos ({detail})', + mixedJmTitle: 'O modo de densidade de impressão difere entre blocos: as páginas seguintes mantêm o seu próprio modo, mas apenas a primeira é editável', unknownTitle: 'Ignorado: comando não reconhecido', reportHeader: 'Relatório de importação ZPL', reportObjectsFmt: 'Objetos importados: {n}', diff --git a/src/locales/ro.ts b/src/locales/ro.ts index d886f4ff..e15a30a8 100644 --- a/src/locales/ro.ts +++ b/src/locales/ro.ts @@ -392,6 +392,11 @@ const ro = { dpmm8: '8 dpmm (203 dpi)', dpmm12: '12 dpmm (300 dpi)', dpmm24: '24 dpmm (600 dpi)', + jmDensity: 'Mod densitate', + jmDensityDefault: 'Implicit (densitate completă)', + jmPageOverrideHintFmt: 'Această pagină își păstrează modul de densitate importat {mode}; selecția modifică modul pentru întregul design', + jmDensityA: 'Densitate completă (explicită)', + jmDensityB: 'Densitate redusă la jumătate (dublează scara formatului)', safeArea: 'Safe area', safeAreaHint: 'Uniform margin inset from every edge. Shows a guide and pins "Align to label" to the margin.', emitZJustify: 'Ancorarea codului de bare pe partea imprimantei', @@ -426,7 +431,9 @@ const ro = { }, densityRescale: { title: 'Modifică densitatea de imprimare', + jmTitle: 'Schimbare mod densitate', fromToFmt: '{from} → {to} dpmm', + jmFromToFmt: 'Densitate efectivă: {from} până la {to} dpmm', question: 'Cum trebuie tratate obiectele existente?', scale: 'Scalează pentru a păstra dimensiunea', scaleHint: 'Ajustează fiecare valoare în puncte astfel încât aranjamentul fizic să rămână același.', @@ -853,6 +860,7 @@ const ro = { fnDefaultDroppedTitleFmt: 'Toate sloturile {fn} ocupate: acest câmp păstrează valoarea implicită a primei pagini', mixedGeoTitle: 'Mai multe dimensiuni de etichetă: paginile ulterioare folosesc prima dimensiune', mixedGeoDetailFmt: '{cmds} diferă între blocuri ({detail})', + mixedJmTitle: 'Modul de densitate a imprimării diferă între blocuri: paginile ulterioare își păstrează propriul mod, dar numai primul este editabil', unknownTitle: 'Omis: comandă nerecunoscută', reportHeader: 'Raport de import ZPL', reportObjectsFmt: 'Obiecte importate: {n}', diff --git a/src/locales/sk.ts b/src/locales/sk.ts index 365e3bc3..eedd336b 100644 --- a/src/locales/sk.ts +++ b/src/locales/sk.ts @@ -392,6 +392,11 @@ const sk = { dpmm8: '8 dpmm (203 dpi)', dpmm12: '12 dpmm (300 dpi)', dpmm24: '24 dpmm (600 dpi)', + jmDensity: 'Režim hustoty', + jmDensityDefault: 'Predvolené (plná hustota)', + jmPageOverrideHintFmt: 'Táto stránka si zachováva svoj importovaný režim hustoty {mode}; výber mení režim platný pre celý návrh', + jmDensityA: 'Plná hustota (explicitne)', + jmDensityB: 'Polovičná hustota (zdvojnásobí mierku formátu)', safeArea: 'Safe area', safeAreaHint: 'Uniform margin inset from every edge. Shows a guide and pins "Align to label" to the margin.', emitZJustify: 'Ukotvenie čiarového kódu na strane tlačiarne', @@ -426,7 +431,9 @@ const sk = { }, densityRescale: { title: 'Zmeniť hustotu tlače', + jmTitle: 'Zmena režimu hustoty', fromToFmt: '{from} → {to} dpmm', + jmFromToFmt: 'Efektívna hustota: {from} až {to} dpmm', question: 'Ako sa majú spracovať existujúce objekty?', scale: 'Škálovať na zachovanie veľkosti', scaleHint: 'Upraví každú hodnotu v bodoch tak, aby fyzické rozloženie zostalo rovnaké.', @@ -853,6 +860,7 @@ const sk = { fnDefaultDroppedTitleFmt: 'Všetky sloty {fn} obsadené: toto pole si ponecháva predvolenú hodnotu prvej strany', mixedGeoTitle: 'Viacero veľkostí štítkov: neskoršie strany používajú prvú veľkosť', mixedGeoDetailFmt: '{cmds} sa medzi blokmi líšia ({detail})', + mixedJmTitle: 'Režim hustoty tlače sa medzi blokmi líši: neskoršie stránky si zachovávajú svoj vlastný režim, ale upraviteľný je iba prvý', unknownTitle: 'Preskočené: príkaz nerozpoznaný', reportHeader: 'Správa o importe ZPL', reportObjectsFmt: 'Importované objekty: {n}', diff --git a/src/locales/sl.ts b/src/locales/sl.ts index ceb3d4e7..89658f1b 100644 --- a/src/locales/sl.ts +++ b/src/locales/sl.ts @@ -392,6 +392,11 @@ const sl = { dpmm8: '8 dpmm (203 dpi)', dpmm12: '12 dpmm (300 dpi)', dpmm24: '24 dpmm (600 dpi)', + jmDensity: 'Način gostote', + jmDensityDefault: 'Privzeto (polna gostota)', + jmPageOverrideHintFmt: 'Ta stran ohrani svoj uvoženi način gostote {mode}; izbira spremeni način za celotno zasnovo', + jmDensityA: 'Polna gostota (eksplicitno)', + jmDensityB: 'Polovična gostota (podvoji merilo formata)', safeArea: 'Safe area', safeAreaHint: 'Uniform margin inset from every edge. Shows a guide and pins "Align to label" to the margin.', emitZJustify: 'Sidranje črtne kode na strani tiskalnika', @@ -426,7 +431,9 @@ const sl = { }, densityRescale: { title: 'Spremeni gostoto tiska', + jmTitle: 'Sprememba načina gostote', fromToFmt: '{from} → {to} dpmm', + jmFromToFmt: 'Dejanska gostota: {from} do {to} dpmm', question: 'Kako naj se obravnavajo obstoječi predmeti?', scale: 'Spremeni merilo za ohranitev velikosti', scaleHint: 'Prilagodi vsako vrednost pik, da fizična postavitev ostane enaka.', @@ -853,6 +860,7 @@ const sl = { fnDefaultDroppedTitleFmt: 'Vse reže {fn} zasedene: to polje ohrani privzeto vrednost prve strani', mixedGeoTitle: 'Več velikosti nalepk: poznejše strani uporabljajo prvo velikost', mixedGeoDetailFmt: '{cmds} se razlikujejo med bloki ({detail})', + mixedJmTitle: 'Način gostote tiskanja se med bloki razlikuje: poznejše strani ohranijo svoj lastni način, vendar je urejljiv samo prvi', unknownTitle: 'Preskočeno: ukaz ni prepoznan', reportHeader: 'Poročilo o uvozu ZPL', reportObjectsFmt: 'Uvoženi predmeti: {n}', diff --git a/src/locales/sr.ts b/src/locales/sr.ts index 7d458418..2cb3b80e 100644 --- a/src/locales/sr.ts +++ b/src/locales/sr.ts @@ -392,6 +392,11 @@ const sr = { dpmm8: '8 dpmm (203 dpi)', dpmm12: '12 dpmm (300 dpi)', dpmm24: '24 dpmm (600 dpi)', + jmDensity: 'Режим густине', + jmDensityDefault: 'Подразумевано (пуна густина)', + jmPageOverrideHintFmt: 'Ова страница задржава свој увезени режим густине {mode}; избор мења режим за читав дизајн', + jmDensityA: 'Пуна густина (експлицитно)', + jmDensityB: 'Половина густине (удвостручује размеру формата)', safeArea: 'Safe area', safeAreaHint: 'Uniform margin inset from every edge. Shows a guide and pins "Align to label" to the margin.', emitZJustify: 'Usidravanje bar koda na strani štampača', @@ -426,7 +431,9 @@ const sr = { }, densityRescale: { title: 'Промени густину штампе', + jmTitle: 'Промена режима густине', fromToFmt: '{from} → {to} dpmm', + jmFromToFmt: 'Ефективна густина: {from} до {to} dpmm', question: 'Како треба поступити са постојећим објектима?', scale: 'Скалирај ради задржавања величине', scaleHint: 'Прилагођава сваку вредност тачака тако да физички распоред остане исти.', @@ -853,6 +860,7 @@ const sr = { fnDefaultDroppedTitleFmt: 'Сви слотови {fn} заузети: ово поље задржава подразумевану вредност прве странице', mixedGeoTitle: 'Више величина налепница: касније странице користе прву величину', mixedGeoDetailFmt: '{cmds} се разликују између блокова ({detail})', + mixedJmTitle: 'Режим густине штампе се разликује између блокова: касније странице задржавају сопствени режим, али само први је могуће уредити', unknownTitle: 'Прескочено: команда није препозната', reportHeader: 'Извештај о увозу ZPL-а', reportObjectsFmt: 'Увезени објекти: {n}', diff --git a/src/locales/sv.ts b/src/locales/sv.ts index e08d428f..c9c401fa 100644 --- a/src/locales/sv.ts +++ b/src/locales/sv.ts @@ -392,6 +392,11 @@ const sv = { dpmm8: '8 dpmm (203 dpi)', dpmm12: '12 dpmm (300 dpi)', dpmm24: '24 dpmm (600 dpi)', + jmDensity: 'Densitetsläge', + jmDensityDefault: 'Standard (full densitet)', + jmPageOverrideHintFmt: 'Den här sidan behåller sitt importerade densitetsläge {mode}; valet ändrar det designomfattande läget', + jmDensityA: 'Full densitet (explicit)', + jmDensityB: 'Halv densitet (dubblar formatskalan)', safeArea: 'Safe area', safeAreaHint: 'Uniform margin inset from every edge. Shows a guide and pins "Align to label" to the margin.', emitZJustify: 'Skrivarsidig streckkodsförankring', @@ -426,7 +431,9 @@ const sv = { }, densityRescale: { title: 'Ändra utskriftstäthet', + jmTitle: 'Ändring av densitetsläge', fromToFmt: '{from} → {to} dpmm', + jmFromToFmt: 'Effektiv densitet: {from} till {to} dpmm', question: 'Hur ska befintliga objekt hanteras?', scale: 'Skala för att behålla storleken', scaleHint: 'Justerar varje punktvärde så att den fysiska layouten förblir densamma.', @@ -853,6 +860,7 @@ const sv = { fnDefaultDroppedTitleFmt: 'Alla {fn}-platser upptagna: det här fältet behåller standardvärdet från den första sidan', mixedGeoTitle: 'Flera etikettstorlekar: senare sidor använder den första storleken', mixedGeoDetailFmt: '{cmds} skiljer sig mellan block ({detail})', + mixedJmTitle: 'Utskriftstäthetsläget skiljer sig mellan block: senare sidor behåller sitt eget läge, men endast det första är redigerbart', unknownTitle: 'Hoppades över: kommandot kändes inte igen', reportHeader: 'ZPL-importrapport', reportObjectsFmt: 'Importerade objekt: {n}', diff --git a/src/locales/tr.ts b/src/locales/tr.ts index f4725daf..91b0f67b 100644 --- a/src/locales/tr.ts +++ b/src/locales/tr.ts @@ -392,6 +392,11 @@ const tr = { dpmm8: '8 nokta/mm (203 dpi)', dpmm12: '12 nokta/mm (300 dpi)', dpmm24: '24 nokta/mm (600 dpi)', + jmDensity: 'Yoğunluk modu', + jmDensityDefault: 'Varsayılan (tam yoğunluk)', + jmPageOverrideHintFmt: 'Bu sayfa içe aktarılan yoğunluk modunu {mode} korur; seçim, tasarımın tamamı için geçerli modu değiştirir', + jmDensityA: 'Tam yoğunluk (açık)', + jmDensityB: 'Yarım yoğunluk (format ölçeğini iki katına çıkarır)', safeArea: 'Safe area', safeAreaHint: 'Uniform margin inset from every edge. Shows a guide and pins "Align to label" to the margin.', emitZJustify: 'Yazıcı taraflı barkod sabitleme', @@ -426,7 +431,9 @@ const tr = { }, densityRescale: { title: 'Baskı yoğunluğunu değiştir', + jmTitle: 'Yoğunluk modu değişikliği', fromToFmt: '{from} → {to} dpmm', + jmFromToFmt: 'Etkin yoğunluk: {from} - {to} dpmm', question: 'Mevcut nesneler nasıl ele alınsın?', scale: 'Boyutu korumak için ölçekle', scaleHint: 'Fiziksel düzenin aynı kalması için her nokta değerini ayarlar.', @@ -853,6 +860,7 @@ const tr = { fnDefaultDroppedTitleFmt: 'Tüm {fn} yuvaları dolu: bu alan ilk sayfanın varsayılan değerini korur', mixedGeoTitle: 'Birden fazla etiket boyutu: sonraki sayfalar ilk boyutu kullanır', mixedGeoDetailFmt: 'Bloklar arasında {cmds} farklılık gösteriyor ({detail})', + mixedJmTitle: 'Baskı yoğunluğu modu bloklar arasında farklılık gösteriyor: sonraki sayfalar kendi modlarını korur, ancak yalnızca ilki düzenlenebilir', unknownTitle: 'Atlandı: komut tanınmadı', reportHeader: 'ZPL içe aktarma raporu', reportObjectsFmt: 'İçe aktarılan nesneler: {n}', diff --git a/src/locales/zh-hans.ts b/src/locales/zh-hans.ts index 26bb1415..4bbeaed6 100644 --- a/src/locales/zh-hans.ts +++ b/src/locales/zh-hans.ts @@ -392,6 +392,11 @@ const zhHans = { dpmm8: '8 点/毫米 (203 dpi)', dpmm12: '12 点/毫米 (300 dpi)', dpmm24: '24 点/毫米 (600 dpi)', + jmDensity: '密度模式', + jmDensityDefault: '默认(全密度)', + jmPageOverrideHintFmt: '此页面保留其导入的密度模式 {mode};该选择将更改整个设计的模式', + jmDensityA: '全密度(显式)', + jmDensityB: '半密度(格式比例加倍)', safeArea: 'Safe area', safeAreaHint: 'Uniform margin inset from every edge. Shows a guide and pins "Align to label" to the margin.', emitZJustify: '打印机端条码锚定', @@ -426,7 +431,9 @@ const zhHans = { }, densityRescale: { title: '更改打印密度', + jmTitle: '密度模式变更', fromToFmt: '{from} → {to} dpmm', + jmFromToFmt: '有效密度:{from} 至 {to} dpmm', question: '应如何处理现有对象?', scale: '缩放以保持尺寸', scaleHint: '调整每个点值,使物理布局保持不变。', @@ -853,6 +860,7 @@ const zhHans = { fnDefaultDroppedTitleFmt: '所有 {fn} 槽位均已占用:该字段保留第一页的默认值', mixedGeoTitle: '存在多种标签尺寸:后续页面使用第一种尺寸', mixedGeoDetailFmt: '{cmds} 在各区块之间不同({detail})', + mixedJmTitle: '各区块之间的打印浓度模式不同:后续页面保留各自的模式,但只有第一个区块可编辑', unknownTitle: '已跳过:命令无法识别', reportHeader: 'ZPL 导入报告', reportObjectsFmt: '已导入对象:{n}', diff --git a/src/locales/zh-hant.ts b/src/locales/zh-hant.ts index c0e39c08..314c858d 100644 --- a/src/locales/zh-hant.ts +++ b/src/locales/zh-hant.ts @@ -392,6 +392,11 @@ const zhHant = { dpmm8: '8 點/公釐 (203 dpi)', dpmm12: '12 點/公釐 (300 dpi)', dpmm24: '24 點/公釐 (600 dpi)', + jmDensity: '密度模式', + jmDensityDefault: '預設(全密度)', + jmPageOverrideHintFmt: '此頁面保留其匯入的密度模式 {mode};該選擇會變更整個設計的模式', + jmDensityA: '全密度(明確指定)', + jmDensityB: '半密度(格式比例加倍)', safeArea: 'Safe area', safeAreaHint: 'Uniform margin inset from every edge. Shows a guide and pins "Align to label" to the margin.', emitZJustify: '印表機端條碼錨定', @@ -426,7 +431,9 @@ const zhHant = { }, densityRescale: { title: '變更列印密度', + jmTitle: '密度模式變更', fromToFmt: '{from} → {to} dpmm', + jmFromToFmt: '有效密度:{from} 至 {to} dpmm', question: '應如何處理現有物件?', scale: '縮放以保持尺寸', scaleHint: '調整每個點值,使實體版面保持不變。', @@ -853,6 +860,7 @@ const zhHant = { fnDefaultDroppedTitleFmt: '所有 {fn} 插槽均已佔用:此欄位保留第一頁的預設值', mixedGeoTitle: '存在多種標籤尺寸:後續頁面使用第一種尺寸', mixedGeoDetailFmt: '{cmds} 在各區塊之間不同({detail})', + mixedJmTitle: '各區塊之間的列印濃度模式不同:後續頁面保留各自的模式,但只有第一個區塊可編輯', unknownTitle: '已略過:無法辨識指令', reportHeader: 'ZPL 匯入報告', reportObjectsFmt: '已匯入物件:{n}', diff --git a/src/registry/barcode1d.panel.tsx b/src/registry/barcode1d.panel.tsx index b10800a5..9e748401 100644 --- a/src/registry/barcode1d.panel.tsx +++ b/src/registry/barcode1d.panel.tsx @@ -107,6 +107,7 @@ export function createBarcode1DPanel(config: Barcode1DPanelConfig): ObjectTypeUi = {
onChange({ width })} @@ -33,6 +34,7 @@ export const boxPanel: ObjectTypeUi = { /> onChange({ height })} @@ -57,6 +59,7 @@ export const boxPanel: ObjectTypeUi = { {!p.filled && ( onChange({ thickness })} diff --git a/src/registry/codablock.panel.tsx b/src/registry/codablock.panel.tsx index 30e62bad..936b6f26 100644 --- a/src/registry/codablock.panel.tsx +++ b/src/registry/codablock.panel.tsx @@ -29,6 +29,7 @@ export const codablockPanel: ObjectTypeUi = {
onChange({ rowHeight })} diff --git a/src/registry/code49.panel.tsx b/src/registry/code49.panel.tsx index 29a34271..231bfa81 100644 --- a/src/registry/code49.panel.tsx +++ b/src/registry/code49.panel.tsx @@ -32,6 +32,7 @@ export const code49Panel: ObjectTypeUi = {
= { {p.lockAspect ? ( onChange({ width: d, height: d })} @@ -31,6 +32,7 @@ export const ellipsePanel: ObjectTypeUi = {
onChange({ width })} @@ -39,6 +41,7 @@ export const ellipsePanel: ObjectTypeUi = { /> onChange({ height })} @@ -93,6 +96,7 @@ export const ellipsePanel: ObjectTypeUi = { {!p.filled && ( onChange({ thickness })} diff --git a/src/registry/image.panel.tsx b/src/registry/image.panel.tsx index d9ddda18..544b91a6 100644 --- a/src/registry/image.panel.tsx +++ b/src/registry/image.panel.tsx @@ -169,6 +169,7 @@ export const imagePanel: ObjectTypeUi = { {/* Width */} w !== undefined && handleWidthChange(w)} diff --git a/src/registry/line.panel.tsx b/src/registry/line.panel.tsx index b7de9dd7..9765f049 100644 --- a/src/registry/line.panel.tsx +++ b/src/registry/line.panel.tsx @@ -51,6 +51,7 @@ export const linePanel: ObjectTypeUi = {
= { = {
onChange({ rowHeight })} diff --git a/src/registry/pdf417.panel.tsx b/src/registry/pdf417.panel.tsx index 546dfdea..3dc9dc8a 100644 --- a/src/registry/pdf417.panel.tsx +++ b/src/registry/pdf417.panel.tsx @@ -23,6 +23,7 @@ export const pdf417Panel: ObjectTypeUi = {
onChange({ rowHeight })} diff --git a/src/registry/symbol.panel.tsx b/src/registry/symbol.panel.tsx index a0dd088f..0ef323f2 100644 --- a/src/registry/symbol.panel.tsx +++ b/src/registry/symbol.panel.tsx @@ -44,6 +44,7 @@ export const symbolPanel: ObjectTypeUi = {
onChange({ height })} @@ -52,6 +53,7 @@ export const symbolPanel: ObjectTypeUi = { /> onChange({ width })} diff --git a/src/registry/text.panel.tsx b/src/registry/text.panel.tsx index a5048c10..775b0754 100644 --- a/src/registry/text.panel.tsx +++ b/src/registry/text.panel.tsx @@ -5,7 +5,7 @@ import { buttonCls, inputCls, labelCls } from "../components/Properties/styles"; import { getFont, loadFontFile } from "@zplab/core/lib/fontCache"; import { useFontCacheVersion } from "../hooks/useFontCacheVersion"; import { useLabelStore } from "../store/labelStore"; -import { currentObjects } from "../store/labelStore.selectors"; +import { currentObjects, currentPageLabel } from "../store/labelStore.selectors"; import { reverseTextHasBacking, reverseTextHasOwnBacking } from "@zplab/core/lib/reverseBacking"; import { RotationSelect } from "../components/Properties/RotationSelect"; import { UnitNumberInput } from "../components/Properties/UnitNumberInput"; @@ -31,10 +31,10 @@ export const textPanel: ObjectTypeUi = { const addReverseBackground = useLabelStore((s) => s.addReverseBackground); const removeReverseBackground = useLabelStore((s) => s.removeReverseBackground); const hasReverseBacking = useLabelStore((s) => - reverseTextHasBacking(currentObjects(s), obj.id, s.label), + reverseTextHasBacking(currentObjects(s), obj.id, currentPageLabel(s)), ); const hasOwnReverseBacking = useLabelStore((s) => - reverseTextHasOwnBacking(currentObjects(s), obj.id, s.label), + reverseTextHasOwnBacking(currentObjects(s), obj.id, currentPageLabel(s)), ); // Font picker options: every alias the user can reference from this @@ -94,6 +94,7 @@ export const textPanel: ObjectTypeUi = {
onChange({ fontHeight })} @@ -102,6 +103,7 @@ export const textPanel: ObjectTypeUi = { /> onChange({ fontWidth })} diff --git a/src/registry/tlc39.panel.tsx b/src/registry/tlc39.panel.tsx index 936f1c5b..b44316d0 100644 --- a/src/registry/tlc39.panel.tsx +++ b/src/registry/tlc39.panel.tsx @@ -23,6 +23,7 @@ export const tlc39Panel: ObjectTypeUi = {
onChange({ height })} @@ -43,6 +44,7 @@ export const tlc39Panel: ObjectTypeUi = {
= new Set( + NON_EMITTING_CONFIG_FIELDS, +); /** Prop keys that never reach emitted ZPL: a props diff touching only these * must not stamp dirty and drop the verbatim overlay. Classifies globally by diff --git a/src/store/labelStore.selectors.ts b/src/store/labelStore.selectors.ts index 1f1e832d..0143b623 100644 --- a/src/store/labelStore.selectors.ts +++ b/src/store/labelStore.selectors.ts @@ -1,15 +1,38 @@ -import type { LabelObject } from '@zplab/core/types/Group'; +import { pageLabelConfig, type LabelObject } from '@zplab/core/types/Group'; import { isDefaultHost, resolveHost, resolveApiKey } from '../lib/labelary'; import { isDesktopShell } from '../lib/platform'; import type { Dataset } from './slices/dataSlice'; import type { ColumnMapping } from '@zplab/core/types/Variable'; import type { LabelState } from './labelStore'; import type { PageState } from './labelStore.internals'; -import { PER_LABEL_ZPL_FIELDS } from '@zplab/core/types/LabelConfig'; +import { PER_LABEL_ZPL_FIELDS, type JmDensity, type LabelConfig } from '@zplab/core/types/LabelConfig'; export const currentObjects = (state: PageState): LabelObject[] => state.pages[state.currentPageIndex]?.objects ?? []; +// pageLabelConfig builds a fresh object per override, which a zustand selector +// would hand back as a new reference on every store read. Cache per (design +// label, density) so subscribers only re-render when one of them changes. +const overrideCache = new WeakMap>(); + +/** The label as the current page prints it: its ^JM override wins so every + * editor-geometry root (mm<->dots, bounds, snap, preflight) and single-page + * emit works in this page's density. Design-scope reads keep `state.label`. */ +export const currentPageLabel = (state: LabelState): LabelConfig => { + const jm = state.pages[state.currentPageIndex]?.jmDensity; + if (jm === undefined || jm === state.label.jmDensity) return state.label; + let byDensity = overrideCache.get(state.label); + if (!byDensity) { + byDensity = new Map(); + overrideCache.set(state.label, byDensity); + } + const cached = byDensity.get(jm); + if (cached) return cached; + const built = pageLabelConfig(state.label, { jmDensity: jm }); + byDensity.set(jm, built); + return built; +}; + /** True while any per-label print override is set; drives the reset button's * visibility so its disappearance after a reset doubles as feedback. */ export const selectHasPerLabelOverrides = (s: LabelState): boolean => diff --git a/src/store/labelStore.test.ts b/src/store/labelStore.test.ts index f364e7e9..b4aaa050 100644 --- a/src/store/labelStore.test.ts +++ b/src/store/labelStore.test.ts @@ -8,6 +8,10 @@ import { __resetPreviewCacheForTests, migrateLegacy, } from './labelStore'; +import { currentPageLabel } from './labelStore.selectors'; +import { importZplText } from '@zplab/core/lib/zplImportService'; +import { dotsToMm } from '@zplab/core/lib/coordinates'; +import { effectiveDpmm } from '@zplab/core/types/LabelConfig'; import { loadFetchedDataset, currentDataContext, isCurrentDataContext } from './datasetActions'; import { isGroup, getAllLeaves, type LabelObject } from '@zplab/core/types/Group'; import { DEFAULT_CANVAS_SETTINGS } from './slices/uiSlice'; @@ -969,6 +973,64 @@ describe('duplicatePage', () => { expect(objs()).toHaveLength(1); expect(defined(objs()[0]).id).not.toBe(originalId); }); + + it('keeps the ^JM override, so the clone prints at the density its dots use', () => { + useLabelStore.setState({ pages: [{ objects: [], jmDensity: 'B' }] }); + state().duplicatePage(0); + expect(defined(state().pages[1]).jmDensity).toBe('B'); + }); +}); + +describe('currentPageLabel', () => { + it('lets a page ^JM override the design density (batch export, preview)', () => { + useLabelStore.setState({ + pages: [{ objects: [] }, { objects: [], jmDensity: 'B' }], + currentPageIndex: 1, + }); + expect(currentPageLabel(state()).jmDensity).toBe('B'); + state().setCurrentPage(0); + expect(currentPageLabel(state()).jmDensity).toBeUndefined(); + }); + + // A fresh object per read would re-render every subscriber on every store + // write, which in a zustand selector means an endless render loop. + it('is reference-stable across reads', () => { + useLabelStore.setState({ pages: [{ objects: [], jmDensity: 'B' }], currentPageIndex: 0 }); + expect(currentPageLabel(state())).toBe(currentPageLabel(state())); + const before = currentPageLabel(state()); + state().setLabelConfig({ widthMm: 80 }); + expect(currentPageLabel(state())).not.toBe(before); + }); + + it('reads the editor geometry of an imported ^JMB page at the halved density', () => { + const r = importZplText( + '^XA^FO10,10^A0N,30,30^FDa^FS^XZ\n^XA^JMB^FO10,10^A0N,30,30^FDb^FS^XZ', + 8, + ); + useLabelStore.setState({ + label: { ...state().label, ...r.labelConfig }, + pages: r.pages, + currentPageIndex: 1, + }); + // 8 dpmm head, halved by ^JMB: the page's 100 dots are 25 mm, not 12.5. + expect(dotsToMm(100, effectiveDpmm(currentPageLabel(state())))).toBe(25); + state().setCurrentPage(0); + expect(dotsToMm(100, effectiveDpmm(currentPageLabel(state())))).toBe(12.5); + }); + + it('builds the preview stream at the current page density', async () => { + const labelary = await import('../lib/labelary'); + const fetchSpy = vi.mocked(labelary.fetchPreview); + fetchSpy.mockClear(); + useLabelStore.setState({ + pages: [{ objects: [] }, { objects: [], jmDensity: 'B' }], + currentPageIndex: 1, + labelaryNoticeAcknowledged: true, + }); + state().addObject('text'); + await state().enterPreviewMode(); + expect(fetchSpy.mock.calls[0]?.[0]).toContain('^JMB'); + }); }); describe('setCurrentPage', () => { diff --git a/src/store/labelStore.ts b/src/store/labelStore.ts index 6a95225b..798ac3d2 100644 --- a/src/store/labelStore.ts +++ b/src/store/labelStore.ts @@ -7,7 +7,9 @@ import { PRINTER_PROFILE_FIELDS, printerProfileSchema } from '@zplab/core/types/ import { visitLeavesInPages, foldSerialLeaf, bindSingleMarkerLeaf, sanitiseVariableNames, safeUniqueNameById } from '@zplab/core/lib/objectTree'; import { insertReverseBackingBoxes, pageNeedsReverseBacking } from '@zplab/core/lib/reverseBacking'; import { dropLegacyFontBindings } from '@zplab/core/lib/customFonts'; -import type { CustomFontMapping, LabelConfig } from '@zplab/core/types/LabelConfig'; +import { reconstructLegacyJmDensity } from '@zplab/core/lib/designFile'; +import type { DesignFilePage } from '@zplab/core/lib/designFile'; +import type { CustomFontMapping, JmDensity, LabelConfig } from '@zplab/core/types/LabelConfig'; import type { LabelObject } from '@zplab/core/types/Group'; import { createPrinterProfileSlice, @@ -47,6 +49,7 @@ export type LabelState = export { currentObjects, + currentPageLabel, canCallLabelary, selectLabelaryNoticeRequired, selectEffectivePreviewProvider, @@ -306,6 +309,14 @@ export function migrateLegacy(persistedState: unknown, version: number): unknown sanitiseVariableNames(s.variables as { name?: unknown; fnNumber?: unknown }[], s.pages); } + // Unconditional (not version-gated): main-era sessions persist at the current + // version but carry legacy overlays whose head ^JM rode only in the bytes, so + // a full regen would drop it. Latch the density back as a page override. + if (Array.isArray(s.pages)) { + const label = s.label as { jmDensity?: JmDensity } | undefined; + if (label) reconstructLegacyJmDensity(label, s.pages as DesignFilePage[]); + } + // Re-validate the rehydrated profile so a legacy snapshot that // violates the schema or a cross-field rule can't crash the slice's // safeParse on the next patch. Cross-field issues report a path diff --git a/src/store/labelStoreEnv.test.ts b/src/store/labelStoreEnv.test.ts index e300d74c..8ed483e6 100644 --- a/src/store/labelStoreEnv.test.ts +++ b/src/store/labelStoreEnv.test.ts @@ -8,6 +8,9 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; * * Lives in a dedicated file because each case needs to re-import the * store module after stubbing import.meta.env. */ +// Re-importing the labelStore module graph can exceed 5s under full-suite load. +vi.setConfig({ testTimeout: 20_000 }); + describe('thirdParty defaults from env', () => { afterEach(() => { vi.unstubAllEnvs(); diff --git a/src/store/pageLabelSeam.test.ts b/src/store/pageLabelSeam.test.ts new file mode 100644 index 00000000..2c348f20 --- /dev/null +++ b/src/store/pageLabelSeam.test.ts @@ -0,0 +1,62 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, it, expect } from "vitest"; + +// Editor geometry runs in the CURRENT PAGE's dot scale, which is the design +// label only until a page carries its own ^JM. currentPageLabel is the one +// resolver; a raw `state.label` / `s.label` read in these files silently +// reintroduces the design density and misplaces every dot on a diverged page. +const GEOMETRY_FILES = [ + "../components/Palette/ObjectPalette.tsx", + "../components/Canvas/LabelCanvas.tsx", + "../components/Canvas/hooks/useKonvaDragController.ts", + "../components/Properties/PropertiesPanel.tsx", + "../components/Properties/UnitNumberInput.tsx", + "../lib/footprintMeasurer.ts", + "./slices/objectSlice.ts", + "./slices/previewSlice.ts", +]; + +// Document-scope reads: they are about the design or the physical head, not +// about the dots of one page, so they legitimately stay on `state.label`. +const ALLOWED = [ + // The whole-document emit; generateMultiPageZPL resolves each page itself. + "const designLabel = useLabelStore((s) => s.label);", + // The dpmm / ^JM selectors and the rescale gate edit the design, not a page. + "rescaleWouldChange(s.pages, s.label, includeCalibrationFields, patch)", + // The Labelary URL picks the physical head the stream's ^JM applies to. + "fetchPreview(zpl, state.label, endpoint.host, endpoint.apiKey)", + // Explicit design scope: label-config fields are stored in the design's own + // density, so a page ^JM must not rescale what they show or write back. + "scope === 'design' ? s.label : currentPageLabel(s)", + // Clock offsets are design-wide and carry no density. + ".label.secondaryClockOffset", + ".label.tertiaryClockOffset", +]; + +// No /g flag: a global regex keeps lastIndex across .test() calls and would +// silently skip lines whose match sits before the stale offset. +const LABEL_READ = /\b(?:state|s)\.label\b/; + +describe("page-label seam", () => { + it("editor geometry reads the page label, never the design label directly", () => { + const offenders: string[] = []; + for (const rel of GEOMETRY_FILES) { + const src = readFileSync(fileURLToPath(new URL(rel, import.meta.url)), "utf8"); + for (const line of src.split("\n")) { + if (!LABEL_READ.test(line)) continue; + if (ALLOWED.some((a) => line.includes(a))) continue; + offenders.push(`${rel}: ${line.trim()}`); + } + } + expect(offenders).toEqual([]); + }); + + // A renamed or moved file would empty the scan without failing it. + it("scans the files it claims to", () => { + for (const rel of GEOMETRY_FILES) { + const src = readFileSync(fileURLToPath(new URL(rel, import.meta.url)), "utf8"); + expect(src, rel).toContain("currentPageLabel"); + } + }); +}); diff --git a/src/store/slices/labelConfigSlice.ts b/src/store/slices/labelConfigSlice.ts index db8eb01f..123c6aa4 100644 --- a/src/store/slices/labelConfigSlice.ts +++ b/src/store/slices/labelConfigSlice.ts @@ -1,5 +1,5 @@ import type { StateCreator } from 'zustand'; -import { PER_LABEL_ZPL_FIELDS, type LabelConfig } from '@zplab/core/types/LabelConfig'; +import { PER_LABEL_ZPL_FIELDS, type JmDensity, type LabelConfig } from '@zplab/core/types/LabelConfig'; import type { Page } from '@zplab/core/types/Group'; import type { Variable, ColumnMapping } from '@zplab/core/types/Variable'; import type { DbSourceRef } from '@zplab/core/types/DataSource'; @@ -9,7 +9,7 @@ import { parseDesignFile, designFileErrors } from '@zplab/core/lib/designFile'; import { selectPreviewLocksEditor } from '../labelStore.selectors'; import { configPatchAffectsEmit } from '../labelStore.internals'; import { dropPageOverlays } from '@zplab/core/lib/pageOverlay'; -import { rescaleDesign } from '../../lib/densityRescale'; +import { rescaleDesign, rescaleParamsFor } from '../../lib/densityRescale'; import type { LabelState } from '../labelStore'; /** zundo attaches `.temporal` to the store api; reach it through the injected @@ -44,8 +44,12 @@ export interface LabelConfigSlice { * Switches focus to the first appended page. */ appendPages: (pages: Page[]) => void; /** Change print density and proportionally rescale every dot-valued field so - * the physical size is preserved (one undo step). */ - rescaleDensity: (toDpmm: number) => void; + * the physical size is preserved (one undo step). `configPatch` stamps extra + * label fields with the same commit (a preset also carries the new size). */ + rescaleDensity: (toDpmm: number, configPatch?: Partial) => void; + /** Switch the ^JM mode and rescale dots by the effective-density ratio so + * the physical size is preserved; a no-ratio switch (A vs unset) just sets. */ + rescaleJmDensity: (jmDensity: JmDensity | undefined) => void; } export const createLabelConfigSlice: StateCreator = (set, get, api) => ({ @@ -140,13 +144,27 @@ export const createLabelConfigSlice: StateCreator + rescaleDensity: (toDpmm, configPatch) => set((state) => { if (selectPreviewLocksEditor(state)) return {}; if (toDpmm === state.label.dpmm) return {}; + const p = rescaleParamsFor({ kind: 'dpmm', toDpmm, configPatch }, state.label); // Geometry changes, so the captured overlay bytes no longer match; drop // them so the rescaled pages regenerate from the model. - const { pages, label } = rescaleDesign(state.pages, state.label, state.label.dpmm, toDpmm); + const { pages, label } = rescaleDesign(state.pages, state.label, p.fromEff, p.toEff, p.patch, p.includeCalibrationFields); + return { label, pages: dropPageOverlays(pages) }; + }), + + rescaleJmDensity: (jmDensity) => + set((state) => { + if (selectPreviewLocksEditor(state)) return {}; + const p = rescaleParamsFor({ kind: 'jm', toJm: jmDensity }, state.label); + // Emission changes either way (^JMA vs none), so overlays go stale + // even without a ratio change; mirror setLabelConfig. + if (p.fromEff === p.toEff) { + return { label: { ...state.label, jmDensity }, pages: dropPageOverlays(state.pages) }; + } + const { pages, label } = rescaleDesign(state.pages, state.label, p.fromEff, p.toEff, p.patch, p.includeCalibrationFields); return { label, pages: dropPageOverlays(pages) }; }), }); diff --git a/src/store/slices/objectSlice.ts b/src/store/slices/objectSlice.ts index 0d8de3d7..dbf11d59 100644 --- a/src/store/slices/objectSlice.ts +++ b/src/store/slices/objectSlice.ts @@ -25,7 +25,7 @@ import { freshPasteCopies, updateCurrentObjects, } from '../labelStore.internals'; -import { selectPreviewLocksEditor, currentObjects } from '../labelStore.selectors'; +import { selectPreviewLocksEditor, currentObjects, currentPageLabel } from '../labelStore.selectors'; import type { LabelState } from '../labelStore'; import { newId } from "@zplab/core/lib/ids"; @@ -134,14 +134,14 @@ export const createObjectSlice: StateCreator = const text = findObjectById(objs, textId); if (!text || isGroup(text) || text.type !== 'text') return {}; if (text.locked || hasLockedAncestor(objs, textId)) return {}; - const box = makeReverseBackingBox(text, state.label); + const box = makeReverseBackingBox(text, currentPageLabel(state)); // Insert into the text's own container, right before it, so it renders // behind. Idempotent: skip if a backing already sits there. let inserted = false; const insertBehind = (list: LabelObject[]): LabelObject[] => { const i = list.findIndex((o) => o.id === textId); if (i >= 0) { - if (precedingBackingExists(list, i, text, state.label)) return list; + if (precedingBackingExists(list, i, text, currentPageLabel(state))) return list; inserted = true; const next = [...list]; next.splice(i, 0, box); @@ -170,7 +170,7 @@ export const createObjectSlice: StateCreator = // Closest feature-style backing before the text (z-order: nearest // behind). Strict match so a shared banner/header isn't deleted. for (let j = i - 1; j >= 0 && !removedId; j--) { - if (isOwnReverseBacking(list[j], text, state.label)) removedId = list[j]?.id; + if (isOwnReverseBacking(list[j], text, currentPageLabel(state))) removedId = list[j]?.id; } return removedId ? list.filter((o) => o.id !== removedId) : list; } @@ -561,7 +561,9 @@ export const createObjectSlice: StateCreator = if (!source) return {}; // Fresh ids + dropped provenance: the clone is net-new (no overlay), so // it regenerates from the model. Omitting `overlay` keeps it that way. + // The ^JM override travels along: the cloned dots are in its density. const cloned: Page = { objects: cloneChildrenFresh(source.objects) }; + if (source.jmDensity !== undefined) cloned.jmDensity = source.jmDensity; const insertPos = index + 1; const newPages = [ ...state.pages.slice(0, insertPos), diff --git a/src/store/slices/previewSlice.ts b/src/store/slices/previewSlice.ts index 098120ce..4eba6cdc 100644 --- a/src/store/slices/previewSlice.ts +++ b/src/store/slices/previewSlice.ts @@ -10,7 +10,7 @@ import { import { getPreviewTransport, getPrinterAddress, getUsbPrinterId } from '../../lib/printerAddress'; import { buildActiveRow } from '@zplab/core/lib/variableBinding'; import { buildPreviewZpl } from '../../lib/printPreview'; -import { currentObjects, selectEffectivePreviewProvider, selectLabelaryEndpoint } from '../labelStore.selectors'; +import { currentObjects, currentPageLabel, selectEffectivePreviewProvider, selectLabelaryEndpoint } from '../labelStore.selectors'; import type { LabelState } from '../labelStore'; import { isDesktopShell } from '../../lib/platform'; @@ -105,7 +105,8 @@ export const createPreviewSlice: StateCreator const provider = selectEffectivePreviewProvider(state); const objs = currentObjects(state); const active = buildActiveRow(state.dataset, state.columnMapping); - const zpl = buildPreviewZpl(state.label, objs, state.variables, active, { blankSamples: true }); + const pageLabel = currentPageLabel(state); + const zpl = buildPreviewZpl(pageLabel, objs, state.variables, active, { blankSamples: true }); // The printer target resolves before the cache lookup so the key can fold // the device in; an unconfigured target fails here, before 'loading'. let printerTarget: PreviewTarget | null = null; @@ -140,7 +141,7 @@ export const createPreviewSlice: StateCreator // immutable updates. const isStale = (): boolean => get().previewMode.status !== 'loading' || - get().label !== state.label || + currentPageLabel(get()) !== pageLabel || currentObjects(get()) !== objs; if (printerTarget) { @@ -210,6 +211,8 @@ export const createPreviewSlice: StateCreator } try { + // Physical head, not the page density: the URL picks the printer the + // stream's ^JM is then interpreted against. const url = await fetchPreview(zpl, state.label, endpoint.host, endpoint.apiKey); if (isStale()) { URL.revokeObjectURL(url); From 3ea39dad0e57c49a32db58b8eb2a937af62bfa93 Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 30 Jul 2026 23:23:54 +0200 Subject: [PATCH 3/4] Update Roadmap --- docs/zpl-roadmap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/zpl-roadmap.md b/docs/zpl-roadmap.md index 7eb3bec8..ccaa9e3f 100644 --- a/docs/zpl-roadmap.md +++ b/docs/zpl-roadmap.md @@ -249,7 +249,7 @@ These need printer-side feedback or are intrinsically connection bound. | `[ ]` | `^JJ` | set auxiliary port | `Native build` | | `[ ]` | `^JS` | sensor select (reflective / transmissive) | `Native build` | | `[ ]` | `~JL` | set label length | `Native build` | -| `[ ]` | `^JM` | set dots per millimeter (B halves density and doubles format scale; A = normal, default) | `Coming soon` | +| `[x]` | `^JM` | set dots per millimeter (B halves density and doubles format scale; A = normal, default) | | | `[ ]` | `~JN` | head test fatal | `Native build` | | `[ ]` | `~JO` | head test non-fatal | `Native build` | | `[ ]` | `~JP` | pause and cancel format | `Native build` | From f91d75cb8e061af1f7a3f442d977472ba4e5ecf8 Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 30 Jul 2026 23:23:54 +0200 Subject: [PATCH 4/4] Update Readme --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ec43a0dd..83a55ca1 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ Edit an imported label and export it again: - **Preserved:** everything you didn't touch comes back byte-for-byte, including fields, label settings, comments, whitespace, and commands the editor doesn't model. A zero-edit import/export cycle reproduces the file exactly. - **Regenerated:** only the objects you edit, add, or delete are re-emitted from the model. The rest of the file is spliced back unchanged. -- **Full-regeneration fallback:** a few constructs make per-object patching unsafe: `^MU` unit scaling, non-default `^CC`/`^CT`/`^CD` command prefixes, non-UTF-8 `^CI` encoding, non-default `^FE` embed delimiters, a bare `^FN` declared outside a field, and a barcode relying on a `^BY` from an earlier field. On such labels the first edit regenerates the whole label; with no edits the export stays byte-for-byte. +- **Full-regeneration fallback:** a few constructs make per-object patching unsafe: `^MU` unit scaling, non-default `^CC`/`^CT`/`^CD` command prefixes, non-UTF-8 `^CI` encoding, non-default `^FE` embed delimiters, a bare `^FN` declared outside a field, an in-span `^JM` density switch, and a barcode relying on a `^BY` from an earlier field. On such labels the first edit regenerates the whole label; with no edits the export stays byte-for-byte. Byte capture at import is deliberately conservative: when a field can't be mapped cleanly to a single object, the whole label falls back to model regeneration, which keeps the content but not the exact bytes. The captured bytes are stored in saved `.json` designs; a design from an older app version with an outdated capture format is detected and rebuilt. @@ -152,7 +152,7 @@ Both `.zpl` and `.json` round-trip cleanly. `.zpl` preserves all printable conte ## Coverage -113 of the 225 ZPL II commands tracked in the [roadmap](docs/zpl-roadmap.md) are supported today. Categorical breakdown: +114 of the 225 ZPL II commands tracked in the [roadmap](docs/zpl-roadmap.md) are supported today. Categorical breakdown: | Area | Supported | |---|---| @@ -169,7 +169,7 @@ Both `.zpl` and `.json` round-trip cleanly. `.zpl` preserves all printable conte | Text & fonts | 7 / 14 | | Print quality | 10 / 18 | | Configuration & persistence | 3 / 5 | -| Hardware / Host comm / RFID / Network | 0 / 87 | +| Hardware / Host comm / RFID / Network | 1 / 87 | ---