From 9c078d08242b8ad5698672ac2c2cee0140ef1bbc Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 24 May 2026 12:19:03 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(zpl):=20^FE=20field-number=20embed=20c?= =?UTF-8?q?haracter=20=E2=80=94=20parser=20+=20generator=20+=20resolve?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds round-trip support for ZPL's `^FE` directive that lets a single `^FD` payload reference multiple `^FN` slots inline. ZPL ground form: ^XA ^FN1^FDsku-default^FS ^FN2^FDprice-default^FS ^FO50,50^A0N,30,30^FD#1#-#2#^FS ^XZ Stored in our model as `«name»` markers in `props.content` — same syntax the canvas already uses for schema-mode variable placeholders, so the marker reads naturally in the properties panel and the single-bind + template paths compose at render time. Three layers: - lib/fnTemplate.ts: marker primitives (extract/resolve/embeds-to- markers / markers-to-embeds / rename / pickEmbedChar). Fresh regex per call so the /g `lastIndex` state can't bleed across callers. - parser: tracks ^FE embedChar (default '#'), bootstraps Variables for unknown FN refs in `^FD` content via the SAME regex shape markersToEmbeds expects (`` or `,...`) so a literal like `Item #5 special` does not phantom-bootstrap. Distinguishes bare `^FN^FD^FS` Variable declarations from implicit-text fields by skipping the FD→text promotion when pendingFn is set. Three former bootstrap paths (bare-decl, single-bind, embed-reference) funnel through one `bootstrapVariable` helper. - generator: pre-scan collects template-referenced fnNumbers, picks an embedChar that doesn't clash with any literal payload text, emits `^FE` (only when ≠ '#'), emits one `^FN^FD^FS` header per referenced Variable that isn't already covered by an inline single-bind field. When pickEmbedChar finds NO safe candidate (pathological payload with every safe char taken) the template emit path is skipped and markers fall through as literal text. Whole header pass + emit-context is extracted to `planTemplateHeader` so generateZPL stays a sequence of label parts. applyBindingToObject scans content for `«name»` markers and substitutes the resolved Variable value (CSV-row-aware, schema-mode aware). Single-bind resolves first, then template markers — so an unusual single-bind value that itself contains markers also resolves. Shared `getObjectStringContent(obj)` helper in variableBinding.ts consolidates the five sites that previously cast `(obj as { props?: { content?: unknown } })` to read content from a heterogeneous LabelObject tree. 23 new unit tests (fnTemplate primitives + parser/generator roundtrip + embedChar fallback to '@' when '#' literal in payload). --- README.md | 4 +- docs/zpl-roadmap.md | 2 +- .../Variables/VariableBindingControl.tsx | 5 +- src/lib/fnTemplate.test.ts | 103 ++++++++++++++ src/lib/fnTemplate.ts | 133 ++++++++++++++++++ src/lib/variableBinding.ts | 48 +++++-- src/lib/zplCommandSupport.ts | 2 +- src/lib/zplGenerator.test.ts | 62 ++++++++ src/lib/zplGenerator.ts | 86 ++++++++++- src/lib/zplParser.ts | 116 ++++++++++++--- src/registry/zplHelpers.ts | 11 ++ src/types/ObjectType.ts | 6 + 12 files changed, 539 insertions(+), 39 deletions(-) create mode 100644 src/lib/fnTemplate.test.ts create mode 100644 src/lib/fnTemplate.ts diff --git a/README.md b/README.md index d7bc91bf..0d6e2233 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ The **ZPL output** panel at the bottom shows the generated ZPL. It updates in re File menu → **Import ZPL**: paste ZPL code directly, or open a `.zpl` file. -> Import round-trips text, barcodes, shapes, images (including printer-stored and compressed graphics), label-header settings, and template fields (`^FN`/`^FV` slots land in the **Variables** tab). Printer-side date/time stamps (`^FC`) have no editor equivalent and are dropped. Anything else the parser doesn't recognize is skipped and listed in the import report. +> Import round-trips text, barcodes, shapes, images (including printer-stored and compressed graphics), label-header settings, and template fields (`^FN`/`^FV` slots land in the **Variables** tab; `^FE` inline embeds like `^FD#1#-#2#` import as `«name»` markers in the field content). Printer-side date/time stamps (`^FC`) have no editor equivalent and are dropped. Anything else the parser doesn't recognize is skipped and listed in the import report. ### Multiple labels (pages) @@ -102,7 +102,7 @@ Both `.zpl` and `.json` round-trip cleanly. `.zpl` preserves all printable conte - Smart alignment and spacing guides - Layers panel with reordering -- Variables: bind text and barcode fields to named defaults that emit as `^FN`/`^FV` slots, round-tripping with printer-side templates +- Variables: bind text and barcode fields to named defaults that emit as `^FN`/`^FV` slots (or `^FE` inline embeds when one field references multiple variables), round-tripping with printer-side templates - CSV batch printing: import a CSV, map columns to Variables, print or export with efficient printer-side data merge (template ships once, each row sends only its overrides) - 32 UI languages (auto-detected from browser) - Light / dark mode (follows OS setting) diff --git a/docs/zpl-roadmap.md b/docs/zpl-roadmap.md index 8d7dae83..11fb68af 100644 --- a/docs/zpl-roadmap.md +++ b/docs/zpl-roadmap.md @@ -39,6 +39,7 @@ What's supported, what's next, what's planned. - [x] `^TB` — text block - [x] `^FN` — variable placeholder - [x] `^FV` — variable data +- [x] `^FE` — field-number embed character - [x] `^BY` — barcode field default ### Text & fonts @@ -104,7 +105,6 @@ What's supported, what's next, what's planned. ### Fields -- [ ] `^FE` — field concatenation - [ ] `^FM` — multiple field origins - [ ] `^FP` — field path (text along path) - [ ] `^CO` — font cache size diff --git a/src/components/Variables/VariableBindingControl.tsx b/src/components/Variables/VariableBindingControl.tsx index 8cd69bf6..63e088ff 100644 --- a/src/components/Variables/VariableBindingControl.tsx +++ b/src/components/Variables/VariableBindingControl.tsx @@ -5,6 +5,7 @@ import type { LabelObject } from '../../types/Group'; import { inputCls } from '../Properties/styles'; import { FieldLabel } from '../ui/FieldLabel'; import { useT } from '../../lib/useT'; +import { getObjectStringContent } from '../../lib/variableBinding'; const CREATE_NEW_SENTINEL = '__create_new__'; @@ -58,9 +59,7 @@ export function VariableBindingControl({ obj }: Props) { // field is currently carrying, preserving the canvas state across // the binding transition. Every bindable type's first ^FD emission // comes from `props.content` (see registry implementations). - const props = (obj as { props?: { content?: unknown } }).props; - const defaultValue = - typeof props?.content === 'string' ? props.content : ''; + const defaultValue = getObjectStringContent(obj) ?? ''; const id = addVariable({ name: trimmed, defaultValue }); if (id === null) { // Two reasons addVariable returns null: name collision or no free diff --git a/src/lib/fnTemplate.test.ts b/src/lib/fnTemplate.test.ts new file mode 100644 index 00000000..342bfe66 --- /dev/null +++ b/src/lib/fnTemplate.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from "vitest"; +import { + hasTemplateMarkers, + extractTemplateRefs, + resolveTemplateMarkers, + embedsToMarkers, + markersToEmbeds, + pickEmbedChar, +} from "./fnTemplate"; +import type { Variable } from "../types/Variable"; + +const vars: Variable[] = [ + { id: "a", name: "sku", fnNumber: 1, defaultValue: "DEFAULT-1" }, + { id: "b", name: "price", fnNumber: 2, defaultValue: "9.99" }, + { id: "c", name: "lot", fnNumber: 5, defaultValue: "X" }, +]; + +describe("hasTemplateMarkers", () => { + it("matches single marker", () => { + expect(hasTemplateMarkers("hello «sku»")).toBe(true); + }); + it("matches multiple markers", () => { + expect(hasTemplateMarkers("«a» and «b»")).toBe(true); + }); + it("returns false on plain text", () => { + expect(hasTemplateMarkers("plain text")).toBe(false); + }); + it("does not match an opening guillemet alone", () => { + expect(hasTemplateMarkers("« no closer")).toBe(false); + }); +}); + +describe("extractTemplateRefs", () => { + it("preserves source order and duplicates", () => { + expect(extractTemplateRefs("«a» and «b» again «a»")).toEqual(["a", "b", "a"]); + }); +}); + +describe("resolveTemplateMarkers", () => { + it("substitutes each marker via the resolver", () => { + const r = resolveTemplateMarkers("«sku»-«price»", (n) => + n === "sku" ? "ABC" : n === "price" ? "19.99" : undefined, + ); + expect(r).toBe("ABC-19.99"); + }); + it("leaves unknown markers literal", () => { + const r = resolveTemplateMarkers("«known»/«missing»", (n) => (n === "known" ? "v" : undefined)); + expect(r).toBe("v/«missing»"); + }); +}); + +describe("embedsToMarkers", () => { + const fnToName = new Map([ + [1, "sku"], + [2, "price"], + ]); + it("converts whole-field embeds with default # delimiter", () => { + expect(embedsToMarkers("Hello #1#-#2#", "#", fnToName)).toBe("Hello «sku»-«price»"); + }); + it("converts substring embeds (slice args discarded)", () => { + expect(embedsToMarkers("#1,0,3#", "#", fnToName)).toBe("«sku»"); + }); + it("respects a custom embedChar set via ^FE", () => { + expect(embedsToMarkers("Hello @1@-@2@", "@", fnToName)).toBe("Hello «sku»-«price»"); + }); + it("leaves embeds for unknown FN numbers literal (loss-less round-trip)", () => { + expect(embedsToMarkers("#9#", "#", fnToName)).toBe("#9#"); + }); +}); + +describe("markersToEmbeds", () => { + it("emits embeds for known variable names + reports the fnNumbers used", () => { + const r = markersToEmbeds("«sku»-«price»", vars, "#"); + expect(r.payload).toBe("#1#-#2#"); + expect([...r.referencedFnNumbers].sort()).toEqual([1, 2]); + }); + it("leaves markers literal when the named variable does not exist", () => { + const r = markersToEmbeds("«sku»-«gone»", vars, "#"); + expect(r.payload).toBe("#1#-«gone»"); + expect([...r.referencedFnNumbers]).toEqual([1]); + }); + it("dedupes referencedFnNumbers when the same name appears twice", () => { + const r = markersToEmbeds("«sku»/«sku»", vars, "#"); + expect(r.payload).toBe("#1#/#1#"); + expect([...r.referencedFnNumbers]).toEqual([1]); + }); + it("uses a non-default embedChar passed by the caller", () => { + const r = markersToEmbeds("«sku»-«price»", vars, "@"); + expect(r.payload).toBe("@1@-@2@"); + }); +}); + +describe("pickEmbedChar", () => { + it("returns # when no payload contains it", () => { + expect(pickEmbedChar(["plain text", "more"])).toBe("#"); + }); + it("falls back to next candidate when # appears in any payload", () => { + expect(pickEmbedChar(["Item #SKU-1", "other"])).toBe("@"); + }); + it("returns null when every candidate is taken", () => { + expect(pickEmbedChar(["#@|%&?!"])).toBe(null); + }); +}); diff --git a/src/lib/fnTemplate.ts b/src/lib/fnTemplate.ts new file mode 100644 index 00000000..a533e090 --- /dev/null +++ b/src/lib/fnTemplate.ts @@ -0,0 +1,133 @@ +import type { Variable } from "../types/Variable"; + +/** + * `^FE` (Field number Embed character) lets a single `^FD` payload + * reference multiple `^FN` slots inline. We store those references in + * `content` as `«variableName»` markers — the same syntax the canvas + * already uses to visualise unbound variables in schema mode, so the + * stored content is human-readable in the properties panel too. + * + * The ZPL boundary still uses the numeric `#n#` embed form; this + * module is the bidirectional bridge. + */ + +/** Single `«name»` marker. Body forbids `»` so `«a»«b»` can't merge. + * Use `markerRe()` for the /g form needed by replace/matchAll — the + * shared `/g` instance would carry `lastIndex` state across callers + * and produce silent off-by-one bugs. */ +const MARKER_BODY = /«([^»]+)»/; +const markerRe = () => /«([^»]+)»/g; + +/** Rewrite every `«oldName»` marker in `content` to `«newName»`. + * Identity-preserving when no marker matches, so callers can + * compare references to skip a downstream update. */ +export function renameTemplateMarker( + content: string, + oldName: string, + newName: string, +): string { + if (oldName === newName) return content; + let touched = false; + const next = content.replace(markerRe(), (full, name: string) => { + if (name !== oldName) return full; + touched = true; + return `«${newName}»`; + }); + return touched ? next : content; +} + +/** True when `content` carries at least one `«…»` marker. */ +export function hasTemplateMarkers(content: string): boolean { + return MARKER_BODY.test(content); +} + +/** Return the variable names referenced by every marker in `content`, + * in source order, with duplicates preserved (caller dedupes). */ +export function extractTemplateRefs(content: string): string[] { + return [...content.matchAll(markerRe())] + .map((m) => m[1]) + .filter((n): n is string => n !== undefined); +} + +/** Replace every `«name»` marker with the result of `resolve(name)`. + * Markers whose name doesn't resolve stay literal — caller decides + * the fallback (drop, keep, error). */ +export function resolveTemplateMarkers( + content: string, + resolve: (name: string) => string | undefined, +): string { + return content.replace(markerRe(), (full, name: string) => { + const v = resolve(name); + return v !== undefined ? v : full; + }); +} + +/** + * Convert a ZPL `^FD`-with-embeds payload (`Hello #1#-#2#`) into a + * template content string with `«name»` markers. Unknown FN numbers + * (no Variable defined) are left as the literal embed text so the + * round-trip stays loss-less on subsequent re-export. + * + * `embedChar` is whatever the most recent `^FE` set (default `#`). + * Both whole-field `#n#` and substring `#n,offset,length,…#` forms + * are recognised; the substring slice arguments are discarded — + * Zebra evaluates them at print time and we don't replicate that + * (lossy here, documented). + */ +export function embedsToMarkers( + payload: string, + embedChar: string, + fnToName: ReadonlyMap, +): string { + // Escape the embed delimiter for the regex; ^FE values are single + // ASCII chars so we don't need a general escape function. + const e = embedChar.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = new RegExp(`${e}(\\d+)(?:,[^${e}]*)?${e}`, "g"); + return payload.replace(re, (full, digits: string) => { + const n = parseInt(digits, 10); + const name = fnToName.get(n); + return name !== undefined ? `«${name}»` : full; + }); +} + +/** + * Convert template markers (`«name»`) in `content` back to ZPL `#n#` + * embeds, using the Variables collection to map name → fnNumber. + * Markers whose name has no matching Variable fall through as literal + * text (the generator's fallback when a binding has been deleted). + * Returns both the encoded payload and the set of fnNumbers actually + * referenced, so the generator can emit the matching `^FN` headers. + */ +export function markersToEmbeds( + content: string, + variables: readonly Variable[], + embedChar: string, +): { payload: string; referencedFnNumbers: ReadonlySet } { + const byName = new Map(variables.map((v) => [v.name, v])); + const referenced = new Set(); + const payload = content.replace(markerRe(), (full, name: string) => { + const v = byName.get(name); + if (!v) return full; + referenced.add(v.fnNumber); + return `${embedChar}${v.fnNumber}${embedChar}`; + }); + return { payload, referencedFnNumbers: referenced }; +} + +/** + * Pick an embed character that doesn't appear literally in any of the + * `payloads`. Default `#` is preferred (matches ZPL's own default and + * needs no `^FE` directive); falls back through a small ranked list + * of safe ASCII punctuation before giving up. Returns `null` when + * every candidate clashes — caller should escape with `^FH` instead + * (rare; would need 7+ literal punctuation chars in the data). + */ +// `^` and `~` are reserved ZPL command prefixes; never offer them. +const EMBED_CHAR_CANDIDATES = ["#", "@", "|", "%", "&", "?", "!"] as const; + +export function pickEmbedChar(payloads: readonly string[]): string | null { + for (const c of EMBED_CHAR_CANDIDATES) { + if (payloads.every((p) => !p.includes(c))) return c; + } + return null; +} diff --git a/src/lib/variableBinding.ts b/src/lib/variableBinding.ts index 7d3ebe1a..40ddfaf0 100644 --- a/src/lib/variableBinding.ts +++ b/src/lib/variableBinding.ts @@ -1,5 +1,20 @@ import type { LabelObject } from "../types/Group"; import type { CsvMapping, Variable } from "../types/Variable"; +import { hasTemplateMarkers, resolveTemplateMarkers } from "./fnTemplate"; + +/** + * Safely read an object's `props.content` as a string. Bindable + * leaves expose `content`; groups and non-bindable leaves don't. + * The union doesn't narrow through `as LabelObject`, so this + * encapsulates the unsafe cast in one place — all consumers that + * walk a heterogeneous object tree (binding resolution, generator + * pre-scan, store rename ripple, Variables panel counts) share + * the same shape check. + */ +export function getObjectStringContent(obj: LabelObject): string | undefined { + const c = (obj as { props?: { content?: unknown } }).props?.content; + return typeof c === "string" ? c : undefined; +} /** * Resolve an object's `variableId` against the variable list and return @@ -158,17 +173,32 @@ export function applyBindingToObject( active: ActiveCsvRow | null = null, mode: RenderMode = "preview", ): T { + const content = getObjectStringContent(obj); + if (content === undefined) return obj; + + // Two resolution paths, applied in order so they compose: + // 1. `variableId` single-bind: whole content is the variable value + // 2. `«name»` template markers (^FE/^FN inline embeds): scan-and- + // substitute, multiple variables per content + // Order matters when a single-bind variable's resolved value itself + // contains template markers — rare but cheap to support correctly. + let next = content; const variable = lookupBoundVariable(obj, variables); - if (!variable) return obj; - const props = (obj as { props?: { content?: unknown } }).props; - if (!props || typeof props.content !== "string") return obj; - const resolved = resolveVariableValue(variable, active, mode); - if (props.content === resolved) return obj; - // The discriminated union doesn't narrow through a spread, so we cast - // back to T. Runtime shape preserves the original variant; only - // `props.content` changes. + if (variable) { + next = resolveVariableValue(variable, active, mode); + } + if (hasTemplateMarkers(next)) { + next = resolveTemplateMarkers(next, (name) => { + const v = variables.find((x) => x.name === name); + if (!v) return undefined; + return resolveVariableValue(v, active, mode); + }); + } + if (next === content) return obj; + // Discriminated union doesn't narrow through spread, cast back to T. + const props = (obj as { props: object }).props; return { ...obj, - props: { ...props, content: resolved }, + props: { ...props, content: next }, } as unknown as T; } diff --git a/src/lib/zplCommandSupport.ts b/src/lib/zplCommandSupport.ts index 0e7c5e15..420302ec 100644 --- a/src/lib/zplCommandSupport.ts +++ b/src/lib/zplCommandSupport.ts @@ -51,7 +51,7 @@ export const ZPL_COMMANDS: readonly ZplCommandInfo[] = [ { cmd: 'FW', status: 'supported', description: 'Field orientation — sets default rotation for subsequent fields' }, { cmd: 'FB', status: 'supported', description: 'Field block — multi-line text with word-wrap and justification' }, { cmd: 'FC', status: 'unsupported', description: 'Field clock — inserts date/time into field data' }, - { cmd: 'FE', status: 'unsupported', description: 'Field concatenation — appends data to the current field' }, + { cmd: 'FE', status: 'supported', description: 'Field-number embed character — redefines the ^FN inline-embed delimiter (default #) used by ^FD/^FV. Imported embeds become «name» template markers; round-trips through generate.' }, { cmd: 'FM', status: 'unsupported', description: 'Multiple field origin locations' }, { cmd: 'FN', status: 'supported', description: 'Field number — variable field placeholder, lands in the Variables tab on import and emits as ^FN{slot} on export' }, { cmd: 'FP', status: 'unsupported', description: 'Field parameter — sets character-by-character text direction' }, diff --git a/src/lib/zplGenerator.test.ts b/src/lib/zplGenerator.test.ts index 63a12111..0b1e94ca 100644 --- a/src/lib/zplGenerator.test.ts +++ b/src/lib/zplGenerator.test.ts @@ -729,6 +729,68 @@ describe('generateZPL — parse/generate roundtrip', () => { expect(props(bc).mode).toBe('A'); }); + it('parses ^FE inline FN embeds into «name» markers + auto-creates variables', () => { + // The templated field references FN2 and FN3 inline; the parser + // auto-creates the Variables when they don't already exist (same + // bootstrap convention as the single-bind ^FN path). + const r = parseZPL( + '^XA^FO50,50^A0N,30,30^FD#2# and then #3#^FS^XZ', + 8, + ); + expect(r.variables.map((v) => ({ fn: v.fnNumber, n: v.name })).sort((a, b) => a.fn - b.fn)) + .toEqual([ + { fn: 2, n: 'field_2' }, + { fn: 3, n: 'field_3' }, + ]); + const text = defined(r.objects.find((o) => o.type === 'text')); + expect(props(text).content).toBe('«field_2» and then «field_3»'); + }); + + it('respects a custom ^FE embed character', () => { + // ^FE@ redefines the embed delimiter, so `@1@` reads as the FN1 + // embed and the literal `#` survives untouched in the output. + const r = parseZPL( + '^XA^FE@^FO50,50^A0N,30,30^FDItem #@1@^FS^XZ', + 8, + ); + const text = defined(r.objects.find((o) => o.type === 'text')); + expect(props(text).content).toBe('Item #«field_1»'); + }); + + it('round-trips a label that uses ^FE inline embeds', () => { + const src = '^XA^FO50,50^A0N,30,30^FD#1#-#2#^FS^XZ'; + const original = parseZPL(src, 8); + const regenerated = generateZPL(BASE_LABEL, original.objects, original.variables); + const reparsed = parseZPL(regenerated, 8); + const text = defined(reparsed.objects.find((o) => o.type === 'text')); + expect(props(text).content).toBe('«field_1»-«field_2»'); + expect(reparsed.variables.map((v) => v.fnNumber).sort()).toEqual([1, 2]); + }); + + it('emits ^FE when payload contains a literal #', () => { + // Pre-build state via the parser so variable ids are real. + const r = parseZPL('^XA^FN1^FDfoo^FS^XZ', 8); + const v = defined(r.variables[0]); + const generated = generateZPL(BASE_LABEL, [ + { + id: 'a', + type: 'text', + x: 10, + y: 10, + rotation: 0, + props: { + content: 'Item #«' + v.name + '»', + fontHeight: 20, + fontWidth: 0, + rotation: 'N', + }, + } as LabelObject, + ], r.variables); + // '#' is in the payload literal, so generator must switch to '@'. + expect(generated).toMatch(/\^FE@/); + expect(generated).toMatch(/\^FDItem #@1@\^FS/); + }); + it('does not leak ^B4 mode from one symbol to the next', () => { // Two B4 fields back-to-back: first explicit mode=3, second omits // the mode parameter. The second must default to 'A' even though diff --git a/src/lib/zplGenerator.ts b/src/lib/zplGenerator.ts index b91929c5..6d0bc8f6 100644 --- a/src/lib/zplGenerator.ts +++ b/src/lib/zplGenerator.ts @@ -1,7 +1,13 @@ import { mmToDots } from './coordinates'; import { ObjectRegistry } from '../registry'; import { fdField, stripZplCommandChars } from '../registry/zplHelpers'; -import type { CustomFontMapping, LabelConfig } from '../types/ObjectType'; +import { + extractTemplateRefs, + hasTemplateMarkers, + pickEmbedChar, +} from './fnTemplate'; +import { getObjectStringContent } from './variableBinding'; +import type { CustomFontMapping, LabelConfig, ZplEmitContext } from '../types/ObjectType'; import type { Variable } from '../types/Variable'; import { isGroup, type LabelObject, type Page } from '../types/Group'; import { getFontBytes } from './fontCache'; @@ -58,6 +64,66 @@ function flattenObjects(objects: LabelObject[]): LabelObject[] { return out; } +/** + * Plan the label-header `^FE` directive + `^FN` declarations that + * inline-embed templates need, plus the emit context that downstream + * per-leaf `toZPL` calls consume. + * + * - Walks the label once: collects single-bind fnNumbers (they emit + * their declaration inline via fdFieldFor) and template-referenced + * fnNumbers (need a header declaration). De-dupes against each + * other so a slot referenced both ways is declared exactly once. + * - Picks an embedChar that doesn't clash with any literal payload + * text (prefers `#`, the ZPL default). If every safe candidate is + * taken — pathological payload — skips the template path entirely + * so markers fall through as literal text instead of producing + * ambiguous embeds. + * - Returns the header lines (`^FE...`, `^FN...`) and an emit ctx + * whose `embedChar` is set only when templates are emittable — + * fdFieldFor checks for it as the "templates allowed" gate. + */ +function planTemplateHeader( + shifted: LabelObject[], + label: LabelConfig, + variables: readonly Variable[], +): { headerLines: string[]; emitCtx: ZplEmitContext } { + const templatePayloads: string[] = []; + const templateFns = new Set(); + const singleBindFns = new Set(); + for (const leaf of flattenObjects(shifted)) { + if (leaf.includeInExport === false) continue; + if (leaf.variableId) { + const v = variables.find((x) => x.id === leaf.variableId); + if (v) singleBindFns.add(v.fnNumber); + } + const c = getObjectStringContent(leaf); + if (c === undefined || !hasTemplateMarkers(c)) continue; + templatePayloads.push(c); + for (const name of extractTemplateRefs(c)) { + const v = variables.find((x) => x.name === name); + if (v) templateFns.add(v.fnNumber); + } + } + const pickedEmbedChar = + templatePayloads.length > 0 ? pickEmbedChar(templatePayloads) : '#'; + if (pickedEmbedChar === null) { + // Markers stay literal in the emitted ZPL. + return { headerLines: [], emitCtx: { label, variables } }; + } + const headerLines: string[] = []; + if (pickedEmbedChar !== '#') headerLines.push(`^FE${pickedEmbedChar}`); + for (const fn of [...templateFns].sort((a, b) => a - b)) { + if (singleBindFns.has(fn)) continue; + const v = variables.find((x) => x.fnNumber === fn); + if (!v) continue; + headerLines.push(`^FN${fn}${fdField(v.defaultValue)}`); + } + return { + headerLines, + emitCtx: { label, variables, embedChar: pickedEmbedChar }, + }; +} + /** Format a `~DY` line for a graphic-upload image. Mirrors the font-upload * helper but parses the bitmap bytes out of `_gfaCache` (shape: * `^GF{A|B|C},total,data,bpr,DATA…`). The format letter is preserved so a @@ -290,21 +356,29 @@ export function generateZPL( return x < 0 || y < 0 ? [] : [{ ...obj, x, y }]; }; + const shifted = + homeX !== 0 || homeY !== 0 || top !== 0 + ? objects.flatMap(shiftOrDrop) + : objects; + + // ^FE inline embeds (`«name»` markers in content) need every + // referenced fnNumber to have a `^FN^FD^FS` declaration + // somewhere in the label format. Compute the header + emit context + // in one helper so the main flow stays a sequence of label parts. + const { headerLines, emitCtx } = planTemplateHeader(shifted, label, variables); + lines.push(...headerLines); + // Groups are structural only — they emit no ZPL of their own. A group // with includeInExport=false cascades the skip to its whole subtree; // otherwise we recurse and let each leaf decide. const emitLeaf = (obj: LabelObject): string[] => { if (obj.includeInExport === false) return []; if (isGroup(obj)) return obj.children.flatMap(emitLeaf); - const zpl = ObjectRegistry[obj.type]?.toZPL(obj, { label, variables }) ?? ''; + const zpl = ObjectRegistry[obj.type]?.toZPL(obj, emitCtx) ?? ''; return obj.comment ? [`^FX${stripZplCommandChars(obj.comment)}\n${zpl}`] : [zpl]; }; - const shifted = - homeX !== 0 || homeY !== 0 || top !== 0 - ? objects.flatMap(shiftOrDrop) - : objects; lines.push(...shifted.flatMap(emitLeaf)); // ^PQ q,p,r,o — emit if quantity > 1 OR any extended param is set. diff --git a/src/lib/zplParser.ts b/src/lib/zplParser.ts index 4d943518..ced4e1fd 100644 --- a/src/lib/zplParser.ts +++ b/src/lib/zplParser.ts @@ -5,6 +5,7 @@ import { uniqueVariableName, type Variable, } from "../types/Variable"; +import { embedsToMarkers } from "./fnTemplate"; import { zplAnchorToModel } from "../components/Canvas/textPositionTransforms"; import { computeTextRenderMetrics } from "../components/Canvas/textRenderMetrics"; import type { LabelObject } from "../types/Group"; @@ -558,6 +559,11 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { let bcCheck = false; let bcRotation: ZplRotation = "N"; let bcCode49Mode: Code49Props["mode"] = "A"; + // ^FE field-number embed character. Default '#'; redefined by + // ^FE. Format-scoped, persists through ^FS (see Zebra ZPL II + // Programming Guide). Reset per parse is implicit — this `let` is + // initialised once at the top of parseZPL. + let embedChar = "#"; let gsSymbology: Gs1DatabarProps["symbology"] = 1; let gsSegments: number | undefined = undefined; // ^BY barcode defaults @@ -659,11 +665,88 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { fbJustify = "L"; }; + /** Get-or-create a Variable for the given FN slot. Three call + * sites — bare `^FN^FD^FS` declarations, single-bind fields, + * and template-embed references — all funnel through here so + * the auto-naming convention (`field_` unless an FX comment + * hints otherwise) and the uniqueName collision logic live in + * one place. */ + const bootstrapVariable = ( + fnNumber: number, + defaultValue: string, + commentHint?: string, + ): Variable => { + const existing = variables.find((v) => v.fnNumber === fnNumber); + if (existing) { + if (!existing.defaultValue && defaultValue) { + existing.defaultValue = defaultValue; + } + return existing; + } + const base = variableNameFromComment(commentHint) ?? `field_${fnNumber}`; + const v: Variable = { + id: crypto.randomUUID(), + name: uniqueVariableName(base, variables), + fnNumber, + defaultValue, + }; + variables.push(v); + return v; + }; + + // Build a fnNumber→name map from the variables collected so far, + // bootstrapping Variables for any FN referenced by embeds in the + // current field's content. Mirrors the single-bind bootstrap at + // the bottom of flushField — same auto-name convention so embed- + // referenced FNs and inline-bound FNs share the same Variable + // entry when they hit the same slot number. + const applyFnEmbeds = (payload: string): string => { + // Match the same shape embedsToMarkers expects: `` or + // `,...`. A naked `` without a closing `` + // would otherwise bootstrap a phantom Variable from a literal + // like `#5 special` and then dangle (no marker emitted). + const e = embedChar.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const embedRe = new RegExp(`${e}(\\d+)(?:,[^${e}]*)?${e}`, "g"); + let m: RegExpExecArray | null; + const seen = new Set(); + while ((m = embedRe.exec(payload)) !== null) { + if (!m[1]) continue; + const n = parseInt(m[1], 10); + if (n < FN_NUMBER_MIN || n > FN_NUMBER_MAX) continue; + seen.add(n); + } + if (seen.size === 0) return payload; + for (const n of seen) bootstrapVariable(n, ""); + const fnToName = new Map(variables.map((v) => [v.fnNumber, v.name])); + return embedsToMarkers(payload, embedChar, fnToName); + }; + const flushField = () => { - if (!fieldType || pendingFD === null) return; - const content = fhActive + if (!fieldType || pendingFD === null) { + // Bare `^FN^FD^FS` (no ^FO / ^A) is a Variable + // declaration, not a field. Register the Variable so the + // default reaches the Variables panel + downstream resolves, + // and clear pendingFn so it doesn't leak into the next field. + if (pendingFn !== null && pendingFD !== null) { + const decl = fhActive + ? decodeFH(pendingFD, fhDelimiter, fhDecoder) + : pendingFD; + bootstrapVariable(pendingFn, decl, pendingFnComment); + pendingFn = null; + pendingFnComment = undefined; + } + pendingFD = null; + return; + } + const rawDecoded = fhActive ? decodeFH(pendingFD, fhDelimiter, fhDecoder) : pendingFD; + // ^FE-style inline FN embeds (`#1#`, `#2,0,3#`, …) get rewritten + // into the canonical `«variableName»` marker form before the + // content reaches downstream code. Bootstrap a Variable for any + // referenced FN that has no existing entry — same auto-naming + // ("field_") used by the single-bind ^FN path below. + const content = applyFnEmbeds(rawDecoded); const posType: "FT" | "FO" = positionIsFT ? "FT" : "FO"; const comment = takeComment(); @@ -984,18 +1067,7 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { if (pendingFn !== null) { const justPushed = objects[objects.length - 1]; if (justPushed) { - let variable = variables.find((v) => v.fnNumber === pendingFn); - if (!variable) { - const fallback = `field_${pendingFn}`; - const base = variableNameFromComment(pendingFnComment) ?? fallback; - variable = { - id: crypto.randomUUID(), - name: uniqueVariableName(base, variables), - fnNumber: pendingFn, - defaultValue: content, - }; - variables.push(variable); - } + const variable = bootstrapVariable(pendingFn, content, pendingFnComment); justPushed.variableId = variable.id; } pendingFn = null; @@ -1253,8 +1325,13 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { // ── Field data / separator ────────────────────────────────────────────── FD(_, rest) { - // Implicit text field: ^FD without a prior ^A uses ^CF defaults - if (!fieldType) { + // Implicit text field: ^FD without a prior ^A uses ^CF defaults. + // Skip the implicit promotion when pendingFn is set — that means + // we're looking at a bare `^FN^FD^FS` Variable + // declaration (the docs-example form for ^FE inline embeds), + // which flushField then routes through the bare-declaration + // path (no field object, just Variable registration). + if (!fieldType && pendingFn === null) { fieldType = "text"; textH = cfHeight || 30; textW = cfWidth || 0; @@ -1901,7 +1978,12 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { }, FV: noop, // field variable — supplies data for ^FN at print time FC: noop, // field clock — inserts date/time (requires printer RTC) - FE: noop, // field concatenation — appends data to current field + FE: (p) => { + // ^FE: redefine the FN-embed delimiter used inside ^FD/^FV. + // Single ASCII character; falls back to '#' when missing/invalid. + const c = p[0]?.[0]; + embedChar = c && c !== "^" && c !== "~" ? c : "#"; + }, FM: noop, // multiple field origin locations FP: noop, // field parameter — per-character text direction MN: noop, // media handling / notch tracking diff --git a/src/registry/zplHelpers.ts b/src/registry/zplHelpers.ts index 2edc8ab3..7610a850 100644 --- a/src/registry/zplHelpers.ts +++ b/src/registry/zplHelpers.ts @@ -1,4 +1,5 @@ import type { LabelObjectBase, ZplEmitContext } from "../types/ObjectType"; +import { hasTemplateMarkers, markersToEmbeds } from "../lib/fnTemplate"; import { modelToZplAnchor } from "../components/Canvas/textPositionTransforms"; import { getTextRenderMetrics } from "../components/Canvas/textRenderMetrics"; import type { LabelObject } from "../types/Group"; @@ -125,6 +126,16 @@ export function fdFieldFor( content: string, ctx?: ZplEmitContext, ): string { + // Template path: content carries `«name»` markers (^FE inline embeds). + // Convert to `#n#`-style embeds using the label-level embedChar; the + // generator emits the corresponding ^FN declarations at the label + // header. Skipped when `embedChar` is unset (the generator signals + // "no safe delimiter available, leave markers literal" — see + // generateZPL's templatesEmittable gate). + if (ctx?.variables && ctx.embedChar && hasTemplateMarkers(content)) { + const { payload } = markersToEmbeds(content, ctx.variables, ctx.embedChar); + return fdField(payload); + } const id = obj.variableId; if (!id || !ctx?.variables) return fdField(content); const variable = ctx.variables.find((v) => v.id === id); diff --git a/src/types/ObjectType.ts b/src/types/ObjectType.ts index e8180614..efe7abb0 100644 --- a/src/types/ObjectType.ts +++ b/src/types/ObjectType.ts @@ -152,6 +152,12 @@ export interface ZplEmitContext { * printer treats the field as a template slot. Absent / empty: every * field emits its own literal content. */ variables?: readonly Variable[]; + /** ^FE embed delimiter active for the current label. Defaults to `#` + * (ZPL's own default). Set by the label-level emitter when it picks + * an alternate char to avoid colliding with literal payload text; + * per-leaf emitters consult it when translating `«name»` markers + * back to `#n#` embed syntax. */ + embedChar?: string; } /** From d5e64228becc9c1da1cc9fa8c5ed66a135034f8b Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 24 May 2026 12:19:14 +0200 Subject: [PATCH 2/4] feat(ui): Insert-variable picker for content fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TemplateContentInput wraps the plain text input in the Text and 1D- barcode properties panels with a small `{x}` button that opens a dropdown of every defined Variable; picking one splices its `«name»` marker into the input at the current cursor position. Lets non-technical users compose multi-variable fields without hand-typing the marker syntax. Templates resolve at render time via the lib/fnTemplate + variableBinding chain from the previous commit. Dropdown closes on outside-click and Esc. The barcode1d wiring splits content at marker boundaries before sanitising literal slices so the restricted-charset filter (e.g. numeric-only EAN13) preserves `«…»` markers on every keystroke instead of stripping them as out-of-charset bytes. Locale: one new key (insertVariable) across all 32 locales for the button title attribute. --- .../Properties/TemplateContentInput.tsx | 114 ++++++++++++++++++ src/locales/ar.ts | 1 + src/locales/bg.ts | 1 + src/locales/cs.ts | 1 + src/locales/da.ts | 1 + src/locales/de.ts | 1 + src/locales/el.ts | 1 + src/locales/en.ts | 1 + src/locales/es.ts | 1 + src/locales/et.ts | 1 + src/locales/fa.ts | 1 + src/locales/fi.ts | 1 + src/locales/fr.ts | 1 + src/locales/he.ts | 1 + src/locales/hr.ts | 1 + src/locales/hu.ts | 1 + src/locales/it.ts | 1 + src/locales/ja.ts | 1 + src/locales/ko.ts | 1 + src/locales/lt.ts | 1 + src/locales/lv.ts | 1 + src/locales/nl.ts | 1 + src/locales/no.ts | 1 + src/locales/pl.ts | 1 + src/locales/pt.ts | 1 + src/locales/ro.ts | 1 + src/locales/sk.ts | 1 + src/locales/sl.ts | 1 + src/locales/sr.ts | 1 + src/locales/sv.ts | 1 + src/locales/tr.ts | 1 + src/locales/zh-hans.ts | 1 + src/locales/zh-hant.ts | 1 + src/registry/barcode1d.tsx | 21 +++- src/registry/text.tsx | 6 +- 35 files changed, 166 insertions(+), 7 deletions(-) create mode 100644 src/components/Properties/TemplateContentInput.tsx diff --git a/src/components/Properties/TemplateContentInput.tsx b/src/components/Properties/TemplateContentInput.tsx new file mode 100644 index 00000000..1b9912a5 --- /dev/null +++ b/src/components/Properties/TemplateContentInput.tsx @@ -0,0 +1,114 @@ +import { useEffect, useRef, useState } from "react"; +import { useT } from "../../lib/useT"; +import { useLabelStore } from "../../store/labelStore"; +import { inputCls } from "./styles"; + +interface Props { + value: string; + onChange: (next: string) => void; + /** Optional sanitiser for restricted-charset fields (e.g. numeric-only + * barcodes). Applied to typed input only — template markers are + * inserted verbatim regardless. */ + sanitise?: (raw: string) => string; + placeholder?: string; + maxLength?: number; +} + +/** + * Text input + "Insert variable" button. The button opens a small + * dropdown listing every defined Variable; picking one splices its + * `«name»` marker into the input at the current cursor position. + * Templates resolve at render time via applyBindingToObject — see + * lib/fnTemplate + lib/variableBinding. + * + * Used by Text and 1D-barcode properties panels in place of the + * plain input so non-technical users can compose multi-variable + * fields without typing the marker syntax by hand. + */ +export function TemplateContentInput({ + value, + onChange, + sanitise, + placeholder, + maxLength, +}: Props) { + const t = useT(); + const variables = useLabelStore((s) => s.variables); + const inputRef = useRef(null); + const rootRef = useRef(null); + const [open, setOpen] = useState(false); + + // Click-outside + Esc close. Mounted only while open so the + // listeners don't fire for every other open menu in the panel. + useEffect(() => { + if (!open) return; + const onPointerDown = (e: PointerEvent) => { + if (!rootRef.current?.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + document.addEventListener("pointerdown", onPointerDown); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("pointerdown", onPointerDown); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + const insertMarker = (name: string) => { + const input = inputRef.current; + const marker = `«${name}»`; + const cursor = input?.selectionStart ?? value.length; + const end = input?.selectionEnd ?? cursor; + const next = value.slice(0, cursor) + marker + value.slice(end); + onChange(next); + setOpen(false); + // Restore focus + place cursor right after the inserted marker. + queueMicrotask(() => { + if (!input) return; + const pos = cursor + marker.length; + input.focus(); + input.setSelectionRange(pos, pos); + }); + }; + + return ( +
+ onChange(sanitise ? sanitise(e.target.value) : e.target.value)} + /> + + {open && variables.length > 0 && ( +
+ {variables.map((v) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/locales/ar.ts b/src/locales/ar.ts index ad946c57..4920acad 100644 --- a/src/locales/ar.ts +++ b/src/locales/ar.ts @@ -138,6 +138,7 @@ const ar = { importZpl: 'استيراد ZPL', keepExistingPages: 'الاحتفاظ بالصفحات الحالية', chooseFile: 'اختيار ملف', + insertVariable: 'إدراج متغير', exportZpl: 'تصدير ZPL', importCsvData: 'استيراد بيانات CSV', exportBatchZplFmt: 'تصدير ZPL دفعي ({n} ملصقات)', diff --git a/src/locales/bg.ts b/src/locales/bg.ts index a4ca5f7b..dffacc5e 100644 --- a/src/locales/bg.ts +++ b/src/locales/bg.ts @@ -138,6 +138,7 @@ const bg = { importZpl: 'Import ZPL', keepExistingPages: 'Запазване на съществуващите страници', chooseFile: 'Избор на файл', + insertVariable: 'Вмъкване на променлива', exportZpl: 'Export ZPL', importCsvData: 'Импортиране на CSV данни', exportBatchZplFmt: 'Експорт на пакетен ZPL ({n} етикета)', diff --git a/src/locales/cs.ts b/src/locales/cs.ts index 0aa1712d..7594d2a0 100644 --- a/src/locales/cs.ts +++ b/src/locales/cs.ts @@ -138,6 +138,7 @@ const cs = { importZpl: 'Import ZPL', keepExistingPages: 'Zachovat stávající stránky', chooseFile: 'Vybrat soubor', + insertVariable: 'Vložit proměnnou', exportZpl: 'Export ZPL', importCsvData: 'Importovat data CSV', exportBatchZplFmt: 'Exportovat dávkové ZPL ({n} etiket)', diff --git a/src/locales/da.ts b/src/locales/da.ts index c67c3f0c..ebef29e1 100644 --- a/src/locales/da.ts +++ b/src/locales/da.ts @@ -138,6 +138,7 @@ const da = { importZpl: 'Import ZPL', keepExistingPages: 'Behold eksisterende sider', chooseFile: 'Vælg fil', + insertVariable: 'Indsæt variabel', exportZpl: 'Export ZPL', importCsvData: 'Importer CSV-data', exportBatchZplFmt: 'Eksportér batch-ZPL ({n} etiketter)', diff --git a/src/locales/de.ts b/src/locales/de.ts index 8ab17e22..79c04879 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -138,6 +138,7 @@ const de = { importZpl: 'Import ZPL', keepExistingPages: 'Bestehende Seiten behalten', chooseFile: 'Datei wählen', + insertVariable: 'Variable einfügen', exportZpl: 'Export ZPL', importCsvData: 'CSV-Daten importieren', exportBatchZplFmt: 'Batch-ZPL exportieren ({n} Etiketten)', diff --git a/src/locales/el.ts b/src/locales/el.ts index 48458d5c..c30c336f 100644 --- a/src/locales/el.ts +++ b/src/locales/el.ts @@ -138,6 +138,7 @@ const el = { importZpl: 'Import ZPL', keepExistingPages: 'Διατήρηση υπαρχουσών σελίδων', chooseFile: 'Επιλογή αρχείου', + insertVariable: 'Εισαγωγή μεταβλητής', exportZpl: 'Export ZPL', importCsvData: 'Εισαγωγή δεδομένων CSV', exportBatchZplFmt: 'Εξαγωγή παρτίδας ZPL ({n} ετικέτες)', diff --git a/src/locales/en.ts b/src/locales/en.ts index d0f4547b..22d1d95d 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -138,6 +138,7 @@ const en = { importZpl: 'Import ZPL', keepExistingPages: 'Keep existing pages', chooseFile: 'Choose file', + insertVariable: 'Insert variable', exportZpl: 'Export ZPL', importCsvData: 'Import CSV data', exportBatchZplFmt: 'Export batch ZPL ({n} labels)', diff --git a/src/locales/es.ts b/src/locales/es.ts index 4b541ea6..e6eefb05 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -138,6 +138,7 @@ const es = { importZpl: 'Import ZPL', keepExistingPages: 'Conservar páginas existentes', chooseFile: 'Elegir archivo', + insertVariable: 'Insertar variable', exportZpl: 'Export ZPL', importCsvData: 'Importar datos CSV', exportBatchZplFmt: 'Exportar ZPL por lotes ({n} etiquetas)', diff --git a/src/locales/et.ts b/src/locales/et.ts index af4a63db..70da018f 100644 --- a/src/locales/et.ts +++ b/src/locales/et.ts @@ -138,6 +138,7 @@ const et = { importZpl: 'Import ZPL', keepExistingPages: 'Säilita olemasolevad lehed', chooseFile: 'Vali fail', + insertVariable: 'Lisa muutuja', exportZpl: 'Export ZPL', importCsvData: 'Impordi CSV-andmed', exportBatchZplFmt: 'Ekspordi partii-ZPL ({n} silti)', diff --git a/src/locales/fa.ts b/src/locales/fa.ts index cc49e2ce..4dc03d97 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -138,6 +138,7 @@ const fa = { importZpl: 'وارد کردن ZPL', keepExistingPages: 'حفظ صفحات موجود', chooseFile: 'انتخاب فایل', + insertVariable: 'درج متغیر', exportZpl: 'خروجی ZPL', importCsvData: 'وارد کردن داده‌های CSV', exportBatchZplFmt: 'خروجی ZPL دسته‌ای ({n} برچسب)', diff --git a/src/locales/fi.ts b/src/locales/fi.ts index 81787b7b..c1ee580b 100644 --- a/src/locales/fi.ts +++ b/src/locales/fi.ts @@ -138,6 +138,7 @@ const fi = { importZpl: 'Import ZPL', keepExistingPages: 'Säilytä olemassa olevat sivut', chooseFile: 'Valitse tiedosto', + insertVariable: 'Lisää muuttuja', exportZpl: 'Export ZPL', importCsvData: 'Tuo CSV-tiedot', exportBatchZplFmt: 'Vie erä-ZPL ({n} etikettiä)', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 898e5672..083e9779 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -138,6 +138,7 @@ const fr = { importZpl: 'Import ZPL', keepExistingPages: 'Conserver les pages existantes', chooseFile: 'Choisir un fichier', + insertVariable: 'Insérer une variable', exportZpl: 'Export ZPL', importCsvData: 'Importer des données CSV', exportBatchZplFmt: 'Exporter ZPL en lot ({n} étiquettes)', diff --git a/src/locales/he.ts b/src/locales/he.ts index 71afdde4..64e0553a 100644 --- a/src/locales/he.ts +++ b/src/locales/he.ts @@ -138,6 +138,7 @@ const he = { importZpl: 'ייבוא ZPL', keepExistingPages: 'שמור על דפים קיימים', chooseFile: 'בחר קובץ', + insertVariable: 'הכנס משתנה', exportZpl: 'ייצוא ZPL', importCsvData: 'ייבוא נתוני CSV', exportBatchZplFmt: 'ייצוא ZPL באצווה ({n} תוויות)', diff --git a/src/locales/hr.ts b/src/locales/hr.ts index 7297e0eb..8d91e7df 100644 --- a/src/locales/hr.ts +++ b/src/locales/hr.ts @@ -138,6 +138,7 @@ const hr = { importZpl: 'Import ZPL', keepExistingPages: 'Zadrži postojeće stranice', chooseFile: 'Odaberi datoteku', + insertVariable: 'Umetni varijablu', exportZpl: 'Export ZPL', importCsvData: 'Uvoz CSV podataka', exportBatchZplFmt: 'Izvoz skupnog ZPL-a ({n} etiketa)', diff --git a/src/locales/hu.ts b/src/locales/hu.ts index 2e39b798..ea2301d8 100644 --- a/src/locales/hu.ts +++ b/src/locales/hu.ts @@ -138,6 +138,7 @@ const hu = { importZpl: 'Import ZPL', keepExistingPages: 'Meglévő oldalak megtartása', chooseFile: 'Fájl kiválasztása', + insertVariable: 'Változó beszúrása', exportZpl: 'Export ZPL', importCsvData: 'CSV-adatok importálása', exportBatchZplFmt: 'Köteg ZPL exportálása ({n} címke)', diff --git a/src/locales/it.ts b/src/locales/it.ts index 2dc0b46a..1f6426dc 100644 --- a/src/locales/it.ts +++ b/src/locales/it.ts @@ -138,6 +138,7 @@ const it = { importZpl: 'Import ZPL', keepExistingPages: 'Mantieni pagine esistenti', chooseFile: 'Scegli file', + insertVariable: 'Inserisci variabile', exportZpl: 'Export ZPL', importCsvData: 'Importa dati CSV', exportBatchZplFmt: 'Esporta ZPL in batch ({n} etichette)', diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 550e8681..7e057dcf 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -138,6 +138,7 @@ const ja = { importZpl: 'ZPL インポート', keepExistingPages: '既存のページを保持', chooseFile: 'ファイルを選択', + insertVariable: '変数を挿入', exportZpl: 'ZPL エクスポート', importCsvData: 'CSVデータをインポート', exportBatchZplFmt: 'バッチZPLをエクスポート ({n} ラベル)', diff --git a/src/locales/ko.ts b/src/locales/ko.ts index aaf61e5f..e307cf16 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -138,6 +138,7 @@ const ko = { importZpl: 'ZPL 가져오기', keepExistingPages: '기존 페이지 유지', chooseFile: '파일 선택', + insertVariable: '변수 삽입', exportZpl: 'ZPL 내보내기', importCsvData: 'CSV 데이터 가져오기', exportBatchZplFmt: '일괄 ZPL 내보내기 ({n}개 라벨)', diff --git a/src/locales/lt.ts b/src/locales/lt.ts index 75f4a37b..bb68e163 100644 --- a/src/locales/lt.ts +++ b/src/locales/lt.ts @@ -138,6 +138,7 @@ const lt = { importZpl: 'Import ZPL', keepExistingPages: 'Išsaugoti esamus puslapius', chooseFile: 'Pasirinkti failą', + insertVariable: 'Įterpti kintamąjį', exportZpl: 'Export ZPL', importCsvData: 'Importuoti CSV duomenis', exportBatchZplFmt: 'Eksportuoti paketinį ZPL ({n} etikečių)', diff --git a/src/locales/lv.ts b/src/locales/lv.ts index d5d41007..b5e6f005 100644 --- a/src/locales/lv.ts +++ b/src/locales/lv.ts @@ -138,6 +138,7 @@ const lv = { importZpl: 'Import ZPL', keepExistingPages: 'Saglabāt esošās lapas', chooseFile: 'Izvēlēties failu', + insertVariable: 'Ievietot mainīgo', exportZpl: 'Export ZPL', importCsvData: 'Importēt CSV datus', exportBatchZplFmt: 'Eksportēt pakešu ZPL ({n} etiķetes)', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index b8db90da..c013acbc 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -138,6 +138,7 @@ const nl = { importZpl: 'Import ZPL', keepExistingPages: 'Bestaande pagina\'s behouden', chooseFile: 'Bestand kiezen', + insertVariable: 'Variabele invoegen', exportZpl: 'Export ZPL', importCsvData: 'CSV-gegevens importeren', exportBatchZplFmt: 'Batch-ZPL exporteren ({n} etiketten)', diff --git a/src/locales/no.ts b/src/locales/no.ts index d644ef22..2955ab21 100644 --- a/src/locales/no.ts +++ b/src/locales/no.ts @@ -138,6 +138,7 @@ const no = { importZpl: 'Import ZPL', keepExistingPages: 'Behold eksisterende sider', chooseFile: 'Velg fil', + insertVariable: 'Sett inn variabel', exportZpl: 'Export ZPL', importCsvData: 'Importer CSV-data', exportBatchZplFmt: 'Eksporter batch-ZPL ({n} etiketter)', diff --git a/src/locales/pl.ts b/src/locales/pl.ts index 9b002024..92444e62 100644 --- a/src/locales/pl.ts +++ b/src/locales/pl.ts @@ -138,6 +138,7 @@ const pl = { importZpl: 'Import ZPL', keepExistingPages: 'Zachowaj istniejące strony', chooseFile: 'Wybierz plik', + insertVariable: 'Wstaw zmienną', exportZpl: 'Export ZPL', importCsvData: 'Importuj dane CSV', exportBatchZplFmt: 'Eksportuj wsadowy ZPL ({n} etykiet)', diff --git a/src/locales/pt.ts b/src/locales/pt.ts index ba4ea3cd..4cdf65e9 100644 --- a/src/locales/pt.ts +++ b/src/locales/pt.ts @@ -138,6 +138,7 @@ const pt = { importZpl: 'Import ZPL', keepExistingPages: 'Manter páginas existentes', chooseFile: 'Escolher ficheiro', + insertVariable: 'Inserir variável', exportZpl: 'Export ZPL', importCsvData: 'Importar dados CSV', exportBatchZplFmt: 'Exportar ZPL em lote ({n} etiquetas)', diff --git a/src/locales/ro.ts b/src/locales/ro.ts index 0eb7c4a8..80e1977d 100644 --- a/src/locales/ro.ts +++ b/src/locales/ro.ts @@ -138,6 +138,7 @@ const ro = { importZpl: 'Import ZPL', keepExistingPages: 'Păstrează paginile existente', chooseFile: 'Alege fișier', + insertVariable: 'Inserează variabilă', exportZpl: 'Export ZPL', importCsvData: 'Importă date CSV', exportBatchZplFmt: 'Exportă ZPL în lot ({n} etichete)', diff --git a/src/locales/sk.ts b/src/locales/sk.ts index 2ea1d77e..902b6ea9 100644 --- a/src/locales/sk.ts +++ b/src/locales/sk.ts @@ -138,6 +138,7 @@ const sk = { importZpl: 'Import ZPL', keepExistingPages: 'Zachovať existujúce stránky', chooseFile: 'Vybrať súbor', + insertVariable: 'Vložiť premennú', exportZpl: 'Export ZPL', importCsvData: 'Importovať údaje CSV', exportBatchZplFmt: 'Exportovať dávkové ZPL ({n} etikiet)', diff --git a/src/locales/sl.ts b/src/locales/sl.ts index da1777fa..7bd20950 100644 --- a/src/locales/sl.ts +++ b/src/locales/sl.ts @@ -138,6 +138,7 @@ const sl = { importZpl: 'Import ZPL', keepExistingPages: 'Ohrani obstoječe strani', chooseFile: 'Izberi datoteko', + insertVariable: 'Vstavi spremenljivko', exportZpl: 'Export ZPL', importCsvData: 'Uvozi podatke CSV', exportBatchZplFmt: 'Izvozi paketni ZPL ({n} etiket)', diff --git a/src/locales/sr.ts b/src/locales/sr.ts index d3324ef5..f3fe287f 100644 --- a/src/locales/sr.ts +++ b/src/locales/sr.ts @@ -138,6 +138,7 @@ const sr = { importZpl: 'Import ZPL', keepExistingPages: 'Задржи постојеће странице', chooseFile: 'Изабери датотеку', + insertVariable: 'Уметни променљиву', exportZpl: 'Export ZPL', importCsvData: 'Увоз CSV података', exportBatchZplFmt: 'Извоз групног ZPL-а ({n} етикета)', diff --git a/src/locales/sv.ts b/src/locales/sv.ts index 2290fb8e..cf58a2ba 100644 --- a/src/locales/sv.ts +++ b/src/locales/sv.ts @@ -138,6 +138,7 @@ const sv = { importZpl: 'Import ZPL', keepExistingPages: 'Behåll befintliga sidor', chooseFile: 'Välj fil', + insertVariable: 'Infoga variabel', exportZpl: 'Export ZPL', importCsvData: 'Importera CSV-data', exportBatchZplFmt: 'Exportera batch-ZPL ({n} etiketter)', diff --git a/src/locales/tr.ts b/src/locales/tr.ts index e5c6c38e..cd043db7 100644 --- a/src/locales/tr.ts +++ b/src/locales/tr.ts @@ -138,6 +138,7 @@ const tr = { importZpl: 'ZPL İçe Aktar', keepExistingPages: 'Mevcut sayfaları koru', chooseFile: 'Dosya seç', + insertVariable: 'Değişken ekle', exportZpl: 'ZPL Dışa Aktar', importCsvData: 'CSV verisi içe aktar', exportBatchZplFmt: 'Toplu ZPL dışa aktar ({n} etiket)', diff --git a/src/locales/zh-hans.ts b/src/locales/zh-hans.ts index 4f620e07..7890804a 100644 --- a/src/locales/zh-hans.ts +++ b/src/locales/zh-hans.ts @@ -138,6 +138,7 @@ const zhHans = { importZpl: '导入 ZPL', keepExistingPages: '保留现有页面', chooseFile: '选择文件', + insertVariable: '插入变量', exportZpl: '导出 ZPL', importCsvData: '导入 CSV 数据', exportBatchZplFmt: '导出批量 ZPL ({n} 个标签)', diff --git a/src/locales/zh-hant.ts b/src/locales/zh-hant.ts index cdf1c991..86e1b515 100644 --- a/src/locales/zh-hant.ts +++ b/src/locales/zh-hant.ts @@ -138,6 +138,7 @@ const zhHant = { importZpl: '匯入 ZPL', keepExistingPages: '保留現有頁面', chooseFile: '選擇檔案', + insertVariable: '插入變數', exportZpl: '匯出 ZPL', importCsvData: '匯入 CSV 資料', exportBatchZplFmt: '匯出批次 ZPL ({n} 個標籤)', diff --git a/src/registry/barcode1d.tsx b/src/registry/barcode1d.tsx index ee097a5d..03b736ba 100644 --- a/src/registry/barcode1d.tsx +++ b/src/registry/barcode1d.tsx @@ -1,13 +1,14 @@ import type { ObjectTypeDefinition, ObjectGroup, LabelObjectBase, HriBehavior } from '../types/ObjectType'; import { useT } from '../lib/useT'; import type { Translations } from '../locales'; -import { inputCls, labelCls } from '../components/Properties/styles'; +import { labelCls } from '../components/Properties/styles'; import { fieldPos, fdFieldFor } from './zplHelpers'; import { commitBarcodeWidthHeightTransform } from './transformHelpers'; import { filterContent, hasValidLength, type ContentSpec } from './contentSpec'; import { type ZplRotation } from './rotation'; import { RotationSelect } from '../components/Properties/RotationSelect'; import { NumberInput } from '../components/Properties/NumberInput'; +import { TemplateContentInput } from '../components/Properties/TemplateContentInput'; export interface Barcode1DProps { content: string; @@ -109,12 +110,24 @@ export function createBarcode1D(config: Barcode1DConfig): ObjectTypeDefinition
- onChange({ content })} + sanitise={(raw) => + // Preserve `«name»` template markers verbatim while + // sanitising the literal slices between them — without + // this guard, restricted-charset barcodes (e.g. EAN13 + // numeric-only) would strip the markers' guillemets on + // every keystroke after insertion. + raw + .split(/(«[^»]+»)/) + .map((s, i) => + i % 2 === 0 ? filterContent(s, config.contentSpec) : s, + ) + .join('') + } maxLength={config.contentSpec?.maxLength} placeholder={loc.placeholder} - onChange={(e) => onChange({ content: filterContent(e.target.value, config.contentSpec) })} /> {!hasValidLength(p.content, config.contentSpec) && loc.placeholder && (

{loc.placeholder}

diff --git a/src/registry/text.tsx b/src/registry/text.tsx index 9c8d176a..39308fd1 100644 --- a/src/registry/text.tsx +++ b/src/registry/text.tsx @@ -10,6 +10,7 @@ import { useFontCacheVersion } from "../hooks/useFontCacheVersion"; import { useLabelStore } from "../store/labelStore"; import { RotationSelect } from "../components/Properties/RotationSelect"; import { NumberInput } from "../components/Properties/NumberInput"; +import { TemplateContentInput } from "../components/Properties/TemplateContentInput"; export interface TextProps { content: string; @@ -206,10 +207,9 @@ export const text: ObjectTypeDefinition = {
- onChange({ content: e.target.value })} + onChange={(content) => onChange({ content })} />
From 19c298407b22e9b5fea1cc227a4cd8ae1710b947 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 24 May 2026 12:19:26 +0200 Subject: [PATCH 3/4] feat(variables): template-aware bindings (counts + rename ripple) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VariablesPanel's per-row binding count now includes `«name»` template references in addition to single-bind `variableId` references, so the user sees real usage of each Variable across both binding styles. De-duped per object — a field that carries both `variableId === V` and `«V»` in its content counts as one place, not two. updateVariable rewrites every `«oldName»` marker in every object's content to `«newName»` when a Variable is renamed — without this ripple, renames would silently dangle the templates (resolve to literal text). Identity-preserving at the leaf level so React memoisation downstream stays effective for objects whose content didn't carry the renamed name. Reuses the shared `getObjectStringContent` helper from variableBinding.ts so the content-reading shape check stays in one place. 3 new tests cover the rename ripple (multiple markers, identity preservation, no-op when name unchanged). --- src/components/Variables/VariablesPanel.tsx | 27 +++++-- src/store/labelStore.templates.test.ts | 80 +++++++++++++++++++++ src/store/labelStore.ts | 47 +++++++++++- 3 files changed, 149 insertions(+), 5 deletions(-) create mode 100644 src/store/labelStore.templates.test.ts diff --git a/src/components/Variables/VariablesPanel.tsx b/src/components/Variables/VariablesPanel.tsx index e8c8491a..83c77b48 100644 --- a/src/components/Variables/VariablesPanel.tsx +++ b/src/components/Variables/VariablesPanel.tsx @@ -22,7 +22,8 @@ import { ConfirmDialog } from '../ui/ConfirmDialog'; import { FieldLabel } from '../ui/FieldLabel'; import { useT } from '../../lib/useT'; import type { Translations } from '../../locales'; -import { getVariableSource, type VariableSource } from '../../lib/variableBinding'; +import { getObjectStringContent, getVariableSource, type VariableSource } from '../../lib/variableBinding'; +import { extractTemplateRefs } from '../../lib/fnTemplate'; import { VariableSourceBadge } from './VariableSourceBadge'; export function VariablesPanel() { @@ -487,18 +488,36 @@ function formatDeleteMessage( } /** Walk every page (groups too) and tally how many fields reference each - * variable. Returns a Map keyed by variable.id. Variables with no - * bindings are absent from the map; callers default to 0. */ + * variable, either via single-bind `variableId` OR via inline + * `«name»` template markers in their content. Returns a Map keyed by + * variable.id. Variables with no bindings are absent; callers + * default to 0. */ function countBindings( pages: { objects: LabelObject[] }[], variables: readonly Variable[], ): Map { const known = new Set(variables.map((v) => v.id)); + const byName = new Map(variables.map((v) => [v.name, v.id])); const counts = new Map(); for (const page of pages) { for (const obj of walkObjects(page.objects)) { + // De-dupe per OBJECT across both binding styles: a field with + // both `variableId === V` and `«V»` in its content counts as + // one usage of V, not two. Mirrors how the user thinks about + // "where is V used" — one field = one place. + const refsInThisObj = new Set(); if (obj.variableId && known.has(obj.variableId)) { - counts.set(obj.variableId, (counts.get(obj.variableId) ?? 0) + 1); + refsInThisObj.add(obj.variableId); + } + const c = getObjectStringContent(obj); + if (c !== undefined) { + for (const name of extractTemplateRefs(c)) { + const id = byName.get(name); + if (id) refsInThisObj.add(id); + } + } + for (const id of refsInThisObj) { + counts.set(id, (counts.get(id) ?? 0) + 1); } } } diff --git a/src/store/labelStore.templates.test.ts b/src/store/labelStore.templates.test.ts new file mode 100644 index 00000000..e6214747 --- /dev/null +++ b/src/store/labelStore.templates.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { useLabelStore } from "./labelStore"; +import type { LabelObject } from "../types/Group"; + +const newTextObj = (id: string, content: string): LabelObject => + ({ + id, + type: "text", + x: 0, + y: 0, + rotation: 0, + props: { + content, + fontHeight: 20, + fontWidth: 0, + rotation: "N", + }, + }) as unknown as LabelObject; + +describe("labelStore — template marker rename ripple", () => { + beforeEach(() => { + useLabelStore.setState({ + variables: [], + pages: [{ objects: [] }], + currentPageIndex: 0, + } as Partial>); + }); + + it("rewrites every «oldName» marker when a variable is renamed", () => { + const id = useLabelStore.getState().addVariable({ name: "sku" }); + expect(id).not.toBeNull(); + useLabelStore.setState({ + pages: [ + { + objects: [ + newTextObj("a", "lone «sku»"), + newTextObj("b", "two «sku» and «sku» again"), + newTextObj("c", "untouched literal"), + ], + }, + ], + } as Partial>); + + useLabelStore.getState().updateVariable(id!, { name: "product" }); + + const objs = useLabelStore.getState().pages[0]!.objects as LabelObject[]; + expect((objs[0] as { props: { content: string } }).props.content) + .toBe("lone «product»"); + expect((objs[1] as { props: { content: string } }).props.content) + .toBe("two «product» and «product» again"); + expect((objs[2] as { props: { content: string } }).props.content) + .toBe("untouched literal"); + }); + + it("preserves object identity when the rename does not touch a leaf", () => { + const id = useLabelStore.getState().addVariable({ name: "sku" }); + const before = newTextObj("a", "no markers here"); + useLabelStore.setState({ + pages: [{ objects: [before] }], + } as Partial>); + + useLabelStore.getState().updateVariable(id!, { name: "product" }); + + const after = useLabelStore.getState().pages[0]!.objects[0]; + expect(after).toBe(before); + }); + + it("leaves markers untouched when the rename target name is unchanged", () => { + const id = useLabelStore.getState().addVariable({ name: "sku" }); + const obj = newTextObj("a", "value: «sku»"); + useLabelStore.setState({ + pages: [{ objects: [obj] }], + } as Partial>); + + useLabelStore.getState().updateVariable(id!, { name: "sku" }); + + const after = useLabelStore.getState().pages[0]!.objects[0]; + expect(after).toBe(obj); + }); +}); diff --git a/src/store/labelStore.ts b/src/store/labelStore.ts index e4d6a696..fe1a4cfe 100644 --- a/src/store/labelStore.ts +++ b/src/store/labelStore.ts @@ -19,6 +19,8 @@ import { } from '../types/Group'; import { locales } from '../locales'; import type { LocaleCode } from '../locales'; +import { renameTemplateMarker } from '../lib/fnTemplate'; +import { getObjectStringContent } from '../lib/variableBinding'; import { isDefaultLabelaryHost, fetchPreview, labelaryErrorMessage } from '../lib/labelary'; import { nextFreeFnNumber, @@ -57,6 +59,35 @@ function isLockBypass(changes: ObjectChanges): boolean { return keys.length > 0 && keys.every((k) => LOCK_BYPASS_KEYS.has(k)); } +/** Apply `renameTemplateMarker` to every leaf's `content` in a subtree. + * Identity-preserving: returns the same array (and same node refs) + * when no markers needed rewriting, so React memoisation downstream + * stays effective for the common case where the rename touched no + * templates. */ +function rewriteTemplateMarkers( + objects: LabelObject[], + oldName: string, + newName: string, +): LabelObject[] { + let changed = false; + const next = objects.map((obj) => { + if (isGroup(obj)) { + const nextChildren = rewriteTemplateMarkers(obj.children, oldName, newName); + if (nextChildren === obj.children) return obj; + changed = true; + return { ...obj, children: nextChildren }; + } + const content = getObjectStringContent(obj); + if (content === undefined) return obj; + const renamed = renameTemplateMarker(content, oldName, newName); + if (renamed === content) return obj; + changed = true; + const props = (obj as { props: object }).props; + return { ...obj, props: { ...props, content: renamed } } as LabelObject; + }); + return changed ? next : objects; +} + function applyObjectChanges( obj: LabelObject, changes: ObjectChanges, @@ -1109,9 +1140,23 @@ export const useLabelStore = create()( if (state.variables.some((v) => v.id !== id && v.fnNumber === changes.fnNumber)) return {}; } - return { + const next: Partial = { variables: state.variables.map((v) => (v.id === id ? { ...v, ...changes } : v)), }; + // Rename ripple: when the variable's name changes, every + // `«oldName»` marker in any object's content needs to point + // at the new name. Without this the templates dangle (resolve + // to literal text) and the user has no obvious way to fix + // them other than re-typing. + if (changes.name !== undefined && changes.name !== existing.name) { + const oldName = existing.name; + const newName = changes.name; + next.pages = state.pages.map((page) => ({ + ...page, + objects: rewriteTemplateMarkers(page.objects, oldName, newName), + })); + } + return next; }), setVariables: (variables) => From 47229ffc8b80c9f762e6b2a4a7ad3ab0f903a16a Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 24 May 2026 12:30:59 +0200 Subject: [PATCH 4/4] perf(variables): O(N+V) map lookups in template resolve + header pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address gemini's perf findings on PR #92: - variableBinding.applyBindingToObject: build a name→Variable map once per call so a field with N markers resolves in O(N+V) instead of N linear scans across the V Variables. - zplGenerator.planTemplateHeader: build id/name/fn maps once before the leaf walk so the per-leaf single-bind lookup + per-marker name lookup + per-fn header emit all stay constant- time. Cuts the worst case from O(N·V) to O(N+V). Per-field Map rebuild inside zplParser.applyFnEmbeds (gemini's 4th finding) deferred — that path also bootstraps Variables so a hoisted-cache invalidation strategy adds more complexity than the inner-loop scan saves at current scale. TemplateContentInput a11y findings (role=menuitem, keyboard nav) deferred to a separate a11y pass alongside other Properties components — keeping this PR scoped to the ^FE feature surface. --- src/lib/variableBinding.ts | 7 ++++--- src/lib/zplGenerator.ts | 14 +++++++++++--- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/lib/variableBinding.ts b/src/lib/variableBinding.ts index 40ddfaf0..5271cee4 100644 --- a/src/lib/variableBinding.ts +++ b/src/lib/variableBinding.ts @@ -188,10 +188,11 @@ export function applyBindingToObject( next = resolveVariableValue(variable, active, mode); } if (hasTemplateMarkers(next)) { + // Map lookup so a field with N markers stays O(N+V) not O(N·V). + const byName = new Map(variables.map((v) => [v.name, v])); next = resolveTemplateMarkers(next, (name) => { - const v = variables.find((x) => x.name === name); - if (!v) return undefined; - return resolveVariableValue(v, active, mode); + const v = byName.get(name); + return v ? resolveVariableValue(v, active, mode) : undefined; }); } if (next === content) return obj; diff --git a/src/lib/zplGenerator.ts b/src/lib/zplGenerator.ts index 6d0bc8f6..ad0b4a51 100644 --- a/src/lib/zplGenerator.ts +++ b/src/lib/zplGenerator.ts @@ -87,20 +87,28 @@ function planTemplateHeader( label: LabelConfig, variables: readonly Variable[], ): { headerLines: string[]; emitCtx: ZplEmitContext } { + // Pre-built maps keep the leaf walk + header emit O(N+V) instead + // of O(N·V) — a label with hundreds of objects and dozens of + // variables would otherwise re-scan the whole variables list per + // leaf per marker. + const varsById = new Map(variables.map((v) => [v.id, v])); + const varsByName = new Map(variables.map((v) => [v.name, v])); + const varsByFn = new Map(variables.map((v) => [v.fnNumber, v])); + const templatePayloads: string[] = []; const templateFns = new Set(); const singleBindFns = new Set(); for (const leaf of flattenObjects(shifted)) { if (leaf.includeInExport === false) continue; if (leaf.variableId) { - const v = variables.find((x) => x.id === leaf.variableId); + const v = varsById.get(leaf.variableId); if (v) singleBindFns.add(v.fnNumber); } const c = getObjectStringContent(leaf); if (c === undefined || !hasTemplateMarkers(c)) continue; templatePayloads.push(c); for (const name of extractTemplateRefs(c)) { - const v = variables.find((x) => x.name === name); + const v = varsByName.get(name); if (v) templateFns.add(v.fnNumber); } } @@ -114,7 +122,7 @@ function planTemplateHeader( if (pickedEmbedChar !== '#') headerLines.push(`^FE${pickedEmbedChar}`); for (const fn of [...templateFns].sort((a, b) => a - b)) { if (singleBindFns.has(fn)) continue; - const v = variables.find((x) => x.fnNumber === fn); + const v = varsByFn.get(fn); if (!v) continue; headerLines.push(`^FN${fn}${fdField(v.defaultValue)}`); }