Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ The **ZPL output** panel at the bottom shows the generated ZPL. It updates in re

The panel is a code editor with ZPL syntax highlighting; overlong graphic payloads are folded. Selecting an object on the canvas highlights the lines it emits, and a notice above the code flags printer-persistent setup commands and device actions.

The first change starts an edit session: canvas and panel editing locks (paging still works) while the canvas previews the parsed buffer live. An invalid buffer (unbalanced `^XA`/`^XZ`, too large) shows the refusal reason and is never applied.
The first change starts an edit session: canvas and panel editing locks (paging still works) while the canvas previews the parsed buffer live. An invalid buffer (unbalanced `^XA`/`^XZ`, too large) shows the refusal reason and is never applied; an imbalance is also marked at the command that caused it.

Leaving the panel applies the edit; a dialog asks first when the reparse reports findings or would drop designer-only state (groups, names, locked/hidden, export exclusions, variable bindings). `Esc` discards; a changed buffer confirms first. An applied edit counts as a new import, so [Import guarantees](#import-guarantees) apply to the result.

Expand Down
31 changes: 30 additions & 1 deletion THIRD-PARTY-LICENSES.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ the same license). Source: https://github.com/gs1/gs1-syntax-dictionary
```
## npm dependencies

62 production packages ship in the bundle, each listed with its
63 production packages ship in the bundle, each listed with its
SPDX license and the verbatim license text from its package.

### @codemirror/commands 6.11.0
Expand Down Expand Up @@ -301,6 +301,35 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
```

### @codemirror/lint 6.9.7

- License: MIT
- Author: Marijn Haverbeke

```
MIT License

Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
```

### @codemirror/state 6.7.1

- License: MIT
Expand Down
9 changes: 9 additions & 0 deletions e2e/sourceEdit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,15 @@ test('a multi-page edit applied by an outside click stays on the current page',
await expect(page.getByText('2 / 2')).toBeVisible();
});

test('an unbalanced draft shows a positioned lint error while typing', async ({ page }) => {
await page.goto('/');
const output = await importSample(page);
// The stray ^XZ is the TRAILING one, so an implementation anchoring every
// kind at offset 0 would mark the opening ^XA and fail here.
await retype(page, output, '^XA^FO10,10^A0N,30,30^FDX^FS^XZ^XZ');
await expect(page.locator('.cm-lintRange-error')).toHaveText('^XZ');
});

