Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ Both `.zpl` and `.json` round-trip cleanly. `.zpl` preserves all printable conte

## Coverage

97 of the 202 ZPL II commands tracked in the [roadmap](docs/zpl-roadmap.md) are supported today. Categorical breakdown:
98 of the 202 ZPL II commands tracked in the [roadmap](docs/zpl-roadmap.md) are supported today. Categorical breakdown:

| Area | Supported |
|---|---|
Expand All @@ -128,7 +128,7 @@ Both `.zpl` and `.json` round-trip cleanly. `.zpl` preserves all printable conte
| Clock & time | 2 / 3 |
| Identity & access | 3 / 3 |
| Graphics | 6 / 12 |
| Media & feed | 4 / 10 |
| Media & feed | 5 / 10 |
| Text & fonts | 6 / 14 |
| Print quality | 4 / 14 |
| Configuration & persistence | 3 / 5 |
Expand Down
2 changes: 1 addition & 1 deletion docs/zpl-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ The Printer Settings Modal (Media & Feed / Print Quality / Clock & Time / Encodi
| `[ ]` | `^MC` | map clear | `Coming soon` |
| `[ ]` | `^MI` | maintenance info message | `Coming soon` |
| `[ ]` | `^MP` | mode protection | `Coming soon` |
| `[ ]` | `^MU` | units of measure | `Coming soon` |
| `[x]` | `^MU` | units of measure | |
| `[ ]` | `^MW` | head cold warning | `Coming soon` |

## Print quality
Expand Down
4 changes: 2 additions & 2 deletions src/components/Canvas/bwipHelpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ describe("getDisplaySize gs1databar sym 7 fallback", () => {
rotation: 0,
props: {
content: "0112345678901231",
moduleWidth: 2,
magnification: 2,
symbology: 7,
segments: 22,
rotation: "N",
Expand Down Expand Up @@ -194,7 +194,7 @@ describe("buildBwipOptions gs1databar Expanded fallback", () => {
rotation: 0,
props: {
content,
moduleWidth: 2,
magnification: 2,
symbology: 6,
segments: 22,
rotation: "N",
Expand Down
8 changes: 4 additions & 4 deletions src/components/Canvas/bwipHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,7 +484,7 @@ export function buildBwipOptions(
}
case "gs1databar": {
const p = obj.props;
const scale = bwipScale1D(p.moduleWidth, renderScale, renderDpmm);
const scale = bwipScale1D(p.magnification, renderScale, renderDpmm);
const sym = p.symbology;
const isExpanded = GS1_DATABAR_EXPANDED_SYMBOLOGIES.has(sym);
// bwip-js needs (AI)data parens; canonical model stores raw digits.
Expand Down Expand Up @@ -741,7 +741,7 @@ export function getDisplaySize(
// bwip renders upright.
let bitmapCrop: BarcodeDisplaySize["bitmapCrop"];
if (obj.type === "gs1databar") {
const bwipSc = get1DBwipScale(obj.props.moduleWidth, scale, dpmm);
const bwipSc = get1DBwipScale(obj.props.magnification, scale, dpmm);
const padPx = GS1_DATABAR_PADDING_ROWS * bwipSc;
if (canvas.height > 2 * padPx) {
bitmapCrop = {
Expand Down Expand Up @@ -823,8 +823,8 @@ function getUprightDisplaySize(
return { w, h };
}
case "gs1databar": {
const modulePx = dotsToPx(obj.props.moduleWidth, scale, dpmm);
const bwipSc = get1DBwipScale(obj.props.moduleWidth, scale, dpmm);
const modulePx = dotsToPx(obj.props.magnification, scale, dpmm);
const bwipSc = get1DBwipScale(obj.props.magnification, scale, dpmm);
const w = (cw / bwipSc) * modulePx;
// bwip-js renders most non-stacked variants at the omni (33-module)
// height regardless of the actual symbology, so trusting `ch` would
Expand Down
95 changes: 95 additions & 0 deletions src/lib/designFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,101 @@ describe('parseDesignFile', () => {
expect(result.error).toBe('invalid_schema');
});

it('migrates legacy gs1databar inside a nested group', () => {
const legacyJson = JSON.stringify({
schemaVersion: 1,
label: { widthMm: 100, heightMm: 60, dpmm: 8 },
pages: [
{
objects: [
{
id: 'g',
type: 'group',
x: 0,
y: 0,
rotation: 0,
children: [
{
id: 'g1',
type: 'gs1databar',
x: 0,
y: 0,
rotation: 0,
props: { content: '0112345678901', moduleWidth: 5, symbology: 1, rotation: 'N' },
},
],
},
],
},
],
});
const result = parseDesignFile(legacyJson);
expect(result.ok).toBe(true);
if (!result.ok) return;
const group = result.value.pages[0]?.objects[0] as { children?: unknown[] };
const child = group.children?.[0] as { props: Record<string, unknown> };
expect(child.props.magnification).toBe(5);
expect(child.props.moduleWidth).toBeUndefined();
});

it('gs1databar migration is idempotent and prefers existing magnification', () => {
// A re-saved file may already carry both keys; the migration must
// leave the new key intact and not overwrite it from the legacy slot.
const mixed = JSON.stringify({
schemaVersion: 1,
label: { widthMm: 100, heightMm: 60, dpmm: 8 },
pages: [
{
objects: [
{
id: 'g1',
type: 'gs1databar',
x: 0,
y: 0,
rotation: 0,
props: { content: '01', moduleWidth: 9, magnification: 4, symbology: 1, rotation: 'N' },
},
],
},
],
});
const result = parseDesignFile(mixed);
expect(result.ok).toBe(true);
if (!result.ok) return;
const obj = result.value.pages[0]?.objects[0] as unknown as { props: Record<string, unknown> };
expect(obj.props.magnification).toBe(4);
expect(obj.props.moduleWidth).toBe(9); // untouched when magnification already present
});

it('migrates legacy gs1databar props.moduleWidth → props.magnification', () => {
const legacyJson = JSON.stringify({
schemaVersion: 1,
label: { widthMm: 100, heightMm: 60, dpmm: 8 },
pages: [
{
objects: [
{
id: 'g1',
type: 'gs1databar',
x: 0,
y: 0,
rotation: 0,
props: { content: '0112345678901', moduleWidth: 4, symbology: 1, rotation: 'N' },
},
],
},
],
});
const result = parseDesignFile(legacyJson);
expect(result.ok).toBe(true);
if (!result.ok) return;
const obj = result.value.pages[0]?.objects[0];
expect(obj?.type).toBe('gs1databar');
const p = (obj as unknown as { props: Record<string, unknown> }).props;
expect(p.magnification).toBe(4);
expect(p.moduleWidth).toBeUndefined();
});

it('rejects an unknown schemaVersion as invalid_schema', () => {
const futureJson = JSON.stringify({
schemaVersion: 999,
Expand Down
24 changes: 24 additions & 0 deletions src/lib/designFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type CsvMapping,
} from "../types/Variable";
import type { LabelObject } from "../types/Group";
import { visitLeavesInPages } from "./objectTree";
import { ok, err, type Result } from "./result";

/** Current design-file schema version. Bump when the persisted shape
Expand Down Expand Up @@ -69,6 +70,8 @@ export function parseDesignFile(text: string): Result<DesignFile, DesignFileErro
return err("parse_error");
}

migrateGs1databarModuleWidth(json);

const v1 = designFileV1Schema.safeParse(json);
if (v1.success) {
return ok({
Expand All @@ -82,6 +85,27 @@ export function parseDesignFile(text: string): Result<DesignFile, DesignFileErro
return err("invalid_schema");
}

/** Rename gs1databar `props.moduleWidth` → `props.magnification` on
* legacy design files. Mirrors the persist-store v5→v6 hop in
* `labelStore.ts`. Idempotent. */
function migrateGs1databarModuleWidth(json: unknown): void {
if (!json || typeof json !== "object") return;
const pages = (json as { pages?: unknown }).pages;
visitLeavesInPages(pages, (leaf) => {
if (
leaf.type === "gs1databar" &&
leaf.props &&
typeof leaf.props === "object" &&
"moduleWidth" in leaf.props &&
!("magnification" in leaf.props)
) {
const props = leaf.props as Record<string, unknown>;
props.magnification = props.moduleWidth;
delete props.moduleWidth;
}
});
}

interface SerializedDesign {
schemaVersion: number;
label: LabelConfig;
Expand Down
32 changes: 32 additions & 0 deletions src/lib/objectTree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/** Shape-erasing helpers for walking a persisted object tree without
* committing to a parsed type. Migrations and the JSON-import path
* both need to visit leaves before the schema is locked in, so the
* walkers must accept `unknown`. */

interface UnknownNode {
type?: unknown;
props?: unknown;
children?: unknown;
}

/** Apply `visit` to every leaf (non-group) node found under `pages`.
* `pages` is treated permissively: any nested `objects`/`children`
* arrays are walked. Mutates leaves in-place via the visitor. */
export function visitLeavesInPages(pages: unknown, visit: (leaf: UnknownNode) => void): void {
if (!Array.isArray(pages)) return;
for (const page of pages) walkNode(page, visit);
}

function walkNode(node: unknown, visit: (leaf: UnknownNode) => void): void {
if (!node || typeof node !== "object" || Array.isArray(node)) return;
const n = node as UnknownNode & { objects?: unknown };
if (Array.isArray(n.objects)) {
for (const child of n.objects) walkNode(child, visit);
return;
}
if (Array.isArray(n.children)) {
for (const child of n.children) walkNode(child, visit);
return;
}
visit(n);
}
47 changes: 47 additions & 0 deletions src/lib/zplGenerator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,32 @@ describe('generateZPL — structure', () => {
expect(zpl).toContain('^LS5');
});

it('does not emit ^MU when muResampling is unset', () => {
const zpl = generateZPL(BASE_LABEL, []);
expect(zpl).not.toContain('^MU');
});

it('emits ^MUD,b,c when muResampling is set', () => {
const zpl = generateZPL(
{ ...BASE_LABEL, muResampling: { formatDpi: 150, outputDpi: 300 } },
[],
);
expect(zpl).toContain('^MUD,150,300');
});

it('emits ^MU directly after ^XA so the printer sees the resampling header first', () => {
const zpl = generateZPL(
{ ...BASE_LABEL, muResampling: { formatDpi: 200, outputDpi: 600 } },
[],
);
const xaIdx = zpl.indexOf('^XA');
const muIdx = zpl.indexOf('^MUD');
const pwIdx = zpl.indexOf('^PW');
expect(xaIdx).toBeGreaterThanOrEqual(0);
expect(muIdx).toBeGreaterThan(xaIdx);
expect(muIdx).toBeLessThan(pwIdx);
});

it('reverse text round-trips: emit ^GB+^FR, parse collapses back to one reverse text', () => {
// Generator emits a filled black ^GB knockout-background followed by
// an ^FR text at the same anchor. The parser detects that pair and
Expand Down Expand Up @@ -705,6 +731,27 @@ describe('generateZPL — parse/generate roundtrip', () => {
expect(reparsed.objects).toHaveLength(original.objects.length);
});

it('round-trips ^MU muResampling pair through parser + generator', () => {
const label = { ...BASE_LABEL, muResampling: { formatDpi: 200, outputDpi: 600 } as const };
const regenerated = generateZPL(label, []);
expect(regenerated).toContain('^MUD,200,600');
const reparsed = parseZPL(regenerated, 8);
expect(reparsed.labelConfig.muResampling).toEqual({ formatDpi: 200, outputDpi: 600 });
});

it('emits gs1databar magnification into both ^BY and ^BR slots', () => {
const objs = [{
id: 'g1', type: 'gs1databar', x: 0, y: 0, rotation: 0,
props: { content: '0112345678901', magnification: 5, symbology: 1 as const, rotation: 'N' as const },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}] as any;
const zpl = generateZPL(BASE_LABEL, objs);
expect(zpl).toContain('^BY5');
expect(zpl).toContain('^BRN,1,5,2,');
const reparsed = parseZPL(zpl, 8);
expect(props(reparsed.objects[0]).magnification).toBe(5);
});

it('preserves text content through a roundtrip', () => {
const original = parseZPL('^XA^FO10,20^A0N,30,0^FDHello World^FS^XZ', 8);
const regenerated = generateZPL(BASE_LABEL, original.objects);
Expand Down
7 changes: 6 additions & 1 deletion src/lib/zplGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,8 +308,13 @@ export function generateZPL(
lines.push(`~SD${v}`);
}

lines.push('^XA');
// a=D since the model is dots-canonical; b,c shape is guaranteed
// by muResamplingSchema.
if (label.muResampling) {
lines.push(`^MUD,${label.muResampling.formatDpi},${label.muResampling.outputDpi}`);
}
lines.push(
'^XA',
`^PW${widthDots}`,
`^LL${heightDots}`,
'^CI28',
Expand Down
Loading