From c8fe83e1501ca54801031f43b34bab7b29c59ae8 Mon Sep 17 00:00:00 2001 From: u8array Date: Wed, 6 May 2026 17:30:44 +0200 Subject: [PATCH 1/3] feat(parser): accumulate consecutive ^FX comments instead of overwriting Hand-written ZPL often splits a logical comment across several ^FX lines before the field they describe. Previously each ^FX overwrote the pending comment, so only the last line reached the imported object. Accumulate them with newline separators; XA/XZ still reset at label boundaries. --- src/lib/zplParser.test.ts | 33 +++++++++++++++++++++++++++++++++ src/lib/zplParser.ts | 13 +++++++++++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/lib/zplParser.test.ts b/src/lib/zplParser.test.ts index 1c301dfc..6ea69f5f 100644 --- a/src/lib/zplParser.test.ts +++ b/src/lib/zplParser.test.ts @@ -192,6 +192,39 @@ describe('parseZPL — ^FX comment', () => { expect(objects).toHaveLength(1); expect(skipped.some((s) => s.startsWith('^FX'))).toBe(false); }); + + it('attaches a single ^FX to the next object as comment', () => { + const { objects } = parseZPL( + '^XA^FXTop section^FO10,20^A0N,30,0^FDText^FS^XZ', + 8, + ); + expect(objects[0].comment).toBe('Top section'); + }); + + it('joins consecutive ^FX lines with a newline', () => { + const { objects } = parseZPL( + '^XA^FXLine 1^FXLine 2^FO10,20^A0N,30,0^FDText^FS^XZ', + 8, + ); + expect(objects[0].comment).toBe('Line 1\nLine 2'); + }); + + it('does not bleed comments across ^XA boundaries', () => { + const { objects } = parseZPL( + '^XA^FXOnly first^XZ^XA^FO10,20^A0N,30,0^FDText^FS^XZ', + 8, + ); + expect(objects[0].comment).toBeUndefined(); + }); + + it('does not reattach a consumed comment to a later object', () => { + const { objects } = parseZPL( + '^XA^FXOnly first^FO10,20^A0N,30,0^FDFirst^FS^FO10,60^A0N,30,0^FDSecond^FS^XZ', + 8, + ); + expect(objects[0].comment).toBe('Only first'); + expect(objects[1].comment).toBeUndefined(); + }); }); // ── ^FH hex encoding ────────────────────────────────────────────────────────── diff --git a/src/lib/zplParser.ts b/src/lib/zplParser.ts index 53ce121a..df5ba71d 100644 --- a/src/lib/zplParser.ts +++ b/src/lib/zplParser.ts @@ -531,6 +531,14 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { // ── Command handler map ──────────────────────────────────────────────────── const noop: Handler = () => void 0; const resetComment: Handler = (_, rest) => { pendingComment = rest.trim() || undefined; }; + // Hand-written ZPL often splits a logical comment across several `^FX` lines + // before the field they describe. Accumulate them so each line survives on + // the imported object's comment field; XA/XZ still reset at label boundaries. + const appendComment: Handler = (_, rest) => { + const next = rest.trim(); + if (!next) return; + pendingComment = pendingComment ? `${pendingComment}\n${next}` : next; + }; const mkBrowserLimit = (prefix: string, delimiter = "^"): Handler => (_, rest) => { const tok = `${delimiter}${prefix}${rest}`; skipped.push(tok); @@ -1083,8 +1091,9 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { // ^XA / ^XZ: label start/end — reset pending comment via empty rest XA: resetComment, XZ: resetComment, - // ^FX: comment field — store for attachment to the next field object - FX: resetComment, + // ^FX: comment field — accumulate across consecutive ^FX lines so the + // assembled text reaches the next field object as one multi-line comment. + FX: appendComment, // These commands carry no canvas-design information and are silently // discarded so they do not pollute importReport.unknown. From e6daa1055b38e8caeda8e6a95704b84e358ba235 Mon Sep 17 00:00:00 2001 From: u8array Date: Wed, 6 May 2026 17:31:32 +0200 Subject: [PATCH 2/3] feat(zpl): filter ^ and ~ from comment text at input and output ^FX has no ^FH escape mechanism, so ^ or ~ inside comment text terminates the command and corrupts the surrounding ZPL. Strip them at the comment input so the user sees what will be saved, and keep the strip in the generator as a safety net. The previous output strip only covered ^; ~ would still break the structure. --- src/components/Properties/PropertiesPanel.tsx | 3 ++- src/lib/zplGenerator.ts | 5 ++--- src/registry/zplHelpers.ts | 10 ++++++++++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/components/Properties/PropertiesPanel.tsx b/src/components/Properties/PropertiesPanel.tsx index 2c23f562..2d241242 100644 --- a/src/components/Properties/PropertiesPanel.tsx +++ b/src/components/Properties/PropertiesPanel.tsx @@ -1,5 +1,6 @@ import { useLabelStore, useCurrentObjects } from "../../store/labelStore"; import { ObjectRegistry } from "../../registry"; +import { stripZplCommandChars } from "../../registry/zplHelpers"; import { dotsToMm, mmToDots } from "../../lib/coordinates"; import { mmToUnit, @@ -137,7 +138,7 @@ export function PropertiesPanel() { rows={2} value={obj.comment ?? ""} onChange={(e) => - updateObject(obj.id, { comment: e.target.value || undefined }) + updateObject(obj.id, { comment: stripZplCommandChars(e.target.value) || undefined }) } /> diff --git a/src/lib/zplGenerator.ts b/src/lib/zplGenerator.ts index d2109840..1018038b 100644 --- a/src/lib/zplGenerator.ts +++ b/src/lib/zplGenerator.ts @@ -1,5 +1,6 @@ import { mmToDots } from './coordinates'; import { ObjectRegistry } from '../registry'; +import { stripZplCommandChars } from '../registry/zplHelpers'; import type { LabelConfig } from '../types/ObjectType'; import type { LabelObject } from '../registry'; import type { Page } from '../store/labelStore'; @@ -29,9 +30,7 @@ export function generateZPL(label: LabelConfig, objects: LabelObject[]): string lines.push(...objects.map((obj) => { const zpl = ObjectRegistry[obj.type]?.toZPL(obj) ?? ''; if (!obj.comment) return zpl; - // Strip ^ to prevent breaking ZPL structure inside the comment text - const safe = obj.comment.replace(/\^/g, ''); - return `^FX${safe}\n${zpl}`; + return `^FX${stripZplCommandChars(obj.comment)}\n${zpl}`; })); if (label.printQuantity && label.printQuantity > 1) { diff --git a/src/registry/zplHelpers.ts b/src/registry/zplHelpers.ts index e0008520..50d4b959 100644 --- a/src/registry/zplHelpers.ts +++ b/src/registry/zplHelpers.ts @@ -6,6 +6,16 @@ export function fieldPos(obj: LabelObjectBase): string { return `^${cmd}${obj.x},${obj.y}`; } +/** + * Remove ZPL command/format prefixes from free-form text. `^FX` (comment) and + * other text-only contexts have no `^FH` escape mechanism, so these chars + * cannot be encoded — strip them so a stray `^` or `~` cannot terminate the + * surrounding command. + */ +export function stripZplCommandChars(s: string): string { + return s.replace(/[\^~]/g, ''); +} + const FH_DELIM = '_'; const NEEDS_FH = /[\^~]/; From 19e6f1e256f4c2f0fe14f553fef4c28d7c468f0f Mon Sep 17 00:00:00 2001 From: u8array Date: Wed, 6 May 2026 17:35:47 +0200 Subject: [PATCH 3/3] fix(test): respect noUncheckedIndexedAccess in ^FX comment tests `tsc --noEmit` (used pre-commit) and `tsc -b` (used in build) disagree on whether array index access on `objects` returns T|undefined. The build config has noUncheckedIndexedAccess on; match the existing optional-chaining pattern used elsewhere in this file. --- src/lib/zplParser.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/lib/zplParser.test.ts b/src/lib/zplParser.test.ts index 6ea69f5f..a72bb701 100644 --- a/src/lib/zplParser.test.ts +++ b/src/lib/zplParser.test.ts @@ -198,7 +198,7 @@ describe('parseZPL — ^FX comment', () => { '^XA^FXTop section^FO10,20^A0N,30,0^FDText^FS^XZ', 8, ); - expect(objects[0].comment).toBe('Top section'); + expect(objects[0]?.comment).toBe('Top section'); }); it('joins consecutive ^FX lines with a newline', () => { @@ -206,7 +206,7 @@ describe('parseZPL — ^FX comment', () => { '^XA^FXLine 1^FXLine 2^FO10,20^A0N,30,0^FDText^FS^XZ', 8, ); - expect(objects[0].comment).toBe('Line 1\nLine 2'); + expect(objects[0]?.comment).toBe('Line 1\nLine 2'); }); it('does not bleed comments across ^XA boundaries', () => { @@ -214,7 +214,7 @@ describe('parseZPL — ^FX comment', () => { '^XA^FXOnly first^XZ^XA^FO10,20^A0N,30,0^FDText^FS^XZ', 8, ); - expect(objects[0].comment).toBeUndefined(); + expect(objects[0]?.comment).toBeUndefined(); }); it('does not reattach a consumed comment to a later object', () => { @@ -222,8 +222,8 @@ describe('parseZPL — ^FX comment', () => { '^XA^FXOnly first^FO10,20^A0N,30,0^FDFirst^FS^FO10,60^A0N,30,0^FDSecond^FS^XZ', 8, ); - expect(objects[0].comment).toBe('Only first'); - expect(objects[1].comment).toBeUndefined(); + expect(objects[0]?.comment).toBe('Only first'); + expect(objects[1]?.comment).toBeUndefined(); }); });