test('Escape asks to discard and restores the export', async ({ page }) => {
await page.goto('/');
const output = await importSample(page);
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"dependencies": {
"@codemirror/commands": "^6.11.0",
"@codemirror/language": "^6.12.4",
"@codemirror/lint": "^6.9.7",
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.9",
"@dnd-kit/core": "^6.3.1",
Expand Down
5 changes: 2 additions & 3 deletions packages/core/src/lib/fontCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,8 @@ function dataUrlBytes(dataUrl: string): Uint8Array | undefined {
/** Resolves true when the face registered, false when the bytes were rejected
* (invalid font, unsupported outlines) or the API is unavailable. Callers on
* the interactive upload path surface the failure; background paths ignore it.
* Binary source, NOT `url(data:...)`: WKWebView enforces font-src even on
* FontFace loads, and byte registration plus `font-src data:` is the combo
* that works across engines (#356). */
* Binary source, NOT `url(data:...)`: the url form is a font-src fetch, which
* the desktop CSP blocks (no `data:` there, deliberately). */
async function registerFontFace(entry: CachedFont): Promise<boolean> {
try {
const bytes = dataUrlBytes(entry.dataUrl);
Expand Down
24 changes: 6 additions & 18 deletions packages/core/src/lib/labelGeometry/textRenderMetrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,6 @@ export interface TextRenderMetrics {
/** Canvas-only resolved bitmap device font (A-H); single source for
* consumers that need the id beside the metrics (line pitch, bounds). */
deviceFontId?: string;
/** Style the render and the measure share. Bold only for the PrintLab
* Font-0 substitute (real bold face, calibrated so); device substitutes
* and custom TTFs render at their real weight like the printer, and
* synthetic bold hollows out under WebKit's 'difference' (#356). */
fontStyle: "normal" | "bold";
}

/** Parser feeds these (no obj at parse time). */
Expand All @@ -45,6 +40,9 @@ export interface TextMetricsInput {
/** Canvas-only bitmap device-font size/scale (A-H snap to magnifications). */
fontSizeDots?: number;
scaleXOverride?: number;
/** Canvas-only: device fonts paint at this weight, so inkWidth must measure
* it too (emit/parse omit it and keep the default bold). */
measureFontStyle?: string;
/** Canvas-only device-font inter-char spacing folded into inkWidth so the
* reverse-box / ^FPR width matches the letter-spaced render. */
letterSpacingDots?: number;
Expand All @@ -64,27 +62,16 @@ export function computeTextRenderMetrics(input: TextMetricsInput): TextRenderMet
input.scaleXOverride ?? (fontWidth > 0 ? fontWidth / fontHeight : 1);
const measureSizeDots =
input.fontSizeDots ?? fontHeight / ZPL_FONT_HEIGHT_TO_CSS_RATIO;
// Style follows the RESOLVED face, so a referenced-but-unloaded TTF that
// falls back to the substitute keeps its calibrated bold (metrics feed the
// anchor math); a real face renders and measures at its own weight.
const fontStyle: "normal" | "bold" = fontFamily === DEFAULT_FONT_FAMILY ? "bold" : "normal";
const glyphWidthDots = measureInkWidthPx(
content,
measureSizeDots,
fontFamily,
fontStyle,
input.measureFontStyle,
);
const spacingDots =
(input.letterSpacingDots ?? 0) * Math.max(0, content.length - 1);
const inkWidthDots = (glyphWidthDots + spacingDots) * fontScaleX;
return {
content,
fontFamily,
fontScaleX,
inkWidthDots,
fontSizeDots: input.fontSizeDots,
fontStyle,
};
return { content, fontFamily, fontScaleX, inkWidthDots, fontSizeDots: input.fontSizeDots };
}

/** Ink extent feeding the FO/I and FO/B anchor shift: cell grid for bitmap
Expand Down Expand Up @@ -149,6 +136,7 @@ export function getTextRenderMetrics(
fontFamilyOverride,
fontSizeDots: device?.fontSizeDots,
scaleXOverride: device?.scaleX,
measureFontStyle: device ? "normal" : undefined,
letterSpacingDots: device?.letterSpacingDots,
}),
yOffsetDots: device?.yOffsetDots,
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/lib/zplImportService.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { parseZPL, type ImportFinding, type ImportReport } from "./zplParser";
import { parseZPL, type ImportFinding, type ImportReport, type UnbalancedFormat } from "./zplParser";
import { replayRiskFindings, dedupCommandsByKind } from "./importReport";
import { dropPageOverlays } from "./pageOverlay";
import { stripDrivePrefix } from "./customFonts";
Expand Down Expand Up @@ -28,8 +28,8 @@ export interface ZplImportResult {
* single-label design shows only once, so other pages render/preflight
* wrong. Interactive import ignores it; the MCP tools reject on it. */
mixedPageGeometry: boolean;
/** Passed through from the parser; the source editor refuses on it. */
unbalancedFormat: boolean;
/** First imbalance from the parser; the source editor refuses on it. */
unbalanced: UnbalancedFormat | null;
}

export function importZplText(zpl: string, dpmm: number): ZplImportResult {
Expand Down Expand Up @@ -261,7 +261,7 @@ export function importZplText(zpl: string, dpmm: number): ZplImportResult {
variables,
report,
mixedPageGeometry: r.mixedPageGeometry || jmDiverges,
unbalancedFormat: r.unbalancedFormat,
unbalanced: r.unbalanced,
};
}

Expand Down
38 changes: 27 additions & 11 deletions packages/core/src/lib/zplParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {
ImportFinding,
ParsedPage,
ParsedZPL,
UnbalancedFormat,
Wildcard,
} from "./zplParser/types";
export type {
Expand All @@ -34,6 +35,7 @@ export type {
ImportReport,
ParsedPage,
ParsedZPL,
UnbalancedFormat,
} from "./zplParser/types";
import type { LabelObject } from "../types/Group";
import type { Variable } from "../types/Variable";
Expand Down Expand Up @@ -212,17 +214,13 @@ export function parseZPL(
const handlers: Record<string, Handler> = {
XA: (p, rest, cmd) => {
commitPendingReverseBg();
if (formatOpen) unbalancedFormat = true;
formatOpen = true;
s.format.embedChar = "#";
s.format.clockChars = { ...DEFAULT_CLOCK_CHARS };
s.format.inFormatHead = true;
resetComment(p, rest, cmd);
},
XZ(p, rest, cmd) {
commitPendingReverseBg();
if (!formatOpen) unbalancedFormat = true;
formatOpen = false;
// Between formats there is no head: a ^JM out here is neither read by the
// next format's lookahead nor rewritable, so it must not be taken for one.
s.format.inFormatHead = false;
Expand Down Expand Up @@ -270,11 +268,12 @@ export function parseZPL(
// replay relies on). Page 0 opens at offset 0 and owns any preamble.
const pages: ParsedPage[] = [];
let mixedPageGeometry = false;
// Tracked at the dispatch handlers, the only ^CC-aware place: a format left
// open never prints (spec p. 375), and an overlay would replay the broken
// bytes verbatim, so callers need to know.
let formatOpen = false;
let unbalancedFormat = false;
// The open format's ^XA, or null: one variable, so "a format is open" and
// "where it opened" cannot disagree. A format left open never prints (spec
// p. 375) and its overlay would replay the broken bytes, so callers refuse.
let openXa: { at: number; cmd: string } | null = null;
// First imbalance only: the best anchor to point the editor at.
let unbalanced: UnbalancedFormat | null = null;
let lastPageW: number | undefined;
let lastPageH: number | undefined;

Expand Down Expand Up @@ -460,6 +459,23 @@ export function parseZPL(
// These findings name bytes that replay verbatim, so report the source prefix.
if (replayRiskCodes.has(cmd)) replayRisk.push(`${zpl[start]}${cmd}`);
else if (deviceActionCodes.has(cmd)) deviceAction.push(`${zpl[start]}${cmd}`);
// Balance bookkeeping needs the token offset and the state the handler is
// about to flip, so it sits here rather than in the two handlers.
if (cmd === "XA" || cmd === "XZ") {
const cmdText = zpl.slice(start, start + 3);
if (cmd === "XZ") {
if (!openXa) unbalanced ??= { kind: "strayXz", at: start, cmd: cmdText };
openXa = null;
} else {
// A second ^XA is itself correct; the defect is the format it
// interrupts, so the error points at THAT opener and carries this
// offset as the place the missing ^XZ belongs.
if (openXa) {
unbalanced ??= { kind: "unclosedXa", ...openXa, related: { at: start, cmd: cmdText } };
}
openXa = { at: start, cmd: cmdText };
}
}
const handler = handlers[cmd] ?? wildcards.find((w) => w.matches(cmd))?.handle;
if (handler) {
const reverseBefore = s.reverseBg;
Expand Down Expand Up @@ -641,7 +657,7 @@ export function parseZPL(
// Close the last page (also the only one for single-block or bare streams);
// its serial-orphan sweep runs inside.
closePage(zpl.length);
if (formatOpen) unbalancedFormat = true;
if (openXa) unbalanced ??= { kind: "unclosedXa", ...openXa };

// Apply the geometry sidecar last so it wins over ^PW/^LL-derived mm and
// restores dpmm, which plain ZPL can't carry.
Expand All @@ -654,7 +670,7 @@ export function parseZPL(
return {
pages,
mixedPageGeometry,
unbalancedFormat,
unbalanced,
labelConfig,
printerProfile,
uploadedFontPaths: [...s.fonts.downloadedFontPaths],
Expand Down
18 changes: 15 additions & 3 deletions packages/core/src/lib/zplParser/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,18 @@ export interface ParsedPage {
bare?: boolean;
}

/** First ^XA/^XZ imbalance. `at` is the command's offset in the source
* string; `cmd` its text (the remapped prefix under ^CC), so diagnostics
* never re-lex the buffer. */
export interface UnbalancedFormat {
kind: 'strayXz' | 'unclosedXa';
at: number;
cmd: string;
/** Where the missing ^XZ belongs: the ^XA that interrupts the open format.
* Absent when the format simply runs to the end of the stream. */
related?: { at: number; cmd: string };
}

export interface ParsedZPL {
/** One entry per ^XA block (single-pass; stream-persistent state like
* ^MU/^CC/^CW carries across pages). At least one page, possibly empty. */
Expand All @@ -83,9 +95,9 @@ export interface ParsedZPL {
* only one size, so callers reject or warn. Diverging ^JM densities are
* folded into the import service's flag of the same name, not this one. */
mixedPageGeometry: boolean;
/** A ^XA never met its ^XZ (or vice versa): the stream never prints and an
* overlay would replay the broken bytes; interactive callers refuse. */
unbalancedFormat: boolean;
/** First ^XA/^XZ imbalance, or null when balanced: such a stream never
* prints and an overlay would replay the broken bytes; callers refuse. */
unbalanced: UnbalancedFormat | null;
labelConfig: Partial<LabelConfig>;
/** EEPROM-persistent printer-state extracted from any Setup-Script
* commands in the stream (^JZ, ^JT, ~TA, ^ST, ^KD, ^SL, ^KL, ^SE,
Expand Down
31 changes: 31 additions & 0 deletions packages/core/src/lib/zplSourceEdit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,37 @@ describe("the current profile survives an apply", () => {
});
});

describe("unbalanced position and kind", () => {
const refusalOf = (text: string) => {
const plan = prepareSourceApply({ text, current: current() });
expect(plan.ok).toBe(false);
return !plan.ok && plan.reason === "unbalanced" ? plan.unbalanced : undefined;
};

it("stray ^XZ points at the ^XZ", () => {
const text = "^XZ^XA^FO10,10^A0N,30,30^FDX^FS^XZ";
expect(refusalOf(text)).toEqual({ kind: "strayXz", at: 0, cmd: "^XZ" });
});

it("a second ^XA blames the format it interrupts, not itself", () => {
// The user forgot the ^XZ between two labels; deleting the second ^XA
// would fuse them, so the marker points at the unterminated opener.
const text = "^XA^FDa^FS^XA^FDb^FS^XZ";
expect(refusalOf(text)).toEqual({
kind: "unclosedXa",
at: 0,
cmd: "^XA",
// Where the missing ^XZ belongs, a whole label away from the opener.
related: { at: text.indexOf("^XA", 1), cmd: "^XA" },
});
});

it("unclosed format points at its opening ^XA", () => {
const text = "^XA^FDx^FS^XZ\n^XA^FO10,10^A0N,30,30^FDy^FS";
expect(refusalOf(text)).toEqual({ kind: "unclosedXa", at: text.indexOf("^XA", 3), cmd: "^XA" });
});
});

describe("format balance", () => {
it("refuses unbalanced ^XA/^XZ instead of baking broken bytes into the overlay", () => {
const cases = [
Expand Down
24 changes: 15 additions & 9 deletions packages/core/src/lib/zplSourceEdit.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { importZplText, mergeSetupFonts, replaceImportLabel } from "./zplImportService";
import type { ImportReport } from "./zplParser";
import type { ImportReport, UnbalancedFormat } from "./zplParser";
import type { LabelConfig } from "../types/LabelConfig";
import type { PrinterProfile } from "../types/PrinterProfile";
import type { Page } from "../types/Group";
Expand Down Expand Up @@ -55,13 +55,17 @@ export interface SourceApplyInput {
current: SourceDocumentState;
}

export type SourceApplyPlan =
| {
ok: false;
reason: "empty" | "noContent" | "blobLine" | "tooLarge" | "unbalanced" | "tooManyPages";
command?: string;
}
| SourceApplyOk;
/** A refusal plus what the editor needs to point at it. The plan's refusal
* arm IS this shape, so callers store it without re-projecting fields. */
export type SourceRefusalInfo =
| { reason: "empty" | "noContent" | "tooLarge" | "tooManyPages" }
| { reason: "blobLine"; command?: string }
| { reason: "unbalanced"; unbalanced: UnbalancedFormat };

export type SourceApplyPlan = ({ ok: false } & SourceRefusalInfo) | SourceApplyOk;

/** The refusal vocabulary, shared by every surface that names one. */
export type SourceRefusal = SourceRefusalInfo['reason'];

export interface SourceApplyOk {
ok: true;
Expand Down Expand Up @@ -101,7 +105,9 @@ export function prepareSourceApply(input: SourceApplyInput): SourceApplyPlan {
const imported = importZplText(input.text, current.label.dpmm);
// The parser is the only ^CC-aware lexer, so the balance verdict is its: an
// open format never prints, and its overlay would replay the broken bytes.
if (imported.unbalancedFormat) return { ok: false, reason: "unbalanced" };
if (imported.unbalanced) {
return { ok: false, reason: "unbalanced", unbalanced: imported.unbalanced };
}
if (imported.pages.length > MAX_SOURCE_PAGES) return { ok: false, reason: "tooManyPages" };
const objectCount = imported.pages.reduce((s, p) => s + p.objects.length, 0);
if (
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp-server/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ function oversizeError(zpl: string): ToolError | null {
* objects/pages, or nothing at all (a bare stream would otherwise pass as an
* empty design and fail much later). */
function importRejection(imported: ZplImportResult): ToolError | null {
if (imported.unbalancedFormat) {
if (imported.unbalanced) {
return {
ok: false,
errors: ["The stream's ^XA/^XZ do not balance; an unterminated format never prints."],
Expand Down
Loading
Loading