Skip to content

Commit 1e05f9f

Browse files
committed
feat(ui): one delivery select for fonts and graphics, derived from design and profile
1 parent c8e9b28 commit 1e05f9f

42 files changed

Lines changed: 895 additions & 383 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { describe, expect, it } from "vitest";
2+
import { applyFontDelivery, applyGraphicDelivery, fontDelivery, graphicDelivery, graphicJobShips } from "./resourceDelivery";
3+
import type { ImageProps } from "../registry/image";
4+
5+
const GFA = "^GFA,4,4,1,00FFFF00";
6+
const image = (extra: Partial<ImageProps["storedAs"]> | null = {}): ImageProps => ({
7+
imageId: "",
8+
widthDots: 8,
9+
heightDots: 4,
10+
threshold: 128,
11+
_gfaCache: GFA,
12+
...(extra === null ? {} : { storedAs: { device: "R", name: "LOGO", ...extra } }),
13+
});
14+
const entry = { path: "R:LOGO.GRF", gfa: GFA };
15+
const recallOnly = { device: "R", name: "LOGO", embedInZpl: false };
16+
17+
describe("fontDelivery", () => {
18+
it("reads job while the design ships the font, even with a profile entry beside it", () => {
19+
expect(fontDelivery({ embedInZpl: true }, "E:A.TTF", [{ path: "E:A.TTF" }])).toBe("job");
20+
expect(fontDelivery({ embedInZpl: true }, "E:A.TTF", undefined)).toBe("job");
21+
});
22+
23+
it("reads setup from the profile entry under any spelling, else printer", () => {
24+
expect(fontDelivery(undefined, "E:A.TTF", [{ path: "e:a.ttf" }])).toBe("setup");
25+
expect(fontDelivery({ embedInZpl: undefined }, "E:A.TTF", undefined)).toBe("printer");
26+
});
27+
28+
it("keeps the profile entry when the job takes over, and drops it only for printer", () => {
29+
const setup = [{ path: "E:A.TTF" }];
30+
expect(applyFontDelivery("job", undefined, "E:A.TTF", setup)).toEqual({ patch: { embedInZpl: true }, setupFonts: setup });
31+
expect(applyFontDelivery("setup", { embedInZpl: true }, "E:A.TTF", undefined)).toEqual({ patch: { embedInZpl: undefined }, setupFonts: [{ path: "E:A.TTF" }] });
32+
expect(applyFontDelivery("printer", undefined, "E:A.TTF", setup)).toEqual({ patch: null, setupFonts: undefined });
33+
});
34+
35+
it("writes nothing the design or profile already holds", () => {
36+
const setup = [{ path: "E:A.TTF" }];
37+
expect(applyFontDelivery("job", { embedInZpl: true }, "E:A.TTF", setup)).toEqual({ patch: null, setupFonts: setup });
38+
expect(applyFontDelivery("setup", undefined, "E:A.TTF", setup).setupFonts).toBe(setup);
39+
const other = [{ path: "E:B.TTF" }];
40+
expect(applyFontDelivery("printer", undefined, "E:A.TTF", other)).toEqual({ patch: null, setupFonts: other });
41+
});
42+
});
43+
44+
describe("graphicDelivery", () => {
45+
it("has no way to choose for an inline ^GF, and reads job while the design ships the bytes", () => {
46+
expect(graphicDelivery(image(null), [entry])).toBeUndefined();
47+
expect(graphicDelivery(image(), [entry])).toBe("job");
48+
expect(graphicDelivery(image({ embedInZpl: false }), [entry])).toBe("setup");
49+
expect(graphicDelivery(image({ embedInZpl: false }), undefined)).toBe("printer");
50+
});
51+
52+
it("keeps a recall spelled with another extension in the choice, the entry named as the upload is", () => {
53+
expect(graphicDelivery(image({ embedInZpl: false, ext: "PNG" }), undefined)).toBe("printer");
54+
expect(graphicDelivery(image({ embedInZpl: false, ext: "PNG" }), [entry])).toBe("setup");
55+
const provided = applyGraphicDelivery("setup", image({ embedInZpl: false, ext: "PNG" }), [entry]);
56+
expect(provided).toEqual({ patch: { storedAs: recallOnly }, setupGraphics: [entry] });
57+
const kept = applyGraphicDelivery("printer", image({ embedInZpl: false, ext: "PNG" }), undefined);
58+
expect(kept).toEqual({ patch: null, setupGraphics: undefined });
59+
});
60+
61+
it("encodes the profile entry once for setup, keeps an existing one, and drops it only for printer", () => {
62+
const fresh = applyGraphicDelivery("setup", image(), undefined);
63+
expect(fresh).toEqual({ patch: { storedAs: recallOnly, _gfaCache: GFA }, setupGraphics: [entry] });
64+
const stale = [{ path: "R:LOGO.GRF", gfa: "^GFA,4,4,1,FFFFFFFF" }];
65+
expect(applyGraphicDelivery("setup", image({ embedInZpl: true }), stale)).toEqual({ patch: { storedAs: recallOnly }, setupGraphics: stale });
66+
expect(applyGraphicDelivery("job", image({ embedInZpl: false }), [entry])).toEqual({ patch: { storedAs: { device: "R", name: "LOGO", embedInZpl: true } }, setupGraphics: [entry] });
67+
expect(applyGraphicDelivery("printer", image({ embedInZpl: false }), [entry])).toEqual({ patch: null, setupGraphics: undefined });
68+
});
69+
70+
it("writes nothing the design or profile already holds", () => {
71+
expect(applyGraphicDelivery("job", image(), [entry])).toEqual({ patch: null, setupGraphics: [entry] });
72+
const kept = applyGraphicDelivery("setup", image({ embedInZpl: false }), [entry]);
73+
expect(kept).toEqual({ patch: null, setupGraphics: [entry] });
74+
const others = [{ path: "R:OTHER.GRF", gfa: GFA }];
75+
expect(applyGraphicDelivery("printer", image({ embedInZpl: false }), others)).toEqual({ patch: null, setupGraphics: others });
76+
});
77+
78+
it("knows when a job would ship nothing, whatever the flag says", () => {
79+
expect(graphicJobShips(image())).toBe(true);
80+
expect(graphicJobShips(image({ embedInZpl: false }))).toBe(true);
81+
expect(graphicJobShips({ ...image(), _gfaCache: undefined })).toBe(false);
82+
expect(graphicJobShips({ ...image(), _gfaCache: "^GFA,4,4,1," })).toBe(false);
83+
expect(graphicJobShips(image(null))).toBe(false);
84+
});
85+
86+
it("refuses setup for bytes the script cannot carry, and does nothing without a printer name", () => {
87+
expect(applyGraphicDelivery("setup", { ...image(), _gfaCache: "^GFA,4,4,1," }, undefined)).toBeUndefined();
88+
expect(applyGraphicDelivery("setup", image(null), undefined)).toBeUndefined();
89+
const huge = `^GFA,600000,600000,1,${"F".repeat(1_200_000)}`;
90+
expect(applyGraphicDelivery("setup", { ...image(), _gfaCache: huge }, undefined)).toEqual({ refused: "tooLarge" });
91+
});
92+
});
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import type { CustomFontMapping } from "../types/LabelConfig";
2+
import type { PrinterProfile, SetupGraphic } from "../types/PrinterProfile";
3+
import { setupGraphicOf, storedGraphicShips, type ImageProps } from "../registry/image";
4+
import { uploadedGraphicPath } from "./storagePath";
5+
import { findSetupEntry, withSetupEntry, withoutSetupEntry } from "./setupEntries";
6+
7+
/** How a file reaches the printer: with every job, once through the setup script, or assumed to be there. */
8+
export type ResourceDelivery = "job" | "setup" | "printer";
9+
export const RESOURCE_DELIVERIES: readonly ResourceDelivery[] = ["job", "setup", "printer"];
10+
11+
type SetupFont = NonNullable<PrinterProfile["setupFonts"]>[number];
12+
type FontShip = Pick<CustomFontMapping, "embedInZpl"> | undefined;
13+
14+
/** An unchanged list comes back as the same reference, so a caller can skip the profile write. */
15+
function withoutEntry<T extends { path: string }>(list: readonly T[] | undefined, path: string): readonly T[] | undefined {
16+
return findSetupEntry(list, path) ? withoutSetupEntry(list, path) : list;
17+
}
18+
19+
/** The design's own choice wins: a font it ships prints from the job's bytes while the profile may still provision it. */
20+
export function fontDelivery(mapping: FontShip, path: string, setupFonts: readonly SetupFont[] | undefined): ResourceDelivery {
21+
if (mapping?.embedInZpl) return "job";
22+
return findSetupEntry(setupFonts, path) ? "setup" : "printer";
23+
}
24+
25+
/** Undefined for an image without a printer name, which prints as inline ^GF and has no way to choose. */
26+
export function graphicDelivery(p: ImageProps, setupGraphics: readonly SetupGraphic[] | undefined): ResourceDelivery | undefined {
27+
if (!p.storedAs) return undefined;
28+
if (p.storedAs.embedInZpl !== false) return "job";
29+
return findSetupEntry(setupGraphics, uploadedGraphicPath(p.storedAs)) ? "setup" : "printer";
30+
}
31+
32+
/** The profile keeps its entry while the job ships the font too. Only "printer" says the entry is unwanted. */
33+
export function applyFontDelivery(
34+
next: ResourceDelivery,
35+
mapping: FontShip,
36+
path: string,
37+
setupFonts: readonly SetupFont[] | undefined,
38+
): { patch: { embedInZpl: true | undefined } | null; setupFonts: readonly SetupFont[] | undefined } {
39+
const ships = mapping?.embedInZpl === true;
40+
const patch = ships === (next === "job") ? null : { embedInZpl: next === "job" ? (true as const) : undefined };
41+
if (next === "job") return { patch, setupFonts };
42+
if (next === "setup") return { patch, setupFonts: findSetupEntry(setupFonts, path) ? setupFonts : withSetupEntry(setupFonts, { path }) };
43+
return { patch, setupFonts: withoutEntry(setupFonts, path) };
44+
}
45+
46+
/** Whether "job" would put bytes into the stream, so the choice never claims what the emit cannot send. */
47+
export function graphicJobShips(p: ImageProps): boolean {
48+
return p.storedAs !== undefined && storedGraphicShips({ ...p, storedAs: { ...p.storedAs, embedInZpl: true } });
49+
}
50+
51+
/** A delivery that provides the file names it as the upload does, so a recall spelled with another
52+
* extension follows the .GRF the job or the script sends. */
53+
export function providedGraphic(p: ImageProps): ImageProps {
54+
if (!p.storedAs || p.storedAs.ext === undefined) return p;
55+
const { ext: _ext, ...storedAs } = p.storedAs;
56+
return { ...p, storedAs };
57+
}
58+
59+
type GraphicDeliveryPatch =
60+
| { patch: Partial<ImageProps> | null; setupGraphics: readonly SetupGraphic[] | undefined }
61+
| { refused: "tooLarge" | "unshippable" };
62+
63+
/** A profile entry the design already has stays as it is, so choosing "setup" encodes only when none exists. */
64+
export function applyGraphicDelivery(
65+
next: ResourceDelivery,
66+
p: ImageProps,
67+
setupGraphics: readonly SetupGraphic[] | undefined,
68+
): GraphicDeliveryPatch | undefined {
69+
const storedAs = p.storedAs;
70+
if (!storedAs) return undefined;
71+
const path = uploadedGraphicPath(storedAs);
72+
const ships = storedAs.embedInZpl !== false;
73+
if (next === "job") return { patch: ships ? null : { storedAs: { ...storedAs, embedInZpl: true } }, setupGraphics };
74+
const recallOnly = { ...storedAs, embedInZpl: false };
75+
if (next === "printer") return { patch: ships ? { storedAs: recallOnly } : null, setupGraphics: withoutEntry(setupGraphics, path) };
76+
const provided = providedGraphic({ ...p, storedAs: recallOnly });
77+
const patch = ships || storedAs.ext !== undefined ? { storedAs: provided.storedAs } : null;
78+
if (findSetupEntry(setupGraphics, path)) return { patch, setupGraphics };
79+
const verdict = setupGraphicOf(provided);
80+
if (!verdict) return undefined;
81+
if (verdict.fit !== "ok") return { refused: verdict.fit };
82+
// The cache holds what was just sent, so the state reads current without a fresh encode.
83+
return { patch: { storedAs: provided.storedAs, _gfaCache: verdict.entry.gfa }, setupGraphics: withSetupEntry(setupGraphics, verdict.entry) };
84+
}

