Skip to content

Commit de8f156

Browse files
committed
feat(export): a stored format the printer holds is recalled, not stored again
1 parent 1e05f9f commit de8f156

59 files changed

Lines changed: 806 additions & 74 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎packages/core/src/lib/designFile.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { z } from "zod";
22
import { DEFAULT_FONT_DEVICE, storedFormatPathSchema } from "./storagePath";
3-
import { JM_DENSITY_VALUES, labelConfigSchema, sanitizeRfidEpc, type JmDensity, type LabelConfig } from "../types/LabelConfig";
3+
import { JM_DENSITY_VALUES, STORED_FORMAT_DELIVERIES, labelConfigSchema, sanitizeRfidEpc, type JmDensity, type LabelConfig, type StoredFormatDelivery } from "../types/LabelConfig";
44
import { labelObjectBaseSchema } from "../types/LabelObject";
55
import {
66
variableSchema,
@@ -38,7 +38,7 @@ export interface DesignFileFailure {
3838

3939
export type DesignFileResult = { ok: true; value: DesignFile } | DesignFileFailure;
4040

41-
export interface DesignFilePage { objects: LabelObject[]; overlay?: BlockOverlay; jmDensity?: JmDensity; storedFormatPath?: string }
41+
export interface DesignFilePage { objects: LabelObject[]; overlay?: BlockOverlay; jmDensity?: JmDensity; storedFormatPath?: string; storedFormatDelivery?: StoredFormatDelivery }
4242
export interface DesignFile {
4343
label: LabelConfig;
4444
pages: DesignFilePage[];
@@ -82,6 +82,7 @@ const pageSchema = z.object({
8282
overlay: blockOverlaySchema.optional().catch(undefined),
8383
jmDensity: z.enum(JM_DENSITY_VALUES).optional(),
8484
storedFormatPath: storedFormatPathSchema().optional(),
85+
storedFormatDelivery: z.enum(STORED_FORMAT_DELIVERIES).optional(),
8586
});
8687

8788
const designFileSchema = z.object({
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { describe, expect, it } from "vitest";
2+
import { contestedFormatKeys, generateBatchZpl, generateMultiPageZPL, generateStoredFormatBlock, recallWayIssue } from "./zplGenerator";
3+
import { pageLabelConfig } from "../types/Group";
4+
import type { LabelObject } from "../types/Group";
5+
import type { LabelConfig, PageLabel } from "../types/LabelConfig";
6+
7+
const label: LabelConfig = { widthMm: 50, heightMm: 30, dpmm: 8 };
8+
const text = (marker: string): LabelObject =>
9+
({ id: `t-${marker}`, type: "text", x: 10, y: 10, rotation: 0, props: { content: `«${marker}»`, fontHeight: 30, fontWidth: 0, rotation: "N" } }) as unknown as LabelObject;
10+
const logo: LabelObject =
11+
({ id: "img", type: "image", x: 10, y: 60, rotation: 0, props: { imageId: "", widthDots: 8, heightDots: 4, threshold: 128, _gfaCache: "^GFA,4,4,1,00FFFF00", storedAs: { device: "R", name: "LOGO" } } }) as unknown as LabelObject;
12+
const variables = [
13+
{ id: "v1", name: "sku", fnNumber: 1, defaultValue: "DEF" },
14+
{ id: "v2", name: "lot", fnNumber: 2, defaultValue: "FALLBACK" },
15+
];
16+
const dataset = { headers: ["sku"], rows: [["A1"], ["B2"]] };
17+
const mapping = { bindings: { v1: "sku" } };
18+
const page = (way?: "setup" | "printer") => ({ objects: [text("sku"), text("lot"), logo], storedFormatPath: "E:JOB.ZPL", ...(way ? { storedFormatDelivery: way } : {}) });
19+
20+
describe("a page that does not store its own format", () => {
21+
it("folds the delivery into the page label only beside a path", () => {
22+
expect(pageLabelConfig(label, page("setup")).storedFormatDelivery).toBe("setup");
23+
expect(pageLabelConfig(label, { storedFormatDelivery: "setup" })).not.toHaveProperty("storedFormatDelivery");
24+
expect(pageLabelConfig({ ...label, storedFormatDelivery: "printer" } as PageLabel, page())).not.toHaveProperty("storedFormatDelivery");
25+
});
26+
27+
it("recalls the rows once the page says the printer holds it, but still ships the logo", () => {
28+
const stored = generateBatchZpl(pageLabelConfig(label, page()), page().objects, variables, dataset, mapping);
29+
expect(stored).toContain("^DFE:JOB.ZPL");
30+
expect(stored).toContain("^FN2^FDFALLBACK^FS");
31+
expect(stored).not.toContain("^FN2^FDFALLBACK^FS\n^XZ\n^XA\n^XFE:JOB.ZPL\n^FN2");
32+
const recall = generateBatchZpl(pageLabelConfig(label, page("printer")), page().objects, variables, dataset, mapping);
33+
expect(recall).not.toContain("^DF");
34+
expect(recall.startsWith("~DYR:LOGO,")).toBe(true);
35+
expect(recall.match(/\^XFE:JOB\.ZPL/g)).toHaveLength(2);
36+
expect(recall).toContain("^FN1^FDA1^FS");
37+
expect(recall.match(/\^FN2\^FDFALLBACK\^FS/g)).toHaveLength(2);
38+
});
39+
40+
it("prints a page as a recall that fills every slot with its default", () => {
41+
const zpl = generateMultiPageZPL(label, [page("setup"), { objects: [text("sku")] }], variables);
42+
expect(zpl.startsWith("~DYR:LOGO,A,G,4,1,00FFFF00\n^XA\n^XFE:JOB.ZPL\n^FN1^FDDEF^FS\n^FN2^FDFALLBACK^FS\n^XZ\n^XA")).toBe(true);
43+
expect(zpl.match(/\^XA/g)).toHaveLength(2);
44+
expect(zpl).not.toContain("^DF");
45+
});
46+
47+
it("makes the block after a recall declare its density, since the stored format set its own", () => {
48+
const half = { objects: [text("sku")], jmDensity: "B" as const };
49+
const plain = { objects: [text("sku")] };
50+
const zpl = generateMultiPageZPL(label, [half, page("printer"), plain], variables);
51+
const blocks = zpl.split("^XZ").filter((b) => b.includes("^XA"));
52+
expect(blocks[0]).toContain("^JMB");
53+
expect(blocks[1]).not.toContain("^JM");
54+
expect(blocks[2]).toContain("^JMA");
55+
});
56+
57+
it("keeps storing while the name is one ^XF could never recall", () => {
58+
const long = { ...page("printer"), storedFormatPath: "E:VERYLONGNAME12.ZPL" };
59+
expect(generateMultiPageZPL(label, [long], variables)).toContain("^DFE:VERYLONGNAME12.ZPL");
60+
expect(generateBatchZpl(pageLabelConfig(label, long), long.objects, variables, dataset, mapping)).toContain("^DFR:LBL.ZPL");
61+
});
62+
63+
it("names the printer files more than one page stores under, however each page spells them", () => {
64+
const keys = contestedFormatKeys([{ storedFormatPath: "E:JOB.ZPL" }, {}, { storedFormatPath: "e:job.zpl" }, { storedFormatPath: "E:OTHER.ZPL" }]);
65+
expect([...keys]).toEqual(["E:JOB.ZPL"]);
66+
const pages = [{ storedFormatPath: "E:JOB.ZPL" }, { storedFormatPath: "e:job.zpl" }, { storedFormatPath: "E:VERYLONGNAME12.ZPL" }, { storedFormatPath: "E:FREE.ZPL" }, {}];
67+
expect(pages.map((p) => recallWayIssue(p, pages))).toEqual(["contested", "contested", "longName", undefined, undefined]);
68+
});
69+
70+
it("stores the format for the setup script with every slot bare and without the uploads a job ships", () => {
71+
const block = generateStoredFormatBlock(pageLabelConfig(label, page("setup")), page().objects, variables);
72+
expect(block.startsWith("^XA\n^DFE:JOB.ZPL")).toBe(true);
73+
expect(block).not.toContain("~DY");
74+
expect(block).toContain("^XGR:LOGO.GRF");
75+
expect(block).toContain("^FN1^FS");
76+
expect(block).toContain("^FN2^FS");
77+
expect(block).not.toContain("^FDDEF");
78+
});
79+
});

‎packages/core/src/lib/zplGenerator.ts‎

Lines changed: 112 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -234,13 +234,28 @@ export function generateMultiPageZplWithMap(
234234
// blocks didn't set. `undefined` means nothing declared one yet, so a block
235235
// inheriting its ^JM from outside this export gets the declaration back.
236236
let wireJm: JmDensity | undefined;
237+
// A recalled format sets whatever density it stored, so the block after it declares its own.
238+
let wireUnknown = false;
237239
const ledger = uploadLedger(pages);
238240
const shippedFonts = new Set<string>();
239241
pages.forEach((p, pageIndex) => {
240242
const pageLabel = pageLabelConfig(label, p);
241243
const carried = new Set((p.overlay?.segments ?? []).flatMap((seg) => (seg.kind === 'upload' ? [seg.key] : [])));
242244
const policy = ledger.policyFor(carried);
243245
const regenPage = () => generateZplBlock(pageLabel, p.objects, variables, undefined, policy.omit, shippedFonts);
246+
const recallPath = recallOnlyPath(pageLabel);
247+
if (recallPath !== undefined) {
248+
// The printer holds the format, so the job keeps only the uploads it still ships and fills every slot.
249+
const regen = regenPage();
250+
ledger.settle(carried, regen.uploads ?? []);
251+
if (out.length > 0 && !out.endsWith('\n')) out += '\n';
252+
const base = out.length;
253+
const { recalls } = planRecalls(pageLabel, p.objects, variables, NO_ROWS, NO_BINDINGS);
254+
out += `${regen.preamble ?? ''}${recallBlock(recallPath, recallFields(recalls))}`;
255+
blocks.push({ start: base, end: out.length });
256+
wireUnknown = true;
257+
return;
258+
}
244259
let emitted: PageBlock;
245260
try {
246261
emitted = emitPageBlock(pageLabel, p, variables, policy, shippedFonts);
@@ -252,13 +267,13 @@ export function generateMultiPageZplWithMap(
252267
}
253268
// Unset means full density, so a page after a ^JMB page resets the wire;
254269
// while nothing is declared yet there is nothing to reset.
255-
const target = pageLabel.jmDensity ?? (wireJm ? 'A' : undefined);
270+
const target = pageLabel.jmDensity ?? (wireJm || wireUnknown ? 'A' : undefined);
256271
// A replayed block whose head the parser never recorded (or whose bytes
257272
// moved) has no place to splice a declaration; a density-only scan of its
258273
// bytes (cold path) decides instead of always regenerating.
259274
if (!emitted.head) {
260275
const cold = reconstructBlockHead(emitted.block);
261-
const action = target === undefined ? 'keep' : headlessAction(cold.density, target, wireJm);
276+
const action = target === undefined ? 'keep' : headlessAction(cold.density, target, wireUnknown ? undefined : wireJm);
262277
// Bytes that disagree on the ^DF regenerate too: there is no head to splice.
263278
if (action === 'regenerate' || cold.storedFormat?.path !== pageLabel.storedFormatPath) {
264279
emitted = regenPage();
@@ -269,11 +284,12 @@ export function generateMultiPageZplWithMap(
269284
}
270285
const stored = applyStoredFormat(emitted, pageLabel.storedFormatPath);
271286
emitted = { ...emitted, block: stored.block, head: stored.head };
272-
const applied = applyJmDensity(emitted, pageLabel.jmDensity, target === wireJm ? undefined : target);
287+
const applied = applyJmDensity(emitted, pageLabel.jmDensity, !wireUnknown && target === wireJm ? undefined : target);
273288
const block = applied.block;
274289
// Track what the block actually carries, not the intent: an unreachable
275290
// head would leave the next block believing a density the wire never got.
276291
wireJm = applied.wireJm ?? wireJm;
292+
wireUnknown = false;
277293
// An overlay page already carries the inter-block separator captured at
278294
// import (the splitter folds it into the preceding block). Only insert a
279295
// newline when the previous block didn't end with one (a fresh or
@@ -305,6 +321,8 @@ interface PageBlock {
305321
block: string;
306322
head: FormatHead | undefined;
307323
spans: ObjectSpan[];
324+
/** Every line ahead of ^XA, each with its newline, so a recall job can keep them and drop the rest. */
325+
preamble?: string;
308326
replayed?: true;
309327
/** Keys of the graphic uploads the block's bytes ship, replayed or emitted. */
310328
uploads?: readonly string[];
@@ -784,21 +802,60 @@ function emitPageBlock(
784802
/** Where a batch stores its template when the design names none ^XF could recall: R: is volatile RAM, matching a single run. */
785803
const BATCH_TEMPLATE_PATH = 'R:LBL.ZPL';
786804

787-
/** Store template via ^DF then emit one ^XA^XF...^XZ recall block per
788-
* CSV row. Unmapped variables fall back to the template's ^FD default. */
789-
export function generateBatchZpl(
790-
// Page-resolved: batch recalls one page's objects, so the caller must hand
791-
// in the label that page prints at.
805+
/** The path a job recalls instead of storing, once the page says so and ^XF can read the name. */
806+
export function recallOnlyPath(page: Pick<PageLabel, 'storedFormatPath' | 'storedFormatDelivery'>): string | undefined {
807+
const path = page.storedFormatPath;
808+
return page.storedFormatDelivery !== undefined && path !== undefined && isRecallableFormatPath(path) ? path : undefined;
809+
}
810+
811+
/** Storage keys more than one page stores its format under, since the printer keeps one format per name. */
812+
export function contestedFormatKeys(pages: readonly Pick<PageLabel, 'storedFormatPath'>[]): Set<string> {
813+
const seen = new Set<string>();
814+
const contested = new Set<string>();
815+
for (const p of pages) {
816+
if (p.storedFormatPath === undefined) continue;
817+
const key = storageKey(p.storedFormatPath);
818+
if (seen.has(key)) contested.add(key);
819+
seen.add(key);
820+
}
821+
return contested;
822+
}
823+
824+
export type RecallWayIssue = 'longName' | 'contested';
825+
826+
/** Why a page may not recall its format: ^XF cannot read the name, or another page stores under it. */
827+
export function recallWayIssue(page: Pick<PageLabel, 'storedFormatPath'>, pages: readonly Pick<PageLabel, 'storedFormatPath'>[]): RecallWayIssue | undefined {
828+
const path = page.storedFormatPath;
829+
if (path === undefined) return undefined;
830+
if (!isRecallableFormatPath(path)) return 'longName';
831+
return contestedFormatKeys(pages).has(storageKey(path)) ? 'contested' : undefined;
832+
}
833+
834+
function recallBlock(path: string, fields: readonly string[]): string {
835+
return ['^XA', `^XF${path}`, ...fields, '^XZ'].join('\n');
836+
}
837+
838+
interface RecallEntry {
839+
fn: number;
840+
colIdx: number;
841+
transform: (s: string) => string;
842+
defaultValue: string;
843+
}
844+
845+
const NO_ROWS = { headers: [] as readonly string[], rows: [] as readonly (readonly string[])[] };
846+
const NO_BINDINGS = { bindings: {} as Record<string, string> };
847+
848+
/** The stored template and the slots a recall supplies: the mapped ones while the job stores the format itself,
849+
* every slot once the printer holds it, since a stored ^FD seals its slot against the recall (ZD230). */
850+
function planRecalls(
792851
label: PageLabel,
793852
objects: LabelObject[],
794853
variables: readonly Variable[],
795-
dataset: {
796-
headers: readonly string[];
797-
rows: readonly (readonly string[])[];
798-
},
854+
dataset: { headers: readonly string[]; rows: readonly (readonly string[])[] },
799855
columnMapping: { bindings: Record<string, string> },
800-
): string {
856+
): { template: PageBlock; path: string; recalls: RecallEntry[] } {
801857
const identity = (s: string) => s;
858+
const recallOnly = recallOnlyPath(label) !== undefined;
802859
// Only leaves that emit an ^FN in the stored template may donate their
803860
// transform: an excluded or home-dropped leaf or a rotated QR shipped as
804861
// ^GFA would stamp a foreign encoding onto the surviving co-consumer.
@@ -814,10 +871,10 @@ export function generateBatchZpl(
814871
const modeDFns = batchBuckets.modeDExclusive;
815872
const plain128Fns = batchBuckets.plainExclusive;
816873
const plainSharedFns = batchBuckets.plainShared;
817-
const overrides: { fn: number; colIdx: number; transform: (s: string) => string }[] = [];
874+
const entries: RecallEntry[] = [];
818875
for (const v of variables) {
819876
const colIdx = boundColumnIndex(v, dataset, columnMapping);
820-
if (colIdx === -1) continue;
877+
if (colIdx === -1 && !recallOnly) continue;
821878
// Apply the bound field's ^FD transform (QR prefix, UPC-E compaction, GS1
822879
// escaping) to each row value, matching the single-format export so the
823880
// recall doesn't overwrite ^FN with an untransformed payload. The bound
@@ -844,29 +901,48 @@ export function generateBatchZpl(
844901
: plain128Fns.has(v.fnNumber)
845902
? (s: string) => planCode128Fd(s, 'templateValue').fd
846903
: identity);
847-
overrides.push({ fn: v.fnNumber, colIdx, transform });
904+
entries.push({ fn: v.fnNumber, colIdx, transform, defaultValue: v.defaultValue });
848905
}
849906

850-
// Mapped slots stay bare in the stored format; the recall supplies them.
851-
const mappedFns = new Set(overrides.map((o) => o.fn));
852-
const templatePath = label.storedFormatPath && isRecallableFormatPath(label.storedFormatPath) ? label.storedFormatPath : BATCH_TEMPLATE_PATH;
853-
const templateStored = generateZplBlock({ ...label, storedFormatPath: templatePath }, objects, variables, mappedFns).block;
907+
// Supplied slots stay bare in the stored format; the recall fills them.
908+
const bareFns = new Set(entries.map((e) => e.fn));
909+
const path = recallOnlyPath(label) ?? (label.storedFormatPath && isRecallableFormatPath(label.storedFormatPath) ? label.storedFormatPath : BATCH_TEMPLATE_PATH);
910+
const template = generateZplBlock({ ...label, storedFormatPath: path }, objects, variables, bareFns);
854911
// A recall for a slot the stored format never declares prints as a stray field (ZD230).
855-
const storedFnSlots = new Set([...templateStored.matchAll(/\^FN(\d+)/g)].map((m) => Number(m[1])));
856-
const recalls = overrides.filter((o) => storedFnSlots.has(o.fn));
857-
858-
const recallBlocks = dataset.rows.map((row) => {
859-
const lines: string[] = ['^XA', `^XF${templatePath}`];
860-
for (const { fn, colIdx, transform } of recalls) {
861-
const value = row[colIdx] ?? '';
862-
// fdField applies ^FH hex-escape for ^/~ so fields don't terminate early.
863-
lines.push(`^FN${fn}${fdField(transform(value))}`);
864-
}
865-
lines.push('^XZ');
866-
return lines.join('\n');
867-
});
912+
const storedFnSlots = new Set([...template.block.matchAll(/\^FN(\d+)/g)].map((m) => Number(m[1])));
913+
return { template, path, recalls: entries.filter((e) => storedFnSlots.has(e.fn)) };
914+
}
868915

869-
return [templateStored, ...recallBlocks].join('\n');
916+
/** A slot takes its row value, or its default when no row or no column supplies one. */
917+
function recallFields(recalls: readonly RecallEntry[], row?: readonly string[]): string[] {
918+
// fdField applies ^FH hex-escape for ^/~ so fields don't terminate early.
919+
return recalls.map(({ fn, colIdx, transform, defaultValue }) =>
920+
`^FN${fn}${fdField(transform(row !== undefined && colIdx !== -1 ? row[colIdx] ?? '' : defaultValue))}`);
921+
}
922+
923+
/** The ^DF frame the setup script stores for a page, every slot bare and the preamble stripped. */
924+
export function generateStoredFormatBlock(label: PageLabel, objects: LabelObject[], variables: readonly Variable[] = []): string {
925+
const { template } = planRecalls(label, objects, variables, NO_ROWS, NO_BINDINGS);
926+
return template.block.slice(template.preamble?.length ?? 0);
927+
}
928+
929+
/** One ^XA^XF...^XZ recall block per CSV row, led by the ^DF template unless the page says the printer holds it. */
930+
export function generateBatchZpl(
931+
// Page-resolved: batch recalls one page's objects, so the caller must hand
932+
// in the label that page prints at.
933+
label: PageLabel,
934+
objects: LabelObject[],
935+
variables: readonly Variable[],
936+
dataset: {
937+
headers: readonly string[];
938+
rows: readonly (readonly string[])[];
939+
},
940+
columnMapping: { bindings: Record<string, string> },
941+
): string {
942+
const { template, path, recalls } = planRecalls(label, objects, variables, dataset, columnMapping);
943+
const recallBlocks = dataset.rows.map((row) => recallBlock(path, recallFields(recalls, row)));
944+
const lead = recallOnlyPath(label) === undefined ? template.block : (template.preamble ?? '').trimEnd();
945+
return [...(lead === '' ? [] : [lead]), ...recallBlocks].join('\n');
870946
}
871947

872948
// ^FT graphics anchor at a bottom corner (spec p.205), so the drop test uses
@@ -1014,6 +1090,7 @@ function generateZplBlock(
10141090
// ~JS is immediate/transient like ~SD; emit before ^XA.
10151091
if (label.backfeedSequence) lines.push(`~JS${label.backfeedSequence}`);
10161092

1093+
const preambleLines = lines.length;
10171094
lines.push('^XA');
10181095
const lineStart = () => lines.reduce((n, l) => n + l.length + 1, 0);
10191096
// Offset just past the ^XA: every preceding line plus its newline.
@@ -1172,6 +1249,7 @@ function generateZplBlock(
11721249
block: aliased.join('\n'),
11731250
head: { caret: '^', at: headAt, jmSpans, ...(dfSpans ? { dfSpans } : {}) },
11741251
spans,
1252+
preamble: aliased.slice(0, preambleLines).map((l) => `${l}\n`).join(''),
11751253
uploads: uploads.keys,
11761254
};
11771255
}

‎packages/core/src/lib/zplSourceEdit.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ describe("the edit gate", () => {
6161
expect(sourceEditGate(blob)).toEqual({ ok: false, reason: "blobLine", command: "^GF" });
6262
const under = `^XA\n^FD${"a".repeat(MAX_SOURCE_LINE_CHARS - 10)}^FS\n^XZ`;
6363
expect(sourceEditGate(under).ok).toBe(true);
64+
expect(sourceEditGate(under, [{ storedFormatPath: "E:JOB.ZPL", storedFormatDelivery: "printer" }])).toEqual({ ok: false, reason: "recallOnly" });
65+
expect(sourceEditGate(under, [{ storedFormatPath: "E:VERYLONGNAME12.ZPL", storedFormatDelivery: "printer" }]).ok).toBe(true);
6466
});
6567

6668
it("refuses an oversized document", () => {
@@ -70,6 +72,8 @@ describe("the edit gate", () => {
7072
ok: false,
7173
reason: "tooLarge",
7274
});
75+
const recall = current({ pages: [{ objects: [], storedFormatPath: "E:JOB.ZPL", storedFormatDelivery: "setup" }] });
76+
expect(prepareSourceApply({ text: "^XA^XFE:JOB.ZPL^FS^XZ", current: recall })).toMatchObject({ ok: false, reason: "recallOnly" });
7377
});
7478
});
7579

0 commit comments

Comments
 (0)