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/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/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/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/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..5271cee4 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,33 @@ 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)) { + // 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 = byName.get(name); + return v ? resolveVariableValue(v, active, mode) : undefined; + }); + } + 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..ad0b4a51 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,74 @@ 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 } { + // 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 = 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 = varsByName.get(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 = varsByFn.get(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 +364,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/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 })} />
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/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) => 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; } /**