‎packages/core/src/registry/image.ts‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,8 @@ export interface ImageProps {
100100
recall?: 'IM';
101101
/** ^XG magnification as written, 1-10 per axis (spec p.373); the preview stays at stored size. */
102102
magnify?: { x: number; y: number };
103-
/** Ship the bitmap bytes via `~DY` alongside the `^XG` reference.
104-
* Default true on first toggle so a single-job ZPL is self-contained.
105-
* False = recall-only: assume the file is already on printer storage,
106-
* emit only `^XG`. Mirrors the customFonts `embedInZpl` pattern. */
103+
/** Ship the bitmap bytes via `~DY` alongside `^XG`. Undefined reads as true, so a fresh entry ships by default.
104+
* False is recall-only, assuming the file is already on printer storage. */
107105
embedInZpl?: boolean;
108106
};
109107
}

‎src/components/Fonts/FontManager.test.tsx‎

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,14 +108,70 @@ describe("FontManager manual mappings", () => {
108108
});
109109
});
110110

111-
describe("FontManager embed toggle", () => {
111+
const choose = (r: ReturnType<typeof render>, option: string) => {
112+
act(() => {
113+
fireEvent.click(r.getAllByRole("button", { name: /^Delivery/ })[0] as HTMLElement);
114+
});
115+
act(() => {
116+
r.getByRole("option", { name: option }).click();
117+
});
118+
};
119+
120+
describe("FontManager delivery", () => {
112121
it("writes only the flag on the mapping, the path being the identity", () => {
113122
act(() => useLabelStore.setState({ label: { widthMm: 70, heightMm: 40, dpmm: 8, customFonts: [{ alias: "M", path: "e:arial.ttf" }] } }));
114123
const r = render(<FontManager />);
124+
choose(r, "With every job");
125+
expect(useLabelStore.getState().label.customFonts).toEqual([{ alias: "M", path: "e:arial.ttf", embedInZpl: true }]);
126+
});
127+
128+
it("moves a font from the job into the setup script and back out to the printer, the profile following each step", () => {
129+
act(() => useLabelStore.setState({ label: { widthMm: 70, heightMm: 40, dpmm: 8, customFonts: [{ alias: "M", path: "E:ARIAL.TTF", embedInZpl: true }] } }));
130+
const r = render(<FontManager />);
131+
choose(r, "Once in the setup script");
132+
expect(useLabelStore.getState().printerProfile.setupFonts).toEqual([{ path: "E:ARIAL.TTF" }]);
133+
expect(useLabelStore.getState().label.customFonts).toEqual([{ alias: "M", path: "E:ARIAL.TTF", embedInZpl: undefined }]);
134+
expect(r.getByText(/setup script of this printer profile/)).toBeTruthy();
135+
choose(r, "Already on the printer");
136+
expect(useLabelStore.getState().printerProfile.setupFonts).toBeUndefined();
137+
expect(r.getByText(/default font/)).toBeTruthy();
138+
});
139+
140+
it("keeps the profile entry when the job takes the font back", () => {
141+
act(() => useLabelStore.setState({ label: { widthMm: 70, heightMm: 40, dpmm: 8, customFonts: [{ alias: "M", path: "E:ARIAL.TTF" }] }, printerProfile: { setupFonts: [{ path: "E:ARIAL.TTF" }] } }));
142+
const r = render(<FontManager />);
143+
choose(r, "With every job");
144+
expect(useLabelStore.getState().printerProfile.setupFonts).toEqual([{ path: "E:ARIAL.TTF" }]);
145+
expect(useLabelStore.getState().label.customFonts?.[0]?.embedInZpl).toBe(true);
146+
});
147+
148+
it("blocks the job for a font without an alias, since no ^CW could name it", () => {
149+
act(() => useLabelStore.setState({ label: { widthMm: 70, heightMm: 40, dpmm: 8 } }));
150+
const r = render(<FontManager />);
115151
act(() => {
116-
fireEvent.click(r.getByLabelText("Send with print job"));
152+
fireEvent.click(r.getAllByRole("button", { name: /^Delivery/ })[0] as HTMLElement);
117153
});
118-
expect(useLabelStore.getState().label.customFonts).toEqual([{ alias: "M", path: "e:arial.ttf", embedInZpl: true }]);
154+
expect((r.getByRole("option", { name: "With every job" }) as HTMLElement).getAttribute("aria-disabled")).toBe("true");
155+
});
156+
157+
it("steps the design back first on undo, spends nothing on a re-pick, and leaves the design still when only the profile moves", () => {
158+
act(() => useLabelStore.setState({ label: { widthMm: 70, heightMm: 40, dpmm: 8, customFonts: [{ alias: "M", path: "E:ARIAL.TTF", embedInZpl: true }] } }));
159+
const temporal = useLabelStore.temporal.getState();
160+
const steps = () => useLabelStore.temporal.getState().pastStates.length;
161+
const before = steps();
162+
const r = render(<FontManager />);
163+
choose(r, "With every job");
164+
expect(steps()).toBe(before);
165+
choose(r, "Once in the setup script");
166+
// The design steps back first, and with the entry still in the profile the derivation reads job again.
167+
act(() => temporal.undo());
168+
expect(useLabelStore.getState().label.customFonts?.[0]?.embedInZpl).toBe(true);
169+
expect(useLabelStore.getState().printerProfile.setupFonts).toEqual([{ path: "E:ARIAL.TTF" }]);
170+
act(() => temporal.redo());
171+
const label = useLabelStore.getState().label;
172+
choose(r, "Already on the printer");
173+
expect(useLabelStore.getState().label).toBe(label);
174+
expect(useLabelStore.getState().printerProfile.setupFonts).toBeUndefined();
119175
});
120176
});
121177

0 commit comments

Comments
 (0)