Skip to content

Commit d588889

Browse files
authored
Merge pull request #108 from u8array/feat/printer-modal-tab5-identity
feat(printer-settings): Tab 5 Identity (^KN only)
2 parents 555d7b1 + fc9d4c6 commit d588889

38 files changed

Lines changed: 367 additions & 9 deletions
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { useId } from "react";
2+
import { useT } from "../../lib/useT";
3+
import { useLabelStore } from "../../store/labelStore";
4+
import { inputCls, labelCls } from "../ui/formStyles";
5+
import { PRINTER_NAME_MAX_LEN } from "../../types/ObjectType";
6+
import {
7+
ZplCommandLabel,
8+
ZplField,
9+
ZplSubField,
10+
} from "./zplFieldPrimitives";
11+
12+
/** Tab 5 of the Printer Settings Modal — printer identity setup.
13+
* Emits ^KN (printer name + description) on the Setup-Script
14+
* channel; both fields fold into a single `^KNname,description`
15+
* command at emit time. The tab stays intentionally focused on
16+
* "what is this printer called" — ^SL (clock-formatting Mode +
17+
* Language) lives in Tab 3 Clock & Time per its semantic home.
18+
* See memory:project_ticket_sl_clock_formatting for the ^SL
19+
* follow-up. */
20+
export function IdentityTab() {
21+
const t = useT();
22+
const label = useLabelStore((s) => s.label);
23+
const setLabelConfig = useLabelStore((s) => s.setLabelConfig);
24+
const loc = t.printerSettings.identity;
25+
const nameId = useId();
26+
27+
return (
28+
<div className="flex flex-col gap-4">
29+
{/* Both inputs fold into one ^KN emit. The tag sits on the
30+
parent ZplField only; the description uses ZplSubField
31+
to share the tag instead of duplicating it (same pattern
32+
as ^PR triple in PrintQualityTab and ^MF pair in
33+
MediaFeedTab). */}
34+
<ZplField>
35+
<ZplCommandLabel text={loc.printerName} command="^KN" htmlFor={nameId} />
36+
<input
37+
id={nameId}
38+
type="text"
39+
maxLength={PRINTER_NAME_MAX_LEN}
40+
className={inputCls}
41+
value={label.printerName ?? ""}
42+
onChange={(e) =>
43+
setLabelConfig({ printerName: e.target.value || undefined })
44+
}
45+
/>
46+
<span className={`${labelCls} normal-case tracking-normal text-muted/70`}>
47+
{loc.printerNameHint}
48+
</span>
49+
50+
<ZplSubField label={loc.printerDescription}>
51+
{(id) => (
52+
<input
53+
id={id}
54+
type="text"
55+
className={inputCls}
56+
value={label.printerDescription ?? ""}
57+
onChange={(e) =>
58+
setLabelConfig({ printerDescription: e.target.value || undefined })
59+
}
60+
/>
61+
)}
62+
</ZplSubField>
63+
</ZplField>
64+
</div>
65+
);
66+
}

‎src/components/PrinterSettings/PrinterSettingsModal.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useLabelStore, type PrinterSettingsTab } from "../../store/labelStore";
55
import { DialogShell } from "../ui/DialogShell";
66
import { ClockAndTimeTab } from "./ClockAndTimeTab";
77
import { EncodingAndLanguageTab } from "./EncodingAndLanguageTab";
8+
import { IdentityTab } from "./IdentityTab";
89
import { MediaFeedTab } from "./MediaFeedTab";
910
import { PrintQualityTab } from "./PrintQualityTab";
1011

@@ -33,6 +34,7 @@ const TAB_COMPONENTS: Partial<Record<PrinterSettingsTab, FC>> = {
3334
printQuality: PrintQualityTab,
3435
clockTime: ClockAndTimeTab,
3536
encodingLanguage: EncodingAndLanguageTab,
37+
identity: IdentityTab,
3638
};
3739

