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 |
---
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` |
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/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/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) {
>
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({
+
+ {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 && (
+