diff --git a/README.md b/README.md index f7908074..8299063b 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/THIRD-PARTY-LICENSES.md b/THIRD-PARTY-LICENSES.md index 19ee71d5..d10b9e14 100644 --- a/THIRD-PARTY-LICENSES.md +++ b/THIRD-PARTY-LICENSES.md @@ -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 @@ -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 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 diff --git a/e2e/sourceEdit.spec.ts b/e2e/sourceEdit.spec.ts index 60797c64..1b72a337 100644 --- a/e2e/sourceEdit.spec.ts +++ b/e2e/sourceEdit.spec.ts @@ -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); diff --git a/package.json b/package.json index f8d915f9..b28a277b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/core/src/lib/fontCache.ts b/packages/core/src/lib/fontCache.ts index 522a99bb..278ef41c 100644 --- a/packages/core/src/lib/fontCache.ts +++ b/packages/core/src/lib/fontCache.ts @@ -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 { try { const bytes = dataUrlBytes(entry.dataUrl); diff --git a/packages/core/src/lib/labelGeometry/textRenderMetrics.ts b/packages/core/src/lib/labelGeometry/textRenderMetrics.ts index 7a68f2c7..9657f51c 100644 --- a/packages/core/src/lib/labelGeometry/textRenderMetrics.ts +++ b/packages/core/src/lib/labelGeometry/textRenderMetrics.ts @@ -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). */ @@ -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; @@ -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 @@ -149,6 +136,7 @@ export function getTextRenderMetrics( fontFamilyOverride, fontSizeDots: device?.fontSizeDots, scaleXOverride: device?.scaleX, + measureFontStyle: device ? "normal" : undefined, letterSpacingDots: device?.letterSpacingDots, }), yOffsetDots: device?.yOffsetDots, diff --git a/packages/core/src/lib/zplImportService.ts b/packages/core/src/lib/zplImportService.ts index 852fee27..53d8be30 100644 --- a/packages/core/src/lib/zplImportService.ts +++ b/packages/core/src/lib/zplImportService.ts @@ -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"; @@ -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 { @@ -261,7 +261,7 @@ export function importZplText(zpl: string, dpmm: number): ZplImportResult { variables, report, mixedPageGeometry: r.mixedPageGeometry || jmDiverges, - unbalancedFormat: r.unbalancedFormat, + unbalanced: r.unbalanced, }; } diff --git a/packages/core/src/lib/zplParser.ts b/packages/core/src/lib/zplParser.ts index 3f72c503..b9bd62e3 100644 --- a/packages/core/src/lib/zplParser.ts +++ b/packages/core/src/lib/zplParser.ts @@ -26,6 +26,7 @@ import type { ImportFinding, ParsedPage, ParsedZPL, + UnbalancedFormat, Wildcard, } from "./zplParser/types"; export type { @@ -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"; @@ -212,8 +214,6 @@ export function parseZPL( const handlers: Record = { XA: (p, rest, cmd) => { commitPendingReverseBg(); - if (formatOpen) unbalancedFormat = true; - formatOpen = true; s.format.embedChar = "#"; s.format.clockChars = { ...DEFAULT_CLOCK_CHARS }; s.format.inFormatHead = true; @@ -221,8 +221,6 @@ export function parseZPL( }, 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; @@ -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; @@ -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; @@ -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. @@ -654,7 +670,7 @@ export function parseZPL( return { pages, mixedPageGeometry, - unbalancedFormat, + unbalanced, labelConfig, printerProfile, uploadedFontPaths: [...s.fonts.downloadedFontPaths], diff --git a/packages/core/src/lib/zplParser/types.ts b/packages/core/src/lib/zplParser/types.ts index fc132804..4ecb5780 100644 --- a/packages/core/src/lib/zplParser/types.ts +++ b/packages/core/src/lib/zplParser/types.ts @@ -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. */ @@ -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; /** EEPROM-persistent printer-state extracted from any Setup-Script * commands in the stream (^JZ, ^JT, ~TA, ^ST, ^KD, ^SL, ^KL, ^SE, diff --git a/packages/core/src/lib/zplSourceEdit.test.ts b/packages/core/src/lib/zplSourceEdit.test.ts index bdb5a841..52d42b74 100644 --- a/packages/core/src/lib/zplSourceEdit.test.ts +++ b/packages/core/src/lib/zplSourceEdit.test.ts @@ -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 = [ diff --git a/packages/core/src/lib/zplSourceEdit.ts b/packages/core/src/lib/zplSourceEdit.ts index 1a378b44..550c0665 100644 --- a/packages/core/src/lib/zplSourceEdit.ts +++ b/packages/core/src/lib/zplSourceEdit.ts @@ -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"; @@ -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; @@ -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 ( diff --git a/packages/mcp-server/src/tools.ts b/packages/mcp-server/src/tools.ts index 760aadc2..3b2d7d37 100644 --- a/packages/mcp-server/src/tools.ts +++ b/packages/mcp-server/src/tools.ts @@ -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."], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c4bbbac..6630b386 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@codemirror/language': specifier: ^6.12.4 version: 6.12.4 + '@codemirror/lint': + specifier: ^6.9.7 + version: 6.9.7 '@codemirror/state': specifier: ^6.7.1 version: 6.7.1 @@ -422,6 +425,9 @@ packages: '@codemirror/language@6.12.4': resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==} + '@codemirror/lint@6.9.7': + resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==} + '@codemirror/state@6.7.1': resolution: {integrity: sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==} @@ -3023,6 +3029,12 @@ snapshots: '@lezer/lr': 1.4.10 style-mod: 4.1.3 + '@codemirror/lint@6.9.7': + dependencies: + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.9 + crelt: 1.0.7 + '@codemirror/state@6.7.1': dependencies: '@marijn/find-cluster-break': 1.0.4 diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e3553162..3cb2aabd 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -22,8 +22,8 @@ } ], "security": { - "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:*; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'", - "devCsp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:5173 ws://localhost:5173; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'" + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:*; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'", + "devCsp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:5173 ws://localhost:5173; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'" } }, "plugins": { diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 362dbc1f..88ba6749 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -5,6 +5,7 @@ import { ObjectPalette } from "./Palette/ObjectPalette"; import { PaletteViewToggle } from "./Palette/PaletteViewToggle"; import { PaletteEditToggle } from "./Palette/PaletteEditToggle"; import { LabelCanvas } from "./Canvas/LabelCanvas"; +import { SourceShadowSync } from "./SourceShadowSync"; import type { LabelCanvasHandle } from "./Canvas/LabelCanvas"; import { RightSidebar } from "./RightSidebar/RightSidebar"; import { HistoryDropdown } from "./History/HistoryDropdown"; @@ -260,6 +261,7 @@ export function AppShell() { return (
+ {/* Desktop: the menu model renders as the native OS menu; undo/redo and the history timeline ride its Edit menu plus the keyboard shortcuts, and theme/language live in the settings. */} diff --git a/src/components/Canvas/KonvaObject.tsx b/src/components/Canvas/KonvaObject.tsx index c9e7e749..1bbb52e9 100644 --- a/src/components/Canvas/KonvaObject.tsx +++ b/src/components/Canvas/KonvaObject.tsx @@ -613,7 +613,10 @@ function KonvaObjectInner({ if (obj.type === "text" && textMetrics) { const p = obj.props; const { content, fontFamily, fontScaleX, fontSizePx } = textMetrics; - const fontStyle = textMetrics.fontStyle; + // Bitmap device fonts (A-H) carry their own weight via the substitute + // family (Vera Mono / Bold, OCR); forcing bold there faux-bolds them + // thicker than Labelary. Font 0 / custom uploads stay bold. + const fontStyle = textMetrics.fontSizeDots != null ? "normal" : "bold"; // Device-font position trims (cap-top / left-bearing) vs Labelary. const deviceXOffPx = dotsToPx(textMetrics.xOffsetDots ?? 0, scale, dpmm); const deviceYOffPx = dotsToPx(textMetrics.yOffsetDots ?? 0, scale, dpmm); diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index 9585e391..dc18590c 100644 --- a/src/components/Canvas/LabelCanvas.tsx +++ b/src/components/Canvas/LabelCanvas.tsx @@ -15,7 +15,6 @@ import { paletteGhostHandlers } from "./paletteGhostMonitor"; import { Stage, Layer, Group, Image as KImage, Rect, Transformer } from "react-konva"; import type Konva from "konva"; import { useLabelStore, currentObjects, currentPageLabel, getCurrentObjects, selectEditorFrozen, selectPreviewLocksEditor, selectRenderPageLabel, selectRenderObjects, selectRenderColumnMapping } from "../../store/labelStore"; -import { useSourceShadowSync } from "../../hooks/useSourceEditShadow"; import { isGroup, getAllLeaves, exportableLeaves, expandSelection, selectionTargetId, findObjectById, canDeleteSelection, canGroupSelection, canUngroupSelection, hasLockedAncestor, isSelectionLocked, type LabelObject } from "@zplab/core/types/Group"; import { pxToDots, dotsToPx, mmToDots, SCREEN_PX_PER_MM } from "@zplab/core/lib/coordinates"; import { effectiveDpmm } from "@zplab/core/types/LabelConfig"; @@ -259,7 +258,6 @@ export const LabelCanvas = forwardRef(function LabelCa } = useLabelStore(); // Everything on canvas is drawn in the current page's dot scale; // designLabel stays the MODEL's design scope for the whole-document emit. - useSourceShadowSync(); const label = useLabelStore(selectRenderPageLabel); const designLabel = useLabelStore((s) => s.label); const objects = useLabelStore(selectRenderObjects); diff --git a/src/components/Output/ZPLOutput.sourceEdit.test.tsx b/src/components/Output/ZPLOutput.sourceEdit.test.tsx index 69939ccf..4f2735c9 100644 --- a/src/components/Output/ZPLOutput.sourceEdit.test.tsx +++ b/src/components/Output/ZPLOutput.sourceEdit.test.tsx @@ -13,13 +13,14 @@ vi.mock("../../lib/clipboard", () => ({ // CodeMirror needs layout APIs jsdom lacks; the props plus the focus handle // are the whole contract (real-CM invariants live in ZplCodeMirror.test.tsx). vi.mock("./ZplCodeMirror", () => { - function MockCodeMirror({ value, onChange, ariaLabel, placeholderText, readOnly, highlightLines, ref }: { + function MockCodeMirror({ value, onChange, ariaLabel, placeholderText, readOnly, highlightLines, diagnostics, ref }: { value: string; onChange: (v: string) => void; ariaLabel: string; placeholderText: string; readOnly?: boolean; highlightLines?: ReadonlySet; + diagnostics?: readonly { from: number; to: number; message: string }[] | null; ref?: React.Ref<{ focus(): void }>; }) { const el = React.useRef(null); @@ -32,6 +33,13 @@ vi.mock("./ZplCodeMirror", () => { value={value} readOnly={readOnly} data-highlights={[...(highlightLines ?? [])].sort((a, b) => a - b).join(",")} + // `null` (keep the previous set) and an empty build are opposites in + // the real component, so the mock must not collapse them. + data-diagnostics={ + diagnostics == null + ? "null" + : JSON.stringify(diagnostics.map((d) => ({ from: d.from, to: d.to, message: d.message }))) + } onChange={(e) => onChange(e.target.value)} /> ); @@ -243,7 +251,7 @@ describe("focus leaving the panel", () => { await leavePanel(container); expect(useLabelStore.getState().sourceEdit.status).toBe("editing"); // The reason lands synchronously with the click, not 300ms later. - expect(useLabelStore.getState().sourceShadow?.refusal).toBe("unbalanced"); + expect(useLabelStore.getState().sourceShadow?.refusal?.reason).toBe("unbalanced"); // The refocus is deferred behind the pointerdown's default focus. await act(async () => { await new Promise((r) => setTimeout(r, 0)); @@ -333,6 +341,68 @@ describe("authoring into an empty document", () => { }); }); +describe("inline diagnostics", () => { + it("a stray ^XZ becomes a positioned error naming the command", async () => { + const { container } = render(); + typeDraft("^XZ^XA^FO10,10^A0N,30,30^FDX^FS^XZ"); + // The held exit pushes the refusal (incl. position) synchronously; the + // debounced shadow sync is hosted at the app root and never runs here. + await leavePanel(container); + const built = JSON.parse(editor().dataset.diagnostics ?? "null") as { + from: number; to: number; message: string; + }[]; + expect(built).toHaveLength(1); + expect(built[0]).toMatchObject({ + from: 0, + to: 3, + message: t().output.lintStrayXzFmt.replace("{cmd}", "^XZ"), + }); + }); +}); + +describe("stale diagnostics", () => { + it("withholds the build while the parse lags the buffer", () => { + // The identity check is the only thing keeping offsets from an older + // parse off a newer document. + render(); + typeDraft("^XZ^XA^FO10,10^A0N,30,30^FDX^FS^XZ"); + act(() => { + useLabelStore.getState().setSourceRefusal( + { reason: "unbalanced", unbalanced: { kind: "strayXz", at: 0, cmd: "^XZ" } }, + "^XZ^XA^FO10,10^A0N,30,30^FDX^FS^XZ", + ); + }); + expect(editor().dataset.diagnostics).not.toBe("null"); + // One more keystroke: the shadow now describes the previous text. + typeDraft("x^XZ^XA^FO10,10^A0N,30,30^FDX^FS^XZ"); + expect(editor().dataset.diagnostics).toBe("null"); + }); +}); + +describe("the refusal banner", () => { + it("drops a located claim while the parse lags, keeps a positionless one", () => { + render(); + typeDraft("^XZ^XA^FO10,10^A0N,30,30^FDX^FS^XZ"); + const stale = "^XZ^XA^FO10,10^A0N,30,30^FDX^FS^XZ"; + act(() => { + useLabelStore.getState().setSourceRefusal( + { reason: "unbalanced", unbalanced: { kind: "strayXz", at: 0, cmd: "^XZ" } }, + stale, + ); + }); + const named = t().output.lintStrayXzFmt.replace("{cmd}", "^XZ"); + expect(screen.queryByText(named)).not.toBeNull(); + // One more keystroke: the sentence names bytes the parse no longer covers. + typeDraft("x" + stale); + expect(screen.queryByText(named)).toBeNull(); + // A reason without a position stays readable through the same lag. + act(() => { + useLabelStore.getState().setSourceRefusal({ reason: "tooLarge" }, stale); + }); + expect(screen.queryByText(t().output.editSourceGateSize)).not.toBeNull(); + }); +}); + describe("outside pointerdown", () => { it("applies synchronously, so the same click acts on the unfrozen store", () => { render( diff --git a/src/components/Output/ZplCodeMirror.test.tsx b/src/components/Output/ZplCodeMirror.test.tsx index 0d677dc7..84b3dcd2 100644 --- a/src/components/Output/ZplCodeMirror.test.tsx +++ b/src/components/Output/ZplCodeMirror.test.tsx @@ -90,6 +90,141 @@ describe("ZplCodeMirror placeholder", () => { }); }); +describe("ZplCodeMirror diagnostics", () => { + it("maps string offsets to doc positions and renders the lint range", async () => { + const { forEachDiagnostic } = await import("@codemirror/lint"); + // CRLF buffer: the one CRLF before the range shifts doc positions by one. + const value = "^XA^FDx^FS\r\n^XZ^XZ"; + const strayAt = value.indexOf("^XZ^XZ") + 3; + const { container } = render( + , + ); + const view = EditorView.findFromDOM(container as HTMLElement); + expect(view).not.toBeNull(); + const seen: { from: number; to: number; message: string }[] = []; + forEachDiagnostic(view!.state, (d, from, to) => seen.push({ from, to, message: d.message })); + expect(seen).toEqual([{ from: strayAt - 1, to: strayAt + 2, message: "boom" }]); + expect(container.querySelector(".cm-lintRange-error")).not.toBeNull(); + }); + + it("clears the marker once a fresh build reports nothing", async () => { + // The early-out must skip only the no-op case; skipping the transition + // leaves a red squiggle on a buffer the user already repaired. + const { forEachDiagnostic } = await import("@codemirror/lint"); + const value = "^XA^FDx^FSstray^XZ"; + const { container, rerender } = render( + , + ); + rerender( + , + ); + const view = EditorView.findFromDOM(container as HTMLElement)!; + const seen: string[] = []; + forEachDiagnostic(view.state, (d) => seen.push(d.message)); + expect(seen).toEqual([]); + }); + + it("renders the repair site as a weaker second mark", async () => { + const { forEachDiagnostic } = await import("@codemirror/lint"); + const value = "^XA^FDa^FS^XA^FDb^FS^XZ"; + const { container } = render( + , + ); + const view = EditorView.findFromDOM(container as HTMLElement)!; + const seen: string[] = []; + forEachDiagnostic(view.state, (d) => seen.push(d.severity)); + // One problem stays one problem: the context mark must not read as a + // second error. + expect(seen).toEqual(["error", "hint"]); + expect(container.querySelector(".cm-lintRange-error")?.textContent).toBe("^XA"); + }); + + it("does not re-dispatch an identical set (an open tooltip would close)", () => { + const value = "^XA^FDx^FSstray^XZ"; + const lint = () => [{ from: 10, to: 15, severity: "error" as const, message: "boom" }]; + const { container, rerender } = render( + , + ); + const view = EditorView.findFromDOM(container as HTMLElement)!; + const dispatches = vi.spyOn(view, "dispatch" as never); + // A settled re-parse hands a fresh but equal array every 300ms. + rerender( + , + ); + expect(dispatches).not.toHaveBeenCalled(); + }); + + it("does not re-dispatch after the lag, when the settled parse marks the same bytes", () => { + // null -> CM maps the mark through the edit -> the fresh build describes + // the same bytes at shifted offsets. The editor already shows them. + const value = "^XA^FDx^FSstray^XZ"; + const { container, rerender } = render( + , + ); + const view = EditorView.findFromDOM(container as HTMLElement)!; + const shifted = "AB" + value; + rerender( + , + ); + const dispatches = vi.spyOn(view, "dispatch" as never); + rerender( + , + ); + expect(dispatches).not.toHaveBeenCalled(); + }); + + it("keeps the previous set live-mapped while the parse lags (null)", async () => { + // Re-dispatching a lagging build would re-anchor its offsets onto the + // newer doc and dim live commands. + const { forEachDiagnostic } = await import("@codemirror/lint"); + const value = "^XA^FDx^FSstray^XZ"; + const lints = [{ from: 10, to: 15, severity: "error" as const, message: "ignored" }]; + const { container, rerender } = render( + , + ); + rerender( + , + ); + const view = EditorView.findFromDOM(container as HTMLElement)!; + const seen: string[] = []; + forEachDiagnostic(view.state, (_d, from, to) => seen.push(view.state.doc.sliceString(from, to))); + expect(seen).toEqual(["stray"]); + }); +}); + describe("ZplCodeMirror history epoch", () => { it("clears the undo history when the epoch bumps", async () => { const changes: string[] = []; diff --git a/src/components/Output/ZplCodeMirror.tsx b/src/components/Output/ZplCodeMirror.tsx index e544e42c..5cfeaa1d 100644 --- a/src/components/Output/ZplCodeMirror.tsx +++ b/src/components/Output/ZplCodeMirror.tsx @@ -12,6 +12,8 @@ import { import { Annotation, Compartment, EditorState, RangeSetBuilder, Transaction, type StateEffect } from '@codemirror/state'; import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; import { codeFolding, foldService, foldGutter, foldKeymap, foldEffect, foldedRanges } from '@codemirror/language'; +import { setDiagnostics, diagnosticCount, forEachDiagnostic, type Diagnostic } from '@codemirror/lint'; +import type { SourceLint } from '../../lib/sourceDiagnostics'; import { zplLineHighlights, opaquePayloadFold, isPureCrlf } from '../../lib/zplCmHighlight'; import { MAX_LINE_RENDER } from '../../lib/zplTokenStyles'; @@ -112,8 +114,32 @@ const theme = EditorView.theme({ '.cm-zplSelectedLine': { backgroundColor: 'color-mix(in srgb, var(--color-accent) 10%, transparent)', }, + // CM's base theme paints the tooltip for a light host (no `dark` declared + // here), and the pane itself is surface-2: without the app's token plus + // elevation the popup reads as text pasted onto the code. + '.cm-tooltip': { + backgroundColor: 'var(--color-surface)', + border: '1px solid var(--color-border)', + borderRadius: '4px', + boxShadow: '0 8px 24px rgb(0 0 0 / 0.35)', + color: 'var(--color-text)', + }, + '.cm-diagnostic': { color: 'var(--color-text)' }, }); +/** Whether the editor already shows exactly these marks, in this order. */ +function sameDiagnostics(state: EditorState, next: readonly Diagnostic[]): boolean { + let i = 0; + let same = true; + forEachDiagnostic(state, (d, from, to) => { + const n = next[i++]; + if (!n || n.from !== from || n.to !== to || n.severity !== d.severity || n.message !== d.message) { + same = false; + } + }); + return same && i === next.length; +} + function highlightDecorations(state: EditorState, lines: ReadonlySet): DecorationSet { const builder = new RangeSetBuilder(); for (const idx of [...lines].sort((a, b) => a - b)) { @@ -129,6 +155,12 @@ function highlightDecorations(state: EditorState, lines: ReadonlySet): D const NO_LINES: ReadonlySet = new Set(); +/** Total map, so a new semantic severity cannot silently render as a hint. */ +const CM_SEVERITY: Record = { + error: 'error', + related: 'hint', +}; + // Compartment payloads built in ONE place each, so mount and reconfigure // cannot silently drift apart. const readOnlyExt = (ro: boolean) => [EditorState.readOnly.of(ro), EditorView.editable.of(!ro)]; @@ -155,6 +187,7 @@ export default function ZplCodeMirror({ readOnly = false, highlightLines = NO_LINES, historyEpoch = 0, + diagnostics = null, ref, }: { value: string; @@ -168,6 +201,9 @@ export default function ZplCodeMirror({ /** Bumping clears the undo history (minimal-splice syncs keep old events * mappable, so without this an undo could resurrect a closed session). */ historyEpoch?: number; + /** Framework-neutral lints in STRING offsets of `value`; converted here. + * `null` keeps the previous set, live-mapped through edits. */ + diagnostics?: readonly SourceLint[] | null; ref?: Ref; }) { const host = useRef(null); @@ -289,6 +325,43 @@ export default function ZplCodeMirror({ } }, [value]); + // After the value sync, so positions land on the fresh doc. Counting CRLFs + // is facet-independent: CM collapses each to ONE position either way. + useEffect(() => { + const view = viewRef.current; + if (!view || diagnostics == null) return; + const lints = diagnostics; + // Dispatching installs lint's extensions and rebuilds the CRLF index, so + // a clean buffer (the common case) must not pay for an empty set. + if (lints.length === 0 && diagnosticCount(view.state) === 0) return; + const crlfIdx: number[] = []; + for (let i = value.indexOf('\r\n'); i !== -1; i = value.indexOf('\r\n', i + 2)) crlfIdx.push(i); + // Binary search, not a running cursor: the mapping must not depend on the + // order the lints arrive in, and a scan per lint is quadratic on a + // CRLF-heavy buffer. + const toDocPos = (offset: number): number => { + let lo = 0; + let hi = crlfIdx.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if ((crlfIdx[mid] ?? 0) < offset) lo = mid + 1; + else hi = mid; + } + return Math.min(offset - lo, view.state.doc.length); + }; + const mapped: Diagnostic[] = lints.map((d) => ({ + from: toDocPos(d.from), + to: toDocPos(d.to), + severity: CM_SEVERITY[d.severity], + message: d.message, + })); + // Compared against what the editor HOLDS: a lagging build is skipped and + // CM maps its marks through edits, so a remembered key would go stale. + // Re-dispatching an unchanged set closes an open tooltip under the pointer. + if (sameDiagnostics(view.state, mapped)) return; + view.dispatch(setDiagnostics(view.state, mapped)); + }, [diagnostics, value]); + useEffect(() => { const view = viewRef.current; if (!view) return; diff --git a/src/components/Output/ZplSourceEditor.tsx b/src/components/Output/ZplSourceEditor.tsx index b62294fb..6f3df46b 100644 --- a/src/components/Output/ZplSourceEditor.tsx +++ b/src/components/Output/ZplSourceEditor.tsx @@ -3,16 +3,25 @@ import { hasEditorLoss } from '@zplab/core/lib/editorStateDiff'; import { prepareSourceApply } from '@zplab/core/lib/zplSourceEdit'; import type { SourceApplyOk } from '@zplab/core/lib/zplSourceEdit'; import { sourceRefusalText, type SourceRefusal } from '../../lib/sourceEditText'; -import { useLabelStore, selectPreviewLocksEditor, selectSourceDocumentState } from '../../store/labelStore'; +import { + useLabelStore, + selectPreviewLocksEditor, + selectShadowDraft, + selectShadowRefusal, + selectSourceDocumentState, +} from '../../store/labelStore'; import type { SourceEditMode } from '../../store/slices/sourceEditSlice'; import { SourceApplyConfirmDialog } from './SourceApplyConfirmDialog'; import { ConfirmDialog } from '../ui/ConfirmDialog'; import { useSessionExit } from '../../hooks/useSessionExit'; import { useT } from '../../hooks/useT'; import ZplCodeMirror, { type ZplCodeMirrorHandle } from './ZplCodeMirror'; +import { buildSourceDiagnostics, type SourceLint } from '../../lib/sourceDiagnostics'; type SessionState = Extract; +const NO_DIAGNOSTICS: readonly SourceLint[] = []; + /** The always-mounted source pane: the first modifying keystroke seeds a * session from the shown export (focus is the mode: one person either types * here or works the canvas). Leaving the panel applies; Escape discards. */ @@ -30,7 +39,7 @@ export function ZplSourceEditor({ value: string; crlfKey: boolean; /** Gate refusal of the shown export; makes the pane read-only. */ - gateRefusal: SourceRefusal | null; + gateRefusal: Exclude | null; highlightedLines: ReadonlySet; panelRef: RefObject; }) { @@ -40,6 +49,18 @@ export function ZplSourceEditor({ const editorRef = useRef(null); const gateMsg = gateRefusal !== null ? sourceRefusalText(gateRefusal, t) : null; + const shadowRefusal = useLabelStore(selectShadowRefusal); + const shadowDraft = useLabelStore(selectShadowDraft); + // Offsets only describe the text they were parsed from, so a lagging shadow + // hands null and the editor keeps mapping its previous set; outside a + // session there is nothing left to map. + const diagnostics: readonly SourceLint[] | null = + session === null + ? NO_DIAGNOSTICS + : shadowDraft === value + ? buildSourceDiagnostics(shadowRefusal, t) + : null; + // The text history belongs to the session: clear it when one ENDS, or an // undo in the still-focused editor could resurrect the closed buffer. // Session start stays undoable. @@ -73,6 +94,7 @@ export function ZplSourceEditor({ highlightLines={highlightedLines} historyEpoch={historyEpoch} placeholderText={t.output.editSourcePlaceholder} + diagnostics={diagnostics} />
{gateMsg !== null && ( @@ -116,10 +138,18 @@ function SessionChrome({ // in the store, the in-flight plan does not survive its session anyway. const [pendingPlan, setPendingPlan] = useState(null); const [confirmDiscard, setConfirmDiscard] = useState(false); - // The shadow slot is the ONE refusal source; it follows every edit, so it - // can never go stale the way a local error state did. - const liveRefusal = useLabelStore((s) => s.sourceShadow?.refusal ?? null); - const liveMsg = liveRefusal !== null ? sourceRefusalText(liveRefusal, t) : null; + const liveRefusal = useLabelStore(selectShadowRefusal); + const liveDraft = useLabelStore(selectShadowDraft); + // A located imbalance names bytes, so it may only be quoted while the parse + // still describes the buffer; the positionless reasons hold either way. + const liveMsg = + liveRefusal === null + ? null + : liveRefusal.reason !== 'unbalanced' + ? sourceRefusalText(liveRefusal.reason, t) + : liveDraft === session.draft + ? (buildSourceDiagnostics(liveRefusal, t)[0]?.message ?? null) + : null; // Whitespace over an empty baseline authored nothing: exits treat it as // clean, or a stray Space traps the user in an unappliable held session. @@ -153,7 +183,7 @@ function SessionChrome({ }); if (!plan.ok) { // The debounced shadow sync would lag the click by up to 300ms. - state.setSourceRefusal(plan.reason); + state.setSourceRefusal(plan, session.draft); // Deferred past the pointerdown's default focus, so Escape keeps working. setTimeout(focusEditor, 0); return false; diff --git a/src/components/SourceShadowSync.tsx b/src/components/SourceShadowSync.tsx new file mode 100644 index 00000000..539007ef --- /dev/null +++ b/src/components/SourceShadowSync.tsx @@ -0,0 +1,9 @@ +import { useSourceShadowSync } from '../hooks/useSourceEditShadow'; + +/** Effect-only host for the parsed draft, which feeds the canvas preview AND + * the editor's diagnostics: owned above both, rendering nothing so the + * per-keystroke draft stays out of the shell's render. */ +export function SourceShadowSync(): null { + useSourceShadowSync(); + return null; +} diff --git a/src/hooks/useSourceEditShadow.test.tsx b/src/hooks/useSourceEditShadow.test.tsx index 5050195c..99a35fe2 100644 --- a/src/hooks/useSourceEditShadow.test.tsx +++ b/src/hooks/useSourceEditShadow.test.tsx @@ -139,7 +139,32 @@ describe("useSourceShadowSync", () => { rerender(); await flushParse(); expect(shadowObjects()).toHaveLength(1); - expect(useLabelStore.getState().sourceShadow?.refusal).toBe("unbalanced"); + const refusal = useLabelStore.getState().sourceShadow?.refusal; + expect(refusal?.reason).toBe("unbalanced"); + // One shape per verdict: the plan's own discriminant must not ride along, + // or the slot holds two different objects for the same state. + expect(refusal && "ok" in refusal).toBe(false); + }); + + it("keeps a refusal the exit pushed for this very draft", () => { + // The exit reports synchronously and HOLDS the session; the debounce still + // armed for the same text must not blank that verdict. + const draft = "^XA^FO10,10^A0N,30,30^FDa^FS"; + const { rerender } = renderHook(() => useSourceShadowSync()); + act(() => { + useLabelStore.setState({ sourceEdit: session(draft, "") }); + }); + rerender(); + act(() => { + useLabelStore.getState().setSourceRefusal( + { reason: "unbalanced", unbalanced: { kind: "unclosedXa", at: 0, cmd: "^XA" } }, + draft, + ); + }); + act(() => { + vi.advanceTimersByTime(350); + }); + expect(useLabelStore.getState().sourceShadow?.refusal?.reason).toBe("unbalanced"); }); it("clears on session end and clamps the render page index", async () => { diff --git a/src/hooks/useSourceEditShadow.ts b/src/hooks/useSourceEditShadow.ts index 5d84ddc8..eaf306df 100644 --- a/src/hooks/useSourceEditShadow.ts +++ b/src/hooks/useSourceEditShadow.ts @@ -25,18 +25,17 @@ export function useSourceShadowSync(): void { const sourceEdit = useLabelStore((s) => s.sourceEdit); const draft = sourceEdit.status === "editing" ? sourceEdit.draft : null; const baseline = sourceEdit.status === "editing" ? sourceEdit.baseline : null; - useEffect(() => { // Session-end actions clear the shadow themselves. if (draft === null || baseline === null) return; const shadow = useLabelStore.getState().setSourceShadow; if (draft === baseline) { - const id = setTimeout(() => shadow({ doc: null, refusal: null }), 0); + const id = setTimeout(() => shadow({ doc: null, refusal: null, draft }), 0); return () => clearTimeout(id); } if (draft.length > MAX_SOURCE_CHARS) { const id = setTimeout(() => { - useLabelStore.getState().setSourceRefusal("tooLarge"); + useLabelStore.getState().setSourceRefusal({ reason: "tooLarge" }, draft); }, 0); return () => clearTimeout(id); } @@ -57,17 +56,20 @@ export function useSourceShadowSync(): void { columnMapping: plan.next.columnMapping, }, refusal: null, + draft, }); } else if ( baseline === '' && (plan.reason === 'empty' || plan.reason === 'noContent' || plan.reason === 'unbalanced') ) { - // Authoring from scratch, every prefix of a label is "broken": a live - // banner until the closing ^XZ would make error the default state. - // An exit attempt still surfaces the reason (synchronous push there). - s.setSourceRefusal(null); + // Authoring from scratch, every prefix of a label is "broken": live + // complaints would make error the default state. An exit attempt + // reports it, and its verdict must survive this branch. + if (useLabelStore.getState().sourceShadow?.draft !== draft) { + s.setSourceRefusal(null, draft); + } } else { - s.setSourceRefusal(plan.reason); + s.setSourceRefusal(plan, draft); } }, DEBOUNCE_MS); return () => clearTimeout(id); diff --git a/src/lib/fontCache.test.ts b/src/lib/fontCache.test.ts index f6528692..ebc5742f 100644 --- a/src/lib/fontCache.test.ts +++ b/src/lib/fontCache.test.ts @@ -41,7 +41,8 @@ describe('fontCache', () => { // ── loadFontFile ───────────────────────────────────────────────────────────── it('registers the FontFace from bytes, never a url() source', async () => { - // Regression #356; the why lives at registerFontFace's doc header. + // Regression (#356 comment): a url(data:...) source is a font-src fetch, + // which the desktop CSP blocks; every upload failed there. const sources: unknown[] = []; const Original = globalThis.FontFace; class CapturingFontFace { diff --git a/src/lib/labelGeometry/textRenderMetrics.test.ts b/src/lib/labelGeometry/textRenderMetrics.test.ts index 34ee6f81..9dba1a28 100644 --- a/src/lib/labelGeometry/textRenderMetrics.test.ts +++ b/src/lib/labelGeometry/textRenderMetrics.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { computeTextRenderMetrics, getTextRenderMetrics } from "@zplab/core/lib/labelGeometry/textRenderMetrics"; +import { getTextRenderMetrics } from "@zplab/core/lib/labelGeometry/textRenderMetrics"; import type { LabelObject } from "@zplab/core/types/Group"; import type { LabelConfig } from "@zplab/core/types/LabelConfig"; @@ -74,34 +74,3 @@ describe("getTextRenderMetrics — device font resolution", () => { expect(m!.content).toBe("Abc"); }); }); - -describe("computeTextRenderMetrics fontStyle", () => { - it("derives the style from the RESOLVED face", async () => { - // Only the PrintLab substitute is bold (calibrated so); see #356. - expect(computeTextRenderMetrics({ content: "X", fontHeight: 30, fontWidth: 0 }).fontStyle) - .toBe("bold"); - // Referenced but NOT loaded: falls back to the substitute, which must - // keep its calibrated bold or the anchor metrics measure the wrong face. - expect( - computeTextRenderMetrics({ - content: "X", fontHeight: 30, fontWidth: 0, printerFontName: "MISSING.TTF", - }).fontStyle, - ).toBe("bold"); - const { loadFontBytes, removeFont } = await import("@zplab/core/lib/fontCache"); - await loadFontBytes(new Uint8Array([1, 2, 3]), "DINALTERNATE.TTF"); - try { - expect( - computeTextRenderMetrics({ - content: "X", fontHeight: 30, fontWidth: 0, printerFontName: "DINALTERNATE.TTF", - }).fontStyle, - ).toBe("normal"); - } finally { - removeFont("DINALTERNATE.TTF"); - } - expect( - computeTextRenderMetrics({ - content: "X", fontHeight: 30, fontWidth: 0, fontFamilyOverride: "'Vera Mono'", - }).fontStyle, - ).toBe("normal"); - }); -}); diff --git a/src/lib/sourceDiagnostics.test.ts b/src/lib/sourceDiagnostics.test.ts new file mode 100644 index 00000000..45f57dcb --- /dev/null +++ b/src/lib/sourceDiagnostics.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { buildSourceDiagnostics } from "./sourceDiagnostics"; +import { formatTemplate } from "./formatTemplate"; +import { fallbackTranslations as en } from "../locales"; + +describe("buildSourceDiagnostics", () => { + it("names each imbalance kind with the bytes it found", () => { + const of = (ub: { kind: "strayXz" | "unclosedXa"; at: number; cmd: string }) => + buildSourceDiagnostics({ reason: "unbalanced", unbalanced: ub }, en); + // The generic banner told the user to ADD what the diagnostic points at. + expect(of({ kind: "strayXz", at: 0, cmd: "#XZ" })).toEqual([{ + from: 0, to: 3, severity: "error", + message: formatTemplate(en.output.lintStrayXzFmt, { cmd: "#XZ" }), + }]); + expect(of({ kind: "unclosedXa", at: 4, cmd: "#XA" })[0]?.message) + .toBe(formatTemplate(en.output.lintUnclosedXaFmt, { cmd: "#XA" })); + }); + + it("adds the repair site as a weaker second mark", () => { + const lints = buildSourceDiagnostics({ + reason: "unbalanced", + unbalanced: { kind: "unclosedXa", at: 0, cmd: "^XA", related: { at: 40, cmd: "^XA" } }, + }, en); + expect(lints.map((l) => [l.from, l.severity])).toEqual([[0, "error"], [40, "related"]]); + expect(lints[1]?.message).toBe(formatTemplate(en.output.lintStillOpenHereFmt, { cmd: "^XA" })); + }); + + it("stays silent for a refusal without a located command", () => { + expect(buildSourceDiagnostics({ reason: "tooLarge" }, en)).toEqual([]); + expect(buildSourceDiagnostics(null, en)).toEqual([]); + }); +}); diff --git a/src/lib/sourceDiagnostics.ts b/src/lib/sourceDiagnostics.ts new file mode 100644 index 00000000..c48d1310 --- /dev/null +++ b/src/lib/sourceDiagnostics.ts @@ -0,0 +1,50 @@ +import type { UnbalancedFormat } from '@zplab/core/lib/zplParser'; +import type { SourceRefusalInfo } from '@zplab/core/lib/zplSourceEdit'; +import { formatTemplate } from './formatTemplate'; +import type { Translations } from '../locales'; + +/** Editor-framework-neutral lint in STRING offsets of its build's text. + * Diagnosis only: a repair would need state the parse does not model + * (command channels, prefix scope, control-character boundaries). */ +export interface SourceLint { + from: number; + to: number; + /** `related` marks context, not a second defect: the editor renders it + * weaker so one problem still reads as one problem. */ + severity: 'error' | 'related'; + message: string; +} + +const KIND_MSG = { + strayXz: 'lintStrayXzFmt', + unclosedXa: 'lintUnclosedXaFmt', +} as const; + +/** The one diagnostics derivation: the imbalance a refused apply reports, + * pointed at the command that caused it. */ +export function buildSourceDiagnostics( + refusal: SourceRefusalInfo | null, + t: Translations, +): SourceLint[] { + if (refusal?.reason !== 'unbalanced') return []; + const ub: UnbalancedFormat = refusal.unbalanced; + const lints: SourceLint[] = [ + { + from: ub.at, + to: ub.at + ub.cmd.length, + severity: 'error', + message: formatTemplate(t.output[KIND_MSG[ub.kind]], { cmd: ub.cmd }), + }, + ]; + // The defect is the unterminated format, but the repair goes here, which in + // a multi-label document is a whole label away from the opener. + if (ub.related) { + lints.push({ + from: ub.related.at, + to: ub.related.at + ub.related.cmd.length, + severity: 'related', + message: formatTemplate(t.output.lintStillOpenHereFmt, { cmd: ub.cmd }), + }); + } + return lints; +} diff --git a/src/lib/sourceEditText.ts b/src/lib/sourceEditText.ts index c5c8b950..95248c73 100644 --- a/src/lib/sourceEditText.ts +++ b/src/lib/sourceEditText.ts @@ -1,12 +1,16 @@ -import { MAX_SOURCE_PAGES, type SourceApplyPlan } from '@zplab/core/lib/zplSourceEdit'; +import { MAX_SOURCE_PAGES, type SourceRefusal } from '@zplab/core/lib/zplSourceEdit'; import type { Translations } from '../locales'; -export type SourceRefusal = Extract['reason']; +export type { SourceRefusal }; -/** The one reason→message mapping for every refusal surface. ZPL command - * names stay out of the locale strings (translator safety) and - * interpolate here. */ -export function sourceRefusalText(reason: SourceRefusal, t: Translations): string { +/** Reason→message for the refusals with no position. A located imbalance is + * named per kind by buildSourceDiagnostics, which is why 'unbalanced' is + * excluded here rather than answered twice. ZPL command names stay out of + * the locale strings (translator safety) and interpolate here. */ +export function sourceRefusalText( + reason: Exclude, + t: Translations, +): string { switch (reason) { case 'empty': return t.importModal.errPasteFirst; @@ -16,8 +20,6 @@ export function sourceRefusalText(reason: SourceRefusal, t: Translations): strin return t.output.editSourceGateBlob; case 'tooLarge': return t.output.editSourceGateSize; - case 'unbalanced': - return t.output.editSourceUnbalancedFmt.replace('{open}', '^XA').replace('{close}', '^XZ'); case 'tooManyPages': return t.output.editSourceTooManyPagesFmt.replace('{max}', String(MAX_SOURCE_PAGES)); } diff --git a/src/lib/zplParser.test.ts b/src/lib/zplParser.test.ts index 89205fd8..196142b8 100644 --- a/src/lib/zplParser.test.ts +++ b/src/lib/zplParser.test.ts @@ -3087,3 +3087,16 @@ describe('parseZPL — ^FT graphic anchors lift to model top-left', () => { expect(props(objects[0]).length).toBe(100); // sqrt(80^2 + 60^2) }); }); + +describe('parseZPL — unbalanced command text', () => { + it('carries the source command under a ^CC prefix remap', () => { + // The remap makes the second format open with #XA; its dangling open must + // label the bytes that are actually in the buffer. + const zpl = '^XA^CC#^FDa#FS#XZ\n#XA#FDb#FS'; + expect(parseZPL(zpl, 8).unbalanced).toEqual({ + kind: 'unclosedXa', + at: zpl.indexOf('#XA'), + cmd: '#XA', + }); + }); +}); diff --git a/src/locales/ar.ts b/src/locales/ar.ts index c2a95f1d..3bc1386c 100644 --- a/src/locales/ar.ts +++ b/src/locales/ar.ts @@ -933,6 +933,9 @@ const ar = { previewNoticeAcknowledge: 'فهمت، تابع', editSource: 'تحرير المصدر', editSourcePlaceholder: 'اكتب أو الصق ZPL هنا', + lintStrayXzFmt: '{cmd} يغلق تنسيقًا لم يُفتح مطلقًا.', + lintUnclosedXaFmt: '{cmd} لا يُغلق أبدًا.', + lintStillOpenHereFmt: '{cmd} لا يزال مفتوحًا هنا', editSourceApply: 'تطبيق', editSourceGateBlob: 'غير متاح: أحد أسطر الكود طويل جدًا بحيث لا يمكن تحريره كنص (عادةً رسم مضمَّن).', editSourceGateSize: 'غير متاح: الكود المُنشأ أكبر من أن يُحرَّر كنص.', @@ -940,7 +943,6 @@ const ar = { editSourceDiscard: 'تجاهل', editSourceConfirmTitle: 'راجع قبل التطبيق', editSourceConfirmApply: 'تطبيق على أي حال', - editSourceUnbalancedFmt: 'رمز غير متوازن: كل كتلة {open} تحتاج إلى {close} المقابل لها.', editSourceTooManyPagesFmt: 'يتجاوز الرمز {max} تنسيق ملصق.', replayRiskSetupFmt: 'إرسال هذا الرمز يغيّر أيضاً إعدادات الطابعة: {commands}', replayRiskActionsFmt: 'إرسال هذا الرمز يشغّل أيضاً إجراءات على الطابعة: {commands}', diff --git a/src/locales/bg.ts b/src/locales/bg.ts index f27176d9..a9821050 100644 --- a/src/locales/bg.ts +++ b/src/locales/bg.ts @@ -933,6 +933,9 @@ const bg = { previewNoticeAcknowledge: 'Разбрах, продължи', editSource: 'Редактиране на кода', editSourcePlaceholder: 'Въведете или поставете ZPL тук', + lintStrayXzFmt: '{cmd} затваря формат, който никога не е бил отворен.', + lintUnclosedXaFmt: '{cmd} никога не се затваря.', + lintStillOpenHereFmt: '{cmd} все още е отворен тук', editSourceApply: 'Прилагане', editSourceGateBlob: 'Недостъпно: един ред от кода е твърде дълъг, за да се редактира като текст (обикновено вградена графика).', editSourceGateSize: 'Недостъпно: генерираният код е твърде голям, за да се редактира като текст.', @@ -940,7 +943,6 @@ const bg = { editSourceDiscard: 'Отхвърляне', editSourceConfirmTitle: 'Проверете преди прилагане', editSourceConfirmApply: 'Прилагане въпреки това', - editSourceUnbalancedFmt: 'Небалансиран код: всеки блок {open} се нуждае от съответния {close}.', editSourceTooManyPagesFmt: 'Кодът надхвърля {max} етикетни формата.', replayRiskSetupFmt: 'Изпращането на този код променя и настройките на принтера: {commands}', replayRiskActionsFmt: 'Изпращането на този код задейства и действия на принтера: {commands}', diff --git a/src/locales/cs.ts b/src/locales/cs.ts index 777c57ed..1e1de903 100644 --- a/src/locales/cs.ts +++ b/src/locales/cs.ts @@ -933,6 +933,9 @@ const cs = { previewNoticeAcknowledge: 'Rozumím, pokračovat', editSource: 'Upravit zdrojový kód', editSourcePlaceholder: 'Zde napište nebo vložte ZPL', + lintStrayXzFmt: '{cmd} uzavírá formát, který nebyl nikdy otevřen.', + lintUnclosedXaFmt: '{cmd} nikdy není uzavřen.', + lintStillOpenHereFmt: '{cmd} je zde stále otevřený', editSourceApply: 'Použít', editSourceGateBlob: 'Nedostupné: jeden řádek kódu je příliš dlouhý na úpravu jako text (obvykle vložená grafika).', editSourceGateSize: 'Nedostupné: vygenerovaný kód je příliš velký na úpravu jako text.', @@ -940,7 +943,6 @@ const cs = { editSourceDiscard: 'Zahodit', editSourceConfirmTitle: 'Zkontrolujte před použitím', editSourceConfirmApply: 'Přesto použít', - editSourceUnbalancedFmt: 'Nevyvážený kód: každý blok {open} potřebuje odpovídající {close}.', editSourceTooManyPagesFmt: 'Kód překračuje {max} formátů etikety.', replayRiskSetupFmt: 'Odeslání tohoto kódu změní také nastavení tiskárny: {commands}', replayRiskActionsFmt: 'Odeslání tohoto kódu spustí také akce tiskárny: {commands}', diff --git a/src/locales/da.ts b/src/locales/da.ts index dcf64a6b..33bb8032 100644 --- a/src/locales/da.ts +++ b/src/locales/da.ts @@ -933,6 +933,9 @@ const da = { previewNoticeAcknowledge: 'Forstået, fortsæt', editSource: 'Rediger kildekode', editSourcePlaceholder: 'Skriv eller indsæt ZPL her', + lintStrayXzFmt: '{cmd} lukker et format, der aldrig blev åbnet.', + lintUnclosedXaFmt: '{cmd} bliver aldrig lukket.', + lintStillOpenHereFmt: '{cmd} er stadig åbent her', editSourceApply: 'Anvend', editSourceGateBlob: 'Ikke tilgængelig: en linje i koden er for lang til at redigere som tekst (som regel en indlejret grafik).', editSourceGateSize: 'Ikke tilgængelig: den genererede kode er for stor til at redigere som tekst.', @@ -940,7 +943,6 @@ const da = { editSourceDiscard: 'Kassér', editSourceConfirmTitle: 'Gennemgå før anvendelse', editSourceConfirmApply: 'Anvend alligevel', - editSourceUnbalancedFmt: 'Ubalanceret kode: hver {open}-blok mangler sin afsluttende {close}.', editSourceTooManyPagesFmt: 'Koden overskrider {max} etiketformater.', replayRiskSetupFmt: 'Afsendelse af denne kode ændrer også printerens indstillinger: {commands}', replayRiskActionsFmt: 'Afsendelse af denne kode udløser også printerhandlinger: {commands}', diff --git a/src/locales/de.ts b/src/locales/de.ts index 6473bce8..6b2188cd 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -963,6 +963,9 @@ const de = { previewNoticeAcknowledge: 'Verstanden, fortfahren', editSource: 'Quelltext bearbeiten', editSourcePlaceholder: 'ZPL hier eingeben oder einfügen', + lintStrayXzFmt: '{cmd} schließt ein Format, das nie geöffnet wurde.', + lintUnclosedXaFmt: '{cmd} wird nie geschlossen.', + lintStillOpenHereFmt: '{cmd} ist hier noch offen', editSourceApply: 'Übernehmen', editSourceGateBlob: 'Nicht verfügbar: Eine Zeile des Codes ist zu lang, um sie als Text zu bearbeiten (meist eine eingebettete Grafik).', editSourceGateSize: 'Nicht verfügbar: Der erzeugte Code ist zu groß, um ihn als Text zu bearbeiten.', @@ -970,7 +973,6 @@ const de = { editSourceDiscard: 'Verwerfen', editSourceConfirmTitle: 'Vor dem Übernehmen prüfen', editSourceConfirmApply: 'Trotzdem übernehmen', - editSourceUnbalancedFmt: 'Unausgeglichener Code: Jeder {open}-Block braucht sein abschließendes {close}.', editSourceTooManyPagesFmt: 'Der Code überschreitet {max} Etikettenformate.', replayRiskSetupFmt: 'Das Senden dieses Codes ändert auch Druckereinstellungen: {commands}', replayRiskActionsFmt: 'Das Senden dieses Codes löst auch Druckeraktionen aus: {commands}', diff --git a/src/locales/el.ts b/src/locales/el.ts index 878c1b09..8f92e0e3 100644 --- a/src/locales/el.ts +++ b/src/locales/el.ts @@ -933,6 +933,9 @@ const el = { previewNoticeAcknowledge: 'Κατάλαβα, συνέχεια', editSource: 'Επεξεργασία κώδικα', editSourcePlaceholder: 'Πληκτρολογήστε ή επικολλήστε ZPL εδώ', + lintStrayXzFmt: '{cmd} κλείνει μια μορφή που δεν ανοίχθηκε ποτέ.', + lintUnclosedXaFmt: '{cmd} δεν κλείνει ποτέ.', + lintStillOpenHereFmt: '{cmd} είναι ακόμα ανοιχτό εδώ', editSourceApply: 'Εφαρμογή', editSourceGateBlob: 'Μη διαθέσιμο: μια γραμμή του κώδικα είναι πολύ μεγάλη για επεξεργασία ως κείμενο (συνήθως ενσωματωμένο γραφικό).', editSourceGateSize: 'Μη διαθέσιμο: ο κώδικας που δημιουργήθηκε είναι πολύ μεγάλος για επεξεργασία ως κείμενο.', @@ -940,7 +943,6 @@ const el = { editSourceDiscard: 'Απόρριψη', editSourceConfirmTitle: 'Έλεγχος πριν από την εφαρμογή', editSourceConfirmApply: 'Εφαρμογή ούτως ή άλλως', - editSourceUnbalancedFmt: 'Μη ισοσταθμισμένος κώδικας: κάθε μπλοκ {open} χρειάζεται το αντίστοιχο {close}.', editSourceTooManyPagesFmt: 'Ο κώδικας υπερβαίνει τις {max} μορφές ετικέτας.', replayRiskSetupFmt: 'Η αποστολή αυτού του κώδικα αλλάζει επίσης ρυθμίσεις του εκτυπωτή: {commands}', replayRiskActionsFmt: 'Η αποστολή αυτού του κώδικα ενεργοποιεί επίσης ενέργειες του εκτυπωτή: {commands}', diff --git a/src/locales/en.ts b/src/locales/en.ts index 84f93fe5..87aa8cfd 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -963,6 +963,9 @@ const en = { previewNoticeAcknowledge: 'Got it, continue', editSource: 'Edit source', editSourcePlaceholder: 'Type or paste ZPL here', + lintStrayXzFmt: '{cmd} closes a format that was never opened.', + lintUnclosedXaFmt: '{cmd} is never closed.', + lintStillOpenHereFmt: '{cmd} is still open here', editSourceApply: 'Apply', editSourceGateBlob: 'Unavailable: one line of the code is too long to edit as text (usually an embedded graphic).', editSourceGateSize: 'Unavailable: the generated code is too large to edit as text.', @@ -970,7 +973,6 @@ const en = { editSourceDiscard: 'Discard', editSourceConfirmTitle: 'Review before applying', editSourceConfirmApply: 'Apply anyway', - editSourceUnbalancedFmt: 'Unbalanced code: every {open} block needs its closing {close}.', editSourceTooManyPagesFmt: 'The code exceeds {max} label formats.', replayRiskSetupFmt: 'Sending this code also changes printer settings: {commands}', replayRiskActionsFmt: 'Sending this code also triggers printer actions: {commands}', diff --git a/src/locales/es.ts b/src/locales/es.ts index 8533741c..e4ea5914 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -933,6 +933,9 @@ const es = { previewNoticeAcknowledge: 'Entendido, continuar', editSource: 'Editar código', editSourcePlaceholder: 'Escribe o pega ZPL aquí', + lintStrayXzFmt: '{cmd} cierra un formato que nunca se abrió.', + lintUnclosedXaFmt: '{cmd} nunca se cierra.', + lintStillOpenHereFmt: '{cmd} sigue abierto aquí', editSourceApply: 'Aplicar', editSourceGateBlob: 'No disponible: una línea del código es demasiado larga para editarla como texto (normalmente un gráfico incrustado).', editSourceGateSize: 'No disponible: el código generado es demasiado grande para editarlo como texto.', @@ -940,7 +943,6 @@ const es = { editSourceDiscard: 'Descartar', editSourceConfirmTitle: 'Revisar antes de aplicar', editSourceConfirmApply: 'Aplicar de todos modos', - editSourceUnbalancedFmt: 'Código no equilibrado: cada bloque {open} necesita su {close} de cierre.', editSourceTooManyPagesFmt: 'El código supera {max} formatos de etiqueta.', replayRiskSetupFmt: 'Enviar este código también cambia la configuración de la impresora: {commands}', replayRiskActionsFmt: 'Enviar este código también ejecuta acciones de la impresora: {commands}', diff --git a/src/locales/et.ts b/src/locales/et.ts index a618fdbb..2366c032 100644 --- a/src/locales/et.ts +++ b/src/locales/et.ts @@ -933,6 +933,9 @@ const et = { previewNoticeAcknowledge: 'Selge, jätka', editSource: 'Muuda lähtekoodi', editSourcePlaceholder: 'Sisestage või kleepige ZPL siia', + lintStrayXzFmt: '{cmd} sulgeb vormingu, mida ei ole kunagi avatud.', + lintUnclosedXaFmt: '{cmd} ei sulgu kunagi.', + lintStillOpenHereFmt: '{cmd} on siin endiselt avatud', editSourceApply: 'Rakenda', editSourceGateBlob: 'Pole saadaval: üks koodirida on tekstina muutmiseks liiga pikk (tavaliselt manustatud graafika).', editSourceGateSize: 'Pole saadaval: loodud kood on tekstina muutmiseks liiga suur.', @@ -940,7 +943,6 @@ const et = { editSourceDiscard: 'Loobu', editSourceConfirmTitle: 'Kontrolli enne rakendamist', editSourceConfirmApply: 'Rakenda siiski', - editSourceUnbalancedFmt: 'Tasakaalustamata kood: iga {open}-plokk vajab lõpetavat {close}.', editSourceTooManyPagesFmt: 'Kood ületab {max} sildivormingut.', replayRiskSetupFmt: 'Selle koodi saatmine muudab ka printeri sätteid: {commands}', replayRiskActionsFmt: 'Selle koodi saatmine käivitab ka printeri toiminguid: {commands}', diff --git a/src/locales/fa.ts b/src/locales/fa.ts index c4846597..d2853edd 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -933,6 +933,9 @@ const fa = { previewNoticeAcknowledge: 'متوجه شدم، ادامه', editSource: 'ویرایش کد منبع', editSourcePlaceholder: 'ZPL را اینجا بنویسید یا جای‌گذاری کنید', + lintStrayXzFmt: '{cmd} قالبی را می‌بندد که هرگز باز نشده است.', + lintUnclosedXaFmt: '{cmd} هرگز بسته نمی‌شود.', + lintStillOpenHereFmt: '{cmd} همچنان اینجا باز است', editSourceApply: 'اعمال', editSourceGateBlob: 'در دسترس نیست: یکی از خطوط کد بلندتر از آن است که به‌صورت متن ویرایش شود (معمولاً یک تصویر جاسازی‌شده).', editSourceGateSize: 'در دسترس نیست: کد تولیدشده بزرگ‌تر از آن است که به‌صورت متن ویرایش شود.', @@ -940,7 +943,6 @@ const fa = { editSourceDiscard: 'نادیده گرفتن', editSourceConfirmTitle: 'پیش از اعمال بررسی کنید', editSourceConfirmApply: 'با این حال اعمال شود', - editSourceUnbalancedFmt: 'کد نامتوازن: هر بلوک {open} به {close} پایانی خود نیاز دارد.', editSourceTooManyPagesFmt: 'کد از {max} قالب برچسب فراتر می‌رود.', replayRiskSetupFmt: 'ارسال این کد تنظیمات چاپگر را نیز تغییر می‌دهد: {commands}', replayRiskActionsFmt: 'ارسال این کد عملیات چاپگر را نیز اجرا می‌کند: {commands}', diff --git a/src/locales/fi.ts b/src/locales/fi.ts index 31de2156..d9e65d6c 100644 --- a/src/locales/fi.ts +++ b/src/locales/fi.ts @@ -933,6 +933,9 @@ const fi = { previewNoticeAcknowledge: 'Selvä, jatka', editSource: 'Muokkaa lähdekoodia', editSourcePlaceholder: 'Kirjoita tai liitä ZPL tähän', + lintStrayXzFmt: '{cmd} sulkee muodon, jota ei koskaan avattu.', + lintUnclosedXaFmt: '{cmd} ei koskaan sulkeudu.', + lintStillOpenHereFmt: '{cmd} on täällä yhä auki', editSourceApply: 'Käytä', editSourceGateBlob: 'Ei käytettävissä: yksi koodirivi on liian pitkä tekstinä muokattavaksi (yleensä upotettu kuva).', editSourceGateSize: 'Ei käytettävissä: luotu koodi on liian suuri tekstinä muokattavaksi.', @@ -940,7 +943,6 @@ const fi = { editSourceDiscard: 'Hylkää', editSourceConfirmTitle: 'Tarkista ennen käyttöönottoa', editSourceConfirmApply: 'Ota silti käyttöön', - editSourceUnbalancedFmt: 'Epätasapainoinen koodi: jokainen {open}-lohko tarvitsee päättävän {close}.', editSourceTooManyPagesFmt: 'Koodi ylittää {max} etikettimuotoa.', replayRiskSetupFmt: 'Tämän koodin lähettäminen muuttaa myös tulostimen asetuksia: {commands}', replayRiskActionsFmt: 'Tämän koodin lähettäminen käynnistää myös tulostimen toimintoja: {commands}', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 42a4c7dc..2826a8dc 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -933,6 +933,9 @@ const fr = { previewNoticeAcknowledge: 'Compris, continuer', editSource: 'Modifier le code', editSourcePlaceholder: 'Saisissez ou collez du ZPL ici', + lintStrayXzFmt: "{cmd} ferme un format qui n'a jamais été ouvert.", + lintUnclosedXaFmt: "{cmd} n'est jamais fermé.", + lintStillOpenHereFmt: '{cmd} est toujours ouvert ici', editSourceApply: 'Appliquer', editSourceGateBlob: 'Indisponible : une ligne du code est trop longue pour être modifiée sous forme de texte (généralement une image intégrée).', editSourceGateSize: 'Indisponible : le code généré est trop volumineux pour être modifié sous forme de texte.', @@ -940,7 +943,6 @@ const fr = { editSourceDiscard: 'Abandonner', editSourceConfirmTitle: "Vérifier avant d'appliquer", editSourceConfirmApply: 'Appliquer quand même', - editSourceUnbalancedFmt: 'Code déséquilibré : chaque bloc {open} doit avoir son {close} de fermeture.', editSourceTooManyPagesFmt: 'Le code dépasse {max} formats d’étiquette.', replayRiskSetupFmt: "L'envoi de ce code modifie aussi les paramètres de l'imprimante : {commands}", replayRiskActionsFmt: "L'envoi de ce code déclenche aussi des actions de l'imprimante : {commands}", diff --git a/src/locales/he.ts b/src/locales/he.ts index 51e03a16..22de1100 100644 --- a/src/locales/he.ts +++ b/src/locales/he.ts @@ -933,6 +933,9 @@ const he = { previewNoticeAcknowledge: 'הבנתי, המשך', editSource: 'עריכת קוד המקור', editSourcePlaceholder: 'הקלד או הדבק ZPL כאן', + lintStrayXzFmt: '{cmd} סוגר תבנית שמעולם לא נפתחה.', + lintUnclosedXaFmt: '{cmd} לעולם לא נסגר.', + lintStillOpenHereFmt: '{cmd} עדיין פתוח כאן', editSourceApply: 'החל', editSourceGateBlob: 'לא זמין: אחת משורות הקוד ארוכה מדי לעריכה כטקסט (בדרך כלל גרפיקה משובצת).', editSourceGateSize: 'לא זמין: הקוד שנוצר גדול מכדי לערוך אותו כטקסט.', @@ -940,7 +943,6 @@ const he = { editSourceDiscard: 'בטל', editSourceConfirmTitle: 'בדיקה לפני החלה', editSourceConfirmApply: 'החל בכל זאת', - editSourceUnbalancedFmt: 'קוד לא מאוזן: לכל בלוק {open} נדרש {close} סוגר.', editSourceTooManyPagesFmt: 'הקוד חורג מ-{max} תבניות תווית.', replayRiskSetupFmt: 'שליחת הקוד הזה משנה גם את הגדרות המדפסת: {commands}', replayRiskActionsFmt: 'שליחת הקוד הזה גם מפעילה פעולות במדפסת: {commands}', diff --git a/src/locales/hr.ts b/src/locales/hr.ts index 7fa9c933..f56115bc 100644 --- a/src/locales/hr.ts +++ b/src/locales/hr.ts @@ -933,6 +933,9 @@ const hr = { previewNoticeAcknowledge: 'Razumijem, nastavi', editSource: 'Uredi izvorni kôd', editSourcePlaceholder: 'Upišite ili zalijepite ZPL ovdje', + lintStrayXzFmt: '{cmd} zatvara format koji nikada nije otvoren.', + lintUnclosedXaFmt: '{cmd} nikada nije zatvoren.', + lintStillOpenHereFmt: '{cmd} je ovdje i dalje otvoren', editSourceApply: 'Primijeni', editSourceGateBlob: 'Nedostupno: jedan redak koda predug je za uređivanje kao tekst (obično ugrađena grafika).', editSourceGateSize: 'Nedostupno: generirani kôd prevelik je za uređivanje kao tekst.', @@ -940,7 +943,6 @@ const hr = { editSourceDiscard: 'Odbaci', editSourceConfirmTitle: 'Provjerite prije primjene', editSourceConfirmApply: 'Ipak primijeni', - editSourceUnbalancedFmt: 'Neuravnotežen kod: svaki blok {open} treba svoj završni {close}.', editSourceTooManyPagesFmt: 'Kôd premašuje {max} formata naljepnice.', replayRiskSetupFmt: 'Slanje ovog koda mijenja i postavke pisača: {commands}', replayRiskActionsFmt: 'Slanje ovog koda pokreće i radnje pisača: {commands}', diff --git a/src/locales/hu.ts b/src/locales/hu.ts index ea2cdcc1..e2e4318a 100644 --- a/src/locales/hu.ts +++ b/src/locales/hu.ts @@ -933,6 +933,9 @@ const hu = { previewNoticeAcknowledge: 'Értem, folytatás', editSource: 'Forráskód szerkesztése', editSourcePlaceholder: 'Írjon vagy illesszen be ide ZPL-t', + lintStrayXzFmt: '{cmd} lezár egy formátumot, amely soha nem lett megnyitva.', + lintUnclosedXaFmt: '{cmd} soha nem záródik le.', + lintStillOpenHereFmt: '{cmd} itt még mindig nyitva van', editSourceApply: 'Alkalmaz', editSourceGateBlob: 'Nem érhető el: a kód egyik sora túl hosszú ahhoz, hogy szövegként szerkeszthető legyen (általában beágyazott grafika).', editSourceGateSize: 'Nem érhető el: a létrehozott kód túl nagy ahhoz, hogy szövegként szerkeszthető legyen.', @@ -940,7 +943,6 @@ const hu = { editSourceDiscard: 'Elvetés', editSourceConfirmTitle: 'Ellenőrzés alkalmazás előtt', editSourceConfirmApply: 'Alkalmazás mindenképp', - editSourceUnbalancedFmt: 'Kiegyensúlyozatlan kód: minden {open} blokkhoz kell a lezáró {close}.', editSourceTooManyPagesFmt: 'A kód meghaladja a(z) {max} címkeformátumot.', replayRiskSetupFmt: 'A kód elküldése a nyomtató beállításait is módosítja: {commands}', replayRiskActionsFmt: 'A kód elküldése nyomtatóműveleteket is elindít: {commands}', diff --git a/src/locales/it.ts b/src/locales/it.ts index ed3241fc..3d5511e0 100644 --- a/src/locales/it.ts +++ b/src/locales/it.ts @@ -933,6 +933,9 @@ const it = { previewNoticeAcknowledge: 'Ho capito, continua', editSource: 'Modifica codice', editSourcePlaceholder: 'Digita o incolla ZPL qui', + lintStrayXzFmt: '{cmd} chiude un formato che non è mai stato aperto.', + lintUnclosedXaFmt: '{cmd} non viene mai chiuso.', + lintStillOpenHereFmt: '{cmd} è ancora aperto qui', editSourceApply: 'Applica', editSourceGateBlob: "Non disponibile: una riga del codice è troppo lunga per essere modificata come testo (di solito un'immagine incorporata).", editSourceGateSize: 'Non disponibile: il codice generato è troppo grande per essere modificato come testo.', @@ -940,7 +943,6 @@ const it = { editSourceDiscard: 'Scarta', editSourceConfirmTitle: 'Controlla prima di applicare', editSourceConfirmApply: 'Applica comunque', - editSourceUnbalancedFmt: 'Codice non bilanciato: ogni blocco {open} richiede il {close} di chiusura.', editSourceTooManyPagesFmt: 'Il codice supera {max} formati di etichetta.', replayRiskSetupFmt: "L'invio di questo codice modifica anche le impostazioni della stampante: {commands}", replayRiskActionsFmt: "L'invio di questo codice avvia anche azioni della stampante: {commands}", diff --git a/src/locales/ja.ts b/src/locales/ja.ts index a79dbd1d..3b22241b 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -933,6 +933,9 @@ const ja = { previewNoticeAcknowledge: '了解しました、続行', editSource: 'ソースを編集', editSourcePlaceholder: 'ZPL を入力または貼り付け', + lintStrayXzFmt: '{cmd} は一度も開かれていないフォーマットを閉じます。', + lintUnclosedXaFmt: '{cmd} は決して閉じられません。', + lintStillOpenHereFmt: '{cmd} はここではまだ開いています', editSourceApply: '適用', editSourceGateBlob: '利用できません: コードの1行が長すぎてテキストとして編集できません (多くは埋め込み画像です)。', editSourceGateSize: '利用できません: 生成されたコードが大きすぎて、テキストとして編集できません。', @@ -940,7 +943,6 @@ const ja = { editSourceDiscard: '破棄', editSourceConfirmTitle: '適用前に確認', editSourceConfirmApply: 'それでも適用', - editSourceUnbalancedFmt: 'コードが対応していません: {open} ブロックには対応する {close} が必要です。', editSourceTooManyPagesFmt: 'コードがラベルフォーマット {max} 件を超えています。', replayRiskSetupFmt: 'このコードを送信すると、プリンターの設定も変更されます: {commands}', replayRiskActionsFmt: 'このコードを送信すると、プリンターの動作も実行されます: {commands}', diff --git a/src/locales/ko.ts b/src/locales/ko.ts index 74661f41..f1658b6d 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -933,6 +933,9 @@ const ko = { previewNoticeAcknowledge: '이해했습니다, 계속', editSource: '소스 편집', editSourcePlaceholder: '여기에 ZPL을 입력하거나 붙여넣으세요', + lintStrayXzFmt: '{cmd}은(는) 한 번도 열리지 않은 형식을 닫습니다.', + lintUnclosedXaFmt: '{cmd}은(는) 절대 닫히지 않습니다.', + lintStillOpenHereFmt: '{cmd}은(는) 여기서 아직 열려 있습니다', editSourceApply: '적용', editSourceGateBlob: '사용할 수 없음: 코드의 한 줄이 너무 길어 텍스트로 편집할 수 없습니다 (대개 포함된 그래픽입니다).', editSourceGateSize: '사용할 수 없음: 생성된 코드가 너무 커서 텍스트로 편집할 수 없습니다.', @@ -940,7 +943,6 @@ const ko = { editSourceDiscard: '버리기', editSourceConfirmTitle: '적용 전 확인', editSourceConfirmApply: '그래도 적용', - editSourceUnbalancedFmt: '코드가 짝이 맞지 않습니다: 모든 {open} 블록에는 닫는 {close}가 필요합니다.', editSourceTooManyPagesFmt: '코드가 라벨 형식 {max}개를 초과합니다.', replayRiskSetupFmt: '이 코드를 전송하면 프린터 설정도 변경됩니다: {commands}', replayRiskActionsFmt: '이 코드를 전송하면 프린터 동작도 실행됩니다: {commands}', diff --git a/src/locales/lt.ts b/src/locales/lt.ts index 216a1c8d..533270a7 100644 --- a/src/locales/lt.ts +++ b/src/locales/lt.ts @@ -933,6 +933,9 @@ const lt = { previewNoticeAcknowledge: 'Supratau, tęsti', editSource: 'Redaguoti pirminį kodą', editSourcePlaceholder: 'Įveskite arba įklijuokite ZPL čia', + lintStrayXzFmt: '{cmd} uždaro formatą, kuris niekada nebuvo atidarytas.', + lintUnclosedXaFmt: '{cmd} niekada neuždaromas.', + lintStillOpenHereFmt: '{cmd} čia vis dar atviras', editSourceApply: 'Taikyti', editSourceGateBlob: 'Negalima: viena kodo eilutė per ilga, kad ją būtų galima redaguoti kaip tekstą (paprastai įterpta grafika).', editSourceGateSize: 'Negalima: sugeneruotas kodas per didelis, kad jį būtų galima redaguoti kaip tekstą.', @@ -940,7 +943,6 @@ const lt = { editSourceDiscard: 'Atmesti', editSourceConfirmTitle: 'Peržiūrėkite prieš taikydami', editSourceConfirmApply: 'Vis tiek taikyti', - editSourceUnbalancedFmt: 'Nesubalansuotas kodas: kiekvienam {open} blokui reikia uždarančio {close}.', editSourceTooManyPagesFmt: 'Kodas viršija {max} etiketės formatų.', replayRiskSetupFmt: 'Išsiuntus šį kodą taip pat pakeičiami spausdintuvo nustatymai: {commands}', replayRiskActionsFmt: 'Išsiuntus šį kodą taip pat paleidžiami spausdintuvo veiksmai: {commands}', diff --git a/src/locales/lv.ts b/src/locales/lv.ts index da9ba5bd..80db12eb 100644 --- a/src/locales/lv.ts +++ b/src/locales/lv.ts @@ -933,6 +933,9 @@ const lv = { previewNoticeAcknowledge: 'Saprotu, turpināt', editSource: 'Rediģēt pirmkodu', editSourcePlaceholder: 'Ievadiet vai ielīmējiet ZPL šeit', + lintStrayXzFmt: '{cmd} aizver formātu, kas nekad netika atvērts.', + lintUnclosedXaFmt: '{cmd} nekad netiek aizvērts.', + lintStillOpenHereFmt: '{cmd} šeit joprojām ir atvērts', editSourceApply: 'Lietot', editSourceGateBlob: 'Nav pieejams: viena koda rinda ir pārāk gara, lai to rediģētu kā tekstu (parasti iegulta grafika).', editSourceGateSize: 'Nav pieejams: ģenerētais kods ir pārāk liels, lai to rediģētu kā tekstu.', @@ -940,7 +943,6 @@ const lv = { editSourceDiscard: 'Atmest', editSourceConfirmTitle: 'Pārbaudiet pirms lietošanas', editSourceConfirmApply: 'Tomēr lietot', - editSourceUnbalancedFmt: 'Nesabalansēts kods: katram {open} blokam nepieciešams noslēdzošais {close}.', editSourceTooManyPagesFmt: 'Kods pārsniedz {max} uzlīmju formātus.', replayRiskSetupFmt: 'Šī koda nosūtīšana maina arī printera iestatījumus: {commands}', replayRiskActionsFmt: 'Šī koda nosūtīšana sāk arī printera darbības: {commands}', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index fe2b49bf..645e6ce8 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -933,6 +933,9 @@ const nl = { previewNoticeAcknowledge: 'Begrepen, doorgaan', editSource: 'Broncode bewerken', editSourcePlaceholder: 'Typ of plak hier ZPL', + lintStrayXzFmt: '{cmd} sluit een formaat dat nooit is geopend.', + lintUnclosedXaFmt: '{cmd} wordt nooit gesloten.', + lintStillOpenHereFmt: '{cmd} is hier nog steeds open', editSourceApply: 'Toepassen', editSourceGateBlob: 'Niet beschikbaar: een regel van de code is te lang om als tekst te bewerken (meestal een ingesloten afbeelding).', editSourceGateSize: 'Niet beschikbaar: de gegenereerde code is te groot om als tekst te bewerken.', @@ -940,7 +943,6 @@ const nl = { editSourceDiscard: 'Weggooien', editSourceConfirmTitle: 'Controleren vóór toepassen', editSourceConfirmApply: 'Toch toepassen', - editSourceUnbalancedFmt: 'Ongebalanceerde code: elk {open}-blok heeft een afsluitende {close} nodig.', editSourceTooManyPagesFmt: 'De code overschrijdt {max} labelformaten.', replayRiskSetupFmt: 'Het verzenden van deze code wijzigt ook printerinstellingen: {commands}', replayRiskActionsFmt: 'Het verzenden van deze code start ook printeracties: {commands}', diff --git a/src/locales/no.ts b/src/locales/no.ts index 3690a7fb..6b51dacb 100644 --- a/src/locales/no.ts +++ b/src/locales/no.ts @@ -933,6 +933,9 @@ const no = { previewNoticeAcknowledge: 'Forstått, fortsett', editSource: 'Rediger kildekode', editSourcePlaceholder: 'Skriv eller lim inn ZPL her', + lintStrayXzFmt: '{cmd} lukker et format som aldri ble åpnet.', + lintUnclosedXaFmt: '{cmd} blir aldri lukket.', + lintStillOpenHereFmt: '{cmd} er fortsatt åpent her', editSourceApply: 'Bruk', editSourceGateBlob: 'Ikke tilgjengelig: en linje i koden er for lang til å redigeres som tekst (som regel en innebygd grafikk).', editSourceGateSize: 'Ikke tilgjengelig: den genererte koden er for stor til å redigeres som tekst.', @@ -940,7 +943,6 @@ const no = { editSourceDiscard: 'Forkast', editSourceConfirmTitle: 'Kontroller før du tar i bruk', editSourceConfirmApply: 'Bruk likevel', - editSourceUnbalancedFmt: 'Ubalansert kode: hver {open}-blokk trenger sin avsluttende {close}.', editSourceTooManyPagesFmt: 'Koden overskrider {max} etikettformater.', replayRiskSetupFmt: 'Å sende denne koden endrer også skriverinnstillinger: {commands}', replayRiskActionsFmt: 'Å sende denne koden utløser også skriverhandlinger: {commands}', diff --git a/src/locales/pl.ts b/src/locales/pl.ts index 311e3a0c..8cddab60 100644 --- a/src/locales/pl.ts +++ b/src/locales/pl.ts @@ -933,6 +933,9 @@ const pl = { previewNoticeAcknowledge: 'Rozumiem, kontynuuj', editSource: 'Edytuj kod źródłowy', editSourcePlaceholder: 'Wpisz lub wklej tutaj ZPL', + lintStrayXzFmt: '{cmd} zamyka format, który nigdy nie został otwarty.', + lintUnclosedXaFmt: '{cmd} nigdy nie jest zamykany.', + lintStillOpenHereFmt: '{cmd} jest tutaj wciąż otwarty', editSourceApply: 'Zastosuj', editSourceGateBlob: 'Niedostępne: jeden wiersz kodu jest zbyt długi, aby edytować go jako tekst (zwykle osadzona grafika).', editSourceGateSize: 'Niedostępne: wygenerowany kod jest zbyt duży, aby edytować go jako tekst.', @@ -940,7 +943,6 @@ const pl = { editSourceDiscard: 'Odrzuć', editSourceConfirmTitle: 'Sprawdź przed zastosowaniem', editSourceConfirmApply: 'Zastosuj mimo to', - editSourceUnbalancedFmt: 'Niezbilansowany kod: każdy blok {open} wymaga zamykającego {close}.', editSourceTooManyPagesFmt: 'Kod przekracza {max} formatów etykiet.', replayRiskSetupFmt: 'Wysłanie tego kodu zmienia także ustawienia drukarki: {commands}', replayRiskActionsFmt: 'Wysłanie tego kodu uruchamia także działania drukarki: {commands}', diff --git a/src/locales/pt.ts b/src/locales/pt.ts index ef69fc3d..55ccea9b 100644 --- a/src/locales/pt.ts +++ b/src/locales/pt.ts @@ -933,6 +933,9 @@ const pt = { previewNoticeAcknowledge: 'Entendi, continuar', editSource: 'Editar código-fonte', editSourcePlaceholder: 'Digite ou cole ZPL aqui', + lintStrayXzFmt: '{cmd} fecha um formato que nunca foi aberto.', + lintUnclosedXaFmt: '{cmd} nunca é fechado.', + lintStillOpenHereFmt: '{cmd} continua aberto aqui', editSourceApply: 'Aplicar', editSourceGateBlob: 'Indisponível: uma linha do código é demasiado longa para ser editada como texto (normalmente um gráfico incorporado).', editSourceGateSize: 'Indisponível: o código gerado é demasiado grande para ser editado como texto.', @@ -940,7 +943,6 @@ const pt = { editSourceDiscard: 'Descartar', editSourceConfirmTitle: 'Rever antes de aplicar', editSourceConfirmApply: 'Aplicar mesmo assim', - editSourceUnbalancedFmt: 'Código não equilibrado: cada bloco {open} precisa do {close} de fecho.', editSourceTooManyPagesFmt: 'O código excede {max} formatos de etiqueta.', replayRiskSetupFmt: 'Enviar este código também altera as definições da impressora: {commands}', replayRiskActionsFmt: 'Enviar este código também executa ações da impressora: {commands}', diff --git a/src/locales/ro.ts b/src/locales/ro.ts index 54b6da21..8acb108c 100644 --- a/src/locales/ro.ts +++ b/src/locales/ro.ts @@ -933,6 +933,9 @@ const ro = { previewNoticeAcknowledge: 'Am înțeles, continuă', editSource: 'Editează codul sursă', editSourcePlaceholder: 'Introduceți sau lipiți ZPL aici', + lintStrayXzFmt: '{cmd} închide un format care nu a fost niciodată deschis.', + lintUnclosedXaFmt: '{cmd} nu este niciodată închis.', + lintStillOpenHereFmt: '{cmd} este încă deschis aici', editSourceApply: 'Aplică', editSourceGateBlob: 'Indisponibil: o linie din cod este prea lungă pentru a fi editată ca text (de obicei un element grafic încorporat).', editSourceGateSize: 'Indisponibil: codul generat este prea mare pentru a fi editat ca text.', @@ -940,7 +943,6 @@ const ro = { editSourceDiscard: 'Renunță', editSourceConfirmTitle: 'Verifică înainte de aplicare', editSourceConfirmApply: 'Aplică oricum', - editSourceUnbalancedFmt: 'Cod neechilibrat: fiecare bloc {open} necesită {close} de închidere.', editSourceTooManyPagesFmt: 'Codul depășește {max} formate de etichetă.', replayRiskSetupFmt: 'Trimiterea acestui cod modifică și setările imprimantei: {commands}', replayRiskActionsFmt: 'Trimiterea acestui cod declanșează și acțiuni ale imprimantei: {commands}', diff --git a/src/locales/sk.ts b/src/locales/sk.ts index d0590246..5d84c92c 100644 --- a/src/locales/sk.ts +++ b/src/locales/sk.ts @@ -933,6 +933,9 @@ const sk = { previewNoticeAcknowledge: 'Rozumiem, pokračovať', editSource: 'Upraviť zdrojový kód', editSourcePlaceholder: 'Sem napíšte alebo vložte ZPL', + lintStrayXzFmt: '{cmd} uzatvára formát, ktorý nebol nikdy otvorený.', + lintUnclosedXaFmt: '{cmd} nie je nikdy uzavretý.', + lintStillOpenHereFmt: '{cmd} je tu stále otvorený', editSourceApply: 'Použiť', editSourceGateBlob: 'Nedostupné: jeden riadok kódu je príliš dlhý na úpravu ako text (zvyčajne vložená grafika).', editSourceGateSize: 'Nedostupné: vygenerovaný kód je príliš veľký na úpravu ako text.', @@ -940,7 +943,6 @@ const sk = { editSourceDiscard: 'Zahodiť', editSourceConfirmTitle: 'Skontrolujte pred použitím', editSourceConfirmApply: 'Aj tak použiť', - editSourceUnbalancedFmt: 'Nevyvážený kód: každý blok {open} potrebuje uzatvárajúci {close}.', editSourceTooManyPagesFmt: 'Kód prekračuje {max} formátov etikety.', replayRiskSetupFmt: 'Odoslanie tohto kódu zmení aj nastavenia tlačiarne: {commands}', replayRiskActionsFmt: 'Odoslanie tohto kódu spustí aj akcie tlačiarne: {commands}', diff --git a/src/locales/sl.ts b/src/locales/sl.ts index d45012ff..01b2ff1d 100644 --- a/src/locales/sl.ts +++ b/src/locales/sl.ts @@ -933,6 +933,9 @@ const sl = { previewNoticeAcknowledge: 'Razumem, nadaljuj', editSource: 'Uredi izvorno kodo', editSourcePlaceholder: 'Vnesite ali prilepite ZPL sem', + lintStrayXzFmt: '{cmd} zapre format, ki nikoli ni bil odprt.', + lintUnclosedXaFmt: '{cmd} nikoli ni zaprt.', + lintStillOpenHereFmt: '{cmd} je tukaj še vedno odprt', editSourceApply: 'Uporabi', editSourceGateBlob: 'Ni na voljo: ena vrstica kode je predolga za urejanje kot besedilo (običajno vgrajena grafika).', editSourceGateSize: 'Ni na voljo: ustvarjena koda je prevelika za urejanje kot besedilo.', @@ -940,7 +943,6 @@ const sl = { editSourceDiscard: 'Zavrzi', editSourceConfirmTitle: 'Preverite pred uporabo', editSourceConfirmApply: 'Vseeno uporabi', - editSourceUnbalancedFmt: 'Neuravnotežena koda: vsak blok {open} potrebuje zaključni {close}.', editSourceTooManyPagesFmt: 'Koda presega {max} formatov nalepke.', replayRiskSetupFmt: 'Pošiljanje te kode spremeni tudi nastavitve tiskalnika: {commands}', replayRiskActionsFmt: 'Pošiljanje te kode sproži tudi dejanja tiskalnika: {commands}', diff --git a/src/locales/sr.ts b/src/locales/sr.ts index 79d4ef26..8dd852df 100644 --- a/src/locales/sr.ts +++ b/src/locales/sr.ts @@ -933,6 +933,9 @@ const sr = { previewNoticeAcknowledge: 'Разумем, настави', editSource: 'Уреди изворни кôд', editSourcePlaceholder: 'Унесите или налепите ZPL овде', + lintStrayXzFmt: '{cmd} затвара формат који никада није отворен.', + lintUnclosedXaFmt: '{cmd} никада није затворен.', + lintStillOpenHereFmt: '{cmd} је овде и даље отворен', editSourceApply: 'Примени', editSourceGateBlob: 'Недоступно: један ред кода је предугачак да би се уређивао као текст (обично уграђена графика).', editSourceGateSize: 'Недоступно: генерисани кôд је превелик да би се уређивао као текст.', @@ -940,7 +943,6 @@ const sr = { editSourceDiscard: 'Одбаци', editSourceConfirmTitle: 'Проверите пре примене', editSourceConfirmApply: 'Ипак примени', - editSourceUnbalancedFmt: 'Неуравнотежен код: сваки блок {open} захтева одговарајући {close}.', editSourceTooManyPagesFmt: 'Код премашује {max} формата налепнице.', replayRiskSetupFmt: 'Слање овог кода мења и подешавања штампача: {commands}', replayRiskActionsFmt: 'Слање овог кода покреће и радње штампача: {commands}', diff --git a/src/locales/sv.ts b/src/locales/sv.ts index f54c6bf0..247f7660 100644 --- a/src/locales/sv.ts +++ b/src/locales/sv.ts @@ -933,6 +933,9 @@ const sv = { previewNoticeAcknowledge: 'Uppfattat, fortsätt', editSource: 'Redigera källkod', editSourcePlaceholder: 'Skriv eller klistra in ZPL här', + lintStrayXzFmt: '{cmd} stänger ett format som aldrig öppnades.', + lintUnclosedXaFmt: '{cmd} stängs aldrig.', + lintStillOpenHereFmt: '{cmd} är fortfarande öppet här', editSourceApply: 'Använd', editSourceGateBlob: 'Inte tillgängligt: en rad i koden är för lång för att redigeras som text (oftast en inbäddad grafik).', editSourceGateSize: 'Inte tillgängligt: den genererade koden är för stor för att redigeras som text.', @@ -940,7 +943,6 @@ const sv = { editSourceDiscard: 'Kasta', editSourceConfirmTitle: 'Granska före användning', editSourceConfirmApply: 'Använd ändå', - editSourceUnbalancedFmt: 'Obalanserad kod: varje {open}-block behöver sitt avslutande {close}.', editSourceTooManyPagesFmt: 'Koden överskrider {max} etikettformat.', replayRiskSetupFmt: 'Att skicka den här koden ändrar även skrivarinställningar: {commands}', replayRiskActionsFmt: 'Att skicka den här koden utlöser även skrivaråtgärder: {commands}', diff --git a/src/locales/tr.ts b/src/locales/tr.ts index 9bdefd17..40732c76 100644 --- a/src/locales/tr.ts +++ b/src/locales/tr.ts @@ -933,6 +933,9 @@ const tr = { previewNoticeAcknowledge: 'Anladım, devam et', editSource: 'Kaynağı düzenle', editSourcePlaceholder: "ZPL'yi buraya yazın veya yapıştırın", + lintStrayXzFmt: '{cmd}, hiç açılmamış bir biçimi kapatır.', + lintUnclosedXaFmt: '{cmd} hiçbir zaman kapatılmaz.', + lintStillOpenHereFmt: '{cmd} burada hâlâ açık', editSourceApply: 'Uygula', editSourceGateBlob: 'Kullanılamıyor: kodun bir satırı metin olarak düzenlenemeyecek kadar uzun (genellikle gömülü bir grafik).', editSourceGateSize: 'Kullanılamıyor: oluşturulan kod metin olarak düzenlenemeyecek kadar büyük.', @@ -940,7 +943,6 @@ const tr = { editSourceDiscard: 'At', editSourceConfirmTitle: 'Uygulamadan önce gözden geçirin', editSourceConfirmApply: 'Yine de uygula', - editSourceUnbalancedFmt: 'Dengesiz kod: her {open} bloğu kapanış {close} gerektirir.', editSourceTooManyPagesFmt: 'Kod {max} etiket biçimini aşıyor.', replayRiskSetupFmt: 'Bu kodu göndermek yazıcı ayarlarını da değiştirir: {commands}', replayRiskActionsFmt: 'Bu kodu göndermek yazıcı işlemlerini de tetikler: {commands}', diff --git a/src/locales/zh-hans.ts b/src/locales/zh-hans.ts index 89328516..77a033fc 100644 --- a/src/locales/zh-hans.ts +++ b/src/locales/zh-hans.ts @@ -933,6 +933,9 @@ const zhHans = { previewNoticeAcknowledge: '知道了,继续', editSource: '编辑源码', editSourcePlaceholder: '在此输入或粘贴 ZPL', + lintStrayXzFmt: '{cmd} 关闭了一个从未打开过的格式。', + lintUnclosedXaFmt: '{cmd} 永远不会被关闭。', + lintStillOpenHereFmt: '{cmd} 在此处仍处于打开状态', editSourceApply: '应用', editSourceGateBlob: '不可用:代码中有一行过长,无法以文本方式编辑(通常是嵌入的图形)。', editSourceGateSize: '不可用:生成的代码过大,无法以文本方式编辑。', @@ -940,7 +943,6 @@ const zhHans = { editSourceDiscard: '放弃', editSourceConfirmTitle: '应用前请检查', editSourceConfirmApply: '仍然应用', - editSourceUnbalancedFmt: '代码不配对:每个 {open} 块都需要对应的 {close}。', editSourceTooManyPagesFmt: '代码超过 {max} 个标签格式。', replayRiskSetupFmt: '发送此代码还会更改打印机设置:{commands}', replayRiskActionsFmt: '发送此代码还会触发打印机动作:{commands}', diff --git a/src/locales/zh-hant.ts b/src/locales/zh-hant.ts index 6ff6d6e6..13de6548 100644 --- a/src/locales/zh-hant.ts +++ b/src/locales/zh-hant.ts @@ -933,6 +933,9 @@ const zhHant = { previewNoticeAcknowledge: '了解,繼續', editSource: '編輯原始碼', editSourcePlaceholder: '在此輸入或貼上 ZPL', + lintStrayXzFmt: '{cmd} 關閉了一個從未開啟過的格式。', + lintUnclosedXaFmt: '{cmd} 永遠不會被關閉。', + lintStillOpenHereFmt: '{cmd} 在此處仍處於開啟狀態', editSourceApply: '套用', editSourceGateBlob: '無法使用:程式碼中有一行過長,無法以文字方式編輯(通常是內嵌的圖形)。', editSourceGateSize: '無法使用:產生的程式碼過大,無法以文字方式編輯。', @@ -940,7 +943,6 @@ const zhHant = { editSourceDiscard: '捨棄', editSourceConfirmTitle: '套用前請檢查', editSourceConfirmApply: '仍要套用', - editSourceUnbalancedFmt: '程式碼不配對:每個 {open} 區塊都需要對應的 {close}。', editSourceTooManyPagesFmt: '程式碼超過 {max} 個標籤格式。', replayRiskSetupFmt: '傳送此程式碼也會變更印表機設定:{commands}', replayRiskActionsFmt: '傳送此程式碼也會觸發印表機動作:{commands}', diff --git a/src/store/labelStore.selectors.ts b/src/store/labelStore.selectors.ts index 89519b70..437d3295 100644 --- a/src/store/labelStore.selectors.ts +++ b/src/store/labelStore.selectors.ts @@ -42,6 +42,11 @@ export const currentPageLabel = (state: LabelState): PageLabel => * page count) derives from the parsed shadow through this one seam; live * state otherwise. Model-facing reads keep the plain fields. */ +/** The refusal of the last completed shadow parse, and the text it describes. + * Session-end writers null the whole shadow, so no status guard is needed. */ +export const selectShadowRefusal = (s: LabelState) => s.sourceShadow?.refusal ?? null; +export const selectShadowDraft = (s: LabelState) => s.sourceShadow?.draft ?? null; + export const selectRenderPages = (s: LabelState) => s.sourceShadow?.doc?.pages ?? s.pages; export const selectRenderVariables = (s: LabelState) => diff --git a/src/store/labelStore.ts b/src/store/labelStore.ts index c1669d47..12223dec 100644 --- a/src/store/labelStore.ts +++ b/src/store/labelStore.ts @@ -60,6 +60,8 @@ export { selectSourceEditing, selectSourceEditDirty, selectDocumentEmits, + selectShadowRefusal, + selectShadowDraft, selectRenderPages, selectRenderVariables, selectRenderColumnMapping, diff --git a/src/store/slices/sourceEditSlice.test.ts b/src/store/slices/sourceEditSlice.test.ts index 2609729b..698ff3b8 100644 --- a/src/store/slices/sourceEditSlice.test.ts +++ b/src/store/slices/sourceEditSlice.test.ts @@ -95,6 +95,7 @@ describe("applyZplSource", () => { sourceShadow: { doc: { label: { widthMm: 70, heightMm: 40, dpmm: 8 }, pages: [basePages[0]!, { objects: [] }], variables: [], columnMapping: null }, refusal: null, + draft: "^XA^FDone^FS^XZ", }, currentPageIndex: 1, }); diff --git a/src/store/slices/sourceEditSlice.ts b/src/store/slices/sourceEditSlice.ts index 9e4f2bf3..98b2e24f 100644 --- a/src/store/slices/sourceEditSlice.ts +++ b/src/store/slices/sourceEditSlice.ts @@ -1,5 +1,5 @@ import type { StateCreator } from 'zustand'; -import type { SourceApplyPlan, SourceApplyOk } from '@zplab/core/lib/zplSourceEdit'; +import type { SourceApplyOk, SourceRefusalInfo } from '@zplab/core/lib/zplSourceEdit'; import type { LabelConfig } from '@zplab/core/types/LabelConfig'; import type { Page } from '@zplab/core/types/Group'; import type { ColumnMapping, Variable } from '@zplab/core/types/Variable'; @@ -20,7 +20,21 @@ export interface SourceShadow { variables: Variable[]; columnMapping: ColumnMapping | null; } | null; - refusal: Extract['reason'] | null; + refusal: SourceRefusalInfo | null; + /** The text `refusal` was parsed from; `doc` can be older (it keeps the + * last good parse). The debounced parse lags the live draft, so position + * consumers must check identity first. */ + draft: string; +} + +/** Canonical stored shape: callers hand in whatever they hold (a plan carries + * its own `ok`), and the slot keeps one object per verdict. */ +function refusalInfo(r: SourceRefusalInfo): SourceRefusalInfo { + if (r.reason === 'unbalanced') return { reason: r.reason, unbalanced: r.unbalanced }; + if (r.reason === 'blobLine') { + return r.command === undefined ? { reason: r.reason } : { reason: r.reason, command: r.command }; + } + return { reason: r.reason }; } // Session identity, not just status: dialog state and plans hang off the @@ -38,8 +52,9 @@ export interface SourceEditSlice { /** Written by the shadow-parse effect and by a refused apply; ignored * outside a session so a late parse cannot resurrect a preview. */ setSourceShadow: (shadow: SourceShadow) => void; - /** Refusal only, keeping the last good doc: the one "set a refusal" op. */ - setSourceRefusal: (refusal: SourceShadow['refusal']) => void; + /** Refusal only, keeping the last good doc: the one "set a refusal" op. + * `draft` is the text the refusal describes. */ + setSourceRefusal: (refusal: SourceShadow['refusal'], draft: string) => void; cancelSourceEdit: () => void; /** Commits a prepared plan as ONE undo step. Unlike loadDesign this keeps * history and dataset: the document keeps its identity, only its ZPL @@ -81,10 +96,16 @@ export const createSourceEditSlice: StateCreator set((state) => (state.sourceEdit.status === 'editing' ? { sourceShadow: shadow } : {})), - setSourceRefusal: (refusal) => + setSourceRefusal: (refusal, draft) => set((state) => state.sourceEdit.status === 'editing' - ? { sourceShadow: { doc: state.sourceShadow?.doc ?? null, refusal } } + ? { + sourceShadow: { + doc: state.sourceShadow?.doc ?? null, + refusal: refusal && refusalInfo(refusal), + draft, + }, + } : {}, ),