3840
/** Modal-box class kept as a constant rather than inline so the

‎src/lib/zplParser.ts‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
DARKNESS_PERMANENT_RANGE,
55
HEAD_TEST_INTERVAL_RANGE,
66
MAX_LABEL_LENGTH_RANGE,
7+
PRINTER_NAME_MAX_LEN,
78
SPEED_RANGE,
89
TEAR_OFF_ADJUST_RANGE,
910
isClockFormat,
@@ -14,6 +15,7 @@ import {
1415
isPrintOrientation,
1516
isPrinterLocale,
1617
isZplMode,
18+
setupScriptUnsafeCharRegex,
1719
} from "../types/ObjectType";
1820
import { parseIntOrUndef } from "./inputParse";
1921
import { parseRealtimeClock } from "./realtimeClock";
@@ -2042,15 +2044,31 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL {
20422044
// in the store and be re-emitted as an injected command.
20432045
SE(_, rest) {
20442046
const v = rest.trim();
2045-
// eslint-disable-next-line no-control-regex -- intentional: blocks control chars to prevent ZPL injection
2046-
if (v && !/[\^~\r\n\x00-\x1f]/.test(v)) labelConfig.encodingTable = v;
2047+
if (v && !setupScriptUnsafeCharRegex.test(v)) labelConfig.encodingTable = v;
20472048
},
20482049
// ^SZ <mode>. Single digit '1' or '2'; same first-char-trim
20492050
// pattern as KD / KL.
20502051
SZ(_, rest) {
20512052
const v = rest.trim()[0] ?? "";
20522053
if (isZplMode(v)) labelConfig.zplMode = v;
20532054
},
2055+
// ^KN <name>,<description>. Both parts free strings; the
2056+
// injection-guard regex mirrors the schema so an imported
2057+
// ZPL cannot smuggle command-introducer chars into either
2058+
// field. Name length cap stays per spec.
2059+
KN(p) {
2060+
const name = (p[0] ?? "").trim();
2061+
if (!name || name.length > PRINTER_NAME_MAX_LEN) return;
2062+
if (setupScriptUnsafeCharRegex.test(name)) return;
2063+
labelConfig.printerName = name;
2064+
const desc = (p[1] ?? "").trim();
2065+
if (desc && !setupScriptUnsafeCharRegex.test(desc)) {
2066+
labelConfig.printerDescription = desc;
2067+
}
2068+
},
2069+
// ^SL belongs in Tab 3 Clock & Time (Mode S/T + Language
2070+
// 1-13), not Identity — handler will land with that follow-
2071+
// up PR. See memory:project_ticket_sl_clock_formatting.
20542072

20552073
// ^CW {alias},{path} — register an alias for a printer-resident font.
20562074
// Subsequent ^A{alias} fields resolve to {path} via the fontAliases

‎src/lib/zplSetupScript.test.ts‎

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, it, expect } from "vitest";
22
import { generateSetupScript, SETUP_SCRIPT_FIELDS } from "./zplSetupScript";
33
import { parseZPL } from "./zplParser";
4-
import type { LabelConfig } from "../types/ObjectType";
4+
import { labelConfigSchema, type LabelConfig } from "../types/ObjectType";
55

66
const base: LabelConfig = { widthMm: 100, heightMm: 50, dpmm: 8 };
77

@@ -94,12 +94,12 @@ describe("generateSetupScript — output shape", () => {
9494
.toBe("^XA\n^SEE:UHANGUL.DAT\n^XZ");
9595
});
9696

97-
it("rejects empty-string encodingTable at the schema layer", async () => {
97+
it("rejects empty-string encodingTable at the schema layer", () => {
9898
// The schema's min(1) makes empty string unreachable through
9999
// labelConfigSchema.parse, so the generator no longer carries
100100
// its own empty-string defense. This test pins the schema
101101
// contract so a future schema loosening would also surface here.
102-
const { labelConfigSchema } = await import("../types/ObjectType");
102+
103103
expect(() => labelConfigSchema.parse({ ...base, encodingTable: "" })).toThrow();
104104
});
105105

@@ -137,16 +137,68 @@ describe("generateSetupScript — output shape", () => {
137137
expect(parsed.encodingTable).toBe(path);
138138
});
139139

140-
it("rejects ZPL injection attempts in ^SE values at the schema layer", async () => {
140+
it("rejects ZPL injection attempts in ^SE values at the schema layer", () => {
141141
// `^SE${value}` interpolation makes this a real injection
142142
// surface for imported / pasted ZPL. Schema must reject any
143143
// value carrying the command-introducer chars.
144-
const { labelConfigSchema } = await import("../types/ObjectType");
144+
145145
expect(() => labelConfigSchema.parse({ ...base, encodingTable: "^SDXY" })).toThrow();
146146
expect(() => labelConfigSchema.parse({ ...base, encodingTable: "E:F.DAT~JR" })).toThrow();
147147
expect(() => labelConfigSchema.parse({ ...base, encodingTable: "E:F.DAT\n^MD30" })).toThrow();
148148
});
149149

150+
it("emits ^KN with name only when description is unset", () => {
151+
expect(generateSetupScript({ ...base, printerName: "Lab-01" }))
152+
.toBe("^XA\n^KNLab-01\n^XZ");
153+
});
154+
155+
it("folds ^KN description into the same line when both are set", () => {
156+
expect(generateSetupScript({ ...base, printerName: "Lab-01", printerDescription: "front desk" }))
157+
.toBe("^XA\n^KNLab-01,front desk\n^XZ");
158+
});
159+
160+
it("skips ^KN emit when only the description is set (name is the anchor)", () => {
161+
expect(generateSetupScript({ ...base, printerDescription: "orphan" })).toBe("");
162+
});
163+
164+
it("skips ^KN emit when name is whitespace-only after trim", () => {
165+
// Schema's min(1) accepts " "; the emit-side trim-guard
166+
// prevents an empty ^KN from going out and silently dropping
167+
// both fields on re-import.
168+
expect(generateSetupScript({ ...base, printerName: " " })).toBe("");
169+
expect(generateSetupScript({ ...base, printerName: " ", printerDescription: "desc" }))
170+
.toBe("");
171+
});
172+
173+
it("round-trips ^KN via the parser without loss", () => {
174+
const orig: LabelConfig = {
175+
...base,
176+
printerName: "Lab-01",
177+
printerDescription: "front desk",
178+
};
179+
const { labelConfig: parsed } = parseZPL(generateSetupScript(orig));
180+
expect(parsed.printerName).toBe("Lab-01");
181+
expect(parsed.printerDescription).toBe("front desk");
182+
});
183+
184+
it("rejects ZPL injection attempts in ^KN values at the schema layer", () => {
185+
186+
expect(() => labelConfigSchema.parse({ ...base, printerName: "^SD30" })).toThrow();
187+
expect(() => labelConfigSchema.parse({ ...base, printerDescription: "front\n^MD0" })).toThrow();
188+
});
189+
190+
it("rejects ^KN names longer than the 16-char spec cap", () => {
191+
192+
expect(() => labelConfigSchema.parse({ ...base, printerName: "abcdefghij0123456" })).toThrow();
193+
});
194+
195+
it("rejects commas in ^KN / ^SE free-string fields (would round-trip-split)", () => {
196+
197+
expect(() => labelConfigSchema.parse({ ...base, printerName: "Lab,01" })).toThrow();
198+
expect(() => labelConfigSchema.parse({ ...base, printerDescription: "front,desk" })).toThrow();
199+
expect(() => labelConfigSchema.parse({ ...base, encodingTable: "E:F.DAT,extra" })).toThrow();
200+
});
201+
150202
it("parser drops ^SE values carrying ZPL command-introducer chars", () => {
151203
// The parser writes into the store without re-running the
152204
// schema; the dangerous-char check mirrors the schema so an
@@ -176,6 +228,8 @@ describe("generateSetupScript — output shape", () => {
176228
"printerLocale",
177229
"encodingTable",
178230
"zplMode",
231+
"printerName",
232+
"printerDescription",
179233
]);
180234
});
181235

‎src/lib/zplSetupScript.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,36 @@ const SETUP_SCRIPT_EMITTERS = {
9494
channel: 'block',
9595
emit: (l) => l.zplMode !== undefined ? `^SZ${l.zplMode}` : null,
9696
},
97+
printerName: {
98+
channel: 'block',
99+
// ^KN takes two positional params; description rides along
100+
// only when set. The `printerDescription` registry entry below
101+
// intentionally returns null on its own (it has no standalone
102+
// ZPL command) — the description is folded in here so the
103+
// ^KN emit stays a single line. Both values are `.trim()`ed
104+
// on emit because the parser trims on import, and asymmetric
105+
// whitespace would silently break the round-trip invariant.
106+
emit: (l) => {
107+
if (l.printerName === undefined) return null;
108+
const name = l.printerName.trim();
109+
// Schema's min(1) accepts whitespace-only strings; trimming
110+
// to empty here drops the emit so the parser's "no name = no
111+
// ^KN" round-trip rule holds. Without this guard, `^KN` (or
112+
// `^KN,desc`) would emit and the parser would silently drop
113+
// both fields on re-import.
114+
if (!name) return null;
115+
const desc = l.printerDescription?.trim();
116+
return desc ? `^KN${name},${desc}` : `^KN${name}`;
117+
},
118+
},
119+
printerDescription: {
120+
// Folded into the ^KN emit above; this entry exists only so
121+
// the SETUP_SCRIPT_FIELDS list (derived via Object.keys) sees
122+
// the field and the no-leak test knows it's per-spec a
123+
// Setup-Script field.
124+
channel: 'block',
125+
emit: () => null,
126+
},
97127
} as const satisfies Partial<Record<keyof LabelConfig, SetupScriptEmitter>>;
98128

99129
/** Public list of the LabelConfig fields that flow through the

‎src/locales/ar.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,11 @@ const ar = {
206206
zplMode1: 'ZPL (legacy)',
207207
zplMode2: 'ZPL II',
208208
},
209+
identity: {
210+
printerName: 'اسم الطابعة',
211+
printerNameHint: 'Max 16 characters. Used in front-panel labels and ZebraNet print server.',
212+
printerDescription: 'وصف الطابعة',
213+
},
209214
},
210215

211216
app: {

‎src/locales/bg.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,11 @@ const bg = {
206206
zplMode1: 'ZPL (legacy)',
207207
zplMode2: 'ZPL II',
208208
},
209+
identity: {
210+
printerName: 'Име на принтера',
211+
printerNameHint: 'Макс. 16 знака. Използва се в етикетите на предния панел и сървъра за печат ZebraNet.',
212+
printerDescription: 'Описание на принтера',
213+
},
209214
},
210215

211216
app: {

‎src/locales/cs.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,11 @@ const cs = {
206206
zplMode1: 'ZPL (legacy)',
207207
zplMode2: 'ZPL II',
208208
},
209+
identity: {
210+
printerName: 'Název tiskárny',
211+
printerNameHint: 'Max. 16 znaků. Používá se v štítcích předního panelu a tiskovém serveru ZebraNet.',
212+
printerDescription: 'Popis tiskárny',
213+
},
209214
},
210215

211216
app: {

‎src/locales/da.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,11 @@ const da = {
206206
zplMode1: 'ZPL (legacy)',
207207
zplMode2: 'ZPL II',
208208
},
209+
identity: {
210+
printerName: 'Printernavn',
211+
printerNameHint: 'Maks. 16 tegn. Bruges i frontpanel-etiketter og ZebraNet-printserver.',
212+
printerDescription: 'Printerbeskrivelse',
213+
},
209214
},
210215

211216
app: {

‎src/locales/de.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,11 @@ const de = {
206206
zplMode1: 'ZPL (Legacy)',
207207
zplMode2: 'ZPL II',
208208
},
209+
identity: {
210+
printerName: 'Druckername',
211+
printerNameHint: 'Max. 16 Zeichen. Wird in Frontpanel-Labels und im ZebraNet-Printserver verwendet.',
212+
printerDescription: 'Druckerbeschreibung',
213+
},
209214
},
210215

211216
app: {

0 commit comments

Comments
 (0)