From 105ff75e76ecfa7486cf76bf5e29e7395ce874f3 Mon Sep 17 00:00:00 2001 From: u8array Date: Fri, 16 Oct 2026 02:04:41 +0200 Subject: [PATCH] feat(printer): a document's host commands become model content and query the printer * feat(core): a page keeps its host commands, folds them into its page label, persists them in the design file and hands them to a duplicate * feat(parser): a host command is asked, ignored or stored through nested recall frames, and the page keeps only what is answered, in canonical spelling * feat(generator): a page asks its tilde commands ahead of ^XA, its caret ones behind it and ahead of ^DF, and its ^HV with the fields * feat(generator): ^DF and ^JM inject behind the head's leading host commands, and a replayed block that would store one regenerates * feat(generator): a batch asks once in its first block, the field questions ride a recall only where the printer already holds the format * feat(import): a routed page names the commands it drops, a bare page that asks keeps its overlay and is wrapped only where a command needs the format * feat(import): a query-only stream counts as content, and a stream whose other pages ask stays pages instead of batch rows * feat(output): the network and USB ways on the desktop query the printer and open its reply, every other way says why no answer comes back * refactor(printer): one reader per host command id, so a reply holding several answers is read by shape * fix(transport): an empty printer reply is silence instead of an error, and the caller words it * chore(output): one hook for a printer read behind a dialog, and one block for a raw reply * feat(mcp): the tool descriptions name a page's host commands, and ^XF keys resolve through the parser's helper * chore(catalog): six host commands answer on the desktop and ~HB becomes planned, with the host-section helpers, coverage and README alongside * i18n: the query wordings, the silent reply and the two import lines in 33 languages --- README.md | 5 +- docs/zpl-coverage.md | 18 +- packages/core/src/catalog/catalog.test.ts | 16 +- packages/core/src/catalog/commands.json | 18 +- packages/core/src/catalog/index.ts | 11 ++ packages/core/src/catalog/schema.ts | 2 +- packages/core/src/lib/designFile.ts | 8 +- packages/core/src/lib/hostCommands.test.ts | 77 ++++++++ packages/core/src/lib/hostCommands.ts | 187 ++++++++++++++++++ packages/core/src/lib/hostDirectory.test.ts | 7 + packages/core/src/lib/hostDirectory.ts | 8 +- packages/core/src/lib/hostReplies.ts | 42 ++++ packages/core/src/lib/hostStatus.fixture.ts | 7 + packages/core/src/lib/hostStatus.test.ts | 27 ++- packages/core/src/lib/hostStatus.ts | 25 ++- packages/core/src/lib/importReport.ts | 20 +- packages/core/src/lib/recallBatch.test.ts | 20 +- packages/core/src/lib/storagePath.ts | 6 + packages/core/src/lib/storedFormat.test.ts | 5 +- .../src/lib/zplGenerator.hostCommands.test.ts | 185 +++++++++++++++++ packages/core/src/lib/zplGenerator.ts | 62 ++++-- packages/core/src/lib/zplHeadScan.ts | 10 +- packages/core/src/lib/zplImportService.ts | 57 ++++-- packages/core/src/lib/zplOverlay/overlay.ts | 3 + packages/core/src/lib/zplParser.ts | 52 +++-- packages/core/src/lib/zplParser/context.ts | 8 + .../src/lib/zplParser/handlers/unsupported.ts | 1 - packages/core/src/lib/zplParser/types.ts | 12 +- packages/core/src/lib/zplSourceEdit.test.ts | 31 +++ packages/core/src/lib/zplSourceEdit.ts | 10 +- packages/core/src/types/Group.ts | 41 +++- packages/core/src/types/LabelConfig.ts | 2 + packages/mcp-server/src/server.ts | 6 +- packages/mcp-server/src/tools.test.ts | 18 +- packages/mcp-server/src/tools.ts | 5 +- src-tauri/src/print.rs | 17 +- src-tauri/src/transport.rs | 23 +-- src-tauri/src/usb.rs | 6 + src/components/Output/AdoptSettingsDialog.tsx | 4 +- src/components/Output/DocumentQueryAction.tsx | 36 ++++ src/components/Output/DocumentReplyDialog.tsx | 38 ++++ .../Output/ImportSetupChoice.test.tsx | 21 ++ src/components/Output/ImportSetupChoice.tsx | 11 +- src/components/Output/ImportSummary.tsx | 2 + src/components/Output/OutputDialog.test.tsx | 17 ++ src/components/Output/OutputDialog.tsx | 20 +- src/components/Output/PrinterCheck.test.tsx | 2 - src/components/Output/PrinterCheck.tsx | 4 +- src/components/Output/PrinterChip.test.tsx | 2 - src/components/Output/PrinterChip.tsx | 28 +-- .../Output/PrinterConfigurationDialog.tsx | 20 +- src/components/Output/PrinterReplyDialog.tsx | 17 +- src/components/Output/QueryHints.tsx | 12 ++ src/components/Output/SystemPrintBody.tsx | 14 +- .../Output/ZPLOutput.sourceEdit.test.tsx | 11 ++ .../Output/ZebraWayPanel.query.test.tsx | 94 +++++++++ .../Output/ZebraWayPanel.testkit.tsx | 8 +- src/components/Output/ZebraWayPanel.tsx | 69 ++++--- .../Output/ZebraWayPanel.usb.test.tsx | 14 ++ src/components/Output/ZplImportModal.test.tsx | 34 ++++ src/components/Output/ZplImportModal.tsx | 19 +- .../PrinterSettings/PrintTargetTab.tsx | 12 +- .../PrinterSettings/PrinterImageDialog.tsx | 4 +- .../PrinterSettings/PrinterStorage.tsx | 22 +-- src/hooks/useReplyRead.ts | 18 ++ src/hooks/useSendNotices.ts | 4 +- src/lib/designFile.test.ts | 8 + src/lib/importReport.ts | 4 + src/lib/outputChoice.test.ts | 9 + src/lib/outputChoice.ts | 15 ++ src/lib/printPreview.test.ts | 8 + src/lib/printPreview.ts | 2 +- src/lib/printTarget.test.ts | 12 +- src/lib/printTarget.ts | 5 + src/lib/printWayText.test.ts | 33 +++- src/lib/printWayText.ts | 18 +- src/lib/printerDocumentQuery.test.ts | 77 ++++++++ src/lib/printerDocumentQuery.ts | 21 ++ src/lib/printerQuery.test.ts | 15 +- src/lib/printerQuery.ts | 14 +- src/lib/printerStatus.test.ts | 14 +- src/lib/printerStatus.ts | 47 ++--- src/lib/printerStatusText.ts | 40 +++- src/lib/sendZpl.test.ts | 8 + src/lib/sourceDiagnostics.test.ts | 5 + src/lib/usbPrint.ts | 1 + src/lib/zplImportService.test.ts | 152 +++++++++++++- src/lib/zplParser.test.ts | 17 +- src/locales/ar.ts | 11 ++ src/locales/bg.ts | 11 ++ src/locales/ca.ts | 11 ++ src/locales/cs.ts | 11 ++ src/locales/da.ts | 11 ++ src/locales/de.ts | 11 ++ src/locales/el.ts | 11 ++ src/locales/en.ts | 11 ++ src/locales/es.ts | 11 ++ src/locales/et.ts | 11 ++ src/locales/fa.ts | 11 ++ src/locales/fi.ts | 11 ++ src/locales/fr.ts | 11 ++ src/locales/he.ts | 11 ++ src/locales/hr.ts | 11 ++ src/locales/hu.ts | 11 ++ src/locales/it.ts | 11 ++ src/locales/ja.ts | 11 ++ src/locales/ko.ts | 11 ++ src/locales/lt.ts | 11 ++ src/locales/lv.ts | 11 ++ src/locales/nl.ts | 11 ++ src/locales/no.ts | 11 ++ src/locales/pl.ts | 11 ++ src/locales/pt.ts | 11 ++ src/locales/ro.ts | 11 ++ src/locales/sk.ts | 11 ++ src/locales/sl.ts | 11 ++ src/locales/sr.ts | 11 ++ src/locales/sv.ts | 11 ++ src/locales/tr.ts | 11 ++ src/locales/zh-hans.ts | 11 ++ src/locales/zh-hant.ts | 11 ++ src/store/documentEmits.test.ts | 15 ++ src/store/labelStore.selectors.ts | 19 +- src/store/labelStore.test.ts | 6 + src/store/pageKeys.tripwire.test.ts | 42 ++++ src/store/slices/objectSlice.ts | 15 +- src/store/slices/previewSlice.ts | 1 + src/store/slices/printerStateSlice.test.ts | 26 ++- src/store/slices/printerStateSlice.ts | 10 + 129 files changed, 2301 insertions(+), 336 deletions(-) create mode 100644 packages/core/src/lib/hostCommands.test.ts create mode 100644 packages/core/src/lib/hostCommands.ts create mode 100644 packages/core/src/lib/hostReplies.ts create mode 100644 packages/core/src/lib/hostStatus.fixture.ts create mode 100644 packages/core/src/lib/zplGenerator.hostCommands.test.ts create mode 100644 src/components/Output/DocumentQueryAction.tsx create mode 100644 src/components/Output/DocumentReplyDialog.tsx create mode 100644 src/components/Output/ImportSetupChoice.test.tsx create mode 100644 src/components/Output/QueryHints.tsx create mode 100644 src/components/Output/ZebraWayPanel.query.test.tsx create mode 100644 src/components/Output/ZplImportModal.test.tsx create mode 100644 src/hooks/useReplyRead.ts create mode 100644 src/lib/printerDocumentQuery.test.ts create mode 100644 src/lib/printerDocumentQuery.ts create mode 100644 src/store/documentEmits.test.ts create mode 100644 src/store/pageKeys.tripwire.test.ts diff --git a/README.md b/README.md index 884598daf..7ed5f4dd8 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ While you edit, the canvas renders the source. Canvas editing and the **Properti Import covers text, barcodes, shapes, label settings and `^FN` fields. Images include printer-stored and compressed graphics. `^FN` slots appear in the **Variables** tab. `^FE` embeds such as `^FD#1#-#2#` import as `«name»` markers. A stream that stores a format with `^DF` and declares `^FN` fields in it opens as that page. Its `^XF` recall blocks that carry only field data become the rows of a dataset, one row per block. - Unrecognised commands are listed in the import report and do not appear on the canvas. +- Host commands such as `~HS` stay with their page. On the network and USB ways they offer **Query printer**, and the reply opens in the dialog. - Commands between fields survive export. - Commands inside a field survive until you edit that object. - Whole-page regeneration drops every unrecognised command on that page. @@ -141,7 +142,7 @@ The **Fonts** tab uploads TrueType fonts and assigns their `^CW` alias. On the d **File → Import CSV data** (web) or **File → Connect data** (desktop) loads a CSV. Assign a column to each variable in the mapping dialog. **File → Export batch ZPL** writes one label per row and names the row count. **File → Output** sends the same rows to the printer, and the dialog names the labels it will produce. -The batch stores the page as a format on the printer and recalls it per row. A page that names a stored format keeps that name when the name itself is at most 8 characters. Longer names fall back to `R:LBL.ZPL`. +The batch stores the page as a format on the printer and recalls it per row. A page that names a stored format keeps that name when the name itself is at most 8 characters. Longer names fall back to `R:LBL.ZPL`. The batch carries only the one page. A stream whose rows or other pages ask the printer something opens as pages instead. On desktop, **Connect data** also reads an Excel worksheet. **File → Settings… → App → Database connection** loads rows from a read-only SQLite, PostgreSQL or MySQL database, with the password in the OS keychain. The design saves the assignments and the connection. @@ -188,7 +189,7 @@ On desktop, **File → Settings… → App → MCP** starts a local MCP server, ## Coverage -132 of the 225 ZPL II commands are modelled in the browser; desktop covers 9 more with a connected printer. 2 more are planned for both builds. 60 need a connected printer and are planned for desktop. See per-command coverage: [docs/zpl-coverage.md](docs/zpl-coverage.md). +132 of the 225 ZPL II commands are modelled in the browser; desktop covers 15 more with a connected printer. 2 more are planned for both builds. 55 need a connected printer and are planned for desktop. See per-command coverage: [docs/zpl-coverage.md](docs/zpl-coverage.md). | Area | Modelled | |---|---| diff --git a/docs/zpl-coverage.md b/docs/zpl-coverage.md index 7a259ea80..f6e6da4e1 100644 --- a/docs/zpl-coverage.md +++ b/docs/zpl-coverage.md @@ -4,8 +4,8 @@ Every command of the ZPL II Programming Guide and what it takes to use it in ZPL | Column | Meaning | |---|---| -| **Web** | The browser build reads the command on import and writes it back on export: as itself, folded into object properties (`^FW`, `^LR`, `^BY`, `^MU`, `^LH`, `^LT`, `^CI`), or into the Setup Script (printer setup commands). Structural commands (`^XA`, `^FS`, prefix changers) carry no user state | -| **Desktop** | The same in the desktop app, which also talks to a connected printer; commands that need one (host queries, calibration, downloads to printer storage, RFID hardware) can only ever get a mark here | +| **Web** | The browser build reads the command on import and writes it back on export: as itself, folded into object properties (`^FW`, `^LR`, `^BY`, `^MU`, `^LH`, `^LT`, `^CI`), or into the Setup Script (printer setup commands). Structural commands (`^XA`, `^FS`, prefix changers) carry no user state. Host commands count only where an answer can come back. The browser build has no channel for one | +| **Desktop** | The same in the desktop app, which also talks to a connected printer; commands that need one (host queries, calibration, downloads to printer storage, RFID hardware) can only ever get a mark here. `~HB` answers only on a mobile printer, so it stays planned until one is measured | | **Docker** | The same in the self-hosted image, which serves the web build today. A container can open a printer connection, so what the desktop reaches that way is planned here | | Mark | Meaning | @@ -273,21 +273,21 @@ Printer-side control, calibration and device actions; most need a connection or | Command | Name | Web | Desktop | Docker | |---|---|:-:|:-:|:-:| -| `~HB` | battery status, mobile printers only | `[ ]` | `[ ]` | `[ ]` | -| `~HD` | printhead diagnostic | `[ ]` | `[~]` | `[~]` | -| `^HF` | host format | `[ ]` | `[~]` | `[~]` | +| `~HB` | battery status, mobile printers only | `[ ]` | `[~]` | `[~]` | +| `~HD` | printhead diagnostic | `[ ]` | `[x]` | `[~]` | +| `^HF` | host format | `[ ]` | `[x]` | `[~]` | | `^HG` | host graphic | `[ ]` | `[x]` | `[~]` | | `^HH` | configuration label return | `[ ]` | `[x]` | `[~]` | | `~HI` | host identification | `[ ]` | `[x]` | `[~]` | | `~HM` | host RAM status | `[ ]` | `[x]` | `[~]` | -| `~HQ` | host query | `[ ]` | `[~]` | `[~]` | +| `~HQ` | host query | `[ ]` | `[x]` | `[~]` | | `~HS` | host status return | `[ ]` | `[x]` | `[~]` | -| `^HT` | host linked fonts list | `[ ]` | `[~]` | `[~]` | -| `~HU` | ZebraNet alert configuration | `[ ]` | `[~]` | `[~]` | +| `^HT` | host linked fonts list | `[ ]` | `[x]` | `[~]` | +| `~HU` | ZebraNet alert configuration | `[ ]` | `[x]` | `[~]` | | `^HV` | host verification | `[ ]` | `[~]` | `[~]` | | `^HW` | host directory | `[ ]` | `[x]` | `[~]` | | `^HY` | upload graphics | `[ ]` | `[x]` | `[~]` | -| `^HZ` | display description information | `[ ]` | `[~]` | `[~]` | +| `^HZ` | display description information | `[ ]` | `[x]` | `[~]` | ## RFID diff --git a/packages/core/src/catalog/catalog.test.ts b/packages/core/src/catalog/catalog.test.ts index e7c00440a..8ce2f0782 100644 --- a/packages/core/src/catalog/catalog.test.ts +++ b/packages/core/src/catalog/catalog.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import raw from "./commands.json"; import { catalogSchema, type StoredCommandEntry } from "./schema"; -import { CATALOG_SECTIONS, ZPL_COMMANDS, catalogEntry, commandId, commandIds, filterCatalog } from "./index"; +import { CATALOG_SECTIONS, ZPL_COMMANDS, catalogEntry, commandId, commandIds, commandSyntax, filterCatalog, isHostCommand } from "./index"; describe("zpl command catalog", () => { it("validates the checked-in data against the schema", () => { @@ -134,6 +134,20 @@ describe("zpl command catalog", () => { expect(filterCatalog("")).toHaveLength(ZPL_COMMANDS.length); }); + it("names a printer-bound loss only where the desktop does not answer the command", () => { + for (const entry of ZPL_COMMANDS) { + if (entry.loss === "printerComms") expect(entry.support.desktop, commandId(entry)).not.toBe("yes"); + } + }); + + it("knows the host section by its fifteen commands and which of them take no parameter", () => { + const hosts = ZPL_COMMANDS.map(commandId).filter(isHostCommand); + expect(hosts).toEqual(["~HB", "~HD", "^HF", "^HG", "^HH", "~HI", "~HM", "~HQ", "~HS", "^HT", "~HU", "^HV", "^HW", "^HY", "^HZ"]); + expect(hosts.filter((id) => commandSyntax(id) === id)).toEqual(["~HB", "~HD", "^HH", "~HI", "~HM", "~HS", "^HT", "~HU"]); + // ~HB answers only on a mobile printer, none measured. + expect(catalogEntry("~HB")?.support.desktop).toBe("planned"); + }); + it("carries the import loss cause the report wording is keyed by", () => { expect(catalogEntry("^A@")?.loss).toBe("fontFace"); expect(catalogEntry("~PH")?.loss).toBeUndefined(); diff --git a/packages/core/src/catalog/commands.json b/packages/core/src/catalog/commands.json index cfe9c2947..6b4efac46 100644 --- a/packages/core/src/catalog/commands.json +++ b/packages/core/src/catalog/commands.json @@ -6145,7 +6145,8 @@ }, "support": { "web": "no", - "desktop": "no" + "desktop": "planned", + "docker": "planned" } }, { @@ -6163,7 +6164,7 @@ }, "support": { "web": "no", - "desktop": "planned", + "desktop": "yes", "docker": "planned" } }, @@ -6201,7 +6202,7 @@ }, "support": { "web": "no", - "desktop": "planned", + "desktop": "yes", "docker": "planned" } }, @@ -6321,7 +6322,7 @@ }, "support": { "web": "no", - "desktop": "planned", + "desktop": "yes", "docker": "planned" } }, @@ -6359,10 +6360,9 @@ }, "support": { "web": "no", - "desktop": "planned", + "desktop": "yes", "docker": "planned" - }, - "loss": "printerComms" + } }, { "cmd": "HU", @@ -6379,7 +6379,7 @@ }, "support": { "web": "no", - "desktop": "planned", + "desktop": "yes", "docker": "planned" } }, @@ -6535,7 +6535,7 @@ }, "support": { "web": "no", - "desktop": "planned", + "desktop": "yes", "docker": "planned" } }, diff --git a/packages/core/src/catalog/index.ts b/packages/core/src/catalog/index.ts index 3fc2939a8..1f8062e8f 100644 --- a/packages/core/src/catalog/index.ts +++ b/packages/core/src/catalog/index.ts @@ -41,6 +41,17 @@ export function catalogEntry(command: string): ZplCommandEntry | undefined { return byId.get(`^${key}`) ?? byId.get(`~${key}`); } +/** The section whose commands all answer over the host port. */ +const HOST_SECTION = "Host communication"; + +/** True for a spelled id in the host section. Prefix-exact, so `^HS` is not `~HS`. */ +export function isHostCommand(command: string): boolean { + return catalogEntry(command)?.section === HOST_SECTION; +} + +/** The format line of the reference, such as `^HWd:o.x,f`, or empty where the catalog has none. */ +export const commandSyntax = (command: string): string => catalogEntry(command)?.reference?.syntax ?? ""; + /** False only when the catalog knows the name under the other prefix alone: `^DG` is not `~DG`. */ export function commandTakesPrefix(name: string, prefix: "^" | "~"): boolean { if (catalogEntry(`${prefix}${name}`)) return true; diff --git a/packages/core/src/catalog/schema.ts b/packages/core/src/catalog/schema.ts index 503353f3c..d6a9d3506 100644 --- a/packages/core/src/catalog/schema.ts +++ b/packages/core/src/catalog/schema.ts @@ -6,7 +6,7 @@ const SUPPORT_LEVELS = ["yes", "planned", "no"] as const; export type SupportLevel = (typeof SUPPORT_LEVELS)[number]; /** What a partial import drops; the app maps each cause to its report wording. */ -const IMPORT_LOSS_CAUSES = ["fontFace", "printerComms", "gfRawBinary", "qrFdMode", "storedGraphic", "shortPayload", "recallMagnification", "checksumMismatch", "oversizeUpload", "oversizeFontUpload", "unreplayableUpload", "cachedFaceDropped", "unshippableUpload", "unshippableFontName", "unshippableGraphicName", "fontVersionReplaced", "fnPartialInsert", "recallFormat", "recallSlot", "recallSettings"] as const; +const IMPORT_LOSS_CAUSES = ["fontFace", "printerComms", "gfRawBinary", "qrFdMode", "storedGraphic", "shortPayload", "recallMagnification", "checksumMismatch", "oversizeUpload", "oversizeFontUpload", "unreplayableUpload", "cachedFaceDropped", "unshippableUpload", "unshippableFontName", "unshippableGraphicName", "fontVersionReplaced", "fnPartialInsert", "recallFormat", "recallSlot", "recallSettings", "droppedHostCommand"] as const; export type ImportLossCause = (typeof IMPORT_LOSS_CAUSES)[number]; /** Support per axis as docs/zpl-coverage.md defines them. The image serves the web build, so `docker` is stored only where it diverges. */ diff --git a/packages/core/src/lib/designFile.ts b/packages/core/src/lib/designFile.ts index 63a19dac0..832fe325a 100644 --- a/packages/core/src/lib/designFile.ts +++ b/packages/core/src/lib/designFile.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { isHostCommandLine } from "./hostCommands"; import { DEFAULT_FONT_DEVICE, storedFormatPathSchema } from "./storagePath"; import { JM_DENSITY_VALUES, STORED_FORMAT_DELIVERIES, labelConfigSchema, sanitizeRfidEpc, type JmDensity, type LabelConfig, type StoredFormatDelivery } from "../types/LabelConfig"; import { labelObjectBaseSchema } from "../types/LabelObject"; @@ -10,6 +11,7 @@ import { } from "../types/Variable"; import { dbSourceRefSchema, type DbSourceRef } from "../types/DataSource"; import type { LabelObject } from "../types/Group"; +import { pageHostCommands } from "../types/Group"; import { blockOverlaySchema, overlayText, type BlockOverlay } from "./zplOverlay/overlay"; import { reconstructLegacyBlockHeads, storedFormatPathOf } from "./zplHeadScan"; import { visitLeavesInPages, foldSerialLeaf, fixTlc39SlotsLeaf, bindSingleMarkerLeaf, migrateGs1DatabarLeaf, safeUniqueNameById } from "./objectTree"; @@ -35,7 +37,7 @@ export interface DesignFileFailure { export type DesignFileResult = { ok: true; value: DesignFile } | DesignFileFailure; -export interface DesignFilePage { objects: LabelObject[]; overlay?: BlockOverlay; jmDensity?: JmDensity; storedFormatPath?: string; storedFormatDelivery?: StoredFormatDelivery } +export interface DesignFilePage { objects: LabelObject[]; overlay?: BlockOverlay; jmDensity?: JmDensity; storedFormatPath?: string; storedFormatDelivery?: StoredFormatDelivery; hostCommands?: readonly string[] } export interface DesignFile { label: LabelConfig; pages: DesignFilePage[]; @@ -80,8 +82,12 @@ const pageSchema = z.object({ jmDensity: z.enum(JM_DENSITY_VALUES).optional(), storedFormatPath: storedFormatPathSchema().optional(), storedFormatDelivery: z.enum(STORED_FORMAT_DELIVERIES).optional(), + hostCommands: z.array(z.string()).optional().transform((lines) => pageHostCommands({ hostCommands: lines?.filter(isHostCommandLine) })), }); +/** The page keys the file persists, for the tripwire that keeps every page field accounted for. */ +export const DESIGN_FILE_PAGE_KEYS = Object.keys(pageSchema.shape) as readonly (keyof typeof pageSchema.shape)[]; + const designFileSchema = z.object({ // v3 predates ^JM: it loads through the legacy JM reconstruction. v4 carries // the persisted density fields; both are read, anything else is rejected. diff --git a/packages/core/src/lib/hostCommands.test.ts b/packages/core/src/lib/hostCommands.test.ts new file mode 100644 index 000000000..31e260642 --- /dev/null +++ b/packages/core/src/lib/hostCommands.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { repliesToHost } from "./hostCommands"; + +describe("repliesToHost", () => { + it("accepts only the prefix the catalog spells", () => { + expect(repliesToHost("^XA~HS^HW^XZ")).toEqual(["~HS", "^HW"]); + expect(repliesToHost("^XA^HS~HW^FO10,10^XZ")).toEqual([]); + }); + + it("names every spelling once, in the order the printer meets them", () => { + expect(repliesToHost("^XA^HW R:*.*^FO10,10^FDx^FS~HS^HH~HS^XZ")).toEqual(["^HWR:*.*", "~HS", "^HH"]); + expect(repliesToHost("^XA~HQES~HQHA~HQES^XZ")).toEqual(["~HQES", "~HQHA"]); + }); + + it("is empty for a document that only prints", () => { + expect(repliesToHost("^XA^FO10,10^A0N,20,20^FDHS^FS^XZ")).toEqual([]); + }); + + // ^HW is a format command, so inside field data it is data, while an immediate ~HS acts anywhere. + it("leaves a tilde the firmware does not intercept as field data", () => { + expect(repliesToHost("^XA^FO10,10^FDsend ~HW the drive^FS^XZ")).toEqual([]); + }); + + it("counts an immediate host command inside field data", () => { + expect(repliesToHost("^XA^FO10,10^FDbefore ~HS after^FS^XZ")).toEqual(["~HS"]); + }); + + it("asks a format command stored behind a ^DF only where the same bytes recall the frame", () => { + const frame = "^XA^DFR:A.ZPL^FS^FO10,10^A0N,20,20^FN1^FS^HWR:*.*^HV1,20,H,E,F~HS^XZ"; + expect(repliesToHost(frame)).toEqual(["~HS"]); + expect(repliesToHost(`${frame}^XA^XFR:A.ZPL^FS^FN1^FDone^FS^XZ`)).toEqual(["~HS", "^HWR:*.*", "^HV1,20,H,E,F"]); + expect(repliesToHost(`${frame}^XA^XFR:B.ZPL^FS^XZ`)).toEqual(["~HS"]); + expect(repliesToHost("^XA^HWR:*.*^DFR:A.ZPL^FS^XZ")).toEqual(["^HWR:*.*"]); + expect(repliesToHost("^XA^FO10,10^FDx^FS^DFR:A.ZPL^FS^HWR:*.*^XZ")).toEqual([]); + }); + + it("resolves a recall like the printer: the device search order, a defaulted extension, and no match for another named device", () => { + expect(repliesToHost("^XA^DFE:A.ZPL^FS^FN1^FS^HWR:*.*^XZ^XA^XFA.ZPL^FS^FN1^FDx^FS^XZ")).toEqual(["^HWR:*.*"]); + expect(repliesToHost("^XA^DFR:A.ZPL^FS^FN1^FS^HWR:*.*^XZ^XA^XFR:A^FS^FN1^FDx^FS^XZ")).toEqual(["^HWR:*.*"]); + expect(repliesToHost("^XA^DFE:A.ZPL^FS^FN1^FS^HWR:*.*^XZ^XA^XFR:A.ZPL^FS^FN1^FDx^FS^XZ")).toEqual([]); + }); + + it("keeps a recall inside a stored frame for that frame's own recall, in either declaration order, through a chain and past a cycle", () => { + const storeA = "^XA^DFR:A.ZPL^FS^FO10,10^A0N,20,20^FN1^FS^HWR:*.*^XZ"; + const storeB = "^XA^DFR:B.ZPL^FS^XFR:A.ZPL^FS^HT^XZ"; + expect(repliesToHost(storeA + storeB)).toEqual([]); + expect(repliesToHost(storeA + storeB + "^XA^XFR:B.ZPL^FS^XZ")).toEqual(["^HWR:*.*", "^HT"]); + expect(repliesToHost(storeB + storeA + "^XA^XFR:B.ZPL^FS^XZ")).toEqual(["^HWR:*.*", "^HT"]); + const storeC = "^XA^DFR:C.ZPL^FS^XFR:B.ZPL^FS^HH^XZ"; + expect(repliesToHost(storeA + storeB + storeC + "^XA^XFR:C.ZPL^FS^XZ")).toEqual(["^HWR:*.*", "^HT", "^HH"]); + const self = "^XA^DFR:S.ZPL^FS^HWE:*.*^XFR:S.ZPL^FS^XZ"; + expect(repliesToHost(self + "^XA^XFR:S.ZPL^FS^XZ")).toEqual(["^HWE:*.*"]); + }); + + it("names the frame after the first usable ^DF, a later one being stored text", () => { + const block = "^XA^DFR:A.ZPL^FS^HZA^FS^DFR:B.ZPL^FS^HH^FS^XZ"; + expect(repliesToHost(block + "^XA^XFR:B.ZPL^FS^XZ")).toEqual([]); + expect(repliesToHost(block + "^XA^XFR:A.ZPL^FS^XZ")).toEqual(["^HZA", "^HH"]); + }); + + it("answers a format command only inside an open format, an immediate one anywhere", () => { + expect(repliesToHost("^HWR:*.*^XA^FO10,10^FDx^FS^XZ")).toEqual([]); + expect(repliesToHost("^XA^FO10,10^FDx^FS^XZ^HWR:*.*")).toEqual([]); + expect(repliesToHost("^XA^FO10,10^FDx^FS^XZ~HS")).toEqual(["~HS"]); + expect(repliesToHost("^HWR:*.*")).toEqual([]); + expect(repliesToHost("^XA^DFR:A.ZPL^FS^HWR:*.*^XZ^XFR:A.ZPL")).toEqual([]); + }); + + it("follows a ^CT remap to the new tilde char", () => { + expect(repliesToHost("^XA^CT/^FS/HS^XZ")).toEqual(["~HS"]); + expect(repliesToHost("^XA^CT/^FS~HS^XZ")).toEqual([]); + }); + + it("follows a ^CC remap to the new caret char", () => { + expect(repliesToHost("^XA^CC//HWR:*.*//XZ")).toEqual(["^HWR:*.*"]); + }); +}); diff --git a/packages/core/src/lib/hostCommands.ts b/packages/core/src/lib/hostCommands.ts new file mode 100644 index 000000000..1dd30aa55 --- /dev/null +++ b/packages/core/src/lib/hostCommands.ts @@ -0,0 +1,187 @@ +import { commandSyntax, commandTakesPrefix, isHostCommand } from "../catalog"; +import { CONTROL_BYTES_RE } from "./binaryText"; +import { canonicalStoredFormatPath, isStoredFormatPath, recallKeys, storageKey } from "./storagePath"; +import { applyPrefixRemap, tokenize, type TokenizerChars } from "./zplParser/helpers"; + +/** A canonical line is a two-letter id behind its prefix, then the parameters. */ +export const hostCommandParts = (line: string): { id: string; params: string } => ({ id: line.slice(0, 3), params: line.slice(3) }); + +/** ^HV returns a ^FN field of its own format, so unlike its section mates it stays with the fields. */ +export const isFieldHostCommand = (id: string): boolean => id === "^HV"; + +const fieldSlotOf = (line: string): number => Number(hostCommandParts(line).params.split(",")[0]); + +function withFieldSlot(line: string, slot: number): string { + const { id, params } = hostCommandParts(line); + const [, ...rest] = params.split(","); + return [`${id}${slot}`, ...rest].join(","); +} + +/** The ^HV lines follow a renumbered slot, since the field they name moved with it. */ +export function renumberFieldHostCommands(lines: readonly string[], slots: ReadonlyMap): string[] { + return lines.map((line) => { + if (!isFieldHostCommand(hostCommandParts(line).id)) return line; + const moved = slots.get(fieldSlotOf(line)); + return moved === undefined ? line : withFieldSlot(line, moved); + }); +} + +/** ^HV's header and terminator are field data, so its padding is the author's bytes, not the stream's. */ +const keepsPadding = (id: string): boolean => id === "^HV"; + +/** The bytes behind the id that are the command's own: none where the catalog's syntax takes no parameter, since the rest + * is the field data around it. A line break around them is always the stream's. */ +export const hostCommandParams = (id: string, rest: string): string => { + if (commandSyntax(id) === id) return ""; + return keepsPadding(id) ? rest.replace(/^[\r\n]+|[\r\n]+$/g, "") : rest.trim(); +}; + +/** A page's questions by where they go. */ +export interface HostCommandPlacement { + /** Ahead of the format, where an immediate command answers with no partial format in its status. */ + immediate: readonly string[]; + /** Once in the head, ahead of a ^DF that would store it. */ + head: readonly string[]; + field: readonly string[]; +} + +export function splitHostCommands(lines: readonly string[] = []): HostCommandPlacement { + const id = (line: string) => hostCommandParts(line).id; + return { + immediate: lines.filter((line) => id(line).startsWith("~")), + head: lines.filter((line) => id(line).startsWith("^") && !isFieldHostCommand(id(line))), + field: lines.filter((line) => isFieldHostCommand(id(line))), + }; +} + +/** The command as the generator spells it: canonical prefix, and the delimiter the syntax spells back to a comma. + * No such spelling exists for a literal comma in a remapped list, or for a remapped delimiter that is also a character + * of the syntax's own punctuation, such as the colon of a d:o.x path. */ +export function canonicalHostCommand(id: string, params: string, delimiter: string): string | undefined { + const syntax = commandSyntax(id).slice(3); + const listed = delimiter !== "," && syntax.includes(","); + if (listed && params.includes(",")) return undefined; + const punctuation = syntax.replace(/[A-Za-z0-9,]/g, ""); + if (listed && punctuation.includes(delimiter) && params.includes(delimiter)) return undefined; + const spelled = `${id}${listed ? params.split(delimiter).join(",") : params}`; + return isHostCommandLine(spelled) ? spelled : undefined; +} + +const PLAIN_CHARS: TokenizerChars = { caretChar: "^", tildeChar: "~", delimiterChar: "," }; + +/** One host command and nothing else, spelled as the generator spells it, so a line regenerates into a block without other bytes riding in. */ +export function isHostCommandLine(line: string): boolean { + if (/[\r\n]/.test(line) || CONTROL_BYTES_RE.test(line)) return false; + const [only, ...rest] = [...hostCommandTokens(line, { ...PLAIN_CHARS })]; + return rest.length === 0 && only?.start === 0 && only.end === line.length && `${only.id}${hostCommandParams(only.id, hostCommandParts(line).params)}` === line; +} + +interface CommandToken { + /** Canonical prefix and name, so a remapped caret still reads as `^`. */ + id: string; + rest: string; + start: number; + end: number; +} + +/** Every command in `zpl` but the ^CC, ^CT and ^CD that remap `chars` as they go, like the parser. */ +function* commandTokens(zpl: string, chars: TokenizerChars): Generator { + for (const t of tokenize(zpl, chars)) { + const id = `${zpl[t.start] === chars.caretChar ? "^" : "~"}${t.cmd}`; + if (applyPrefixRemap(chars, t.cmd, t.rest[0])) continue; + yield { id, rest: t.rest, start: t.start, end: t.end }; + } +} + +interface HostCommandToken { + id: string; + params: string; + start: number; + end: number; +} + +/** Whether `zpl` holds a caret command the printer honours only inside an open format. */ +export function needsOpenFormat(zpl: string): boolean { + for (const t of commandTokens(zpl, { ...PLAIN_CHARS })) { + if (t.id.startsWith("^") && !commandTakesPrefix(t.id.slice(1), "~")) return true; + } + return false; +} + +/** The host commands in `zpl` in the order the printer meets them. */ +export function* hostCommandTokens(zpl: string, chars: TokenizerChars): Generator { + for (const t of commandTokens(zpl, chars)) { + if (isHostCommand(t.id)) yield { id: t.id, params: hostCommandParams(t.id, t.rest), start: t.start, end: t.end }; + } +} + +/** Where the printer answers a host command: an immediate one anywhere, a format one only inside an open format, stored + * behind the block's ^DF and ignored otherwise. */ +export type HostAsk = "asked" | "ignored" | "stored"; + +interface HostCommandAsk extends HostCommandToken { + ask: HostAsk; +} + +/** A stored frame in printer order: its format questions and the recalls it makes, both resolved at its own recall. */ +type FrameEntry = HostCommandAsk | { recall: string }; + +/** The host commands in `zpl` in printer order with their {@link HostAsk}. A stored command is listed again as asked at + * every ^XF of its frame, through the recalls the frame holds. */ +export function* hostCommandAsks(zpl: string, chars: TokenizerChars, formatOpen = false): Generator { + const frames = new Map(); + // The visited set ends a recall cycle, so a frame that recalls itself stops there. + function* recalledAsks(path: string, visited: ReadonlySet): Generator { + for (const key of recallKeys(path.trimEnd())) { + const entries = frames.get(key); + if (entries === undefined) continue; + if (visited.has(key)) return; + const seen = new Set(visited).add(key); + for (const entry of entries) { + if ("recall" in entry) yield* recalledAsks(entry.recall, seen); + else yield { ...entry, ask: "asked" }; + } + return; + } + } + let open = formatOpen; + let frame: FrameEntry[] | undefined; + let named = false; + for (const t of commandTokens(zpl, chars)) { + if (t.id === "^XA" || t.id === "^XZ") { + open = t.id === "^XA"; + frame = undefined; + named = false; + } else if (t.id === "^DF" && open) { + // The first ^DF opens the frame and the first usable path names it, as the parser keys its template. + frame ??= []; + const raw = t.rest.trimEnd(); + if (!named && isStoredFormatPath(raw)) { + frames.set(storageKey(canonicalStoredFormatPath(raw)), frame); + named = true; + } + } else if (t.id === "^XF" && open) { + if (frame !== undefined) frame.push({ recall: t.rest }); + else yield* recalledAsks(t.rest, new Set()); + } else if (isHostCommand(t.id)) { + const token = { id: t.id, params: hostCommandParams(t.id, t.rest), start: t.start, end: t.end }; + if (t.id.startsWith("~") || (open && frame === undefined)) yield { ...token, ask: "asked" }; + else if (frame === undefined) yield { ...token, ask: "ignored" }; + else { + const stored: HostCommandAsk = { ...token, ask: "stored" }; + frame.push(stored); + yield stored; + } + } + } +} + +/** The host commands a document asks, each spelling once, in the order the printer meets them. */ +export function repliesToHost(zpl: string): string[] { + const found: string[] = []; + for (const t of hostCommandAsks(zpl, { ...PLAIN_CHARS })) { + const spelled = `${t.id}${t.params}`; + if (t.ask === "asked" && !found.includes(spelled)) found.push(spelled); + } + return found; +} diff --git a/packages/core/src/lib/hostDirectory.test.ts b/packages/core/src/lib/hostDirectory.test.ts index 4dfe4e112..7aeb2068f 100644 --- a/packages/core/src/lib/hostDirectory.test.ts +++ b/packages/core/src/lib/hostDirectory.test.ts @@ -24,6 +24,13 @@ describe("parseHostDirectories", () => { expect(dirs[1]?.objects.map(hostObjectPath)).toEqual(["E:ANGELINE_VINTAGE.TTF", "E:CHK06.PNG"]); }); + it("keeps the later listing of a drive a reply lists twice", () => { + const twice = + `${STX}\r\n- DIR R:*.* \r\n* R:OLD.ZPL 214 \r\n${ETX}` + + `${STX}\r\n- DIR R:*.* \r\n* R:NEW.ZPL 214 \r\n- 7467008 bytes free R: RAM \r\n${ETX}`; + expect(parseHostDirectories(twice).map((d) => [d.device, d.objects.map((o) => o.name), d.bytesFree])).toEqual([["R", ["NEW"], 7467008]]); + }); + it("reads firmware objects with their flag columns and ignores lines before the first DIR head", () => { const dirs = parseHostDirectories(`noise\n- DIR Z:*.*\n* Z:A.FNT 6839 P A \n* Z:EAN-13.BAR 0 P `); expect(dirs).toHaveLength(1); diff --git a/packages/core/src/lib/hostDirectory.ts b/packages/core/src/lib/hostDirectory.ts index 6d9f98f95..87340a168 100644 --- a/packages/core/src/lib/hostDirectory.ts +++ b/packages/core/src/lib/hostDirectory.ts @@ -44,8 +44,12 @@ export function parseHostDirectories(body: string): HostDirectory[] { for (const line of replyLines(body)) { const [, drive = ""] = DIR_LINE.exec(line) ?? []; if (drive) { - current = { device: drive.toUpperCase(), objects: [], bytesFree: undefined }; - out.push(current); + // A document that asks ^HW twice lists a drive twice, and the later listing is the fresher one. + const listing: HostDirectory = { device: drive.toUpperCase(), objects: [], bytesFree: undefined }; + const earlier = out.findIndex((d) => d.device === listing.device); + if (earlier === -1) out.push(listing); + else out[earlier] = listing; + current = listing; continue; } if (!current) continue; diff --git a/packages/core/src/lib/hostReplies.ts b/packages/core/src/lib/hostReplies.ts new file mode 100644 index 000000000..ec8477bce --- /dev/null +++ b/packages/core/src/lib/hostReplies.ts @@ -0,0 +1,42 @@ +import { hostCommandParts } from "./hostCommands"; +import { parseHostDirectories, type HostDirectory } from "./hostDirectory"; +import { + parseHostIdentification, + parseHostMemory, + parseHostQueryStatus, + parseHostStatus, + type HostMemory, + type HostQueryStatus, + type HostStatus, + type PrinterIdentity, +} from "./hostStatus"; + +/** What the parsers read from a reply, under the command that was asked. */ +export interface HostReplyParts { + identity?: PrinterIdentity; + flags?: HostQueryStatus; + memory?: HostMemory; + status?: HostStatus; + directories?: HostDirectory[]; +} + +function present(key: K, value: HostReplyParts[K]): HostReplyParts { + return value === undefined ? {} : { [key]: value }; +} + +const nonEmpty = (directories: HostDirectory[]): HostDirectory[] | undefined => (directories.length > 0 ? directories : undefined); + +const HOST_READINGS: readonly { id: string; read: (body: string) => HostReplyParts }[] = [ + { id: "~HI", read: (body) => present("identity", parseHostIdentification(body)) }, + // Only the ES flags of ~HQ have a parser. + { id: "~HQ", read: (body) => present("flags", parseHostQueryStatus(body)) }, + { id: "~HM", read: (body) => present("memory", parseHostMemory(body)) }, + { id: "~HS", read: (body) => present("status", parseHostStatus(body)) }, + { id: "^HW", read: (body) => present("directories", nonEmpty(parseHostDirectories(body))) }, +]; + +/** A reading matches an asked command by its id, since ~HQ names its subject behind it. A parser that finds nothing leaves its field out. */ +export function parseHostReplies(body: string, asked: readonly string[]): HostReplyParts { + const ids = asked.map((command) => hostCommandParts(command).id); + return HOST_READINGS.filter((r) => ids.includes(r.id)).reduce((parts, r) => ({ ...parts, ...r.read(body) }), {}); +} diff --git a/packages/core/src/lib/hostStatus.fixture.ts b/packages/core/src/lib/hostStatus.fixture.ts new file mode 100644 index 000000000..391a3b41b --- /dev/null +++ b/packages/core/src/lib/hostStatus.fixture.ts @@ -0,0 +1,7 @@ +// Replies measured on a ZD230, V89.21.46Z. +export const STX = "\u0002"; +export const ETX = "\u0003"; + +export const ZD230_HI = "ZD230,V89.21.46Z,8,4096KB,"; +export const ZD230_HS = `${STX}030,0,0,0892,000,0,0,0,000,0,0,0${ETX}\r\n${STX}000,0,0,0,1,2,0,0,0,0,3${ETX}\r\n${STX}1234${ETX}`; +export const ZD230_HW = `${STX}\r\n- DIR R:*.* \r\n* R:PRE.GRF 49924 \r\n\r\n- 7467008 bytes free R: RAM \r\n${ETX}`; diff --git a/packages/core/src/lib/hostStatus.test.ts b/packages/core/src/lib/hostStatus.test.ts index 2d1c5b898..8876462ed 100644 --- a/packages/core/src/lib/hostStatus.test.ts +++ b/packages/core/src/lib/hostStatus.test.ts @@ -1,12 +1,10 @@ import { describe, it, expect } from "vitest"; +import { ETX, STX, ZD230_HI, ZD230_HS } from "./hostStatus.fixture"; import { hostStatusAnswers, parseHostIdentification, parseHostMemory, parseHostQueryStatus, parseHostStatus, printerReadiness } from "./hostStatus"; -const STX = "\u0002"; -const ETX = "\u0003"; - describe("host replies", () => { it("reads the identity line of ~HI", () => { - expect(parseHostIdentification("ZD230,V89.21.46Z,8,4096KB,\r\n")).toEqual({ + expect(parseHostIdentification(`${ZD230_HI}\r\n`)).toEqual({ model: "ZD230", firmware: "V89.21.46Z", dpmm: 8, @@ -16,6 +14,20 @@ describe("host replies", () => { expect(parseHostIdentification("XXXXXX,V1.0.0,dpm,000KB,X")?.dpmm).toBeUndefined(); expect(parseHostIdentification("ZD230,V1.0,8,4096KB,X\rZZZ,V2,6,1024KB,")?.options).toBe("X"); expect(parseHostIdentification("")).toBeUndefined(); + expect(parseHostIdentification("ZQ520,89.21.46Z,8,4096,X")).toEqual({ model: "ZQ520", firmware: "89.21.46Z", dpmm: 8, memoryKb: 4096, options: "X" }); + // A lone ~HS string or memory line is not an identity. + expect(parseHostIdentification("030,0,0,0892,000,0,0,0,000,0,0,0")).toBeUndefined(); + expect(parseHostIdentification("4096,2048,1500")).toBeUndefined(); + }); + + it("finds the identity line behind the ~HS strings of the same reply", () => { + expect(parseHostIdentification(`${ZD230_HS}\r\n${ZD230_HI}`)).toEqual({ + model: "ZD230", + firmware: "V89.21.46Z", + dpmm: 8, + memoryKb: 4096, + options: "", + }); }); it("reads the two state strings of ~HS behind their control bytes", () => { @@ -71,7 +83,14 @@ describe("host replies", () => { it("reads the memory line of ~HM", () => { expect(parseHostMemory("1024,0780,0780")).toEqual({ totalKb: 1024, maxKb: 780, availableKb: 780 }); + expect(parseHostMemory("1024,0800,0696,0")).toEqual({ totalKb: 1024, maxKb: 800, availableKb: 696 }); expect(parseHostMemory("1024")).toBeUndefined(); + // A lone ~HS string is not memory. + expect(parseHostMemory("030,0,0,0892,000,0,0,0,000,0,0,0")).toBeUndefined(); + }); + + it("finds the memory line behind the ~HS strings of the same reply", () => { + expect(parseHostMemory(`${ZD230_HS}\r\n4096,2048,1500`)).toEqual({ totalKb: 4096, maxKb: 2048, availableKb: 1500 }); }); it("decodes the guide's ~HQES example into media out, head open and a printhead to clean", () => { diff --git a/packages/core/src/lib/hostStatus.ts b/packages/core/src/lib/hostStatus.ts index 6631b34f4..ce89d1029 100644 --- a/packages/core/src/lib/hostStatus.ts +++ b/packages/core/src/lib/hostStatus.ts @@ -27,10 +27,21 @@ export interface PrinterIdentity { options: string; } +/** One reply can hold several host commands' answers, so each parser finds its line by shape, not by position. */ +const splitLines = (body: string): string[][] => replyLines(body).map((line) => line.split(",")); +/** A reply to one command alone is one line, matched loosely, as long as its shape is not another command's. */ +const onlyLine = (lines: string[][], shape: (f: string[]) => boolean): string[] | undefined => { + const [only, second] = lines; + return only !== undefined && second === undefined && shape(only) ? only : undefined; +}; + /** ~HI answers one line `model,firmware,dpmm,memoryKB,options` (spec p.224). */ export function parseHostIdentification(body: string): PrinterIdentity | undefined { - const [first] = replyLines(body); - const [model, firmware, dpmm, memory, ...options] = first?.split(",") ?? []; + const lines = splitLines(body); + const line = + lines.find((f) => f.length >= 4 && /^V[\d.]/i.test(f[1] ?? "") && /^\d+\s*KB$/i.test(f[3] ?? "")) ?? + onlyLine(lines, (f) => f.length >= 4 && /[A-Za-z]/.test(f[1] ?? "")); + const [model, firmware, dpmm, memory, ...options] = line ?? []; if (!model || !firmware) return undefined; return { model, firmware, dpmm: int(dpmm), memoryKb: int(memory?.replace(/KB$/i, "")), options: options.join(",") }; } @@ -57,8 +68,7 @@ export interface HostStatus { /** Of the three strings ~HS answers, the state sits in the first two (spec p.233). */ export function parseHostStatus(body: string): HostStatus | undefined { - // Some firmware prepends a banner line, so the state strings are picked by field count. - const fields = replyLines(body).map((line) => line.split(",")); + const fields = splitLines(body); const first = fields.findIndex((f) => f.length >= 12); const a = fields[first] ?? []; const b = fields.slice(first + 1).find((f) => f.length >= 11) ?? []; @@ -92,8 +102,11 @@ export interface HostMemory { /** ~HM answers one line `total,maximum,available` in kilobytes (spec p.225). */ export function parseHostMemory(body: string): HostMemory | undefined { - const [first] = replyLines(body); - const [total, max, available] = (first?.split(",") ?? []).map(int); + const lines = splitLines(body); + const numeric = (f: string[]) => f.every((v) => /^\d+$/.test(v.trim())); + // The guide's example answers four fields, unmeasured on the ZD230. + const line = lines.find((f) => f.length === 3 && numeric(f)) ?? onlyLine(lines, (f) => f.length === 4 && numeric(f)); + const [total, max, available] = (line ?? []).map(int); if (total === undefined || max === undefined || available === undefined) return undefined; return { totalKb: total, maxKb: max, availableKb: available }; } diff --git a/packages/core/src/lib/importReport.ts b/packages/core/src/lib/importReport.ts index 842390564..37b27655e 100644 --- a/packages/core/src/lib/importReport.ts +++ b/packages/core/src/lib/importReport.ts @@ -26,11 +26,24 @@ export function replayRiskFindings(report: ImportReport): ImportFinding[] { return report.findings.filter((f) => f.kind === 'replayRisk'); } +/** The pages whose overlay routing strips. */ +export const riskPageIndexes = (report: ImportReport): ReadonlySet => new Set(replayRiskFindings(report).map((f) => f.pageIndex)); + /** Routable setup commands plus non-routable device actions, for the prompt list. */ export function printerCommandFindings(report: ImportReport): ImportFinding[] { return report.findings.filter((f) => f.kind === 'replayRisk' || f.kind === 'deviceAction'); } +/** Findings about bytes only an overlay replays, so they leave with the overlay. */ +const OVERLAY_BOUND_KINDS: ReadonlySet = new Set(['lossyEdit', 'deviceAction']); +export const overlayBound = (f: ImportFinding): boolean => OVERLAY_BOUND_KINDS.has(f.kind); + +/** The commands a routed page loses with its overlay, so the choice is made knowing them. */ +export function commandsLostByRouting(report: ImportReport): string[] { + const riskPages = riskPageIndexes(report); + return [...new Set(report.findings.filter((f) => f.kind === 'deviceAction' && riskPages.has(f.pageIndex)).map((f) => f.command))]; +} + /** `pageIndex` of a finding about the stream as a whole, which no page routing may drop. */ export const DOCUMENT_FINDING = -1; @@ -41,13 +54,11 @@ export function resolveRoutedReport( report: ImportReport, keptPageIndexes: readonly number[], ): ImportReport { - const riskPages = new Set(replayRiskFindings(report).map((f) => f.pageIndex)); + const riskPages = riskPageIndexes(report); const newIndexOf = new Map(keptPageIndexes.map((orig, i) => [orig, i])); const findings = report.findings.flatMap((f) => { if (f.kind === 'replayRisk') return []; - if ((f.kind === 'lossyEdit' || f.kind === 'deviceAction') && riskPages.has(f.pageIndex)) { - return []; - } + if (overlayBound(f) && riskPages.has(f.pageIndex)) return []; if (f.pageIndex === DOCUMENT_FINDING) return [f]; const newIndex = newIndexOf.get(f.pageIndex); // A setup-only page leaves with its block, but what the profile could not take is still lost. @@ -66,6 +77,7 @@ export function reportOf(findings: ImportFinding[]): ImportReport { unknown: dedupCommandsByKind(findings, 'unknown'), replayRisk: dedupCommandsByKind(findings, 'replayRisk'), deviceAction: dedupCommandsByKind(findings, 'deviceAction'), + hostQuery: dedupCommandsByKind(findings, 'hostQuery'), unterminatedField: dedupCommandsByKind(findings, 'unterminatedField'), hexControl: dedupCommandsByKind(findings, 'hexControl', 0), }; diff --git a/packages/core/src/lib/recallBatch.test.ts b/packages/core/src/lib/recallBatch.test.ts index a370eafdd..a9f50be57 100644 --- a/packages/core/src/lib/recallBatch.test.ts +++ b/packages/core/src/lib/recallBatch.test.ts @@ -3,7 +3,7 @@ import { importZplText } from "./zplImportService"; import { generateBatchZpl, generateMultiPageZPL } from "./zplGenerator"; import { prepareSourceApply, type SourceDocumentState } from "./zplSourceEdit"; import type { LabelConfig, PageLabel } from "../types/LabelConfig"; -import type { LabelObject } from "../types/Group"; +import { pageLabelConfig, type LabelObject, type Page } from "../types/Group"; const label: LabelConfig = { widthMm: 50, heightMm: 30, dpmm: 8 }; const obj = (id: string, type: string, props: object, y = 10): LabelObject => @@ -374,6 +374,24 @@ describe("^XF recall blocks fold into rows of their ^DF template", () => { expect(charset.batch).toBeUndefined(); }); + it("folds a template that asks the printer, and keeps the stream as pages when a row or another page asks too", () => { + const asking = importZplText([`^XA^DFR:A.ZPL^FS~HS${FIELD}^XZ`, "^XA^XFR:A.ZPL^FS^FN1^FDone^FS^XZ"].join(NL), 8); + expect(asking.batch?.dataset.rows).toEqual([["one"]]); + const page = asking.pages[0] as Page; + expect(generateBatchZpl(pageLabelConfig(label, page), page.objects, asking.variables, asking.batch!.dataset, asking.batch!.columnMapping)).toContain("~HS"); + // A send carries only the batch, so one asking row keeps every row as a page. + const row = importZplText([template("R:A.ZPL", FIELD), "^XA^XFR:A.ZPL^FS^FN1^FDone^FS^XZ", "^XA^XFR:A.ZPL^FS^FN1^FDtwo^FS~HM^XZ"].join(NL), 8); + expect(row.batch).toBeUndefined(); + expect(row.pages).toHaveLength(3); + const out = generateMultiPageZPL(label, row.pages, row.variables); + expect(out).toContain("~HM"); + expect(out).toContain("^FDtwo"); + // A page beside the batch asks too: the send would carry the batch alone and drop it. + const beside = importZplText(["^XA~HS^XZ", template("R:A.ZPL", FIELD), "^XA^XFR:A.ZPL^FS^FN1^FDone^FS^XZ"].join(NL), 8); + expect(beside.batch).toBeUndefined(); + expect(generateMultiPageZPL(label, beside.pages, beside.variables)).toContain("~HS"); + }); + it("binds no column to another page's variable, even when every slot number is taken or a page follows the template", () => { const cover = `^XA${Array.from({ length: 99 }, (_, i) => `^FO5,${5 + i * 5}^A0N,20,20^FN${i + 1}^FDc${i + 1}^FS`).join("")}^XZ`; const exhausted = importZplText([cover, template("R:A.ZPL", "^FO10,500^A0N,30,30^FN1^FS"), "^XA^XFR:A.ZPL^FS^FN1^FDrowA^FS^XZ"].join(NL), 8); diff --git a/packages/core/src/lib/storagePath.ts b/packages/core/src/lib/storagePath.ts index ba31e49d2..0e9b58db9 100644 --- a/packages/core/src/lib/storagePath.ts +++ b/packages/core/src/lib/storagePath.ts @@ -105,6 +105,12 @@ export function recallCandidates(p: StoragePath): string[] { return devices.map((device) => storageKey(formatStoragePath({ ...p, device }, true))); } +/** An ^XF path's keys through recallCandidates, the extension defaulting to .ZPL. */ +export function recallKeys(path: string): string[] { + const ref = parseStoragePath(path); + return ref ? recallCandidates({ ...ref, ext: ref.ext ?? "ZPL" }) : []; +} + /** ^ID pattern as a test over `device:name.ext`: `*` is the only wildcard, device defaults to R: and extension to .GRF (spec p.245). */ export function storagePathMatcher(pattern: string): (path: string) => boolean { const withDevice = storageKey(pattern); diff --git a/packages/core/src/lib/storedFormat.test.ts b/packages/core/src/lib/storedFormat.test.ts index d885b2900..eac16590e 100644 --- a/packages/core/src/lib/storedFormat.test.ts +++ b/packages/core/src/lib/storedFormat.test.ts @@ -215,10 +215,13 @@ describe("^DF in the design file, the source pane and the page label", () => { expect(removed.ok && removed.next.pages[0]?.storedFormatPath).toBeUndefined(); }); - it("folds the page's ^DF into its label and never another page's", () => { + it("folds the page's ^DF and host commands into its label and never another page's", () => { expect(pageLabelConfig(label, {})).toBe(label); expect(pageLabelConfig(label, { storedFormatPath: "R:B.ZPL" }).storedFormatPath).toBe("R:B.ZPL"); const carried = pageLabelConfig(label, { storedFormatPath: "R:B.ZPL" }); expect(pageLabelConfig(carried, {}).storedFormatPath).toBeUndefined(); + const asking = pageLabelConfig(label, { hostCommands: ["~HS"] }); + expect(asking.hostCommands).toEqual(["~HS"]); + expect(pageLabelConfig(asking, {}).hostCommands).toBeUndefined(); }); }); diff --git a/packages/core/src/lib/zplGenerator.hostCommands.test.ts b/packages/core/src/lib/zplGenerator.hostCommands.test.ts new file mode 100644 index 000000000..b963f4747 --- /dev/null +++ b/packages/core/src/lib/zplGenerator.hostCommands.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect } from "vitest"; +import { generateBatchZpl, generateMultiPageZPL } from "./zplGenerator"; +import { parseDesignFile, serializeDesign } from "./designFile"; +import { importZplText } from "./zplImportService"; +import { dropPageOverlays } from "./pageOverlay"; +import type { LabelConfig } from "../types/LabelConfig"; +import { pageLabelConfig, type Page } from "../types/Group"; + +const label: LabelConfig = { widthMm: 50, heightMm: 30, dpmm: 8 }; + +describe("host commands on a page", () => { + it("ask an immediate command ahead of the format and a format command right after ^XA, ahead of a ^DF that would store it", () => { + const out = generateMultiPageZPL(label, [{ objects: [], jmDensity: "B", storedFormatPath: "R:A.ZPL", hostCommands: ["~HS", "^HWR:*.*"] }], []); + expect(out).toContain("~HS"); + expect(out.indexOf("~HS")).toBeLessThan(out.indexOf("^XA")); + expect(out.indexOf("^XA")).toBeLessThan(out.indexOf("^HWR:*.*")); + expect(out.indexOf("^HWR:*.*")).toBeLessThan(out.indexOf("^DFR:A.ZPL")); + expect(out.indexOf("^DFR:A.ZPL")).toBeLessThan(out.indexOf("^JMB")); + }); + + it("enter the model in canonical spelling once the ^CC or ^CD that spelled them is gone", () => { + const r = importZplText("^XA^CC/^FO20,40/GB50,50,2/GB60,60,2/FS/HWR:*.*/XZ", 8); + expect(r.pages[0]?.overlay).toBeUndefined(); + expect(r.pages[0]?.hostCommands).toEqual(["^HWR:*.*"]); + const delim = importZplText("^XA^CD;^FO20;40^GB50;50;2^GB60;60;2^FS^HV1;20;H;E;F^XZ", 8); + expect(delim.pages[0]?.hostCommands).toEqual(["^HV1,20,H,E,F"]); + }); + + it("stay ahead of a ^DF set after the import, so the stored format never asks", () => { + const r = importZplText("^XA~HS^FO10,10^A0N,20,20^FDx^FS^XZ", 8); + const page = { ...(r.pages[0] as Page), storedFormatPath: "R:A.ZPL" }; + const out = generateMultiPageZPL(r.labelConfig as LabelConfig, [page], r.variables); + expect(out.indexOf("~HS")).toBeLessThan(out.indexOf("^DFR:A.ZPL")); + expect(out.split("~HS")).toHaveLength(2); + }); + + it("regenerate a replayed block whose later question a ^DF set after the import would store", () => { + const late = importZplText("^XA^FO10,10^A0N,20,20^FDx^FS^HWR:*.*^XZ", 8); + const lateOut = generateMultiPageZPL(late.labelConfig as LabelConfig, [{ ...(late.pages[0] as Page), storedFormatPath: "R:A.ZPL" }], late.variables); + expect(lateOut.indexOf("^HWR:*.*")).toBeLessThan(lateOut.indexOf("^DFR:A.ZPL")); + expect(lateOut.split("^HWR:*.*")).toHaveLength(2); + const led = importZplText("^XA^HV1,20,H,E,F~HS^FO10,10^A0N,20,20^FDx^FS^XZ", 8); + const ledOut = generateMultiPageZPL(led.labelConfig as LabelConfig, [{ ...(led.pages[0] as Page), storedFormatPath: "R:A.ZPL" }], led.variables); + expect(ledOut.indexOf("~HS")).toBeLessThan(ledOut.indexOf("^DFR:A.ZPL")); + expect(ledOut.indexOf("^DFR:A.ZPL")).toBeLessThan(ledOut.indexOf("^HV1,20,H,E,F")); + }); + + it("let a ^DF set after the import land ahead of a ^HV the block asks first, so the frame keeps it", () => { + const r = importZplText("^XA^HV1,20,H,E,F^FO10,10^A0N,20,20^FDx^FS^XZ", 8); + const out = generateMultiPageZPL(r.labelConfig as LabelConfig, [{ ...(r.pages[0] as Page), storedFormatPath: "R:A.ZPL" }], r.variables); + expect(out).toContain("^XA^DFR:A.ZPL^HV1,20,H,E,F^FO10,10"); + }); + + it("report a format command ahead of the first ^XA as dropped, and keep an immediate one there", () => { + const r = importZplText("^HWR:*.*^XA^FO10,10^A0N,20,20^FDx^FS^XZ", 8); + expect(r.pages[0]?.hostCommands).toBeUndefined(); + expect(r.report.hostQuery).toEqual([]); + expect(r.report.findings).toContainEqual(expect.objectContaining({ kind: "partial", command: "^HW", loss: "droppedHostCommand" })); + expect(importZplText("~HS^XA^FO10,10^A0N,20,20^FDx^FS^XZ", 8).pages[0]?.hostCommands).toEqual(["~HS"]); + }); + + it("report a format command behind the block's own ^DF as dropped, since only the frame would ask it", () => { + const behind = importZplText("^XA^DFR:A.ZPL^FS^FO10,10^A0N,20,20^FDx^FS^HWR:*.*^XZ", 8); + expect(behind.pages[0]?.hostCommands).toBeUndefined(); + expect(behind.report.findings).toContainEqual(expect.objectContaining({ kind: "partial", command: "^HW", loss: "droppedHostCommand" })); + expect(importZplText("^XA^HWR:*.*^DFR:A.ZPL^FS^FO10,10^A0N,20,20^FDx^FS^XZ", 8).pages[0]?.hostCommands).toEqual(["^HWR:*.*"]); + const wrapped = importZplText("^XA^HWR:\n*.*^FO10,10^A0N,20,20^FDx^FS^XZ", 8); + expect(wrapped.pages[0]?.hostCommands).toBeUndefined(); + expect(wrapped.report.findings).toContainEqual(expect.objectContaining({ kind: "partial", command: "^HW", loss: "droppedHostCommand" })); + }); + + it("are dropped by the design file unless they are canonical host command lines", () => { + const lines = ["^XZ^XA^FO0,0^GB10,10,1^FS", "hello", "~HS^XZ^XA^MCY^JUF", "~HS~HS", " ~HS", "~HS\u0000", "~hs", "^HW R:*.*", "~HS", "^HWR:*.*", "~HQES", "^HV1,20,H,E,F"]; + const json = serializeDesign(label, [{ objects: [], hostCommands: lines }], []); + const loaded = parseDesignFile(json); + if (!loaded.ok) throw new Error(loaded.error); + expect(loaded.value.pages[0]?.hostCommands).toEqual(["~HS", "^HWR:*.*", "~HQES", "^HV1,20,H,E,F"]); + }); + + it("come out once, whether the overlay replays them or the model regenerates them", () => { + const r = importZplText("^XA~HS^FO10,10^A0N,20,20^FDx^FS^XZ", 8); + const count = (text: string) => text.split("~HS").length - 1; + expect(count(generateMultiPageZPL(r.labelConfig as LabelConfig, r.pages, r.variables))).toBe(1); + // Appending to an open design drops the overlays, as the store does. + expect(count(generateMultiPageZPL(r.labelConfig as LabelConfig, dropPageOverlays(r.pages), r.variables))).toBe(1); + }); + + it("replay a bare immediate command as it came, with no format around it", () => { + const r = importZplText("~HS", 8); + expect(r.pages[0]?.hostCommands).toEqual(["~HS"]); + expect(generateMultiPageZPL(r.labelConfig as LabelConfig, r.pages, r.variables)).toBe("~HS"); + }); + + it("keep ^HV's trailing space through the import and the regenerated block", () => { + const r = importZplText("^XA^FO10,10^A0N,20,20^FN1^FS^HV1,20,HEADER ^XZ", 8); + expect(r.pages[0]?.hostCommands).toEqual(["^HV1,20,HEADER "]); + expect(generateMultiPageZPL(r.labelConfig as LabelConfig, dropPageOverlays(r.pages), r.variables)).toContain("^HV1,20,HEADER \n^XZ"); + }); + + it("keep ^HV with the fields it reads, in the block and in the stored frame", () => { + const out = generateMultiPageZPL(label, [{ objects: [], hostCommands: ["~HS", "^HV1,20,H,E,F"] }], []); + expect(out).toContain("~HS"); + expect(out.indexOf("~HS")).toBeLessThan(out.indexOf("^PW")); + expect(out.indexOf("^HV1,20,H,E,F")).toBeGreaterThan(out.indexOf("^CI28")); + expect(out.indexOf("^HV1,20,H,E,F")).toBeLessThan(out.indexOf("^XZ")); + const r = importZplText(["^XA^DFR:A.ZPL^FS^FO20,20^A0N,20,20^FN1^FS^HV1,20,H,E,F~HS^XZ", "^XA^XFR:A.ZPL^FS^FN1^FDone^FS^XZ"].join(String.fromCharCode(10)), 8); + const page = r.pages[0] as Page; + const batch = generateBatchZpl(pageLabelConfig(label, page), page.objects, r.variables, r.batch!.dataset, r.batch!.columnMapping); + const frameEnd = batch.indexOf("^XZ" + String.fromCharCode(10)) + 4; + const [frame, recall] = [batch.slice(0, frameEnd), batch.slice(frameEnd)]; + expect(frame).toContain("^HV1,20,H,E,F"); + expect(frame).not.toContain("~HS"); + expect(recall).toContain("~HS"); + expect(recall.indexOf("~HS")).toBeLessThan(recall.indexOf("^XA")); + expect(recall).not.toContain("^HV"); + }); + + it.each([ + ["~HB", ""], ["~HD", ""], ["^HF", "R:FMT.ZPL"], ["^HG", "R:LOGO.GRF"], ["^HH", ""], ["~HI", ""], ["~HM", ""], ["~HQ", "ES"], + ["~HS", ""], ["^HT", ""], ["~HU", ""], ["^HV", "1,20,H,E,F"], ["^HW", "R:*.*"], ["^HY", "E:LOGO.PNG"], ["^HZ", "O"], + ])("%s%s is reported, kept in the model and placed by its kind", (id, payload) => { + const r = importZplText(`^XA${id}${payload}^FO20,40^GB50,50,2^GB60,60,2^FS^XZ`, 8); + expect(r.pages[0]?.overlay).toBeUndefined(); + expect(r.report.hostQuery).toEqual([`${id}${payload}`]); + expect(r.pages[0]?.hostCommands).toEqual([`${id}${payload}`]); + const out = generateMultiPageZPL(r.labelConfig as LabelConfig, r.pages, r.variables); + expect(out.split(`${id}${payload}`)).toHaveLength(2); + if (id === "^HV") expect(out.indexOf(id)).toBeGreaterThan(out.indexOf("^CI28")); + else expect(out.indexOf(id)).toBeLessThan(out.indexOf("^PW")); + }); + + it("report a ^HW whose remapped delimiter is its path's own colon, and spell a remapped semicolon back", () => { + const colon = importZplText("^XA^CD:^FO20:40^GB50:50:2^GB60:60:2^FS^HWR:*.*^XZ", 8); + expect(colon.pages[0]?.hostCommands).toBeUndefined(); + expect(colon.report.findings).toContainEqual(expect.objectContaining({ kind: "partial", command: "^HW", loss: "droppedHostCommand" })); + const semicolon = importZplText("^XA^CD;^FO20;40^GB50;50;2^GB60;60;2^FS^HWR:*.*;B^XZ", 8); + expect(semicolon.pages[0]?.hostCommands).toEqual(["^HWR:*.*,B"]); + }); + + it("rename a ^HV whose slot moved", () => { + const NL = String.fromCharCode(10); + const moved = importZplText(["^XA^FO10,10^A0N,20,20^FN1^FDa^FS^XZ", "^XA^FO10,10^A0N,20,20^FN1^FDb^FS^HV1,20,H,E,F^XZ"].join(NL), 8); + expect(moved.report.findings.some((f) => f.kind === "fnRenumbered")).toBe(true); + expect(moved.pages[1]?.hostCommands).toEqual(["^HV2,20,H,E,F"]); + }); + + it("report a ^HV whose remapped list holds a literal comma as dropped, and keep it only while its overlay replays it", () => { + const replayed = importZplText("^XA^CD;^FO20;40^A0N;20;20^FDx^FS^HV1;20;A,B;END;F^XZ", 8); + expect(replayed.pages[0]?.overlay).toBeDefined(); + expect(replayed.pages[0]?.hostCommands).toBeUndefined(); + expect(generateMultiPageZPL(replayed.labelConfig as LabelConfig, replayed.pages, replayed.variables)).toContain("^HV1;20;A,B;END;F"); + const dropped = importZplText("^XA^CD;^FO20;40^GB50;50;2^GB60;60;2^FS^HV1;20;A,B;END;F^XZ", 8); + expect(dropped.pages[0]?.overlay).toBeUndefined(); + expect(generateMultiPageZPL(dropped.labelConfig as LabelConfig, dropped.pages, dropped.variables)).not.toContain("^HV"); + expect(dropped.report.hostQuery).toEqual([]); + expect(dropped.report.findings).toContainEqual(expect.objectContaining({ kind: "partial", command: "^HV", loss: "droppedHostCommand" })); + }); + + it("keep ^HV on every recall of a frame this app never stored, and ask the immediate question once in either case", () => { + const r = importZplText(["^XA^DFR:A.ZPL^FS^FO20,20^A0N,20,20^FN1^FS^HV1,20,H,E,F~HS^XZ", "^XA^XFR:A.ZPL^FS^FN1^FDone^FS^XZ", "^XA^XFR:A.ZPL^FS^FN1^FDtwo^FS^XZ"].join(String.fromCharCode(10)), 8); + const page = r.pages[0] as Page; + const blocks = (delivery: "setup" | "printer") => + generateBatchZpl(pageLabelConfig(label, { ...page, storedFormatDelivery: delivery }), page.objects, r.variables, r.batch!.dataset, r.batch!.columnMapping).split("^XZ" + String.fromCharCode(10) + "^XA"); + const held = blocks("printer"); + expect(held).toHaveLength(2); + expect(held.map((b) => b.includes("^HV1,20,H,E,F"))).toEqual([true, true]); + expect(held.map((b) => b.includes("~HS"))).toEqual([true, false]); + const [first] = held; + if (first === undefined) throw new Error("two blocks"); + expect(first.indexOf("^FDone")).toBeLessThan(first.indexOf("^HV1,20,H,E,F")); + const stored = blocks("setup"); + expect(stored.map((b) => b.includes("^HV"))).toEqual([false, false]); + expect(stored.map((b) => b.includes("~HS"))).toEqual([true, false]); + const doc = generateMultiPageZPL(label, [{ ...page, storedFormatDelivery: "printer" }], r.variables); + expect(doc.indexOf("^XFR:A.ZPL")).toBeLessThan(doc.indexOf("^HV1,20,H,E,F")); + expect(doc.split("^HV")).toHaveLength(2); + }); + + it("stay ahead of a ^DF when the block's tilde was remapped", () => { + const r = importZplText("^XA^CT#^XZ" + String.fromCharCode(10) + "^XA#HS^FO10,10^A0N,20,20^FDx^FS^XZ", 8); + const page = { ...(r.pages[1] as Page), storedFormatPath: "R:A.ZPL" }; + const out = generateMultiPageZPL(r.labelConfig as LabelConfig, [page], r.variables); + expect(out.indexOf("#HS")).toBeLessThan(out.indexOf("^DFR:A.ZPL")); + }); +}); diff --git a/packages/core/src/lib/zplGenerator.ts b/packages/core/src/lib/zplGenerator.ts index 0c132665d..33bb39c3f 100644 --- a/packages/core/src/lib/zplGenerator.ts +++ b/packages/core/src/lib/zplGenerator.ts @@ -1,12 +1,13 @@ import { mmToDots } from './coordinates'; import { CONTROL_BYTES_RE } from './binaryText'; import { unsafeRawFieldSpans } from './zplParser/helpers'; +import { hostCommandTokens, isFieldHostCommand, splitHostCommands, type HostCommandPlacement } from './hostCommands'; import { rewriteRawFieldSpans } from './zplParser/decoders/gfa'; import { getEntry, loneBindFdTransform, usesPlainCode128Escape, BARCODE_1D_TYPES } from '../registry'; import { fdField, stripZplCommandChars, GRAPHIC_ANCHOR_TYPES, printerAnchoredX } from '../registry/zplHelpers'; import { stripZplParamChars } from './zplParams'; import { isRecallableFormatPath, storageKey, storageRefMatchesPath } from './storagePath'; -import { recallOnlyPath } from './storedFormat'; +import { formatDelivery, recallOnlyPath } from './storedFormat'; import { extractTemplateRefs, hasTemplateMarkers, @@ -251,7 +252,7 @@ export function generateMultiPageZplWithMap( if (out.length > 0 && !out.endsWith('\n')) out += '\n'; const base = out.length; const { recalls } = planRecalls(pageLabel, p.objects, variables, NO_ROWS, NO_BINDINGS); - out += `${regen.preamble ?? ''}${recallBlock(recallPath, recallFields(recalls))}`; + out += `${regen.preamble ?? ''}${recallBlock(recallPath, recallFields(recalls), recallHostCommands(pageLabel, 'first'))}`; blocks.push({ start: base, end: out.length }); wireUnknown = true; return; @@ -282,6 +283,9 @@ export function generateMultiPageZplWithMap( wireJm = target; } } + if (emitted.head && !emitted.head.dfSpans?.length && pageLabel.storedFormatPath !== undefined && wouldStoreAHostCommand(emitted.block, emitted.head)) { + emitted = regenPage(); + } const stored = applyStoredFormat(emitted, pageLabel.storedFormatPath); emitted = { ...emitted, block: stored.block, head: stored.head }; const applied = applyJmDensity(emitted, pageLabel.jmDensity, !wireUnknown && target === wireJm ? undefined : target); @@ -437,6 +441,25 @@ function shiftHead(head: FormatHead, edits: readonly HeadEdit[], dfSpans: readon }; } +/** The head's leading run of questions: a ^DF or ^JM goes behind it, and a format question past it would be stored + * with the format. The run ends at the first byte that is not a question, so no ^CC sits inside it. */ +function headHostCommandRun(block: string, head: FormatHead): { anchor: number; storesAQuestion: boolean } { + const tail = block.slice(head.at); + let anchor = 0; + let leading = true; + let storesAQuestion = false; + // A design file without the recorded tilde may store a question behind a ^CT remap. + for (const t of hostCommandTokens(tail, { caretChar: head.caret, tildeChar: head.tilde ?? '~', delimiterChar: ',' })) { + leading &&= tail.slice(anchor, t.start).trim() === '' && !isFieldHostCommand(t.id); + if (leading) anchor = t.end; + else if (!isFieldHostCommand(t.id)) storesAQuestion = true; + } + return { anchor: head.at + anchor, storesAQuestion }; +} + +const headAnchor = (block: string, head: FormatHead): number => headHostCommandRun(block, head).anchor; +const wouldStoreAHostCommand = (block: string, head: FormatHead): boolean => headHostCommandRun(block, head).storesAQuestion; + /** Rewrites, cuts or inserts the block's ^DF so it names `path`. Untouched bytes replay as they are; * once the model differs it owns the head, and a second declaration is cut with the first rewritten. */ function applyStoredFormat( @@ -449,12 +472,13 @@ function applyStoredFormat( const first = spans[0]; if (!first) { if (path === undefined) return { block, head, edits: [] }; - // Right after the ^XA (p.174); a ^JM inserted later lands behind it. + // At the head (p.174), behind the questions the block asks first, where a ^JM inserted later lands too. + const at = headAnchor(block, head); const injected = `${head.caret}DF${path}`; - const edits = [{ at: head.at, delta: injected.length }]; + const edits = [{ at, delta: injected.length }]; return { - block: `${block.slice(0, head.at)}${injected}${block.slice(head.at)}`, - head: shiftHead(head, edits, [{ start: head.at, end: head.at + injected.length, caret: head.caret, path }]), + block: `${block.slice(0, at)}${injected}${block.slice(at)}`, + head: shiftHead(head, edits, [{ start: at, end: at + injected.length, caret: head.caret, path }]), edits, }; } @@ -516,10 +540,10 @@ function applyJmDensity( if (!target) return { block, wireJm: undefined, edits: [] }; // Behind the head's last ^JM or ^DF, whichever comes later, since an unreadable ^JM must not // outrank a fresh one and a density ahead of the ^DF would sit outside the stored format. - // With no spans, the ^XA caret is the injection point. + // With no spans, the head behind any question is the injection point. const lastDf = head.dfSpans?.[head.dfSpans.length - 1]; const tail = [head.jmSpans[head.jmSpans.length - 1], lastDf].filter((s) => s !== undefined).sort((a, b) => a.end - b.end).pop(); - const at = tail?.end ?? head.at; + const at = tail?.end ?? headAnchor(block, head); const caret = tail?.caret ?? head.caret; const injected = `${caret}JM${target}`; return { @@ -802,8 +826,16 @@ function emitPageBlock( /** Where a batch stores its template when the design names none ^XF could recall: R: is volatile RAM, matching a single run. */ const BATCH_TEMPLATE_PATH = 'R:LBL.ZPL'; -function recallBlock(path: string, fields: readonly string[]): string { - return ['^XA', `^XF${path}`, ...fields, '^XZ'].join('\n'); +function recallBlock(path: string, fields: readonly string[], asks: HostCommandPlacement): string { + return [...asks.immediate, '^XA', ...asks.head, `^XF${path}`, ...fields, ...asks.field, '^XZ'].join('\n'); +} + +/** What a recall block asks: the immediate and head questions once per job, the field questions only where the printer + * already holds the format. */ +function recallHostCommands(label: PageLabel, block: 'first' | 'repeat'): HostCommandPlacement { + const { immediate, head, field } = splitHostCommands(label.hostCommands); + const once = block === 'first'; + return { immediate: once ? immediate : [], head: once ? head : [], field: formatDelivery(label) === 'printer' ? field : [] }; } interface RecallEntry { @@ -878,7 +910,8 @@ function planRecalls( // Supplied slots stay bare in the stored format; the recall fills them. const bareFns = new Set(entries.map((e) => e.fn)); const path = recallOnlyPath(label) ?? (label.storedFormatPath && isRecallableFormatPath(label.storedFormatPath) ? label.storedFormatPath : BATCH_TEMPLATE_PATH); - const template = generateZplBlock({ ...label, storedFormatPath: path }, objects, variables, bareFns); + // Only the field questions are stored, the recall asks the rest, so no question goes out twice. + const template = generateZplBlock({ ...label, storedFormatPath: path, hostCommands: splitHostCommands(label.hostCommands).field }, objects, variables, bareFns); // A recall for a slot the stored format never declares prints as a stray field (ZD230). const stored = declaredSlots(template.block); return { template, path, recalls: entries.filter((e) => stored.has(e.fn)) }; @@ -920,7 +953,8 @@ export function generateBatchZpl( columnMapping: { bindings: Record }, ): string { const { template, path, recalls } = planRecalls(label, objects, variables, dataset, columnMapping); - const recallBlocks = dataset.rows.map((row) => recallBlock(path, recallFields(recalls, row))); + // The job asks once, in its first block. + const recallBlocks = dataset.rows.map((row, i) => recallBlock(path, recallFields(recalls, row), recallHostCommands(label, i === 0 ? 'first' : 'repeat'))); const lead = recallOnlyPath(label) === undefined ? template.block : (template.preamble ?? '').trimEnd(); return [...(lead === '' ? [] : [lead]), ...recallBlocks].join('\n'); } @@ -1064,12 +1098,15 @@ function generateZplBlock( // ~JS is immediate/transient like ~SD; emit before ^XA. if (label.backfeedSequence) lines.push(`~JS${label.backfeedSequence}`); + const questions = splitHostCommands(label.hostCommands); + lines.push(...questions.immediate); const preambleLines = lines.length; lines.push('^XA'); const lineStart = () => lines.reduce((n, l) => n + l.length + 1, 0); // Offset just past the ^XA: every preceding line plus its newline. const headAt = lineStart() - 1; + lines.push(...questions.head); // ^DF stores everything after it, so it comes first (p.174), and without ^FS // (p.269: a terminator ahead of ^JM disables the density). let dfSpans: DfSpan[] | undefined; @@ -1205,6 +1242,7 @@ function generateZplBlock( lines.push(`^PQ${pq}`); } + lines.push(...questions.field); lines.push('^XZ'); // Per-line aliasing so the span offsets stay true against the final bytes; diff --git a/packages/core/src/lib/zplHeadScan.ts b/packages/core/src/lib/zplHeadScan.ts index 51b0e04f5..a19ac7bd6 100644 --- a/packages/core/src/lib/zplHeadScan.ts +++ b/packages/core/src/lib/zplHeadScan.ts @@ -41,6 +41,8 @@ interface HeadToken { isCaret: boolean; /** Caret prefix live at this token, for a ^JM behind a ^CC remap. */ caret: string; + /** Tilde prefix live at this token, so a head records what its questions were spelled with. */ + tilde: string; /** Delimiter live at this token, for reading a ^JM value. */ delim: string; } @@ -60,7 +62,7 @@ function* headTokens(zpl: string, fromOffset: number, st: PrefixState): Generato for (const t of tokenize(zpl.slice(fromOffset), st)) { if (applyPrefixRemap(st, t.cmd, t.rest[0])) continue; const start = fromOffset + t.start; - yield { cmd: t.cmd, rest: t.rest, start, isCaret: zpl[start] === st.caretChar, caret: st.caretChar, delim: st.delimiterChar }; + yield { cmd: t.cmd, rest: t.rest, start, isCaret: zpl[start] === st.caretChar, caret: st.caretChar, tilde: st.tildeChar, delim: st.delimiterChar }; } } @@ -110,6 +112,7 @@ function scanBlockHead( ): { density: JmDensity | undefined; head: FormatHead | undefined; storedFormat: StoredFormatHead | undefined } { let seenXa = false; let openerCaret = ""; + let openerTilde = ""; let at: number | null = null; let inHead = false; let density: JmDensity | undefined; @@ -121,6 +124,7 @@ function scanBlockHead( seenXa = true; inHead = true; openerCaret = t.caret; + openerTilde = t.tilde; at = t.start + 3; } continue; @@ -136,7 +140,7 @@ function scanBlockHead( density = jmDensityOf(t.rest, t.delim) ?? density; jmSpans.push({ start: t.start, end: t.start + MIN_JM_SPAN + t.rest.trimEnd().length, delim: t.delim, caret: t.caret }); } - const head: FormatHead | undefined = at === null ? undefined : { caret: openerCaret, at, jmSpans, ...(storedFormat ? { dfSpans: storedFormat.spans } : {}) }; + const head: FormatHead | undefined = at === null ? undefined : { caret: openerCaret, tilde: openerTilde, at, jmSpans, ...(storedFormat ? { dfSpans: storedFormat.spans } : {}) }; return { density, head, storedFormat }; } @@ -180,7 +184,7 @@ export function scanBareStream( let inHead = true; const st: PrefixState = { caretChar: chars.caretChar, tildeChar: chars.tildeChar, delimiterChar: delimiter }; for (const t of headTokens(zpl, 0, st)) { - if (t.cmd === "XA") return { hasXa: true, density: undefined, storedFormat: undefined }; + if (t.cmd === "XA" && t.isCaret) return { hasXa: true, density: undefined, storedFormat: undefined }; if (t.cmd === "FS" || t.cmd === "XZ") { inHead = false; continue; } if (!inHead) continue; storedFormat = collectStoredFormat(storedFormat, storedFormatOf(t)); diff --git a/packages/core/src/lib/zplImportService.ts b/packages/core/src/lib/zplImportService.ts index 70dd60bd7..925b11fa1 100644 --- a/packages/core/src/lib/zplImportService.ts +++ b/packages/core/src/lib/zplImportService.ts @@ -1,7 +1,12 @@ import { payloadSettings } from "./payloadProps"; +import { overlayText, type BlockOverlay } from "./zplOverlay/overlay"; +import { prefixCharsAt, spellPrefix } from "./zplCanonicalPrefixes"; import type { ParsedPage, PageSource, SourceSpan } from "./zplParser/types"; import { parseZPL, type FontLossReason, type ImportFinding, type ImportReport, type ParsedZPL, type UnbalancedFormat } from "./zplParser"; -import { DOCUMENT_FINDING, replayRiskFindings, reportOf } from "./importReport"; +import { DOCUMENT_FINDING, overlayBound, reportOf, riskPageIndexes } from "./importReport"; +import { needsOpenFormat, renumberFieldHostCommands } from "./hostCommands"; +import { pageEmits, pageHasContent, pageHostCommands } from "../types/Group"; + import { dropPageOverlays } from "./pageOverlay"; import { mergeSetupEntries } from "./setupEntries"; import { releaseStaged, stageImages, type CachedImage } from "./imageCache"; @@ -20,6 +25,18 @@ import { resolveForMeasure } from "./barcodeDims"; import { clockCtxFromLabel } from "./variableBinding"; import { measureFootprintDots } from "./footprintProber"; +/** The closer takes the prefix a ^CC left in force, and the head offsets move with the wrapper. */ +function wrapBareOverlay(overlay: BlockOverlay): BlockOverlay { + const text = overlayText(overlay); + const sep = text.includes("\r\n") ? "\r\n" : "\n"; + const open = `^XA${sep}`; + const close = /\r?\n$/.test(text) ? "" : sep; + const shift = (s: T): T => ({ ...s, start: s.start + open.length, end: s.end + open.length }); + const head = overlay.head + ? { ...overlay.head, at: 3, jmSpans: overlay.head.jmSpans.map(shift), dfSpans: overlay.head.dfSpans?.map(shift) } + : undefined; + return { ...overlay, segments: [{ kind: "raw", text: open }, ...overlay.segments, { kind: "raw", text: `${close}${spellPrefix("^XZ", prefixCharsAt(text))}` }], head }; +} export interface ZplImportResult { labelConfig: Partial; /** Setup-Script fields extracted from the import. Caller decides @@ -74,6 +91,9 @@ function foldRecallPages(pages: readonly ParsedPage[]): RecallFold | null { const recalled = new Set(pages.flatMap((p) => (p.recall?.slotsOnly ? [p.recall.pageIndex] : []))); const [templateIndex] = recalled; if (templateIndex === undefined || recalled.size > 1 || pages[templateIndex]?.variables.length === 0) return null; + // A question outside the template would go out once per row, so the stream stays as pages. A device action + // between blocks carries no answer and folds. + if (pages.some((page, i) => i !== templateIndex && page.hostCommands.length > 0)) return null; const rows = pages.flatMap((page, pageIndex) => page.recall?.pageIndex === templateIndex && page.recall.slotsOnly ? [{ pageIndex, values: new Map(page.variables.map((v) => [v.fnNumber, v.defaultValue])) }] @@ -148,6 +168,7 @@ function assembleImport(r: ParsedZPL, dpmm: number): ZplImportResult { page.variables = [...page.variables.slice(0, v0), ...declarations, ...page.variables.slice(v1)]; } const keptHere: { sourceFn: number; kept: Variable }[] = []; + const movedSlots = new Map(); // Objects link to their variable by marker NAME. When this page's variable // merges into an earlier page's (same ^FN number) under a different name, // OR a new fnNumber's name collides and gets disambiguated, its `«name»` @@ -179,6 +200,7 @@ function assembleImport(r: ParsedZPL, dpmm: number): ZplImportResult { continue; } fnNumber = free; + movedSlots.set(v.fnNumber, free); findings.push({ kind: "fnRenumbered", command: `^FN${v.fnNumber} → ^FN${free}`, pageIndex: outIndex(i) }); } const uniqueName = uniqueVariableName(v.name, variables); @@ -222,19 +244,23 @@ function assembleImport(r: ParsedZPL, dpmm: number): ZplImportResult { zNormalized = true; } } - // A bare page (no ^XA wrapper) usually carries just fonts/profile. But a - // wrapper-less paste of real fields also lands here; import those as a page - // so they aren't silently dropped (no overlay: the wrapper-less source has - // nothing to replay byte-for-byte, and re-export adds the ^XA/^XZ wrapper). + // A bare page usually carries just fonts and profile, but a wrapper-less paste of real fields lands here too + // and must not be dropped. Re-export adds the ^XA/^XZ wrapper, so the overlay goes unless the page asks. const jmDensity = page.labelConfig.jmDensity; const storedFormatPath = page.storedFormatPath; - if (!page.bare || page.objects.length > 0) { - pages.push(page.bare ? { objects: page.objects, jmDensity, storedFormatPath } : { objects: page.objects, overlay: page.overlay, jmDensity, storedFormatPath }); + // A bare host command replays, so this one bare case keeps its overlay, wrapped only where a command needs the format. + const questions = pageHostCommands(page); + const bareQuery = page.bare === true && r.unbalanced === null && page.overlay !== undefined && questions !== undefined; + if (!page.bare || pageHasContent(page)) { + const overlay = bareQuery && page.overlay !== undefined && needsOpenFormat(overlayText(page.overlay)) ? wrapBareOverlay(page.overlay) : page.overlay; + const asked = questions === undefined ? {} : { hostCommands: renumberFieldHostCommands(questions, movedSlots) }; + const own: Page = { objects: page.objects, jmDensity, storedFormatPath, ...asked }; + pages.push(page.bare && !bareQuery ? own : { ...own, overlay }); pageSources.push({ span: page.span, objectSpans: page.objectSpans, objectFrames: page.objectFrames }); } for (const f of page.findings) { - // A bare page replays nothing, so its lossyEdit caveat is moot. - if (page.bare && f.kind === "lossyEdit") continue; + // A bare page without its overlay replays nothing, so the findings bound to those bytes go with it. + if (page.bare && !bareQuery && overlayBound(f)) continue; findings.push({ ...f, pageIndex: outIndex(f.pageIndex) }); } }); @@ -403,6 +429,15 @@ function assembleImport(r: ParsedZPL, dpmm: number): ZplImportResult { }; } +/** Whether the stream is worth committing: content on a page, a setting, or a profile value. */ +export function documentHasContent(imported: ZplImportResult): boolean { + return ( + imported.pages.some(pageHasContent) || + Object.keys(imported.labelConfig).length > 0 || + Object.keys(imported.printerProfile).length > 0 + ); +} + /** Current label patched with the imported stream, except ^JM, which the * import always sets (absent means full density): inheriting the open * document's mode would reinterpret every imported dot at the wrong scale. */ @@ -467,14 +502,14 @@ export function routeSetupCommands( keptPageIndexes: result.pages.map((_p, i) => i), }; } - const riskPages = new Set(replayRiskFindings(result.report).map((f) => f.pageIndex)); + const riskPages = riskPageIndexes(result.report); // A routed page left empty (setup-only block) must not survive as a blank // page; keptPageIndexes lets the report drop/remap its findings. const dropped = dropPageOverlays(result.pages, (_p, i) => riskPages.has(i)); const pages: Page[] = []; const keptPageIndexes: number[] = []; dropped.forEach((p, i) => { - if (p.objects.length > 0 || !riskPages.has(i)) { + if (pageEmits(p) || !riskPages.has(i)) { pages.push(p); keptPageIndexes.push(i); } diff --git a/packages/core/src/lib/zplOverlay/overlay.ts b/packages/core/src/lib/zplOverlay/overlay.ts index eeb2eed5e..309e57948 100644 --- a/packages/core/src/lib/zplOverlay/overlay.ts +++ b/packages/core/src/lib/zplOverlay/overlay.ts @@ -43,6 +43,8 @@ export interface OverlayFrame { export interface FormatHead { /** Command prefix char in effect at the opener. */ caret: string; + /** Control prefix char in effect at the opener, absent in design files written before it was recorded. */ + tilde?: string; /** Offset just past the `^XA`; 0 when the block has no wrapper. */ at: number; /** Spans of the head's own `^JM` commands, in source order. Invalid values @@ -190,6 +192,7 @@ export const blockOverlaySchema = z head: z .object({ caret: z.string().length(1), + tilde: z.string().length(1).optional(), at: z.number().int().min(0), jmSpans: z.array(z.object({ start: z.number().int().min(0), end: z.number().int().min(0), delim: z.string().length(1), caret: z.string().length(1) })), dfSpans: z.array(z.object({ start: z.number().int().min(0), end: z.number().int().min(0), caret: z.string().length(1), path: z.string().optional() })).optional(), diff --git a/packages/core/src/lib/zplParser.ts b/packages/core/src/lib/zplParser.ts index 2808c56f1..bbcff9c6c 100644 --- a/packages/core/src/lib/zplParser.ts +++ b/packages/core/src/lib/zplParser.ts @@ -12,10 +12,11 @@ import { getObjectStringContent } from "./variableBinding"; import { parseLabelMetaComment, type LabelMeta } from "./zplLabelMeta"; import { stripLineWrap, stripTrailingSpaces, tokenize, trimmedSpanEnd, type ZplToken } from "./zplParser/helpers"; import { lookaheadJmDensity, lookaheadStoredFormat, scanBareStream } from "./zplHeadScan"; -import { parseStoragePath, recallCandidates, storageKey } from "./storagePath"; +import { recallKeys, storageKey } from "./storagePath"; import { printShape } from "./payloadProps"; import { qrPrintsAsGraphic } from "./objectBounds"; -import { commandTakesPrefix, commandTwinSplit } from "../catalog"; +import { commandTakesPrefix, commandTwinSplit, isHostCommand } from "../catalog"; +import { canonicalHostCommand, hostCommandAsks, hostCommandParams, isFieldHostCommand, type HostAsk } from "./hostCommands"; import { claimVariableName, createParserState, deriveUnitScale, REGEN_LOSSY_REASONS, notePartial, openTailHasContent, payloadSummary, releaseVariableName, resetFormatScopedState, type FnDefaultCandidate, type PartialNote, type RegenLossyReason, type SpannedToken, type UnterminatedField, resolveLiveFonts } from "./zplParser/context"; import { createCloseField } from "./zplParser/flushField"; import { createBarcodeHandlers } from "./zplParser/handlers/barcodes"; @@ -158,7 +159,7 @@ export const BY_CONSUMING_BARCODE_TYPES = new Set([ ]); /** Commands whose replay acts on the device, not the design. A catalog rule cannot stand in: web - * support "no" plus a handler also holds for ^FM, ^HT and ^LF. */ + * support "no" plus a handler also holds for ^FM and ^LF. */ export const DEVICE_ACTION_IDS: ReadonlySet = new Set([ "~JA", "~JC", "~JD", "~JE", "^JI", "~JI", "~JR", "^ID", "^IS", "~EG", "~PH", "~PP", "~PM", "~PR", @@ -207,7 +208,7 @@ export function parseZPL( // so per-format repeats of a command are re-reported on their own page. const { objects, labelConfig, printerProfile, variables, - browserLimit, unknown, replayRisk, deviceAction, unterminated, hexControl, + browserLimit, unknown, replayRisk, deviceAction, hostQuery, unterminated, hexControl, } = s.result; const takeComment = (): string | undefined => { @@ -337,6 +338,7 @@ export function parseZPL( unknown: unknown.length, replay: replayRisk.length, device: deviceAction.length, + host: hostQuery.length, unterminated: unterminated.length, hexControl: hexControl.length, span: overlaySpans.length, @@ -360,6 +362,7 @@ export function parseZPL( // without one keeps at 0, where a ^JM is equally valid. head: { caret: s.format.caretChar, + tilde: s.format.tildeChar, at: 0, jmSpans: [] as JmSpan[], dfSpans: undefined as DfSpan[] | undefined, @@ -375,13 +378,21 @@ export function parseZPL( // leading ^JM is resolved here instead; a ^JM ahead of a real ^XA is not. const bare = scanBareStream(zpl, s.format, s.format.delimiterChar); s.format.inFormatHead = !bare.hasXa; + // The same walk the output dialog reads, so the two cannot disagree. The first verdict per offset wins, so a + // recall that lists a stored token again does not overwrite it. A wrapper-less stream counts as open, since a + // format question there makes the import wrap it. + const hostAsks = new Map(); + for (const a of hostCommandAsks(zpl, { caretChar: s.format.caretChar, tildeChar: s.format.tildeChar, delimiterChar: s.format.delimiterChar }, !bare.hasXa)) { + if (!hostAsks.has(a.start)) hostAsks.set(a.start, a.ask); + } if (bare.density) { s.format.jmDensity = bare.density; labelConfig.jmDensity = bare.density; s.format.unitScale = deriveUnitScale(s.format, dpmm); } - // No head to record spans in: the wrapper-less source regenerates anyway. + // A bare page may replay its bytes, so its head keeps the ^DF spans like a wrapped page does. pg.storedFormatPath = bare.storedFormat?.path; + pg.head.dfSpans = bare.storedFormat?.spans; const bucketFindings = ( pageIndex: number, @@ -390,6 +401,7 @@ export function parseZPL( unk: readonly SpannedToken[], replay: readonly SpannedToken[], device: readonly SpannedToken[], + host: readonly SpannedToken[], dropped: readonly UnterminatedField[], hexControl: readonly SpannedToken[], ): ImportFinding[] => [ @@ -399,6 +411,7 @@ export function parseZPL( ...unk.map((t): ImportFinding => ({ kind: "unknown", command: t.command, pageIndex, span: t.span })), ...replay.map((t): ImportFinding => ({ kind: "replayRisk", command: t.command, pageIndex, span: t.span })), ...device.map((t): ImportFinding => ({ kind: "deviceAction", command: t.command, pageIndex, span: t.span })), + ...host.map((t): ImportFinding => ({ kind: "hostQuery", command: t.command, pageIndex, span: t.span })), ...hexControl.map((t): ImportFinding => ({ kind: "hexControl", command: t.command, pageIndex, span: t.span })), ]; @@ -408,6 +421,8 @@ export function parseZPL( // Judged at page close, once bytes behind the ^XZ have had their say. let pendingClose: { kind: "store"; template: Template } | { kind: "recall"; template: Template; carried: Set; atRecall: string | null } | null = null; const closePage = (end: number): void => { + // A bare page ends at the stream, so its open tail is read here instead of at a ^XZ. + if (!s.sawXa && openTailHasContent(s)) pg.openTail = true; // ^SN stripped a single-bind marker: drop this format's variables that no // marker in THIS page's objects points at (declarations stay). for (let i = variables.length - 1; i >= pg.vari; i--) { @@ -441,6 +456,7 @@ export function parseZPL( unknown.slice(pg.unknown), replayRisk.slice(pg.replay), deviceAction.slice(pg.device), + hostQuery.slice(pg.host), unterminated.slice(pg.unterminated), hexControl.slice(pg.hexControl), ); @@ -489,6 +505,8 @@ export function parseZPL( objects: pageObjects, variables: pageVariables, findings, + // A question between two blocks rides the page whose ^XZ it followed. + hostCommands: hostQuery.slice(pg.host).map((t) => t.canonical), labelSize: { widthMm: w, heightMm: h }, labelConfig: pageConfig, span: { start: pg.start, end }, @@ -504,8 +522,8 @@ export function parseZPL( const replayed: [number, number] = [rc.objects[0] - pg.obj, rc.objects[1] - pg.obj]; const row = closing?.kind === "recall" ? closing : undefined; // A row prints the replay and nothing else: no object, finding or state of its own. A control byte in a - // row value is the row's data, a device action prints nothing anywhere, and the edit loss is the template's. - const ownFinding = findings.some((f) => f.kind !== "partial" && f.kind !== "hexControl" && f.kind !== "deviceAction" && f.kind !== "lossyEdit") + // row value is the row's data, a device action or host query prints nothing anywhere, and the edit loss is the template's. + const ownFinding = findings.some((f) => f.kind !== "partial" && f.kind !== "hexControl" && f.kind !== "deviceAction" && f.kind !== "hostQuery" && f.kind !== "lossyEdit") || [...s.result.partials.keys()].some((key) => !row?.carried.has(key)); const ownObject = replayed[0] !== 0 || replayed[1] !== pageObjects.length; // ZD230-measured: the recalled fields form under the state at ^XF, so the replay at ^XZ is @@ -742,6 +760,7 @@ export function parseZPL( // head the lookahead reads, so export must not rewrite them either. pg.head = { caret: s.format.caretChar, + tilde: s.format.tildeChar, at: start - pg.start + 3, jmSpans: [], dfSpans: undefined, @@ -762,7 +781,17 @@ export function parseZPL( return; } - if (rest.trim() || cmd.trim()) { + if (takesPrefix && isHostCommand(canonical)) { + // The design keeps what the printer answers, and of a stored frame only the field questions. + const ask = hostAsks.get(start); + if (ask !== "asked" && !(ask === "stored" && isFieldHostCommand(canonical))) { + notePartial(s.result, canonical, "droppedHostCommand"); + } else { + const spelled = canonicalHostCommand(canonical, hostCommandParams(canonical, rest), s.format.delimiterChar); + if (spelled === undefined) notePartial(s.result, canonical, "droppedHostCommand"); + else hostQuery.push({ command: sourceToken, span: s.result.tokenSpan, canonical: spelled }); + } + } else if (rest.trim() || cmd.trim()) { // Source prefix from `start`: tilde commands must not surface as `^`. unknown.push({ command: payloadSummary(`${zpl[start] ?? "^"}${cmd}`, rest), span: s.result.tokenSpan }); } @@ -797,9 +826,7 @@ export function parseZPL( const replayRecall = (): void => { const path = s.result.recallFormatPath; if (path === undefined) return; - const ref = parseStoragePath(path); - if (!ref) return; - const template = recallCandidates({ ...ref, ext: ref.ext ?? "ZPL" }).map((key) => templates.get(key)).find((t) => t !== undefined); + const template = recallKeys(path).map((key) => templates.get(key)).find((t) => t !== undefined); if (!template) return; // Slot declarations feed the replay. A variable bound by one of the block's own fields stays. const own = variables.slice(pg.vari); @@ -811,7 +838,7 @@ export function parseZPL( const variablesFrom = variables.length; const replayFrom = { objects: objectsFrom, variables: variablesFrom }; // The template's findings were reported on its own page. - const marks = { unknown: unknown.length, browser: browserLimit.length, replay: replayRisk.length, device: deviceAction.length, unterminated: unterminated.length, hexControl: hexControl.length }; + const marks = { unknown: unknown.length, browser: browserLimit.length, replay: replayRisk.length, device: deviceAction.length, host: hostQuery.length, unterminated: unterminated.length, hexControl: hexControl.length }; const partials = s.result.partials; s.result.partials = new Map(); const replayPartials = s.result.partials; @@ -830,6 +857,7 @@ export function parseZPL( browserLimit.length = marks.browser; replayRisk.length = marks.replay; deviceAction.length = marks.device; + hostQuery.length = marks.host; unterminated.length = marks.unterminated; // A control byte in a row value truncates that row, so its finding stays with the block. const rowHexControl = hexControl.slice(marks.hexControl).filter((t, i, all) => t.span !== undefined && t.span.start >= pg.start && all.findIndex((o) => o.span?.start === t.span?.start) === i); diff --git a/packages/core/src/lib/zplParser/context.ts b/packages/core/src/lib/zplParser/context.ts index c4638c6f3..aee53371c 100644 --- a/packages/core/src/lib/zplParser/context.ts +++ b/packages/core/src/lib/zplParser/context.ts @@ -74,6 +74,11 @@ export interface SpannedToken { span?: SourceSpan; } +/** A question the design keeps: spelled as the generator spells it, since the block may regenerate without the remap that spelled the source. */ +export interface HostQueryToken extends SpannedToken { + canonical: string; +} + /** Shared parse accumulators; pages slice the arrays via offset marks, the * document-wide fields hand back via `ParsedZPL`. */ export interface ParserResult { @@ -109,6 +114,8 @@ export interface ParserResult { unknown: SpannedToken[]; replayRisk: SpannedToken[]; deviceAction: SpannedToken[]; + /** Commands the printer answers over the host port, such as ~HS. */ + hostQuery: HostQueryToken[]; /** Every in-range ^FN slot the tokenizer saw, including on fields that end * up passthrough-only (no Variable). Import renumbering must avoid these: * overlays replay the original bytes. */ @@ -648,6 +655,7 @@ export function createParserState(): ParserState { unknown: [], replayRisk: [], deviceAction: [], + hostQuery: [], sourceFnNumbers: new Set(), }, label: { diff --git a/packages/core/src/lib/zplParser/handlers/unsupported.ts b/packages/core/src/lib/zplParser/handlers/unsupported.ts index bc697d110..a6b0192ad 100644 --- a/packages/core/src/lib/zplParser/handlers/unsupported.ts +++ b/packages/core/src/lib/zplParser/handlers/unsupported.ts @@ -23,7 +23,6 @@ export function createUnsupportedHandlers(s: ParserState): Record deleteStoredObjects(s, "R:*.GRF"), - HT: browserLimit, LF: browserLimit, }; } diff --git a/packages/core/src/lib/zplParser/types.ts b/packages/core/src/lib/zplParser/types.ts index c3f86d79f..d1167e9de 100644 --- a/packages/core/src/lib/zplParser/types.ts +++ b/packages/core/src/lib/zplParser/types.ts @@ -12,6 +12,7 @@ export type ImportFindingKind = | "unknown" | "replayRisk" | "deviceAction" + | "hostQuery" | "lossyEdit" | "fnRenumbered" | "fnDefaultDropped" @@ -34,7 +35,7 @@ export interface SourceSpan { export interface ImportFinding { kind: ImportFindingKind; /** Command token. For 'partial' the bare code (e.g. "^A@"). For - * 'browserLimit' / 'unknown' the full token including parameters + * 'browserLimit' / 'unknown' / 'hostQuery' the full token including parameters * (e.g. "^GFA,8,8,0,…"). For 'lossyEdit' a human-readable reason. * For 'unterminatedField' the ^FO/^FT token that opened the dropped field. For * 'hexControl' the hex escape as written. */ @@ -66,7 +67,7 @@ export interface FontLoss { export interface ImportReport { findings: ImportFinding[]; - // The buckets below are dedup views for these seven kinds only. + // The buckets below are dedup views for these eight kinds only. // Every other kind (lossyEdit, fnRenumbered, fnDefaultDropped, mixedPageGeometry) // lives solely in `findings`; iterate `findings` for a kind-complete view. /** Commands imported with known loss. Deduplicated by command code. */ @@ -79,6 +80,8 @@ export interface ImportReport { replayRisk: string[]; /** Device actions in source spelling: run on re-emit, no profile field, not routable. */ deviceAction: string[]; + /** Host commands in source spelling: the printer answers them over the host port. */ + hostQuery: string[]; /** Openers of fields the printer discards: no ^FS before the next ^FO/^FT. * Deduplicated by opener token, so two dropped fields at the same coordinates collapse. */ unterminatedField: string[]; @@ -94,6 +97,8 @@ export interface ParsedPage { * import service's job). */ variables: Variable[]; findings: ImportFinding[]; + /** Host commands in canonical spelling. */ + hostCommands: string[]; /** Source-patch overlay for byte-verbatim replay, present only when * `captureOverlay` is set and every parsed object linked to a source span; * `undefined` means the block regenerates from the model. */ @@ -113,8 +118,7 @@ export interface ParsedPage { /** The parser's recall record, its ranges made page-relative. * `slotsOnly`: the block held nothing but the recall and slot data, so it prints only that format. */ recall?: { pageIndex: number; slotsOnly: boolean; objects: [number, number]; variables: [number, number]; declarations: Variable[] }; - /** Page 0 only: stream had no ^XA wrapper (bare field paste). Re-export - * regenerates the wrapper, so the overlay is suppressed by the caller. */ + /** Page 0 only: the stream had no ^XA wrapper, a bare field paste. */ bare?: boolean; /** This page's bytes in the source (page 0 owns any preamble). */ span: SourceSpan; diff --git a/packages/core/src/lib/zplSourceEdit.test.ts b/packages/core/src/lib/zplSourceEdit.test.ts index 5e2523c89..db7a4746e 100644 --- a/packages/core/src/lib/zplSourceEdit.test.ts +++ b/packages/core/src/lib/zplSourceEdit.test.ts @@ -8,6 +8,7 @@ import { } from "./zplSourceEdit"; import { generateMultiPageZPL } from "./zplGenerator"; import { replayRiskFindings } from "./importReport"; +import { repliesToHost } from "./hostCommands"; import type { LabelConfig } from "../types/LabelConfig"; import type { Page } from "../types/Group"; import type { PrinterProfile } from "../types/PrinterProfile"; @@ -328,4 +329,34 @@ describe("degenerate buffers", () => { expect(plan.ok).toBe(true); if (plan.ok) expect(plan.next.pages).toEqual([{ objects: [] }]); }); + + it("refuses a format that neither asks nor draws", () => { + expect(prepareSourceApply({ text: "^XA^XZ", current: current() })).toMatchObject({ + ok: false, + reason: "noContent", + }); + }); +}); + +describe("a buffer that only queries the printer", () => { + const applied = (text: string): string[] => { + const plan = prepareSourceApply({ text, current: current() }); + expect(plan.ok).toBe(true); + if (!plan.ok) return []; + expect(plan.objectCount).toBe(0); + return repliesToHost(generateMultiPageZPL(plan.next.label, plan.next.pages, plan.next.variables)); + }; + + it("applies a status request and keeps it in the export", () => { + expect(applied("^XA\n~HS\n^XZ")).toEqual(["~HS"]); + }); + + it("applies a directory listing and keeps it in the export", () => { + expect(applied("^XA^HWE:*.*^XZ")).toEqual(["^HWE:*.*"]); + }); + + it("keeps a bare stream of immediate commands", () => { + expect(applied("~HS")).toEqual(["~HS"]); + expect(applied("~HS\n~HM")).toEqual(["~HS", "~HM"]); + }); }); diff --git a/packages/core/src/lib/zplSourceEdit.ts b/packages/core/src/lib/zplSourceEdit.ts index 9b31f6238..f5809e7b9 100644 --- a/packages/core/src/lib/zplSourceEdit.ts +++ b/packages/core/src/lib/zplSourceEdit.ts @@ -1,6 +1,6 @@ import { LABEL_META_KEYS } from "./zplLabelMeta"; import type { CachedImage } from "./imageCache"; -import { importZplText, type ZplImportResult, mergeSetupUploads, replaceImportLabel } from "./zplImportService"; +import { documentHasContent, importZplText, type ZplImportResult, mergeSetupUploads, replaceImportLabel } from "./zplImportService"; import { repairPrinterProfile, SETUP_UPLOAD_FIELDS } from "../types/PrinterProfile"; import type { ImportReport, UnbalancedFormat } from "./zplParser"; import type { LabelConfig } from "../types/LabelConfig"; @@ -124,13 +124,7 @@ export function prepareSourceApply(input: SourceApplyInput): SourceApplyPlan { } 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 ( - objectCount === 0 && - Object.keys(imported.labelConfig).length === 0 && - Object.keys(imported.printerProfile).length === 0 - ) { - return { ok: false, reason: "noContent" }; - } + if (!documentHasContent(imported)) return { ok: false, reason: "noContent" }; let label = replaceImportLabel(current.label, imported.labelConfig); const uploads = mergeSetupUploads(current.printerProfile, imported.printerProfile); diff --git a/packages/core/src/types/Group.ts b/packages/core/src/types/Group.ts index 13d3a4a2a..a6d217069 100644 --- a/packages/core/src/types/Group.ts +++ b/packages/core/src/types/Group.ts @@ -24,6 +24,8 @@ export interface Page { storedFormatPath?: string; /** How this page's ^DF reaches the printer without this design storing it again. */ storedFormatDelivery?: StoredFormatDelivery; + /** Host commands in canonical spelling, such as `~HS` or `^HWR:*.*`: a regenerated block asks what its source asked. */ + hostCommands?: readonly string[]; /** Source-patch overlay of the ^XA…^XZ block this page was imported from, * letting export replay untouched bytes verbatim. Absent on fresh designs * and on pages the parser couldn't fully link; those regenerate from the @@ -31,21 +33,42 @@ export interface Page { overlay?: BlockOverlay; } +/** The questions a page asks, an empty list being none. */ +export const pageHostCommands = (page: Pick): readonly string[] | undefined => + page.hostCommands?.length ? page.hostCommands : undefined; + +/** The fields of a label that are one page's own, so a label handed in with another page's values drops them. */ +const PAGE_SCOPED_KEYS = ['storedFormatPath', 'storedFormatDelivery', 'hostCommands'] as const; + /** The label as this page prints it: its own ^JM wins, so a block imported at * a diverging density keeps that density through export (coordinates included, * since every dots<->mm boundary reads it through effectiveDpmm). */ -export function pageLabelConfig(label: LabelConfig, page: Pick): PageLabel { - // The only mint backed by a real resolution: folding the page's ^JM and ^DF in. +export function pageLabelConfig(label: LabelConfig, page: Pick): PageLabel { + // The only mint backed by a real resolution: folding the page's ^JM, ^DF and host commands in. const folded = withJmDensity(label, page.jmDensity) as PageLabel; - if (page.storedFormatPath === undefined && folded.storedFormatPath === undefined) return folded; - // A label handed in with another page's ^DF is not this page's. - const { storedFormatPath: _other, storedFormatDelivery: _way, ...base } = folded; - if (page.storedFormatPath === undefined) return base as PageLabel; - const way = page.storedFormatDelivery === undefined ? {} : { storedFormatDelivery: page.storedFormatDelivery }; - return { ...base, storedFormatPath: page.storedFormatPath, ...way } as PageLabel; + const own: Partial> = {}; + if (page.storedFormatPath !== undefined) { + own.storedFormatPath = page.storedFormatPath; + if (page.storedFormatDelivery !== undefined) own.storedFormatDelivery = page.storedFormatDelivery; + } + const questions = pageHostCommands(page); + if (questions !== undefined) own.hostCommands = questions; + if (Object.keys(own).length === 0 && PAGE_SCOPED_KEYS.every((key) => folded[key] === undefined)) return folded; + const { storedFormatPath: _path, storedFormatDelivery: _delivery, hostCommands: _questions, ...base } = folded; + return { ...base, ...own }; +} + +/** Content the design keeps: objects or questions. An empty replayed block is none. */ +export function pageHasContent(page: Pick): boolean { + return page.objects.length > 0 || pageHostCommands(page) !== undefined; +} + +/** True when the page exports bytes: objects, a replayed overlay, or a question for the printer. */ +export function pageEmits(page: Pick): boolean { + return page.objects.length > 0 || page.overlay !== undefined || pageHostCommands(page) !== undefined; } -/** The label for a render that must print: a preview never stores. */ +/** The label for a render that must print and ask nothing: a preview never stores or queries. */ export function withoutStoredFormat(label: LabelConfig): PageLabel { return pageLabelConfig(label, {}); } diff --git a/packages/core/src/types/LabelConfig.ts b/packages/core/src/types/LabelConfig.ts index 58f7ded9c..4f7a30257 100644 --- a/packages/core/src/types/LabelConfig.ts +++ b/packages/core/src/types/LabelConfig.ts @@ -339,6 +339,8 @@ export type PageLabel = LabelConfig & { storedFormatPath?: string; /** Absent means every job stores the format again. */ storedFormatDelivery?: StoredFormatDelivery; + /** The page's host commands, folded in like its ^DF. */ + hostCommands?: readonly string[]; }; /** The label slice device-font resolution reads (^CF default + ^CW-style diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index d99a661d5..1a1aff31e 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -154,7 +154,8 @@ export function buildServer(options: BuildServerOptions = {}): McpServer { "is stored as a format and recalled once per row, bound by the file's csvMapping: the page " + "batch.formatPath names, else the sole page with storedFormatPath, else the only page. " + "Other pages are left out and noted. A page whose storedFormatDelivery is setup or printer " + - "is recalled instead of stored, as the app's own export does; the field is set in the app, not by create_draft.", + "is recalled instead of stored, as the app's own export does; the field is set in the app, not by create_draft. " + + "A page's hostCommands are asked once per batch job, ahead of its fields. ^HV rides with the fields. A job that stores the format keeps it there. Where the printer already holds the format, every recall block asks it.", inputSchema: strictInput(exportZplInputSchema.shape, INSIDE_DESIGN_FILE), }, async ({ designFile, metadata, batch }) => json(exportZpl(designFile, { metadata, batch })), @@ -166,7 +167,7 @@ export function buildServer(options: BuildServerOptions = {}): McpServer { title: "Validate raw ZPL", description: "Parse a raw ZPL stream (one page per ^XA block) and report it: object/page " + - "count, detected label, parser findings (unknown/partial/hardware-bound " + + "count, detected label, parser findings (unknown/partial/hardware-bound/host-answered " + "commands, fields the printer discards for a missing ^FS), preflight warnings, " + "per-object bounds (dots), and bbox overlaps. A stream that stores a format with ^DF, declares " + "^FN fields in it and recalls it with ^XF once per row reports the rows' headers and count as `batch`. " + @@ -189,6 +190,7 @@ export function buildServer(options: BuildServerOptions = {}): McpServer { "measured, same as the app import) and enable the label's emit1dZJustify gate. " + "A stream that stores a format with ^DF, declares ^FN fields in it and recalls it with ^XF " + "once per row folds those rows into `batch` bound by a csvMapping. Other pages stay pages. " + + "Host commands such as ~HS stay on their page as hostCommands and are reported as hostQuery. " + "Hand batch to export_zpl.", inputSchema: strictInput(zplInputShape), }, diff --git a/packages/mcp-server/src/tools.test.ts b/packages/mcp-server/src/tools.test.ts index 119acd243..412d467af 100644 --- a/packages/mcp-server/src/tools.test.ts +++ b/packages/mcp-server/src/tools.test.ts @@ -274,9 +274,10 @@ describe("mcp-server tools", () => { }); it("validate_zpl surfaces commands it could not fully model", () => { - // ^HT needs a printer to answer (browserLimit); ^JU is a setup command (replayRisk). - const v = ok(validateZpl("^XA^FO10,10^FDX^FS^HT^JUS^XZ")); - expect(v.findings.browserLimit).toEqual(["^HT"]); + // ^LF needs printer hardware, ^HT is answered over the host port, ^JU is a setup command. + const v = ok(validateZpl("^XA^FO10,10^FDX^FS^LF^HT^JUS^XZ")); + expect(v.findings.browserLimit).toEqual(["^LF"]); + expect(v.findings.hostQuery).toEqual(["^HT"]); expect(v.findings.replayRisk).toContain("^JU"); }); @@ -448,13 +449,14 @@ describe("mcp-server tools", () => { // Roundtrip guarantee: unmodeled commands survive import → export via the // captured overlay instead of being silently dropped. - it("re-exports unmodeled commands verbatim after import_zpl", () => { - const imported = ok(importZpl("^XA^FO10,10^A0N,30,30^FDX^FS^HT^JUS^XZ")); - expect(imported.findings.browserLimit).toEqual(["^HT"]); + it("re-exports an unmodeled command and a host command verbatim after import_zpl", () => { + const imported = ok(importZpl("^XA^FO10,10^A0N,30,30^FDX^FS^LF^HT^JUS^XZ")); + expect(imported.findings.browserLimit).toEqual(["^LF"]); + expect(imported.findings.hostQuery).toEqual(["^HT"]); const back = exportZpl(imported.designFile); expect(back.ok).toBe(true); if (!back.ok) return; - expect(back.zpl).toContain("^HT"); + expect(back.zpl).toContain("^LF^HT"); expect(back.zpl).toContain("^JUS"); }); @@ -467,7 +469,7 @@ describe("mcp-server tools", () => { const v = ok(validateZpl("^XA^FO10,10^FDX^FS^XZ")); expect(v.findings).not.toHaveProperty("findings"); expect(Object.keys(v.findings).sort()).toEqual( - ["browserLimit", "deviceAction", "hexControl", "partial", "replayRisk", "unknown", "unterminatedField"], + ["browserLimit", "deviceAction", "hexControl", "hostQuery", "partial", "replayRisk", "unknown", "unterminatedField"], ); }); diff --git a/packages/mcp-server/src/tools.ts b/packages/mcp-server/src/tools.ts index 0a03defd9..cc7fa955f 100644 --- a/packages/mcp-server/src/tools.ts +++ b/packages/mcp-server/src/tools.ts @@ -9,7 +9,7 @@ import { generateBatchZpl, generateMultiPageZPL } from "@zplab/core/lib/zplGener import { contestedFormatKeys, recallOnlyPath } from "@zplab/core/lib/storedFormat"; import { pageLabelConfig } from "@zplab/core/types/Group"; import { boundColumnIndex } from "@zplab/core/lib/variableBinding"; -import { isRecallableFormatPath, parseStoragePath, recallCandidates, storageKey } from "@zplab/core/lib/storagePath"; +import { isRecallableFormatPath, parseStoragePath, recallKeys, storageKey } from "@zplab/core/lib/storagePath"; import { zplForExport } from "@zplab/core/lib/zplLabelMeta"; import { importZplText, type ZplImportResult } from "@zplab/core/lib/zplImportService"; import { @@ -205,8 +205,7 @@ export function exportZpl( const named = opts.batch?.formatPath; const namedRef = named === undefined ? undefined : isRecallableFormatPath(named) ? parseStoragePath(named) : null; if (named !== undefined && !namedRef) return { ok: false, errors: ["batch.formatPath is not a name ^XF could recall"] }; - // A bare name resolves through the printer's device search, as ^XF does. - const namedKeys = namedRef ? recallCandidates({ ...namedRef, ext: namedRef.ext ?? "ZPL" }) : undefined; + const namedKeys = named === undefined ? undefined : recallKeys(named); const stored = pages.filter((p) => p.storedFormatPath !== undefined && (namedKeys === undefined || namedKeys.includes(storageKey(p.storedFormatPath)))); const page = stored.length === 1 ? stored[0] : stored.length === 0 && named === undefined && pages.length === 1 ? pages[0] : undefined; if (opts.batch && !page) return { ok: false, errors: ["batch needs the page its formatPath names, exactly one page with storedFormatPath, or a single-page design"] }; diff --git a/src-tauri/src/print.rs b/src-tauri/src/print.rs index 62557dfc4..5379d9e2c 100644 --- a/src-tauri/src/print.rs +++ b/src-tauri/src/print.rs @@ -47,7 +47,11 @@ pub async fn send_zpl_tcp(host: String, port: u16, zpl: String) -> Result Result R } /// Read one printer reply: wait for the first byte, then collect until an idle -/// gap marks the end. A close/short-read (0 bytes) also ends the reply; a -/// reply that never starts, trickles past the deadline, or overflows the bound -/// is an error. An empty body is reported as "no response" because print-only -/// endpoints accept a job silently, which is not a queryable response. +/// gap or a close ends it. A trickle past the deadline or an overflow is an error, +/// while an empty body means the printer sent nothing and the caller decides. pub(crate) async fn read_reply( stream: &mut R, ) -> Result, String> { @@ -102,13 +100,10 @@ pub(crate) async fn read_reply( body.extend_from_slice(&buf[..n]); } Ok(Err(e)) => return Err(e.to_string()), - Err(_) if body.is_empty() => return Err("no response from printer".to_string()), - Err(_) => break, // idle gap after data = end of reply + // Timeout: end of reply. + Err(_) => break, } } - if body.is_empty() { - return Err("no response from printer".to_string()); - } Ok(body) } @@ -138,21 +133,19 @@ mod tests { } #[test] - fn read_reply_reports_no_response() { + fn read_reply_returns_empty_when_nothing_arrives() { rt().block_on(async { let (_a, mut b) = tokio::io::duplex(1024); - let err = read_reply(&mut b).await.unwrap_err(); - assert_eq!(err, "no response from printer"); + assert!(read_reply(&mut b).await.unwrap().is_empty()); }); } #[test] - fn read_reply_treats_eof_without_data_as_no_response() { + fn read_reply_returns_empty_on_eof_without_data() { rt().block_on(async { let (a, mut b) = tokio::io::duplex(1024); drop(a); - let err = read_reply(&mut b).await.unwrap_err(); - assert_eq!(err, "no response from printer"); + assert!(read_reply(&mut b).await.unwrap().is_empty()); }); } diff --git a/src-tauri/src/usb.rs b/src-tauri/src/usb.rs index c0fe3b3fc..e4fd378be 100644 --- a/src-tauri/src/usb.rs +++ b/src-tauri/src/usb.rs @@ -66,6 +66,7 @@ pub enum UsbQueryResult { Data { body: String, }, + Silent, /// See classify_lp_io_err. #[cfg(target_os = "linux")] PermissionDenied, @@ -75,6 +76,9 @@ pub enum UsbQueryResult { #[cfg(any(target_os = "linux", target_os = "macos"))] impl UsbQueryResult { fn from_reply(body: Vec) -> Self { + if body.is_empty() { + return Self::Silent; + } Self::Data { body: String::from_utf8_lossy(&body).into_owned(), } @@ -556,6 +560,8 @@ mod tests { assert_eq!(json, r#"{"kind":"data","body":"~DY"}"#); let json = serde_json::to_string(&UsbQueryResult::NotFound).unwrap(); assert_eq!(json, r#"{"kind":"not_found"}"#); + let json = serde_json::to_string(&UsbQueryResult::from_reply(Vec::new())).unwrap(); + assert_eq!(json, r#"{"kind":"silent"}"#); } #[test] diff --git a/src/components/Output/AdoptSettingsDialog.tsx b/src/components/Output/AdoptSettingsDialog.tsx index 331060d6a..b51888f95 100644 --- a/src/components/Output/AdoptSettingsDialog.tsx +++ b/src/components/Output/AdoptSettingsDialog.tsx @@ -2,14 +2,14 @@ import { useState } from "react"; import { adoptionDiff, isAdoptable, preselectedRows, type AdoptionRow } from "@zplab/core/lib/printerSettingsAdoption"; import type { SgdSetting } from "@zplab/core/lib/sgd"; import { useT } from "../../hooks/useT"; -import type { PrinterOutcome } from "../../lib/printerQuery"; +import type { ReplyShown } from "../../hooks/useReplyRead"; import { selectEditorFrozen, useLabelStore } from "../../store/labelStore"; import { labelCls, primaryButtonCls, zplCommandTagCls } from "../ui/formStyles"; import { Tooltip } from "../ui/Tooltip"; import { PrinterReplyDialog } from "./PrinterReplyDialog"; interface Props { - read: PrinterOutcome | "reading"; + read: ReplyShown; portal?: boolean; onClose: () => void; } diff --git a/src/components/Output/DocumentQueryAction.tsx b/src/components/Output/DocumentQueryAction.tsx new file mode 100644 index 000000000..eff9d0371 --- /dev/null +++ b/src/components/Output/DocumentQueryAction.tsx @@ -0,0 +1,36 @@ +import { useReplyRead } from "../../hooks/useReplyRead"; +import { useT } from "../../hooks/useT"; +import type { DocumentReply } from "../../lib/printerDocumentQuery"; +import type { PrinterQueryFailure } from "../../lib/printerQuery"; +import type { PrintTransport } from "../../lib/printTarget"; +import { useLabelStore } from "../../store/labelStore"; +import { primaryButtonCls } from "../ui/formStyles"; +import { DocumentReplyDialog } from "./DocumentReplyDialog"; + +interface Props { + zpl: () => string; + commands: readonly string[]; + transport: PrintTransport; + disabled: boolean; + onFailure: (failure: PrinterQueryFailure) => void; +} + +export function DocumentQueryAction({ zpl, commands, transport, disabled, onFailure }: Props) { + const t = useT(); + const query = useLabelStore((s) => s.queryDocument); + const reply = useReplyRead(); + const run = () => + reply.start(async () => { + const result = await query(zpl(), commands, transport); + if (result.kind !== "ok") onFailure(result); + return result; + }); + return ( + <> + + {reply.read !== undefined && } + + ); +} diff --git a/src/components/Output/DocumentReplyDialog.tsx b/src/components/Output/DocumentReplyDialog.tsx new file mode 100644 index 000000000..acd8c224f --- /dev/null +++ b/src/components/Output/DocumentReplyDialog.tsx @@ -0,0 +1,38 @@ +import { useT } from "../../hooks/useT"; +import type { DocumentReply } from "../../lib/printerDocumentQuery"; +import type { ReplyShown } from "../../hooks/useReplyRead"; +import { documentReplyLines } from "../../lib/printerStatusText"; +import { PrinterReplyDialog, RawReplyBlock } from "./PrinterReplyDialog"; + +interface Props { + read: ReplyShown; + onClose: () => void; +} + +/** The printer's answer to the document: what a parser could read, above the bytes as they arrived. */ +export function DocumentReplyDialog({ read, onClose }: Props) { + const t = useT(); + const loc = t.printerSettings.printerStatus; + return ( + + {(reply) => { + const lines = documentReplyLines(loc, t.printerSettings.objects, reply); + return ( + <> + {lines.length > 0 && ( +
+ {/* Two readings can word the same line, so the position is the only stable key. */} + {lines.map((line, index) => ( +

+ {line} +

+ ))} +
+ )} + + + ); + }} +
+ ); +} diff --git a/src/components/Output/ImportSetupChoice.test.tsx b/src/components/Output/ImportSetupChoice.test.tsx new file mode 100644 index 000000000..167d9e9e7 --- /dev/null +++ b/src/components/Output/ImportSetupChoice.test.tsx @@ -0,0 +1,21 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach } from "vitest"; +import { render, cleanup } from "@testing-library/react"; +import type { ImportFinding } from "@zplab/core/lib/importReport"; +import { ImportSetupChoice } from "./ImportSetupChoice"; +import { fallbackTranslations as en } from "../../locales"; +import { formatTemplate } from "../../lib/formatTemplate"; + +afterEach(cleanup); + +const setup: ImportFinding = { kind: "replayRisk", command: "^JUS", pageIndex: 0 }; + +describe("ImportSetupChoice", () => { + it("names the commands a routed page loses, and stays quiet when nothing is lost", () => { + const lost = render( undefined} />); + expect(lost.getByRole("status").textContent).toBe(formatTemplate(en.app.importSetupDropsFmt, { commands: "~JC" })); + cleanup(); + const clean = render( undefined} />); + expect(clean.queryByRole("status")).toBeNull(); + }); +}); diff --git a/src/components/Output/ImportSetupChoice.tsx b/src/components/Output/ImportSetupChoice.tsx index df6acd9cf..41b2034a7 100644 --- a/src/components/Output/ImportSetupChoice.tsx +++ b/src/components/Output/ImportSetupChoice.tsx @@ -1,11 +1,14 @@ import type { ImportFinding } from '@zplab/core/lib/importReport'; import type { SetupCommandChoice } from '@zplab/core/lib/zplImportService'; import { useT } from '../../hooks/useT'; +import { formatTemplate } from '../../lib/formatTemplate'; import { FindingRow } from './ImportSummary'; interface Props { /** Routable setup commands plus (for awareness) device actions. */ findings: ImportFinding[]; + /** The device actions a routed page loses, from commandsLostByRouting. */ + lost: readonly string[]; /** False in append-mode: appended pages regenerate, so "keep" can't hold. */ canKeep: boolean; onChoose: (choice: SetupCommandChoice) => void; @@ -13,9 +16,10 @@ interface Props { /** Pre-commit prompt for imported setup commands; preselects "setup script" * (their channel), so they don't silently reconfigure the printer. */ -export function ImportSetupChoice({ findings, canKeep, onChoose }: Props) { +export function ImportSetupChoice({ findings, lost, canKeep, onChoose }: Props) { const t = useT(); const multiPage = findings.some((f) => f.pageIndex > 0); + const lostCommands = lost.join(', '); return ( <> @@ -31,6 +35,11 @@ export function ImportSetupChoice({ findings, canKeep, onChoose }: Props) {

{t.app.importSetupBody}

+ {lostCommands && ( +

+ {formatTemplate(t.app.importSetupDropsFmt, { commands: lostCommands })} +

+ )}
+ {action}
); } -export function ZebraWayPanel({ zpl, way, devices }: { zpl: () => string; way: PrintTransport; devices: PrintDeviceLists }) { +export function ZebraWayPanel({ zpl, way, devices, query }: { zpl: () => string; way: PrintTransport; devices: PrintDeviceLists; query: DocumentQueryFacts }) { const t = useT(); const printTarget = useLabelStore((s) => s.printTarget); const host = printTarget.host; @@ -195,18 +194,15 @@ export function ZebraWayPanel({ zpl, way, devices }: { zpl: () => string; way: P } } - const checkProps = (sending: boolean) => ({ - transport: way, - sendBusy: sending, - onFailure: (failure: PrinterQueryFailure) => { - if (failure.kind !== "permission_denied") return; - setUsbNeedsSetup(true); - setUsbStatus({ type: "error", message: t.zebraPrint.usbPermissionDenied }); - }, - }); + const onReadFailure = (failure: PrinterQueryFailure) => { + if (failure.kind !== "permission_denied") return; + setUsbNeedsSetup(true); + setUsbStatus({ type: "error", message: t.zebraPrint.usbPermissionDenied }); + }; + const checkProps = (sending: boolean) => ({ transport: way, sendBusy: sending, onFailure: onReadFailure }); - // Every way reaches the same device, so every way may ask it. Only the desktop shell can read a - // reply, and a way that cannot be questioned itself falls back to the configured address. + // Every way reaches the same device, so every way may ask it, and a way that cannot be questioned + // itself falls back to the configured address. const addressCheck = (sending: boolean) => { if (!isDesktopShell) return null; const resolved = resolveQueryTarget(printTarget, way); @@ -283,6 +279,10 @@ export function ZebraWayPanel({ zpl, way, devices }: { zpl: () => string; way: P }, ]; const activeView = views.find((v) => v.key === way); + const offer = queryOffer(t.zebraPrint, query, way, isDesktopShell); + const netSendDisabled = !host || netStatus.type === "sending" || locked; + const action = (send: ReactNode, sendDisabled: boolean): ReactNode => + offer.queries ? : send; if (way === "network") { return ( @@ -294,15 +294,16 @@ export function ZebraWayPanel({ zpl, way, devices }: { zpl: () => string; way: P )} + +
{addressCheck(netStatus.type === "sending") ?? } - + {action( + , + netSendDisabled, + )}
@@ -310,5 +311,17 @@ export function ZebraWayPanel({ zpl, way, devices }: { zpl: () => string; way: P ); } - return activeView ? : null; + return activeView ? ( + + {activeView.sendLabel} + , + activeView.sendDisabled, + )} + /> + ) : null; } diff --git a/src/components/Output/ZebraWayPanel.usb.test.tsx b/src/components/Output/ZebraWayPanel.usb.test.tsx index bf948826d..f38d19ddc 100644 --- a/src/components/Output/ZebraWayPanel.usb.test.tsx +++ b/src/components/Output/ZebraWayPanel.usb.test.tsx @@ -18,6 +18,8 @@ vi.mock("../../lib/printerStatus", () => ({ readPrinterStatus: (...args: unknown[]) => readPrinterStatus(...args), readPrinterConfiguration: vi.fn(), })); +const queryDocument = vi.fn(); +vi.mock("../../lib/printerDocumentQuery", () => ({ queryDocument: (...args: unknown[]) => queryDocument(...args) })); vi.mock("../../lib/usbPrint", async (importOriginal) => ({ ...(await importOriginal>()), listUsbPrinters: vi.fn().mockResolvedValue([{ id: "usb1", name: "Zebra ZD230", vendor_id: "0a5f" }]), @@ -36,6 +38,18 @@ beforeEach(() => { }); describe("ZebraWayPanel USB access", () => { + it("offers the setup route after a denied query, as after a denied check", async () => { + queryDocument.mockResolvedValue({ kind: "permission_denied" }); + act(() => useLabelStore.setState({ printerState: { phase: "idle" }, printTarget: { ...DEFAULT_PRINT_TARGET, transport: "usb", usbId: "usb1" } })); + const r = render( "^XA~HS^XZ"} query={{ commands: ["~HS"], prints: false }} />); + await waitFor(() => expect((r.getByText(en.zebraPrint.queryButton) as HTMLButtonElement).disabled).toBe(false)); + await act(async () => { + fireEvent.click(r.getByText(en.zebraPrint.queryButton)); + }); + expect(r.getByText(en.zebraPrint.usbSetupAccess)).toBeTruthy(); + expect(r.getByText(en.zebraPrint.usbPermissionDenied)).toBeTruthy(); + }); + it("offers the setup route with its message after a denied check, as after a denied send", async () => { readPrinterStatus.mockResolvedValue({ kind: "permission_denied" }); act(() => useLabelStore.setState({ printerState: { phase: "idle" }, printTarget: { ...DEFAULT_PRINT_TARGET, transport: "usb", usbId: "usb1" } })); diff --git a/src/components/Output/ZplImportModal.test.tsx b/src/components/Output/ZplImportModal.test.tsx new file mode 100644 index 000000000..ebfcc3109 --- /dev/null +++ b/src/components/Output/ZplImportModal.test.tsx @@ -0,0 +1,34 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { render, cleanup, fireEvent, screen } from "@testing-library/react"; +import { ZplImportModal } from "./ZplImportModal"; +import { useLabelStore } from "../../store/labelStore"; +import { sendZpl } from "../../lib/sendZpl"; +import { repliesToHost } from "@zplab/core/lib/hostCommands"; + +beforeEach(() => { + useLabelStore.setState({ label: { widthMm: 70, heightMm: 40, dpmm: 8 }, pages: [{ objects: [] }], variables: [], currentPageIndex: 0, selectedIds: [] }); +}); + +afterEach(cleanup); + +const t = () => useLabelStore.getState().translations; +const paste = (text: string) => { + fireEvent.change(screen.getByRole("textbox"), { target: { value: text } }); + fireEvent.click(screen.getByRole("button", { name: t().importModal.importAction })); +}; + +describe("the import dialog's content gate", () => { + it("accepts a paste that only queries the printer", () => { + render(); + paste("^XA\n~HS\n^XZ"); + expect(screen.queryByText(t().importModal.errNoObjects)).toBeNull(); + expect(repliesToHost(sendZpl(useLabelStore.getState()))).toEqual(["~HS"]); + }); + + it("refuses a format that neither asks nor draws", () => { + render(); + paste("^XA^XZ"); + expect(screen.getByText(t().importModal.errNoObjects)).toBeTruthy(); + }); +}); diff --git a/src/components/Output/ZplImportModal.tsx b/src/components/Output/ZplImportModal.tsx index 5ded2bd6c..9fc02cb9a 100644 --- a/src/components/Output/ZplImportModal.tsx +++ b/src/components/Output/ZplImportModal.tsx @@ -1,11 +1,12 @@ import { useRef, useState } from 'react'; import { XMarkIcon, ClipboardDocumentIcon, CheckIcon, FolderOpenIcon } from '@heroicons/react/16/solid'; -import { importZplText, routeSetupCommands, type ZplImportResult, type SetupCommandChoice } from '@zplab/core/lib/zplImportService'; +import { documentHasContent, importZplText, routeSetupCommands, type ZplImportResult, type SetupCommandChoice } from '@zplab/core/lib/zplImportService'; +import { pageEmits } from '@zplab/core/types/Group'; import { readFileAsZplText } from '../../lib/readFile'; import { useLabelStore } from '../../store/labelStore'; import { commitUsedImages } from '@zplab/core/lib/imageUsage'; import { formatReportAsText, type ImportResult } from '../../lib/importReport'; -import { replayRiskFindings, printerCommandFindings, resolveRoutedReport, type ImportReport } from '@zplab/core/lib/importReport'; +import { replayRiskFindings, printerCommandFindings, resolveRoutedReport, commandsLostByRouting, type ImportReport } from '@zplab/core/lib/importReport'; import { ImportSummaryBody } from './ImportSummary'; import { ImportSetupChoice } from './ImportSetupChoice'; import { useT } from '../../hooks/useT'; @@ -36,8 +37,7 @@ export function ZplImportModal({ onClose }: Props) { // On a fresh designer (one empty page) we hide the toggle entirely // because the resulting [empty, imported] state would just be clutter // the user has to clean up manually. - const hasExistingContent = - pages.length > 1 || (pages[0]?.objects.length ?? 0) > 0; + const hasExistingContent = pages.length > 1 || (pages[0] !== undefined && pageEmits(pages[0])); // Stop on the result view only for what the changed canvas cannot show: findings, uploads parked in the profile, rows left behind. const finishImport = (totalObjects: number, report: ImportReport, changed: { fonts: number; graphics: number; settings: number }, droppedRows: number) => { @@ -71,14 +71,8 @@ export function ZplImportModal({ onClose }: Props) { // they obtain the text. const processImport = (text: string) => { const imported = importZplText(text, dpmm); - const { labelConfig, printerProfile, pages } = imported; - const totalObjects = pages.reduce((s, p) => s + p.objects.length, 0); - if ( - totalObjects === 0 && - Object.keys(labelConfig).length === 0 && - Object.keys(printerProfile).length === 0 && - imported.report.findings.length === 0 - ) { + // A findings-only stream earns the report view. + if (!documentHasContent(imported) && imported.report.findings.length === 0) { setError(t.importModal.errNoObjects); return; } @@ -148,6 +142,7 @@ export function ZplImportModal({ onClose }: Props) { {pending ? ( { const imported = pending; diff --git a/src/components/PrinterSettings/PrintTargetTab.tsx b/src/components/PrinterSettings/PrintTargetTab.tsx index 430c27cdd..dbd0acd89 100644 --- a/src/components/PrinterSettings/PrintTargetTab.tsx +++ b/src/components/PrinterSettings/PrintTargetTab.tsx @@ -1,11 +1,10 @@ -import { useState } from "react"; import type { SgdSetting } from "@zplab/core/lib/sgd"; +import { useReplyRead } from "../../hooks/useReplyRead"; import { useT } from "../../hooks/useT"; import { useLabelStore } from "../../store/labelStore"; import { isDesktopShell } from "../../lib/platform"; import { effectiveTransport, offeredTransports, type PrintTransport } from "../../lib/printTarget"; import { wayHint, wayLabel } from "../../lib/printWayText"; -import type { PrinterOutcome } from "../../lib/printerQuery"; import { pickerOptions, useBrowserPrintDevices, useLocalPrinters, useUsbPrinters, type DeviceListState } from "../../hooks/usePrintDevices"; import { AdoptSettingsDialog } from "../Output/AdoptSettingsDialog"; import { buttonCls, sectionHeadingCls } from "../ui/formStyles"; @@ -34,7 +33,7 @@ export function PrintTargetTab() { const setPrintTarget = useLabelStore((s) => s.setPrintTarget); const readPrinterSettings = useLabelStore((s) => s.readPrinterSettings); const reading = useLabelStore((s) => s.printerReading) !== undefined; - const [settings, setSettings] = useState | "reading" | undefined>(); + const settings = useReplyRead(); const usb = useUsbPrinters(isDesktopShell); const local = useLocalPrinters(isDesktopShell); const bp = useBrowserPrintDevices(); @@ -43,10 +42,7 @@ export function PrintTargetTab() { // A device way without devices stays visible, and then its reason replaces the description. const deviceWayHint = (value: PrintTransport) => (offered.includes(value) ? undefined : t.zebraPrint.noPrinters); - const openSettings = () => { - setSettings("reading"); - void readPrinterSettings().then((result) => setSettings((current) => (current === "reading" ? result : current))); - }; + const openSettings = () => settings.start(readPrinterSettings); const ways: { value: PrintTransport; desktopOnly?: boolean }[] = [ { value: "network" }, { value: "browserprint" }, @@ -97,7 +93,7 @@ export function PrintTargetTab() { {t.printerSettings.printerStatus.adoptFromPrinter} )} - {settings && setSettings(undefined)} />} + {settings.read && } ); } diff --git a/src/components/PrinterSettings/PrinterImageDialog.tsx b/src/components/PrinterSettings/PrinterImageDialog.tsx index 37ad71b07..225337fef 100644 --- a/src/components/PrinterSettings/PrinterImageDialog.tsx +++ b/src/components/PrinterSettings/PrinterImageDialog.tsx @@ -1,9 +1,9 @@ -import type { PrinterOutcome } from "../../lib/printerQuery"; +import type { ReplyShown } from "../../hooks/useReplyRead"; import { PrinterReplyDialog } from "../Output/PrinterReplyDialog"; interface Props { path: string; - read: PrinterOutcome | "reading"; + read: ReplyShown; /** Null is a blank sample for a font and an unreadable raster for a graphic, so the caller words it. */ blankText: string; onClose: () => void; diff --git a/src/components/PrinterSettings/PrinterStorage.tsx b/src/components/PrinterSettings/PrinterStorage.tsx index 710dac305..84f12636b 100644 --- a/src/components/PrinterSettings/PrinterStorage.tsx +++ b/src/components/PrinterSettings/PrinterStorage.tsx @@ -5,12 +5,12 @@ import { isWritableDevice } from "@zplab/core/lib/storagePath"; import { deleteKnowledge, driveUsage, listedObject, originState, type DeleteKnowledge, type OriginState, type StoredObjectKind, type StoredObjectOrigin } from "@zplab/core/lib/storedObjectOrigins"; import { isDesktopShell } from "../../lib/platform"; import { usePrinterListing } from "../../hooks/usePrinterListing"; +import { useReplyRead } from "../../hooks/useReplyRead"; import { useT } from "../../hooks/useT"; import type { Translations } from "../../locales"; import { formatTemplate } from "../../lib/formatTemplate"; -import type { PrinterOutcome } from "../../lib/printerQuery"; import type { PrinterObjectsState } from "../../store/slices/printerObjectsSlice"; -import { failureText, readingText } from "../../lib/printerStatusText"; +import { driveUsageText, failureText, kilobytes, readingText } from "../../lib/printerStatusText"; import { selectPrinterObjects, useLabelStore } from "../../store/labelStore"; import { ConfirmDialog } from "../ui/ConfirmDialog"; import { MemoryBar } from "../ui/MemoryBar"; @@ -19,8 +19,6 @@ import { buttonCls, disabledCls, sectionHeadingCls, zplCommandTagCls } from "../ import { PrinterImageDialog } from "./PrinterImageDialog"; import { useStoredObjectHover } from "../../hooks/useStoredObjectHover"; -const kb = (bytes: number): string => String(bytes === 0 ? 0 : Math.max(1, Math.round(bytes / 1024))); - export function StoredObjectsHeading() { const loc = useT().printerSettings.objects; return ( @@ -85,7 +83,7 @@ export function PrinterStorageUsage() {
- {formatTemplate(loc.usageFmt, { device: usage.device, used: kb(usage.used), free: kb(usage.free) })} + {driveUsageText(loc, usage)} @@ -93,7 +91,7 @@ export function PrinterStorageUsage() { {formatTemplate(loc.usageObjectFmt, { path: hostObjectPath(share), - kb: kb(share.size), + kb: kilobytes(share.size), percent: ((share.size / usage.capacity) * 100).toFixed(1), })} @@ -119,7 +117,7 @@ export function PrinterOriginMark({ origin }: { origin: StoredObjectOrigin }) { return ( {loc[ORIGIN_KEY[state]]} - {origin.hostObject && {formatTemplate(loc.objectSizeFmt, { kb: kb(origin.hostObject.size) })}} + {origin.hostObject && {formatTemplate(loc.objectSizeFmt, { kb: kilobytes(origin.hostObject.size) })}} ); } @@ -141,14 +139,10 @@ export function PrinterObjectActions({ origin, kind }: { origin: StoredObjectOri const [pendingDelete, setPendingDelete] = useState(false); // The message belongs to the listing it was reported against, so the next read retires it. const [issue, setIssue] = useState<{ listing: PrinterObjectsState; text: string }>(); - const [image, setImage] = useState | "reading">(); + const image = useReplyRead(); const writable = isWritableDevice(object.device); - const show = () => { - setImage("reading"); - // The dialog closed while the read ran, so its result has nowhere to land. - void readImage(object).then((read) => setImage((current) => (current === undefined ? current : read))); - }; + const show = () => image.start(() => readImage(object)); const confirmDelete = () => { setPendingDelete(false); setIssue(undefined); @@ -185,7 +179,7 @@ export function PrinterObjectActions({ origin, kind }: { origin: StoredObjectOri onCancel={() => setPendingDelete(false)} /> )} - {image && setImage(undefined)} />} + {image.read && } ); } diff --git a/src/hooks/useReplyRead.ts b/src/hooks/useReplyRead.ts new file mode 100644 index 000000000..58520871d --- /dev/null +++ b/src/hooks/useReplyRead.ts @@ -0,0 +1,18 @@ +import { useState } from "react"; +import type { PrinterOutcome } from "../lib/printerQuery"; + +export type ReplyShown = PrinterOutcome | "reading"; +type ReplyRead = ReplyShown | undefined; + +/** A printer read behind a dialog: a reply that lands after the user closed it must not reopen it. */ +export function useReplyRead(): { read: ReplyRead; start: (run: () => Promise>) => void; close: () => void } { + const [read, setRead] = useState>(); + return { + read, + start: (run) => { + setRead("reading"); + void run().then((result) => setRead((current) => (current === "reading" ? result : current))); + }, + close: () => setRead(undefined), + }; +} diff --git a/src/hooks/useSendNotices.ts b/src/hooks/useSendNotices.ts index 6da48f9c9..4a9cdd64b 100644 --- a/src/hooks/useSendNotices.ts +++ b/src/hooks/useSendNotices.ts @@ -1,16 +1,14 @@ import { deliveryNotices, exportPrinterImpact, printerImpactNotices } from "../lib/exportImpact"; -import { sendZpl } from "../lib/sendZpl"; import { selectCanBatchExport, useLabelStore } from "../store/labelStore"; import { useT } from "./useT"; /** What the bytes about to go out do to the printer, synchronously, because the banner must exist the * moment the send button goes live. */ -export function useSendNotices(): readonly string[] { +export function useSendNotices(zpl: string): readonly string[] { const t = useT(); const pages = useLabelStore((s) => s.pages); const currentPageIndex = useLabelStore((s) => s.currentPageIndex); const batch = useLabelStore(selectCanBatchExport); - const zpl = useLabelStore((s) => sendZpl(s, 1)); // A batch recalls one page, so only that page's delivery is at stake. const printed = batch ? pages.slice(currentPageIndex, currentPageIndex + 1) : pages; diff --git a/src/lib/designFile.test.ts b/src/lib/designFile.test.ts index 8dc974669..5a2aa0582 100644 --- a/src/lib/designFile.test.ts +++ b/src/lib/designFile.test.ts @@ -103,6 +103,14 @@ describe('parseDesignFile', () => { expect(result.value.pages[0]?.objects).toHaveLength(1); }); + it('drops host command lines that are not canonical and leaves no empty list behind', () => { + const file = { schemaVersion: 5, label: { widthMm: 70, heightMm: 40, dpmm: 8 }, pages: [{ objects: [], hostCommands: ['~HS~JR', '~HS'] }, { objects: [], hostCommands: ['junk'] }], variables: [] }; + const result = parseDesignFile(JSON.stringify(file)); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.pages.map((p) => p.hostCommands)).toEqual([['~HS'], undefined]); + }); + it('conforms loaded props: a string to its set or dropped, a fraction off an integer slot, a number past its range kept', () => { const file = { schemaVersion: 5, diff --git a/src/lib/importReport.ts b/src/lib/importReport.ts index fe6c8d5fb..735f05c1e 100644 --- a/src/lib/importReport.ts +++ b/src/lib/importReport.ts @@ -50,6 +50,7 @@ const LOSS_KEY: Record = { recallFormat: 'lossRecallFormat', recallSlot: 'lossRecallSlot', recallSettings: 'lossRecallSettings', + droppedHostCommand: 'lossDroppedHostCommand', }; /** Loss description of a partial: the finding's own cause, else the catalog row's. */ @@ -88,6 +89,9 @@ export function describeFinding( if (f.kind === 'deviceAction') { return { title: tr.deviceActionTitle, detail: f.command }; } + if (f.kind === 'hostQuery') { + return { title: tr.hostQueryTitle, detail: f.command }; + } if (f.kind === 'lossyEdit') { return { title: tr.lossyEditTitle, detail: f.command }; } diff --git a/src/lib/outputChoice.test.ts b/src/lib/outputChoice.test.ts index 1a4f6f014..0de6f1348 100644 --- a/src/lib/outputChoice.test.ts +++ b/src/lib/outputChoice.test.ts @@ -7,6 +7,7 @@ import { effectiveScope, offeredKinds, offeredScopes, + printsFields, renderableOutput, type OutputFacts, } from "./outputChoice"; @@ -22,6 +23,14 @@ const everything: OutputFacts = { batchRowCount: 3, }; +describe("printsFields", () => { + it("is true only for a label that has objects, never for a setup script", () => { + expect(printsFields("label", everything)).toBe(true); + expect(printsFields("setupScript", everything)).toBe(false); + expect(printsFields("label", { ...everything, hasObjects: false })).toBe(false); + }); +}); + describe("anyProducible", () => { it("opens the dialog for a config-only document", () => { // An imported overlay with no objects is a legitimate setup job, but there is nothing to draw. diff --git a/src/lib/outputChoice.ts b/src/lib/outputChoice.ts index 3ff00162f..1dd1e98e6 100644 --- a/src/lib/outputChoice.ts +++ b/src/lib/outputChoice.ts @@ -1,4 +1,5 @@ // The two levels of the output dialog: where the label goes, and how, where there is a choice. +import { hostCommandParts } from "@zplab/core/lib/hostCommands"; import { effectiveTransport, type PrintTransport, type PrintWay } from "./printTarget"; /** The bytes the dialog is about: the label itself, or a script that only configures the printer. */ @@ -65,6 +66,20 @@ export function offeredKinds(source: OutputSource, f: OutputFacts): OutputKind[] return (["print", "file"] as const).filter((kind) => can[kind]); } +export interface DocumentQueryFacts { + commands: readonly string[]; + prints: boolean; +} + +export const asksPrinter = (query: DocumentQueryFacts): boolean => query.commands.length > 0; + +/** ^HV answers only after the print, past the reader's idle gap, so a document asking it keeps the plain send. */ +export const answersOnReceipt = (commands: readonly string[]): boolean => + commands.every((command) => hostCommandParts(command).id !== "^HV"); + +/** The fields ride along with a query only when the label itself goes out. */ +export const printsFields = (source: OutputSource, f: OutputFacts): boolean => source === "label" && f.hasObjects; + /** A rendered label is what the system dialog and a PNG carry, and a setup script draws nothing. */ export const renderableOutput = (source: OutputSource, f: OutputFacts): boolean => source === "label" && f.hasObjects && !f.sourceEditing; diff --git a/src/lib/printPreview.test.ts b/src/lib/printPreview.test.ts index 4183d2eef..9dbc5a0e7 100644 --- a/src/lib/printPreview.test.ts +++ b/src/lib/printPreview.test.ts @@ -16,6 +16,14 @@ const blankBarcode = (): LabelObject[] => [ } as LabelObject, ]; +describe("buildPreviewZpl host commands", () => { + it("never asks the printer, with or without the blank-field samples", () => { + const asking = { ...label, hostCommands: ["~HS", "^HV1,20,H,E,F"] } as PageLabel; + expect(buildPreviewZpl(asking, blankBarcode(), [], null)).not.toMatch(/~HS|\^HV/); + expect(buildPreviewZpl(asking, blankBarcode(), [], null, { blankSamples: true })).not.toMatch(/~HS|\^HV/); + }); +}); + describe("buildPreviewZpl blank-field samples", () => { it("overlay opt-in renders a blank barcode with its symbology sample", () => { const zpl = buildPreviewZpl(label, blankBarcode(), [], null, { blankSamples: true }); diff --git a/src/lib/printPreview.ts b/src/lib/printPreview.ts index b33b73443..5961959b5 100644 --- a/src/lib/printPreview.ts +++ b/src/lib/printPreview.ts @@ -34,6 +34,6 @@ export function buildPreviewZpl( const substituted = applyBindingToTree(objects, variables, active, "preview", clockCtxFromLabel(label), ctrlParityFor); const previewed = opts.blankSamples ? withBlankSamples(substituted) : substituted; // A preview is never re-imported, so nothing but printer bytes goes to the renderer, - // and a render must print, so the format is not stored (^DF prints nothing, p.1704). + // and a render must print, so the format is not stored (^DF prints nothing, p.1704) and nothing is asked. return stripSidecarComments(generateZPL(withoutStoredFormat(label), previewed, [])); } diff --git a/src/lib/printTarget.test.ts b/src/lib/printTarget.test.ts index eda823d98..3b3e38c68 100644 --- a/src/lib/printTarget.test.ts +++ b/src/lib/printTarget.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { DEFAULT_PRINT_TARGET, effectiveTransport, needsRawChannel, offeredTransports, parsePort, readLegacyPrintTarget, resolveQueryTarget } from "./printTarget"; +import { carriesReply, DEFAULT_PRINT_TARGET, effectiveTransport, needsRawChannel, offeredTransports, parsePort, readLegacyPrintTarget, resolveQueryTarget } from "./printTarget"; function storageOf(entries: Record) { const map = new Map(Object.entries(entries)); @@ -42,6 +42,16 @@ describe("printTarget", () => { expect(needsRawChannel("system", "labelary")).toBe(false); }); + it("names the ways that can bring an answer back", () => { + expect(carriesReply("network", true)).toBe(true); + expect(carriesReply("usb", true)).toBe(true); + expect(carriesReply("browserprint", true)).toBe(false); + expect(carriesReply("local", true)).toBe(false); + expect(carriesReply("system", true)).toBe(false); + expect(carriesReply("network", false)).toBe(false); + expect(carriesReply("usb", false)).toBe(false); + }); + it("takes the pre-0.7.0 browser keys over once and clears them", () => { const storage = storageOf({ zebra_print_ip: " 172.17.17.175 ", diff --git a/src/lib/printTarget.ts b/src/lib/printTarget.ts index 522962f9c..46dced8a9 100644 --- a/src/lib/printTarget.ts +++ b/src/lib/printTarget.ts @@ -62,6 +62,11 @@ export function needsRawChannel(way: PrintWay, renderer: PreviewProvider): boole return way === "network" || way === "usb" || (way === "system" && renderer === "printer"); } +/** Only the desktop shell holds a bidirectional channel, so only its network and USB ways bring an answer back. */ +export function carriesReply(way: PrintWay, desktop: boolean): boolean { + return desktop && (way === "network" || way === "usb"); +} + /** The stored way when the build offers it, else the network form, without rewriting the choice. */ export function effectiveTransport(transport: PrintTransport, offered: readonly PrintWay[]): PrintTransport { return offered.includes(transport) ? transport : "network"; diff --git a/src/lib/printWayText.test.ts b/src/lib/printWayText.test.ts index 8718548bc..38dbeced2 100644 --- a/src/lib/printWayText.test.ts +++ b/src/lib/printWayText.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import en from "../locales/en"; -import { wayHint } from "./printWayText"; +import { queryOffer, wayHint } from "./printWayText"; +import { asksPrinter } from "./outputChoice"; describe("wayHint", () => { it("promises a reply only in the build that can read one", () => { @@ -13,3 +14,33 @@ describe("wayHint", () => { expect(wayHint(en.zebraPrint, "browserprint", false)).toBe(en.zebraPrint.wayBrowserPrint); }); }); + +describe("queryOffer", () => { + const loc = en.zebraPrint; + const prints = { commands: ["~HS"], prints: true }; + const asks = { commands: ["~HS"], prints: false }; + + it("offers nothing for a document that only prints", () => { + expect(asksPrinter({ commands: [], prints: true })).toBe(false); + expect(queryOffer(loc, { commands: [], prints: true }, "network", true)).toEqual({ queries: false, hints: [] }); + expect(queryOffer(loc, { commands: [], prints: true }, "system", false)).toEqual({ queries: false, hints: [] }); + }); + + it("replaces the send where the reply can come back and names the printed fields that ride along", () => { + expect(queryOffer(loc, asks, "network", true)).toEqual({ queries: true, hints: [loc.queryHint] }); + expect(queryOffer(loc, prints, "usb", true)).toEqual({ queries: true, hints: [loc.queryHint, loc.queryMixedHint] }); + }); + + it("offers the send, not the query, where a reply comes only after the print, and the system way keeps its own reason", () => { + const late = { commands: ["~HS", "^HV1,20,H,E,F"], prints: true }; + expect(queryOffer(loc, late, "network", true)).toEqual({ queries: false, hints: [loc.queryAfterPrint] }); + expect(queryOffer(loc, late, "local", false)).toEqual({ queries: false, hints: [loc.queryAfterPrint] }); + expect(queryOffer(loc, late, "system", true)).toEqual({ queries: false, hints: [loc.queryNoRender] }); + }); + + it("keeps the send and says the answer is lost where no channel carries it, the web build included", () => { + expect(queryOffer(loc, asks, "local", false)).toEqual({ queries: false, hints: [loc.queryNoChannel] }); + expect(queryOffer(loc, prints, "local", false)).toEqual({ queries: false, hints: [loc.queryNoChannel] }); + expect(queryOffer(loc, asks, "browserprint", true)).toEqual({ queries: false, hints: [loc.queryNoChannel] }); + }); +}); diff --git a/src/lib/printWayText.ts b/src/lib/printWayText.ts index e85f926a1..8ea36bb69 100644 --- a/src/lib/printWayText.ts +++ b/src/lib/printWayText.ts @@ -1,5 +1,6 @@ // One wording per way to the printer, shared by the output dialog and the printer settings. -import type { PrintWay } from "./printTarget"; +import { carriesReply, type PrintWay } from "./printTarget"; +import { answersOnReceipt, asksPrinter, type DocumentQueryFacts } from "./outputChoice"; import type { Translations } from "../locales"; type Loc = Translations["zebraPrint"]; @@ -26,3 +27,18 @@ export const wayLabel = (loc: Loc, way: PrintWay): string => loc[LABEL_KEY[way]] * wording is the only one that depends on the build. */ export const wayHint = (loc: Loc, way: PrintWay, desktop: boolean): string => way === "network" && !desktop ? loc.wayNetworkWeb : loc[WAY_KEY[way]]; + +interface QueryOffer { + /** True where the query replaces the send, since an answer can come back. */ + queries: boolean; + hints: string[]; +} + +/** What the dialog offers a document that asks the printer something, for the way at hand. */ +export function queryOffer(loc: Loc, query: DocumentQueryFacts, way: PrintWay, desktop: boolean): QueryOffer { + if (!asksPrinter(query)) return { queries: false, hints: [] }; + if (way === "system") return { queries: false, hints: [loc.queryNoRender] }; + if (!answersOnReceipt(query.commands)) return { queries: false, hints: [loc.queryAfterPrint] }; + if (!carriesReply(way, desktop)) return { queries: false, hints: [loc.queryNoChannel] }; + return { queries: true, hints: query.prints ? [loc.queryHint, loc.queryMixedHint] : [loc.queryHint] }; +} diff --git a/src/lib/printerDocumentQuery.test.ts b/src/lib/printerDocumentQuery.test.ts new file mode 100644 index 000000000..f90f46585 --- /dev/null +++ b/src/lib/printerDocumentQuery.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from "vitest"; +import { ETX, STX, ZD230_HI, ZD230_HS, ZD230_HW } from "@zplab/core/lib/hostStatus.fixture"; +import { fallbackTranslations as en } from "../locales"; +import { queryDocument } from "./printerDocumentQuery"; +import type * as PrinterQuery from "./printerQuery"; +import { documentReplyLines } from "./printerStatusText"; +import { formatTemplate } from "./formatTemplate"; + +const queryPrinter = vi.fn(); +vi.mock("./printerQuery", async (importOriginal) => ({ ...(await importOriginal()), queryPrinter: (...args: Parameters) => queryPrinter(...args) })); + +const TARGET = { kind: "network", host: "172.17.17.175", port: 9100 } as const; +const loc = en.printerSettings.printerStatus; + +const HI = `${ZD230_HI}\r\n`; +const HS = `${ZD230_HS}\r\n`; + +describe("queryDocument", () => { + it("sends the document unchanged and interprets the replies its commands earned", async () => { + // The identity line sits behind the status strings. + queryPrinter.mockResolvedValue({ kind: "ok", value: `${HS}${HI}${ZD230_HW}` }); + const document = "^XA~HS~HI^HWR:*.*^FO10,10^FDx^FS^XZ"; + const res = await queryDocument(TARGET, document, ["~HS", "~HI", "^HW"]); + expect(queryPrinter).toHaveBeenCalledWith(TARGET, document); + if (res.kind !== "ok") throw new Error(res.kind); + expect(res.value.commands).toEqual(["~HS", "~HI", "^HW"]); + expect(res.value.parsed.identity?.model).toBe("ZD230"); + expect(res.value.parsed.status?.labelLengthDots).toBe(892); + expect(res.value.parsed.directories?.[0]?.objects).toHaveLength(1); + expect(res.value.raw).toContain("ZD230,V89.21.46Z"); + expect(res.value.raw).not.toContain(STX); + }); + + it("leaves a parser out where the document never asked it", async () => { + queryPrinter.mockResolvedValue({ kind: "ok", value: HS }); + const res = await queryDocument(TARGET, "^XA~HS^XZ", ["~HS"]); + if (res.kind !== "ok") throw new Error(res.kind); + expect(res.value.parsed.status).toBeDefined(); + expect(res.value.parsed.identity).toBeUndefined(); + expect(res.value.parsed.directories).toBeUndefined(); + }); + + it("hands the ^HH echo back as the raw reply", async () => { + queryPrinter.mockResolvedValue({ kind: "ok", value: `${STX}DARKNESS\r\n+10.0\r\n${ETX}` }); + const res = await queryDocument(TARGET, "^XA^HH^XZ", ["^HH"]); + if (res.kind !== "ok") throw new Error(res.kind); + expect(res.value.parsed).toEqual({}); + expect(res.value.configuration).toBe(true); + expect(res.value.raw).toBe("DARKNESS\r\n+10.0"); + }); + + it("tells a silent printer apart and hands a transport failure straight back", async () => { + queryPrinter.mockResolvedValue({ kind: "silent" }); + expect(await queryDocument(TARGET, "^XA~HU^XZ", ["~HU"])).toEqual({ kind: "silent" }); + queryPrinter.mockResolvedValue({ kind: "ok", value: `${STX}${ETX}` }); + expect(await queryDocument(TARGET, "^XA~HU^XZ", ["~HU"])).toEqual({ kind: "silent" }); + queryPrinter.mockResolvedValue({ kind: "refused", port: 9100 }); + expect(await queryDocument(TARGET, "^XA~HS^XZ", ["~HS"])).toEqual({ kind: "refused", port: 9100 }); + }); +}); + +describe("documentReplyLines", () => { + it("names the state, the model and the listed objects", async () => { + queryPrinter.mockResolvedValue({ kind: "ok", value: `${HI}${HS}${ZD230_HW}` }); + const res = await queryDocument(TARGET, "^XA~HI~HS^HWR:*.*^XZ", ["~HI", "~HS", "^HW"]); + if (res.kind !== "ok") throw new Error(res.kind); + const lines = documentReplyLines(en.printerSettings.printerStatus, en.printerSettings.objects, res.value); + expect(lines[0]).toBe(loc.ready); + expect(lines).toContain(`${loc.model}: ZD230 V89.21.46Z`); + expect(lines).toContain("R:PRE.GRF"); + expect(lines.at(-1)).toBe(formatTemplate(en.printerSettings.objects.usageFmt, { device: "R", used: "49", free: "7292" })); + }); + + it("stays empty where no parser read anything", () => { + expect(documentReplyLines(en.printerSettings.printerStatus, en.printerSettings.objects, { raw: "x", commands: ["^HF"], configuration: false, parsed: {} })).toEqual([]); + }); +}); diff --git a/src/lib/printerDocumentQuery.ts b/src/lib/printerDocumentQuery.ts new file mode 100644 index 000000000..992997f20 --- /dev/null +++ b/src/lib/printerDocumentQuery.ts @@ -0,0 +1,21 @@ +import { hostCommandParts } from "@zplab/core/lib/hostCommands"; +import { parseHostReplies, type HostReplyParts } from "@zplab/core/lib/hostReplies"; +import { answered, queryPrinter, type PrinterOutcome } from "./printerQuery"; +import type { QueryTarget } from "./printTarget"; + +export interface DocumentReply { + raw: string; + commands: readonly string[]; + /** ^HH answers with the configuration dump, which the dialog titles as such. */ + configuration: boolean; + parsed: HostReplyParts; +} + +/** The document already carries the commands the printer answers, so nothing is appended. */ +export async function queryDocument(target: QueryTarget, zpl: string, commands: readonly string[]): Promise> { + const res = await queryPrinter(target, zpl); + if (res.kind !== "ok") return res; + const text = answered(res); + if (text.kind !== "ok") return text; + return { kind: "ok", value: { raw: text.value, commands, configuration: commands.some((command) => hostCommandParts(command).id === "^HH"), parsed: parseHostReplies(text.value, commands) } }; +} diff --git a/src/lib/printerQuery.test.ts b/src/lib/printerQuery.test.ts index 780809fe6..9e4650e76 100644 --- a/src/lib/printerQuery.test.ts +++ b/src/lib/printerQuery.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { queryPrinter } from "./printerQuery"; +import { answered, queryPrinter } from "./printerQuery"; import type * as TauriCore from "@tauri-apps/api/core"; vi.mock("./platform", () => ({ isDesktopShell: true })); @@ -19,11 +19,22 @@ describe("queryPrinter over the network", () => { expect(await queryPrinter(target, "~HQES")).toEqual({ kind: "error", message: "no response from printer" }); }); - it("forwards a refused connection and a reply", async () => { + it("forwards a refused connection, a silent printer and a reply", async () => { invoke.mockResolvedValueOnce({ kind: "refused" }); expect(await queryPrinter(target, "~HI")).toEqual({ kind: "refused", port: 9100 }); invoke.mockResolvedValueOnce({ kind: "data", body: "ZD230" }); expect(await queryPrinter(target, "~HI")).toEqual({ kind: "ok", value: "ZD230" }); expect(invoke).toHaveBeenLastCalledWith("query_zpl_tcp", { host: "172.17.17.175", port: 9100, zpl: "~HI" }); + invoke.mockResolvedValueOnce({ kind: "silent" }); + expect(await queryPrinter(target, "~HU")).toEqual({ kind: "silent" }); + }); +}); + +describe("answered", () => { + it("keeps a reply's spaces, drops the line break around it, and calls a bare frame or whitespace silence", () => { + expect(answered({ kind: "ok", value: " HEADER 1 \r\n" })).toEqual({ kind: "ok", value: " HEADER 1 " }); + expect(answered({ kind: "ok", value: "\u0002\u0003" })).toEqual({ kind: "silent" }); + expect(answered({ kind: "ok", value: "" })).toEqual({ kind: "silent" }); + expect(answered({ kind: "ok", value: " " })).toEqual({ kind: "silent" }); }); }); diff --git a/src/lib/printerQuery.ts b/src/lib/printerQuery.ts index c0b9ad6c7..287895a7e 100644 --- a/src/lib/printerQuery.ts +++ b/src/lib/printerQuery.ts @@ -1,3 +1,4 @@ +import { unframed } from "@zplab/core/lib/hostStatus"; import { errorMessage } from "./errorMessage"; import { isDesktopShell } from "./platform"; import type { QueryTarget } from "./printTarget"; @@ -7,6 +8,7 @@ export type PrinterQueryFailure = | { kind: "unconfigured" } | { kind: "busy" } | { kind: "unparsed" } + | { kind: "silent" } | { kind: "ignored" } | { kind: "refused"; port: number } | { kind: "unreachable" } @@ -16,9 +18,16 @@ export type PrinterQueryFailure = export type PrinterOutcome = { kind: "ok"; value: T } | PrinterQueryFailure; +/** A reply that is only a frame is silence. Spaces are kept, since they may be an ^HV header or terminator. */ +export function answered(outcome: PrinterOutcome): PrinterOutcome { + if (outcome.kind !== "ok") return outcome; + const text = unframed(outcome.value).replace(/^[\r\n]+|[\r\n]+$/g, ""); + return text.trim() ? { kind: "ok", value: text } : { kind: "silent" }; +} + /** Mirrors the Rust TcpQueryResult. */ interface TcpQueryResult { - kind: "data" | "refused" | "unreachable"; + kind: "data" | "silent" | "refused" | "unreachable"; body?: string; } @@ -31,6 +40,8 @@ export function printerFailureMessage(failure: PrinterQueryFailure): string { return "Another printer read is still running. Try again in a moment."; case "unparsed": return "The printer answered, but the reply could not be read."; + case "silent": + return "The printer did not answer."; case "ignored": return "The printer ignored the command. The object is still there."; case "refused": @@ -62,6 +73,7 @@ export async function queryPrinter(target: QueryTarget, zpl: string): Promise ({ const target = { kind: "network", host: "172.17.17.175", port: 9100 } as const; const data = (body: string) => ({ kind: "ok", value: body }); -const identity = data("ZD230,V89.21.46Z,8,4096KB,"); +const identity = data(ZD230_HI); const clear = data("PRINTER STATUS\r\nERRORS: 0 00000000 00000000\r\nWARNINGS: 0 00000000 00000000"); const memory = data("4096,2048,1500"); const strings = data("\u0002030,0,0,1200,000,0,0,0,000,0,0,0\u0003\r\n\u0002001,0,0,0,1,2,0,0,00000000,1,000\u0003\r\n"); @@ -41,7 +42,7 @@ describe("readPrinterStatus", () => { .mockResolvedValueOnce(memory); const result = await readPrinterStatus(target); expect(queryPrinter).toHaveBeenCalledTimes(3); - expect(result).toMatchObject({ kind: "ok", value: { flags: { errors: ["headOpen"] }, status: undefined, withheld: true } }); + expect(result).toMatchObject({ kind: "ok", value: { flags: { errors: ["headOpen"] }, withheld: true } }); }); it("keeps the identity and still asks the status strings when the flag and memory queries fail", async () => { @@ -54,8 +55,9 @@ describe("readPrinterStatus", () => { expect(queryPrinter.mock.calls.map((c) => c[1])).toEqual(["~HI", "~HQES", "~HM", "~HS"]); expect(result).toMatchObject({ kind: "ok", - value: { identity: { model: "ZD230" }, flags: undefined, memory: undefined, status: { printMode: 2 }, withheld: false }, + value: { identity: { model: "ZD230" }, status: { printMode: 2 }, withheld: false }, }); + expect(result.kind === "ok" && "memory" in result.value).toBe(false); }); it("forwards a failed first contact unchanged", async () => { @@ -67,9 +69,9 @@ describe("readPrinterStatus", () => { }); describe("readPrinterConfiguration", () => { - it("returns the echo as text without its frame bytes", async () => { - queryPrinter.mockResolvedValueOnce(data("\u0002 ZD230 PRINTER\r\n V89.21.46Z FIRMWARE\u0003")); - expect(await readPrinterConfiguration(target)).toEqual({ kind: "ok", value: "ZD230 PRINTER\r\n V89.21.46Z FIRMWARE" }); + it("returns the echo as text without its frame bytes, its padding kept", async () => { + queryPrinter.mockResolvedValueOnce(data("\u0002 ZD230 PRINTER\r\n V89.21.46Z FIRMWARE\r\n\u0003")); + expect(await readPrinterConfiguration(target)).toEqual({ kind: "ok", value: " ZD230 PRINTER\r\n V89.21.46Z FIRMWARE" }); expect(queryPrinter).toHaveBeenLastCalledWith(target, "^XA^HH^XZ"); }); }); diff --git a/src/lib/printerStatus.ts b/src/lib/printerStatus.ts index de6399d9d..a826d1358 100644 --- a/src/lib/printerStatus.ts +++ b/src/lib/printerStatus.ts @@ -1,45 +1,30 @@ -import { - hostStatusAnswers, - parseHostIdentification, - parseHostMemory, - parseHostQueryStatus, - parseHostStatus, - unframed, - type HostMemory, - type HostQueryStatus, - type HostStatus, - type PrinterIdentity, -} from "@zplab/core/lib/hostStatus"; -import { queryPrinter, type PrinterOutcome } from "./printerQuery"; +import { parseHostReplies, type HostReplyParts } from "@zplab/core/lib/hostReplies"; +import { hostStatusAnswers, unframed } from "@zplab/core/lib/hostStatus"; +import { answered, queryPrinter, type PrinterOutcome } from "./printerQuery"; import type { QueryTarget } from "./printTarget"; -export interface PrinterStatusReport { - identity: PrinterIdentity | undefined; - flags: HostQueryStatus | undefined; - memory: HostMemory | undefined; - /** Set only when ~HS was asked and its reply parsed. */ - status: HostStatus | undefined; +export interface PrinterStatusReport extends Pick { /** True when the flags name a state that makes ~HS silent. */ withheld: boolean; /** Each reply under its command, so the user can read what the parsers could not. */ raw: string; } -const parsed = (outcome: PrinterOutcome | undefined, parse: (text: string) => T | undefined): T | undefined => - outcome?.kind === "ok" ? parse(outcome.value) : undefined; +const read = (outcome: PrinterOutcome | undefined, id: string): HostReplyParts => + outcome?.kind === "ok" ? parseHostReplies(outcome.value, [id]) : {}; /** Asks ~HS only when the flags say the printer would answer. */ export async function readPrinterStatus(target: QueryTarget, onStep?: (command: string) => void): Promise> { - const ask = (command: string) => { + const ask = async (command: string) => { onStep?.(command); - return queryPrinter(target, command); + return answered(await queryPrinter(target, command)); }; const hi = await ask("~HI"); if (hi.kind !== "ok") return hi; const es = await ask("~HQES"); const hm = await ask("~HM"); - const flags = parsed(es, parseHostQueryStatus); - const withheld = flags !== undefined && !hostStatusAnswers(flags); + const esParts = read(es, "~HQES"); + const withheld = esParts.flags !== undefined && !hostStatusAnswers(esParts.flags); const hs = withheld ? undefined : await ask("~HS"); const raw: string[] = []; for (const [command, outcome] of [["~HI", hi], ["~HQES", es], ["~HM", hm], ["~HS", hs]] as const) { @@ -47,19 +32,11 @@ export async function readPrinterStatus(target: QueryTarget, onStep?: (command: } return { kind: "ok", - value: { - identity: parseHostIdentification(hi.value), - flags, - memory: parsed(hm, parseHostMemory), - status: parsed(hs, parseHostStatus), - withheld, - raw: raw.join("\n\n"), - }, + value: { ...read(hi, "~HI"), ...esParts, ...read(hm, "~HM"), ...read(hs, "~HS"), withheld, raw: raw.join("\n\n") }, }; } /** ^HH echoes the configuration label to the host as text (spec p.222). */ export async function readPrinterConfiguration(target: QueryTarget): Promise> { - const res = await queryPrinter(target, "^XA^HH^XZ"); - return res.kind === "ok" ? { kind: "ok", value: unframed(res.value).trim() } : res; + return answered(await queryPrinter(target, "^XA^HH^XZ")); } diff --git a/src/lib/printerStatusText.ts b/src/lib/printerStatusText.ts index cb04c8950..c837b1f0d 100644 --- a/src/lib/printerStatusText.ts +++ b/src/lib/printerStatusText.ts @@ -1,5 +1,8 @@ -// One wording per printer condition, shared by chip, check and dialog. -import { printerReadiness, type HostWarningFlag, type PrinterCondition, type PrinterReadiness } from "@zplab/core/lib/hostStatus"; +// One wording per printer condition and reading, shared by chip, check and dialog. +import { hostObjectPath } from "@zplab/core/lib/hostDirectory"; +import { printerReadiness, type HostMemory, type HostWarningFlag, type PrinterCondition, type PrinterIdentity, type PrinterReadiness } from "@zplab/core/lib/hostStatus"; +import { driveUsage, hostListing, type DriveUsage } from "@zplab/core/lib/storedObjectOrigins"; +import type { DocumentReply } from "./printerDocumentQuery"; import type { PrinterQueryFailure } from "./printerQuery"; import type { PrinterStatusReport } from "./printerStatus"; import type { Translations } from "../locales"; @@ -50,6 +53,8 @@ export function failureText(loc: Loc, failure: PrinterQueryFailure): string { return loc.failBusy; case "unparsed": return loc.failUnparsed; + case "silent": + return loc.failSilent; case "ignored": return loc.failIgnored; case "refused": @@ -76,3 +81,34 @@ export const reportView = (loc: Loc, report: PrinterStatusReport | undefined): R const readiness = printerReadiness(report.flags, report.status); return { report, readiness, line: readinessText(loc, readiness) }; }; + +export const modelText = (identity: PrinterIdentity): string => `${identity.model} ${identity.firmware}`; +export const memoryText = (loc: Loc, memory: HostMemory): string => + formatTemplate(loc.memoryFmt, { available: String(memory.availableKb), max: String(memory.maxKb) }); + +/** A listed object under a kilobyte is still there, so only a true zero reads as nothing. */ +export const kilobytes = (bytes: number): string => String(bytes === 0 ? 0 : Math.max(1, Math.round(bytes / 1024))); + +export const driveUsageText = (loc: Translations["printerSettings"]["objects"], usage: Pick): string => + formatTemplate(loc.usageFmt, { device: usage.device, used: kilobytes(usage.used), free: kilobytes(usage.free) }); + +/** A document's reply as lines, in the wording each reading already has elsewhere. */ +export function documentReplyLines(loc: Loc, storage: Translations["printerSettings"]["objects"], reply: DocumentReply): string[] { + const { status, identity, memory, flags, directories } = reply.parsed; + const lines: string[] = []; + if (flags !== undefined || status !== undefined) { + const readiness = printerReadiness(flags, status); + lines.push(readinessText(loc, readiness).text); + if (flags !== undefined) lines.push(`${loc.warnings}: ${warningsText(loc, readiness)}`); + } + if (identity !== undefined) lines.push(`${loc.model}: ${modelText(identity)}`); + if (memory !== undefined) lines.push(`${loc.memory}: ${memoryText(loc, memory)}`); + if (directories !== undefined) { + const listing = hostListing(directories); + lines.push(...listing.objects.map(hostObjectPath)); + lines.push( + ...driveUsage(listing).map((d) => driveUsageText(storage, d)), + ); + } + return lines; +} diff --git a/src/lib/sendZpl.test.ts b/src/lib/sendZpl.test.ts index 956e44fcb..6d89f5689 100644 --- a/src/lib/sendZpl.test.ts +++ b/src/lib/sendZpl.test.ts @@ -41,6 +41,14 @@ describe("sendZpl", () => { expect(zpl.match(/\^XFR:LBL\.ZPL/g)).toHaveLength(3); }); + it("asks the page's host commands once per job, in the first recall block", () => { + useLabelStore.setState({ pages: [{ ...pages[0]!, hostCommands: ["~HS"] }], dataset, columnMapping: { bindings: { v1: "sku" }, headerSnapshot: ["sku"] } }); + const zpl = sendZpl(useLabelStore.getState()); + expect(zpl.split("~HS")).toHaveLength(2); + expect(zpl.indexOf("^DFR:LBL.ZPL")).toBeLessThan(zpl.indexOf("~HS")); + expect(sendZpl(useLabelStore.getState(), 1).split("~HS")).toHaveLength(2); + }); + it("keeps the printer impact of the whole job in a one-row sample", () => { useLabelStore.setState({ dataset, columnMapping: { bindings: { v1: "sku" }, headerSnapshot: ["sku"] } }); const s = useLabelStore.getState(); diff --git a/src/lib/sourceDiagnostics.test.ts b/src/lib/sourceDiagnostics.test.ts index abb52d04a..c41c69748 100644 --- a/src/lib/sourceDiagnostics.test.ts +++ b/src/lib/sourceDiagnostics.test.ts @@ -63,6 +63,11 @@ describe("buildSourceDiagnostics", () => { ]); }); + it("words a host query with the report title", () => { + const f: ImportFinding = { kind: "hostQuery", command: "~HS", pageIndex: 0, span: { start: 3, end: 6 } }; + expect(describeFinding(f, en.importReport)).toEqual({ title: en.importReport.hostQueryTitle, detail: "~HS" }); + }); + it("offers the ^FS repair only on a dropped field, at the field's last byte", () => { const dropped: ImportFinding = { kind: "unterminatedField", command: "^FO40,40", pageIndex: 0, span: { start: 3, end: 29 } }; const other: ImportFinding = { kind: "deviceAction", command: "~PH", pageIndex: 0, span: { start: 40, end: 43 } }; diff --git a/src/lib/usbPrint.ts b/src/lib/usbPrint.ts index 71f3b1c7e..17a0f7540 100644 --- a/src/lib/usbPrint.ts +++ b/src/lib/usbPrint.ts @@ -23,6 +23,7 @@ export type UsbPrintResult = /** Mirrors the Rust UsbQueryResult; `error` covers the invoke rejection. */ export type UsbQueryResult = | { kind: "data"; body: string } + | { kind: "silent" } | { kind: "permission_denied" } | { kind: "not_found" } | UsbError; diff --git a/src/lib/zplImportService.test.ts b/src/lib/zplImportService.test.ts index 4aa740532..fcda41e09 100644 --- a/src/lib/zplImportService.test.ts +++ b/src/lib/zplImportService.test.ts @@ -1,13 +1,14 @@ import { describe, it, expect } from 'vitest'; -import { importZplText, routeSetupCommands } from '@zplab/core/lib/zplImportService'; +import { documentHasContent, importZplText, routeSetupCommands } from '@zplab/core/lib/zplImportService'; import { mergeSetupEntries } from '@zplab/core/lib/setupEntries'; import { generateSetupScript } from './zplSetupScript'; import { generateMultiPageZPL, generateZPL } from '@zplab/core/lib/zplGenerator'; import { parseZPL } from '@zplab/core/lib/zplParser'; +import { repliesToHost } from '@zplab/core/lib/hostCommands'; import { hasFontBytes, loadFontBytesSync } from '@zplab/core/lib/fontCache'; import { describeFinding, formatReportAsText } from './importReport'; import { fallbackTranslations } from '../locales'; -import { DOCUMENT_FINDING, replayRiskFindings, printerCommandFindings, resolveRoutedReport } from '@zplab/core/lib/importReport'; +import { DOCUMENT_FINDING, replayRiskFindings, printerCommandFindings, resolveRoutedReport, commandsLostByRouting } from '@zplab/core/lib/importReport'; import { isSetupPath, printerProfileSchema, type PrinterProfile } from '@zplab/core/types/PrinterProfile'; import type { LabelConfig } from '@zplab/core/types/LabelConfig'; @@ -293,11 +294,11 @@ describe('importZplText: findings.pageIndex', () => { }); it('stamps the correct page index across multiple blocks', () => { - // Page 0: clean. Page 1: ^A@ partial. Page 2: ^HT browser-limit + ^XX unknown. + // Page 0: clean. Page 1: ^A@ partial. Page 2: ^LF browser-limit + ^XX unknown. const zpl = [ '^XA^FO0,0^A0N,30,0^FDclean^FS^XZ', '^XA^FO0,0^A@N,30,0,E:A.TTF^FDfont^FS^XZ', - '^XA^HT^XX99^XZ', + '^XA^LF^XX99^XZ', ].join('\n'); const { report } = importZplText(zpl, 8); const byPage = (idx: number) => @@ -949,6 +950,16 @@ describe('routeSetupCommands', () => { expect(printerProfile.setupFonts).toEqual([{ path: 'E:SETUP.TTF' }]); }); + it('keeps the query of a routed page, since the model regenerates it, and names only the device action it loses', () => { + const imported = importZplText('^XA^JUS~HS~JC^FO10,10^A0N,20,0^FDhi^FS^XZ', 8); + expect(imported.report.hostQuery).toEqual(['~HS']); + expect(commandsLostByRouting(imported.report)).toEqual(['~JC']); + const { pages, keptPageIndexes } = routeSetupCommands('setupScript', imported); + expect(pages[0]?.overlay).toBeUndefined(); + expect(generateMultiPageZPL(imported.labelConfig as LabelConfig, pages, imported.variables)).toContain('~HS'); + expect(resolveRoutedReport(imported.report, keptPageIndexes).hostQuery).toEqual(['~HS']); + }); + it('setupScript keeps the profile but drops the overlay of replay-risk pages', () => { const imported = importZplText(STREAM, 8); const { printerProfile, pages } = routeSetupCommands('setupScript', imported); @@ -1139,16 +1150,16 @@ describe('replay-risk report helpers', () => { }); it('drops findings on a removed page and remaps survivors to the new index', () => { - // Page 0 (setup-only, ^HT finding) is routed away; page 1 has an ^A@ partial. + // Page 0 (setup-only, ^LF finding) is routed away; page 1 has an ^A@ partial. const zpl = - '^XA^ST05,20,2026,12,00,00^HT^XZ\n' + + '^XA^ST05,20,2026,12,00,00^LF^XZ\n' + '^XA^FO0,0^A@N,20,0,E:A.TTF^FDx^FS^XZ'; const imported = importZplText(zpl, 8); const { pages, keptPageIndexes } = routeSetupCommands('remove', imported); expect(pages).toHaveLength(1); // the setup-only page 0 is dropped expect(keptPageIndexes).toEqual([1]); const stripped = resolveRoutedReport(imported.report, keptPageIndexes); - // The removed page's ^HT finding is gone; the survivor is remapped to page 0. + // The removed page's ^LF finding is gone; the survivor is remapped to page 0. expect(stripped.browserLimit).toEqual([]); expect(stripped.findings.some((f) => f.kind === 'browserLimit')).toBe(false); const partial = stripped.findings.filter((f) => f.kind === 'partial'); @@ -1157,6 +1168,133 @@ describe('replay-risk report helpers', () => { }); }); +describe('a host command in the import report', () => { + it('drops a format command behind the closing ^XZ, as the printer ignores it', () => { + const after = importZplText('^XA^FO10,10^A0N,20,20^FDx^FS^XZ^HWR:*.*', 8); + expect(after.pages.map((p) => p.hostCommands)).toEqual([undefined]); + expect(after.report.findings.filter((f) => f.kind === 'partial').map((f) => f.command)).toEqual(['^HW']); + const twice = importZplText('^XA^HWR:*.*^XZ^HWR:*.*', 8); + expect(twice.pages[0]?.hostCommands).toEqual(['^HWR:*.*']); + }); + + it('treats a stream whose only XA carries the tilde as wrapper-less, so a format command there is asked', () => { + expect(importZplText('~XA^HWR:*.*^FO10,10^A0N,20,20^FDx^FS', 8).pages[0]?.hostCommands).toEqual(['^HWR:*.*']); + }); + + it('drops a format command behind a ^DF anywhere in the block, keeping only the field question', () => { + const r = importZplText('^XA^FO10,10^A0N,20,20^FN1^FS^DFR:A.ZPL^FS^HWR:*.*^HV1,20^XZ', 8); + expect(r.pages[0]?.hostCommands).toEqual(['^HV1,20']); + expect(r.report.findings.filter((f) => f.kind === 'partial').map((f) => f.command)).toContain('^HW'); + }); + + it('keeps the query when the overlay never formed, since the model carries it', () => { + const r = importZplText('^XA~HS^FO20,40^GB50,50,2^GB60,60,2^FS^XZ', 8); + expect(r.pages[0]?.overlay).toBeUndefined(); + expect(r.pages[0]?.hostCommands).toEqual(['~HS']); + expect(generateMultiPageZPL(r.labelConfig as LabelConfig, r.pages, r.variables)).toContain('~HS'); + }); + + it('is a host query, never an unknown or hardware-limited command', () => { + for (const [zpl, command] of [['~HS', '~HS'], ['^XA~HS^XZ', '~HS'], ['^XA^HT^XZ', '^HT'], ['^XA^HWR:*.*^XZ', '^HWR:*.*']] as const) { + const kinds = importZplText(zpl, 8).report.findings.map((f) => [f.kind, f.command]); + expect(kinds).toContainEqual(['hostQuery', command]); + expect(kinds.filter(([k]) => k === 'unknown' || k === 'browserLimit')).toEqual([]); + } + }); + + it('reports every occurrence on its page and none on the rows that replay a template', () => { + const r = importZplText('^XA~HS~HS^FO10,10^A0N,20,20^FDx^FS^XZ', 8); + expect(r.report.findings.filter((f) => f.kind === 'hostQuery').map((f) => f.command)).toEqual(['~HS', '~HS']); + const NL = String.fromCharCode(10); + const replayed = importZplText(['^XA^DFR:A.ZPL^FS~HS^FO10,10^A0N,30,30^FN1^FS^XZ', '^XA^XFR:A.ZPL^FS^FN1^FDone^FS^XZ', '^XA^XFR:A.ZPL^FS^FN1^FDtwo^FS^XZ'].join(NL), 8); + expect(replayed.report.findings.filter((f) => f.kind === 'hostQuery').map((f) => [f.command, f.pageIndex])).toEqual([['~HS', 0]]); + }); +}); + +describe('a bare query page', () => { + it('keeps a host command that shares the paste with fields, and exports it wrapped', () => { + const r = importZplText('~HS^FO10,10^A0N,20,20^FDx^FS', 8); + expect(r.pages[0]?.objects).toHaveLength(1); + const out = generateMultiPageZPL(r.labelConfig as LabelConfig, r.pages, r.variables); + expect(repliesToHost(out)).toEqual(['~HS']); + expect(out.startsWith('^XA')).toBe(true); + expect(out.trimEnd().endsWith('^XZ')).toBe(true); + }); + + it('wraps a format host command, which answers only inside a format', () => { + const r = importZplText('^HWR:*.*', 8); + expect(generateMultiPageZPL(r.labelConfig as LabelConfig, r.pages, r.variables)).toBe('^XA\n^HWR:*.*\n^XZ'); + const trailing = importZplText('^HWR:*.*\n', 8); + expect(generateMultiPageZPL(trailing.labelConfig as LabelConfig, trailing.pages, trailing.variables)).toBe('^XA\n^HWR:*.*\n^XZ'); + }); + + it('closes the wrapper with the prefix a ^CC left in force', () => { + const r = importZplText('^CC##FO10,10#A0N,20,20#FDx#FS~HS', 8); + expect(generateMultiPageZPL(r.labelConfig as LabelConfig, r.pages, r.variables)).toBe('^XA\n^CC##FO10,10#A0N,20,20#FDx#FS~HS\n#XZ'); + }); + + it('wraps a bare stream only where a command needs the format, whatever caret spells it', () => { + const remapped = importZplText('~CC#\n#HWR:*.*', 8); + const out = generateMultiPageZPL(remapped.labelConfig as LabelConfig, remapped.pages, remapped.variables); + expect(out.startsWith('^XA')).toBe(true); + expect(repliesToHost(out)).toEqual(['^HWR:*.*']); + const immediate = importZplText('^CC#~HS', 8); + expect(generateMultiPageZPL(immediate.labelConfig as LabelConfig, immediate.pages, immediate.variables)).toBe('^CC#~HS'); + }); + + it('leaves an unbalanced paste alone, so a stray ^XZ is not closed twice, and still asks from the model', () => { + const r = importZplText('~HS^FO10,10^A0N,20,20^FDx^FS^XZ', 8); + expect(r.unbalanced?.kind).toBe('strayXz'); + expect(r.pages[0]?.overlay).toBeUndefined(); + expect(generateMultiPageZPL(r.labelConfig as LabelConfig, r.pages, r.variables)).toContain('~HS'); + expect(documentHasContent(importZplText('~HS^XZ', 8))).toBe(true); + }); + + it('rewrites the stored-format path in place instead of adding a second ^DF', () => { + const r = importZplText('^DFR:A.ZPL^FS~HS', 8); + const out = generateMultiPageZPL(r.labelConfig as LabelConfig, r.pages, r.variables); + expect(out.split('^DF')).toHaveLength(2); + expect(out).toContain('^DFR:A.ZPL^FS~HS'); + }); + + it('drops a device action with the overlay of a bare field paste, since the wrapper regenerates without it', () => { + const r = importZplText('~JA^FO10,10^A0N,20,20^FDx^FS', 8); + expect(r.pages[0]?.overlay).toBeUndefined(); + expect(r.report.deviceAction).toEqual([]); + }); + + it('records an open tail, so an appended field gets its ^FS before the added ^XZ', () => { + const r = importZplText('~HS^FO10,10^A0N,20,20^FDx', 8); + expect(r.pages[0]?.overlay?.openTail).toBe(true); + }); + + it('keeps the lossyEdit finding, because the page replays its overlay', () => { + const r = importZplText('^LRY~HS', 8); + expect(r.pages[0]?.overlay).toBeDefined(); + expect(r.report.findings.some((f) => f.kind === 'lossyEdit')).toBe(true); + }); +}); + +describe('documentHasContent', () => { + const has = (zpl: string) => documentHasContent(importZplText(zpl, 8)); + + it('counts a host command as content', () => { + expect(has('^XA\n~HS\n^XZ')).toBe(true); + expect(has('^XA^HWE:*.*^XZ')).toBe(true); + }); + + it('counts objects, label config and profile fields as content', () => { + expect(has('^XA^FO10,10^A0N,20,20^FDx^FS^XZ')).toBe(true); + expect(has('^XA^PW800^XZ')).toBe(true); + expect(has('^JZY')).toBe(true); + }); + + it('rejects a format that neither asks nor draws', () => { + expect(has('^XA^XZ')).toBe(false); + expect(has('no zpl here')).toBe(false); + }); +}); + describe('printerCommandFindings', () => { it('returns replayRisk and deviceAction findings together', () => { const { report } = importZplText('^XA^KNfoo~JC^FO10,10^A0N,20,0^FDx^FS^XZ', 8); diff --git a/src/lib/zplParser.test.ts b/src/lib/zplParser.test.ts index c899ac244..140560d42 100644 --- a/src/lib/zplParser.test.ts +++ b/src/lib/zplParser.test.ts @@ -2813,9 +2813,10 @@ describe('parseZPL — partial findings', () => { }); describe('parseZPL — browserLimit findings', () => { - it('records ^HT as browserLimit', () => { - const { findings } = parseSingle('^XA^HT^XZ', 8); - expect(commandsOf({ findings }, 'browserLimit')).toEqual(['^HT']); + it('records ^LF as browserLimit and ^HT, which answers over the host port, as a host query', () => { + const { findings } = parseSingle('^XA^LF^HT^XZ', 8); + expect(commandsOf({ findings }, 'browserLimit')).toEqual(['^LF']); + expect(commandsOf({ findings }, 'hostQuery')).toEqual(['^HT']); }); it('records an upload in a format it cannot decode as browserLimit', () => { @@ -2838,10 +2839,10 @@ describe('parseZPL — unknown findings', () => { }); it('surfaces unknown/browserLimit tokens without a trailing newline', () => { - const parsed = parseSingle('^XA\n^XX99\n^HT\n^XZ', 8); + const parsed = parseSingle('^XA\n^XX99\n^LF\n^XZ', 8); const skipped = [...commandsOf(parsed, 'browserLimit'), ...commandsOf(parsed, 'unknown')]; expect(commandsOf(parsed, 'unknown')).toContain('^XX99'); - expect(commandsOf(parsed, 'browserLimit')).toContain('^HT'); + expect(commandsOf(parsed, 'browserLimit')).toContain('^LF'); for (const s of [...commandsOf(parsed, 'unknown'), ...commandsOf(parsed, 'browserLimit'), ...skipped]) { expect(s).toBe(s.trimEnd()); } @@ -2887,7 +2888,7 @@ describe('parseZPL — unknown findings', () => { describe('parseZPL: page findings', () => { it('emits one finding per kind with the page index stamped', () => { - const zpl = '^XA^FO0,0^A@N,30,0,E:ARIAL.TTF^FDText^FS^HT^XX99^XZ'; + const zpl = '^XA^FO0,0^A@N,30,0,E:ARIAL.TTF^FDText^FS^LF^XX99^XZ'; const { findings } = parseSingle(zpl, 8); const kinds = findings.map((f) => f.kind); expect(kinds).toContain('partial'); @@ -2896,10 +2897,10 @@ describe('parseZPL: page findings', () => { expect(findings.every((f) => f.pageIndex === 0)).toBe(true); // Tripwire: findings emit in source order. A pipeline split that // collects unknowns / browser-limits in a separate pass would - // reorder these. Order: ^A@ (L1 partial) → ^HT (L1 browserLimit) → + // reorder these. Order: ^A@ (L1 partial) → ^LF (L1 browserLimit) → // ^XX (L1 unknown). expect(findings.map((f) => f.command)).toEqual([ - '^A@', '^HT', '^XX99', + '^A@', '^LF', '^XX99', ]); }); diff --git a/src/locales/ar.ts b/src/locales/ar.ts index aacf8bdfc..900b923cd 100644 --- a/src/locales/ar.ts +++ b/src/locales/ar.ts @@ -1022,6 +1022,7 @@ const ar = { failPermissionDenied: 'لا يمكن الوصول إلى طابعة USB. امنح الإذن في مربع حوار الإرسال، علامة التبويب USB.', failBusy: 'ما زالت هناك قراءة أخرى للطابعة قيد التنفيذ. حاول مرة أخرى بعد قليل.', failUnparsed: 'ردّت الطابعة، لكن تعذّرت قراءة الرد.', + failSilent: 'لم تجب الطابعة.', failIgnored: 'تجاهلت الطابعة الأمر. لا يزال العنصر موجودًا.', rawReply: 'الرد الخام', configuration: 'التكوين', @@ -1063,6 +1064,7 @@ const ar = { keepExistingPages: 'الاحتفاظ بالصفحات الحالية', importSetupTitle: 'يحتوي ملف ZPL هذا على أوامر إعداد الطابعة', importSetupBody: 'يتم تشغيلها على الطابعة عند طباعة الملصق أو تصديره. اختر كيفية التعامل معها:', + importSetupDropsFmt: 'النقل أو الإزالة يسقط أيضًا {commands}، لأن بايتات الملصق وحدها تحملها.', importSetupKeep: 'الاحتفاظ في الملصق', importSetupToSetup: 'نقل إلى نص الإعداد', importSetupRemove: 'إزالة', @@ -1177,6 +1179,7 @@ const ar = { lossRecallFormat: 'يستدعي هذا الجزء تنسيقًا مخزّنًا ويبقى صفحة بدلاً من صف بيانات. تحتاج الصفوف إلى تنسيق مخزّن سابقًا في الدفق يُعرّف الحقول، وإلى أجزاء لا تحمل سوى بيانات الحقول.', lossRecallSlot: 'يوفر الاستدعاء رقم حقل لم يعلنه التنسيق المخزّن قط. تطبعه الطابعة كحقل شارد لا يستطيع التصميم عرضه.', lossRecallSettings: 'تُبدّل الصفوف إعدادًا يقرؤه الحقل من بياناته، مثل مستوى تصحيح الأخطاء. يحتفظ التصميم بإعداد الصف الأول.', + lossDroppedHostCommand: 'لا يحتفظ التصميم بأمر المضيف هذا، لذلك لا تطرحه الكتلة المعاد إنشاؤها بعد الآن.', browserLimitTitle: 'تم التخطي: يتطلب أجهزة الطابعة', replayRiskTitle: 'أمر إعداد الطابعة: يُنفَّذ على الطابعة عند التصدير/الطباعة', deviceActionTitle: 'إجراء جهاز الطابعة: يُنفَّذ على الطابعة عند التصدير/الطباعة', @@ -1187,6 +1190,7 @@ const ar = { mixedGeoDetailFmt: 'تختلف {cmds} بين الكتل ({detail})', mixedJmTitle: 'يختلف وضع كثافة الطباعة بين الكتل: تحتفظ الصفحات اللاحقة بوضعها الخاص، لكن يمكن تعديل الكتلة الأولى فقط', unknownTitle: 'تم التخطي: أمر غير معروف', + hostQueryTitle: 'أمر مضيف: يطلب ردًا من الطابعة عند إرسال المستند', unterminatedFieldTitleFmt: 'الحقل غير مغلق بـ {fs}: تتجاهله الطابعة', hexControlTitle: 'حرف تحكم في بيانات الحقل: تتجاهل الطابعة باقي الحقل', reportHeader: 'تقرير استيراد ZPL', @@ -2011,6 +2015,13 @@ const ar = { usbSetupAccess: 'إعداد الوصول', usbSetupFlatpak: 'داخل Flatpak، لا يمكن للتطبيق تثبيت قاعدة udev بنفسه. ثبّت الملف 70-zplab.rules من مستودع ZPLab على النظام المضيف، ثم أعد توصيل الطابعة.', usbNotFound: 'لم يتم العثور على طابعة USB، أعد توصيلها وقم بالتحديث.', + queryButton: 'استعلام الطابعة', + queryHint: 'يسأل هذا المستند الطابعة شيئًا. يفتح الرد هنا.', + queryNoChannel: 'لا يحمل هذا المسار أي رد، ولذلك يضيع الرد.', + queryNoRender: 'هذه الطريقة تطبع صورة، فلا مكان للرد.', + queryMixedHint: 'تُطبع الحقول الأخرى أيضًا.', + queryAfterPrint: 'يصل رد ^HV بعد الطباعة. لا ينتظر ZPLab كل هذا الوقت، لذا يُرسل المستند بدون استعلام.', + replyTitle: 'رد الطابعة', }, pdfExport: { title: 'تصدير PDF', diff --git a/src/locales/bg.ts b/src/locales/bg.ts index b4d62f376..15573e61e 100644 --- a/src/locales/bg.ts +++ b/src/locales/bg.ts @@ -1022,6 +1022,7 @@ const bg = { failPermissionDenied: 'Няма достъп до USB принтера. Предоставете го в диалога за изпращане, раздел USB.', failBusy: 'Друго четене от принтера все още се изпълнява. Опитайте отново след малко.', failUnparsed: 'Принтерът отговори, но отговорът не можа да бъде прочетен.', + failSilent: 'Принтерът не отговори.', failIgnored: 'Принтерът игнорира командата. Обектът все още е там.', rawReply: 'Необработен отговор', configuration: 'Конфигурация', @@ -1063,6 +1064,7 @@ const bg = { keepExistingPages: 'Запазване на съществуващите страници', importSetupTitle: 'Този ZPL файл съдържа команди за настройка на принтера', importSetupBody: 'Те се изпълняват на принтера при печат или експортиране на етикета. Изберете как да бъдат обработени:', + importSetupDropsFmt: 'Преместването или премахването изхвърля и {commands}, защото само байтовете на етикета ги носят.', importSetupKeep: 'Запази в етикета', importSetupToSetup: 'Премести в скрипт за настройка', importSetupRemove: 'Премахване', @@ -1177,6 +1179,7 @@ const bg = { lossRecallFormat: 'Този блок извиква съхранен формат и остава страница, а не ред с данни. Редовете се нуждаят от формат, съхранен по-рано в потока, който декларира полета, и от блокове, които носят само данни за полета.', lossRecallSlot: 'Извикването предоставя номер на поле, който съхраненият формат никога не декларира. Принтерът го отпечатва като изгубено поле, което дизайнът не може да покаже.', lossRecallSettings: 'Редовете сменят настройка, която полето чете от своите данни, например нивото на корекция на грешки. Дизайнът запазва настройката от първия ред.', + lossDroppedHostCommand: 'Дизайнът не запазва тази хост команда, затова новосъздаден блок вече не я задава.', browserLimitTitle: 'Пропуснато: изисква хардуер на принтера', replayRiskTitle: 'Команда за настройка на принтера: изпълнява се на принтера при експортиране/печат', deviceActionTitle: 'Действие на устройството на принтера: изпълнява се на принтера при експортиране/печат', @@ -1187,6 +1190,7 @@ const bg = { mixedGeoDetailFmt: '{cmds} се различават между блоковете ({detail})', mixedJmTitle: 'Режимът на плътност на печат се различава между блоковете: следващите страници запазват собствения си режим, но само първият е редактируем', unknownTitle: 'Пропуснато: командата не е разпозната', + hostQueryTitle: 'Хост команда: изисква отговор от принтера при изпращане на документа', unterminatedFieldTitleFmt: 'Полето не е затворено с {fs}: принтерът го игнорира', hexControlTitle: 'Контролен знак в данните на полето: принтерът отхвърля останалата част от полето', reportHeader: 'Отчет за импортиране на ZPL', @@ -2011,6 +2015,13 @@ const bg = { usbSetupAccess: 'Настройване на достъп', usbSetupFlatpak: 'В Flatpak приложението не може само да инсталира правилото за udev. Инсталирайте 70-zplab.rules от хранилището на ZPLab на хост системата, след което включете принтера отново.', usbNotFound: 'USB принтерът не е намерен, включете го отново и обновете.', + queryButton: 'Запитване към принтера', + queryHint: 'Този документ пита принтера нещо. Отговорът се отваря тук.', + queryNoChannel: 'По този път не се връща отговор, затова отговорът се губи.', + queryNoRender: 'Този начин отпечатва изображение, така че отговорът няма къде да отиде.', + queryMixedHint: 'Останалите полета също се отпечатват.', + queryAfterPrint: 'Отговорът на ^HV идва след печата. ZPLab не чака толкова дълго, затова документът се изпраща без запитване.', + replyTitle: 'Отговор на принтера', }, pdfExport: { title: 'Експорт на PDF', diff --git a/src/locales/ca.ts b/src/locales/ca.ts index 8613f7cf9..e37816b3d 100644 --- a/src/locales/ca.ts +++ b/src/locales/ca.ts @@ -993,6 +993,7 @@ const ca = { failPermissionDenied: 'No hi ha accés a la impressora USB. Autoritza’l a la pestanya USB del diàleg d’impressió.', failBusy: 'Encara hi ha una altra lectura de la impressora en curs. Torna-ho a provar d’aquí a un moment.', failUnparsed: 'La impressora ha respost, però no s’ha pogut llegir la resposta.', + failSilent: 'La impressora no ha respost.', failIgnored: 'La impressora ha ignorat l’ordre. L’objecte encara hi és.', rawReply: 'Resposta en brut', configuration: 'Configuració', @@ -1057,6 +1058,7 @@ const ca = { keepExistingPages: 'Conserva les pàgines existents', importSetupTitle: 'Aquest ZPL conté ordres de configuració de la impressora', importSetupBody: 'S’executen a la impressora quan s’imprimeix o s’exporta l’etiqueta. Tria com tractar-les:', + importSetupDropsFmt: 'Moure o eliminar també descarta {commands}, perquè només els bytes de l’etiqueta els porten.', importSetupKeep: 'Conserva a l’etiqueta', importSetupToSetup: 'Mou a l’script de configuració', importSetupRemove: 'Elimina', @@ -1171,6 +1173,7 @@ const ca = { lossRecallFormat: 'Aquest bloc recupera un format emmagatzemat i es manté com a pàgina en lloc de fila de dades. Les files requereixen un format desat abans al flux que declari camps, i blocs que només continguin dades de camp.', lossRecallSlot: 'La recuperació proporciona un número de camp que el format emmagatzemat no declara. La impressora l’imprimeix com a camp aïllat que el disseny no pot mostrar.', lossRecallSettings: 'Les files canvien un paràmetre que el camp llegeix de les dades, com ara el nivell de correcció d’errors. El disseny conserva el valor de la primera fila.', + lossDroppedHostCommand: 'El disseny no conserva aquesta ordre de host, per això un bloc regenerat ja no la demana.', browserLimitTitle: 'Omès: requereix maquinari d’impressora', replayRiskTitle: 'Ordre de configuració: s’executa a la impressora en exportar o imprimir', deviceActionTitle: 'Acció del dispositiu: s’executa a la impressora en exportar o imprimir', @@ -1181,6 +1184,7 @@ const ca = { mixedGeoDetailFmt: '{cmds} difereixen entre blocs ({detail})', mixedJmTitle: 'El mode de densitat difereix entre blocs: les pàgines posteriors conserven el seu mode, però només es pot editar el primer', unknownTitle: 'Omès: ordre no reconeguda', + hostQueryTitle: 'Ordre de host: demana una resposta a la impressora quan s’envia el document', unterminatedFieldTitleFmt: 'Camp sense tancar amb {fs}: la impressora el descarta', hexControlTitle: 'Caràcter de control a les dades del camp: la impressora descarta la resta del camp', reportHeader: 'Informe d’importació ZPL', @@ -1252,6 +1256,13 @@ const ca = { usbSetupAccess: 'Configura l’accés', usbSetupFlatpak: 'Dins de Flatpak, l’aplicació no pot instal·lar la regla udev. Instal·la 70-zplab.rules del repositori ZPLab al sistema amfitrió i torna a connectar la impressora.', usbNotFound: 'No s’ha trobat la impressora USB; torna-la a connectar i actualitza.', + queryButton: 'Consulta la impressora', + queryHint: 'Aquest document fa una consulta a la impressora. La resposta s’obre aquí.', + queryNoChannel: 'Aquesta via no porta cap resposta, la resposta de la impressora es perd.', + queryNoRender: 'Aquesta via imprimeix una imatge, així que la resposta no té on anar.', + queryMixedHint: 'Els altres camps també s’imprimeixen.', + queryAfterPrint: "La resposta ^HV arriba després de la impressió. ZPLab no espera tant, així que el document s'envia sense consulta.", + replyTitle: 'Resposta de la impressora', }, pdfExport: { title: 'Exporta PDF', diff --git a/src/locales/cs.ts b/src/locales/cs.ts index c2ba9f376..eeab2673e 100644 --- a/src/locales/cs.ts +++ b/src/locales/cs.ts @@ -1022,6 +1022,7 @@ const cs = { failPermissionDenied: 'Bez přístupu k tiskárně USB. Udělte jej v dialogu odeslání, karta USB.', failBusy: 'Jiné čtení z tiskárny ještě probíhá. Zkuste to znovu za okamžik.', failUnparsed: 'Tiskárna odpověděla, ale odpověď se nepodařilo přečíst.', + failSilent: 'Tiskárna neodpověděla.', failIgnored: 'Tiskárna příkaz ignorovala. Objekt stále zůstává.', rawReply: 'Nezpracovaná odpověď', configuration: 'Konfigurace', @@ -1063,6 +1064,7 @@ const cs = { keepExistingPages: 'Zachovat stávající stránky', importSetupTitle: 'Tento soubor ZPL obsahuje příkazy pro nastavení tiskárny', importSetupBody: 'Spustí se na tiskárně při tisku nebo exportu štítku. Vyberte, jak s nimi naložit:', + importSetupDropsFmt: 'Přesunutí nebo odstranění zahodí i {commands}, protože je nesou jen vlastní bajty štítku.', importSetupKeep: 'Ponechat ve štítku', importSetupToSetup: 'Přesunout do skriptu nastavení', importSetupRemove: 'Odebrat', @@ -1177,6 +1179,7 @@ const cs = { lossRecallFormat: 'Tento blok vyvolává uložený formát a zůstává stránkou místo řádku dat. Řádky potřebují formát uložený dříve v datovém proudu, který deklaruje pole, a bloky, které nesou pouze data polí.', lossRecallSlot: 'Vyvolání poskytuje číslo pole, které uložený formát nikdy nedeklaruje. Tiskárna ho vytiskne jako osiřelé pole, které návrh nedokáže zobrazit.', lossRecallSettings: 'Řádky mění nastavení, které pole čte ze svých dat, například úroveň opravy chyb. Návrh zachová nastavení prvního řádku.', + lossDroppedHostCommand: 'Návrh tento hostitelský příkaz neuchovává, proto jej znovu vytvořený blok už nepoloží.', browserLimitTitle: 'Přeskočeno: vyžaduje hardware tiskárny', replayRiskTitle: 'Nastavovací příkaz tiskárny: spustí se na tiskárně při exportu/tisku', deviceActionTitle: 'Akce zařízení tiskárny: spustí se na tiskárně při exportu/tisku', @@ -1187,6 +1190,7 @@ const cs = { mixedGeoDetailFmt: '{cmds} se mezi bloky liší ({detail})', mixedJmTitle: 'Režim hustoty tisku se mezi bloky liší: pozdější stránky si zachovávají svůj vlastní režim, ale upravitelný je pouze první', unknownTitle: 'Přeskočeno: příkaz nerozpoznán', + hostQueryTitle: 'Hostitelský příkaz: při odeslání dokumentu si vyžádá odpověď tiskárny', unterminatedFieldTitleFmt: 'Pole není uzavřeno pomocí {fs}: tiskárna ho zahodí', hexControlTitle: 'Řídicí znak v datech pole: tiskárna zahodí zbytek pole', reportHeader: 'Zpráva o importu ZPL', @@ -2011,6 +2015,13 @@ const cs = { usbSetupAccess: 'Nastavit přístup', usbSetupFlatpak: 'Uvnitř Flatpaku si aplikace nemůže pravidlo udev nainstalovat sama. Nainstalujte 70-zplab.rules z repozitáře ZPLab na hostitelský systém a tiskárnu znovu připojte.', usbNotFound: 'Tiskárna USB nebyla nalezena, znovu ji zapojte a obnovte.', + queryButton: 'Dotázat se tiskárny', + queryHint: 'Tento dokument se tiskárny na něco ptá. Odpověď se otevře zde.', + queryNoChannel: 'Touto cestou se žádná odpověď nevrací, proto se ztrácí.', + queryNoRender: 'Tato cesta tiskne obrázek, takže odpověď nemá kam dorazit.', + queryMixedHint: 'Ostatní pole se vytisknou také.', + queryAfterPrint: 'Odpověď ^HV přichází až po tisku. ZPLab tak dlouho nečeká, proto se dokument odešle bez dotazu.', + replyTitle: 'Odpověď tiskárny', }, pdfExport: { title: 'Exportovat PDF', diff --git a/src/locales/da.ts b/src/locales/da.ts index dca7f8982..225250e66 100644 --- a/src/locales/da.ts +++ b/src/locales/da.ts @@ -1022,6 +1022,7 @@ const da = { failPermissionDenied: 'Ingen adgang til USB-printeren. Giv den i sende-dialogen, fanen USB.', failBusy: 'En anden udlæsning fra printeren kører stadig. Prøv igen om lidt.', failUnparsed: 'Printeren svarede, men svaret kunne ikke læses.', + failSilent: 'Printeren svarede ikke.', failIgnored: 'Printeren ignorerede kommandoen. Objektet er stadig der.', rawReply: 'Rå-svar', configuration: 'Konfiguration', @@ -1063,6 +1064,7 @@ const da = { keepExistingPages: 'Behold eksisterende sider', importSetupTitle: 'Denne ZPL indeholder printeropsætningskommandoer', importSetupBody: 'De køres på printeren, når etiketten udskrives eller eksporteres. Vælg, hvordan de skal håndteres:', + importSetupDropsFmt: 'Flyt eller Fjern dropper også {commands}, som kun etikettens egne bytes bærer.', importSetupKeep: 'Behold i etiket', importSetupToSetup: 'Flyt til opsætningsscript', importSetupRemove: 'Fjern', @@ -1177,6 +1179,7 @@ const da = { lossRecallFormat: 'Denne blok kalder et gemt format og forbliver en side i stedet for en datarække. Rækker kræver et format, der er gemt tidligere i streamen og som deklarerer felter, samt blokke, der kun indeholder feltdata.', lossRecallSlot: 'Genkaldelsen angiver et feltnummer, som det gemte format aldrig erklærer. Printeren udskriver det som et herreløst felt, som designet ikke kan vise.', lossRecallSettings: 'Rækkerne skifter en indstilling, som feltet læser fra sine data, for eksempel fejlkorrektionsniveauet. Designet beholder indstillingen fra den første række.', + lossDroppedHostCommand: 'Designet beholder ikke denne værtskommando, så en gendannet blok stiller den ikke længere.', browserLimitTitle: 'Sprunget over: kræver printerhardware', replayRiskTitle: 'Printer-opsætningskommando: køres på printeren ved eksport/print', deviceActionTitle: 'Printerens enhedshandling: køres på printeren ved eksport/print', @@ -1187,6 +1190,7 @@ const da = { mixedGeoDetailFmt: '{cmds} adskiller sig mellem blokke ({detail})', mixedJmTitle: 'Udskriftstæthedstilstanden er forskellig mellem blokke: senere sider beholder deres egen tilstand, men kun den første kan redigeres', unknownTitle: 'Sprunget over: kommando ikke genkendt', + hostQueryTitle: 'Værtskommando: beder printeren om et svar, når dokumentet sendes', unterminatedFieldTitleFmt: 'Feltet er ikke lukket med {fs}: printeren kasserer det', hexControlTitle: 'Kontroltegn i feltets data: printeren kasserer resten af feltet', reportHeader: 'ZPL-importrapport', @@ -2011,6 +2015,13 @@ const da = { usbSetupAccess: 'Konfigurer adgang', usbSetupFlatpak: 'Inde i Flatpak kan appen ikke selv installere udev-reglen. Installer 70-zplab.rules fra ZPLab-repositoriet på værtssystemet, og tilslut derefter printeren igen.', usbNotFound: 'USB-printer ikke fundet. Tilslut den igen, og opdater.', + queryButton: 'Forespørg printeren', + queryHint: 'Dette dokument spørger printeren om noget. Svaret åbnes her.', + queryNoChannel: 'Ad den vej kommer der intet svar, så svaret går tabt.', + queryNoRender: 'Denne vej udskriver et billede, så svaret har ingen steder at lande.', + queryMixedHint: 'De øvrige felter bliver også printet.', + queryAfterPrint: '^HV-svaret kommer efter udskriften. ZPLab venter ikke så længe, så dokumentet sendes uden forespørgsel.', + replyTitle: 'Printersvar', }, pdfExport: { title: 'Eksportér PDF', diff --git a/src/locales/de.ts b/src/locales/de.ts index 1dc4c150b..addc892b4 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -1022,6 +1022,7 @@ const de = { failPermissionDenied: 'Kein Zugriff auf den USB-Drucker. Gewähre ihn im Sende-Dialog, Tab USB.', failBusy: 'Eine andere Abfrage des Druckers läuft noch. Versuche es gleich noch einmal.', failUnparsed: 'Der Drucker hat geantwortet, aber die Antwort ließ sich nicht lesen.', + failSilent: 'Der Drucker hat nicht geantwortet.', failIgnored: 'Der Drucker hat den Befehl ignoriert. Das Objekt ist weiterhin vorhanden.', rawReply: 'Rohantwort', configuration: 'Konfiguration', @@ -1063,6 +1064,7 @@ const de = { keepExistingPages: 'Bestehende Seiten behalten', importSetupTitle: 'Dieses ZPL enthält Drucker-Setup-Befehle', importSetupBody: 'Sie werden beim Drucken oder Exportieren des Etiketts auf dem Drucker ausgeführt. Wählen Sie, wie damit verfahren werden soll:', + importSetupDropsFmt: 'Verschieben oder Entfernen verwirft auch {commands}, denn nur die Bytes des Etiketts tragen sie.', importSetupKeep: 'Im Etikett behalten', importSetupToSetup: 'In Setup-Skript verschieben', importSetupRemove: 'Entfernen', @@ -1177,6 +1179,7 @@ const de = { lossRecallFormat: 'Dieser Abrufblock ruft ein gespeichertes Format auf und bleibt eine Seite statt einer Datenzeile. Zeilen brauchen ein zuvor im Stream gespeichertes Format, das Felder deklariert, sowie Blöcke, die nur Felddaten enthalten.', lossRecallSlot: 'Der Abruf liefert eine Feldnummer, die das gespeicherte Format nie deklariert. Der Drucker druckt sie als verwaistes Feld, das sich im Design nicht anzeigen lässt.', lossRecallSettings: 'Die Zeilen wechseln eine Einstellung, die das Feld aus seinen Daten liest, etwa die Fehlerkorrekturstufe. Das Design behält die Einstellung der ersten Zeile.', + lossDroppedHostCommand: 'Das Design behält diesen Host-Befehl nicht, deshalb stellt ein neu erzeugter Block ihn nicht mehr.', browserLimitTitle: 'Übersprungen: erfordert Drucker-Hardware', replayRiskTitle: 'Drucker-Setup-Befehl: wird beim Export/Druck auf dem Drucker ausgeführt', deviceActionTitle: 'Drucker-Geräteaktion: wird beim Export/Druck auf dem Drucker ausgeführt', @@ -1187,6 +1190,7 @@ const de = { mixedGeoDetailFmt: '{cmds} unterscheiden sich zwischen Blöcken ({detail})', mixedJmTitle: 'Der Druckdichte-Modus unterscheidet sich zwischen den Blöcken: spätere Seiten behalten ihren eigenen Modus, aber nur der erste ist bearbeitbar', unknownTitle: 'Übersprungen: Befehl nicht erkannt', + hostQueryTitle: 'Host-Befehl: fordert beim Senden des Dokuments eine Antwort vom Drucker an', unterminatedFieldTitleFmt: 'Feld nicht mit {fs} geschlossen: der Drucker verwirft es', hexControlTitle: 'Steuerzeichen in den Felddaten: der Drucker verwirft den Rest des Felds', reportHeader: 'ZPL-Importbericht', @@ -1259,6 +1263,13 @@ const de = { usbSetupAccess: 'Zugriff einrichten', usbSetupFlatpak: 'Innerhalb von Flatpak kann die App die udev-Regel nicht selbst installieren. Installiere 70-zplab.rules aus dem ZPLab-Repository auf dem Host und schließe den Drucker danach erneut an.', usbNotFound: 'USB-Drucker nicht gefunden, neu einstecken und aktualisieren.', + queryButton: 'Drucker abfragen', + queryHint: 'Dieses Dokument fragt etwas beim Drucker ab. Die Antwort öffnet sich hier.', + queryNoChannel: 'Über diesen Weg kommt keine Antwort zurück, deshalb geht die Rückmeldung des Druckers verloren.', + queryNoRender: 'Dieser Weg druckt ein Bild, deshalb hat die Antwort hier keinen Platz.', + queryMixedHint: 'Die übrigen Felder werden ebenfalls gedruckt.', + queryAfterPrint: 'Die ^HV-Antwort kommt erst nach dem Druck. ZPLab wartet nicht so lange, deshalb wird das Dokument ohne Abfrage gesendet.', + replyTitle: 'Druckerantwort', }, pdfExport: { title: 'PDF exportieren', diff --git a/src/locales/el.ts b/src/locales/el.ts index d700f8533..0399f66d3 100644 --- a/src/locales/el.ts +++ b/src/locales/el.ts @@ -1022,6 +1022,7 @@ const el = { failPermissionDenied: 'Δεν υπάρχει πρόσβαση στον εκτυπωτή USB. Παραχωρήστε την στο παράθυρο αποστολής, καρτέλα USB.', failBusy: 'Μια άλλη ανάγνωση του εκτυπωτή εκτελείται ακόμη. Δοκιμάστε ξανά σε λίγο.', failUnparsed: 'Ο εκτυπωτής απάντησε, αλλά η απάντηση δεν ήταν δυνατό να διαβαστεί.', + failSilent: 'Ο εκτυπωτής δεν απάντησε.', failIgnored: 'Ο εκτυπωτής αγνόησε την εντολή. Το αντικείμενο βρίσκεται ακόμη εκεί.', rawReply: 'Ανεπεξέργαστη απάντηση', configuration: 'Διαμόρφωση', @@ -1063,6 +1064,7 @@ const el = { keepExistingPages: 'Διατήρηση υπαρχουσών σελίδων', importSetupTitle: 'Αυτό το ZPL περιέχει εντολές ρύθμισης εκτυπωτή', importSetupBody: 'Εκτελούνται στον εκτυπωτή κατά την εκτύπωση ή την εξαγωγή της ετικέτας. Επιλέξτε πώς θα τις χειριστείτε:', + importSetupDropsFmt: 'Η μετακίνηση ή η αφαίρεση απορρίπτει και {commands}, γιατί μόνο τα bytes της ετικέτας τα μεταφέρουν.', importSetupKeep: 'Διατήρηση στην ετικέτα', importSetupToSetup: 'Μετακίνηση στο σενάριο ρύθμισης', importSetupRemove: 'Αφαίρεση', @@ -1177,6 +1179,7 @@ const el = { lossRecallFormat: 'Αυτό το μπλοκ ανακαλεί μια αποθηκευμένη μορφή και παραμένει σελίδα αντί για γραμμή δεδομένων. Οι γραμμές χρειάζονται μια μορφή αποθηκευμένη νωρίτερα στη ροή που δηλώνει πεδία, και μπλοκ που περιέχουν μόνο δεδομένα πεδίων.', lossRecallSlot: 'Η ανάκληση παρέχει έναν αριθμό πεδίου που η αποθηκευμένη μορφή δεν δηλώνει ποτέ. Ο εκτυπωτής το εκτυπώνει ως αδέσποτο πεδίο που ο σχεδιασμός δεν μπορεί να εμφανίσει.', lossRecallSettings: 'Οι γραμμές αλλάζουν μια ρύθμιση που το πεδίο διαβάζει από τα δεδομένα του, όπως το επίπεδο διόρθωσης σφαλμάτων. Το σχέδιο κρατά τη ρύθμιση της πρώτης γραμμής.', + lossDroppedHostCommand: 'Το σχέδιο δεν κρατά αυτή την εντολή host, γι’ αυτό ένα αναδημιουργημένο μπλοκ δεν τη θέτει πια.', browserLimitTitle: 'Παραλείφθηκε: απαιτεί υλικό εκτυπωτή', replayRiskTitle: 'Εντολή ρύθμισης εκτυπωτή: εκτελείται στον εκτυπωτή κατά την εξαγωγή/εκτύπωση', deviceActionTitle: 'Ενέργεια συσκευής εκτυπωτή: εκτελείται στον εκτυπωτή κατά την εξαγωγή/εκτύπωση', @@ -1187,6 +1190,7 @@ const el = { mixedGeoDetailFmt: 'Τα {cmds} διαφέρουν μεταξύ των μπλοκ ({detail})', mixedJmTitle: 'Η λειτουργία πυκνότητας εκτύπωσης διαφέρει μεταξύ των μπλοκ: οι επόμενες σελίδες διατηρούν τη δική τους λειτουργία, αλλά μόνο η πρώτη είναι επεξεργάσιμη', unknownTitle: 'Παραλείφθηκε: μη αναγνωρίσιμη εντολή', + hostQueryTitle: 'Εντολή κεντρικού υπολογιστή: ζητά απάντηση από τον εκτυπωτή κατά την αποστολή του εγγράφου', unterminatedFieldTitleFmt: 'Το πεδίο δεν έκλεισε με {fs}: ο εκτυπωτής το απορρίπτει', hexControlTitle: 'Χαρακτήρας ελέγχου στα δεδομένα του πεδίου: ο εκτυπωτής απορρίπτει το υπόλοιπο του πεδίου', reportHeader: 'Αναφορά εισαγωγής ZPL', @@ -2011,6 +2015,13 @@ const el = { usbSetupAccess: 'Ρύθμιση πρόσβασης', usbSetupFlatpak: 'Μέσα στο Flatpak η εφαρμογή δεν μπορεί να εγκαταστήσει μόνη της τον κανόνα udev. Εγκαταστήστε το 70-zplab.rules από το αποθετήριο ZPLab στο σύστημα υποδοχής και συνδέστε ξανά τον εκτυπωτή.', usbNotFound: 'Ο εκτυπωτής USB δεν βρέθηκε. Συνδέστε τον ξανά και ανανεώστε.', + queryButton: 'Ερώτημα στον εκτυπωτή', + queryHint: 'Αυτό το έγγραφο ρωτά κάτι τον εκτυπωτή. Η απάντηση ανοίγει εδώ.', + queryNoChannel: 'Με αυτόν τον τρόπο δεν επιστρέφει απάντηση, οπότε η απάντηση χάνεται.', + queryNoRender: 'Αυτός ο τρόπος εκτυπώνει εικόνα, οπότε η απάντηση δεν έχει πού να πάει.', + queryMixedHint: 'Τα υπόλοιπα πεδία εκτυπώνονται επίσης.', + queryAfterPrint: 'Η απάντηση ^HV έρχεται μετά την εκτύπωση. Το ZPLab δεν περιμένει τόσο, οπότε το έγγραφο στέλνεται χωρίς ερώτημα.', + replyTitle: 'Απάντηση εκτυπωτή', }, pdfExport: { title: 'Εξαγωγή PDF', diff --git a/src/locales/en.ts b/src/locales/en.ts index 0fbe16332..0b46bba2e 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -1022,6 +1022,7 @@ const en = { failPermissionDenied: 'No access to the USB printer. Grant it in the print dialog, USB tab.', failBusy: 'Another printer read is still running. Try again in a moment.', failUnparsed: 'The printer answered, but the reply could not be read.', + failSilent: 'The printer did not answer.', failIgnored: 'The printer ignored the command. The object is still there.', rawReply: 'Raw reply', configuration: 'Configuration', @@ -1063,6 +1064,7 @@ const en = { keepExistingPages: 'Keep existing pages', importSetupTitle: 'This ZPL contains printer setup commands', importSetupBody: 'They run on the printer when the label is printed or exported. Choose how to handle them:', + importSetupDropsFmt: "Move or Remove also drops {commands}, which only the label's own bytes carry.", importSetupKeep: 'Keep in label', importSetupToSetup: 'Move to setup script', importSetupRemove: 'Remove', @@ -1177,6 +1179,7 @@ const en = { lossRecallFormat: 'This block recalls a stored format and stays a page instead of a data row. Rows need a format stored earlier in the stream that declares fields, and blocks that carry nothing but field data.', lossRecallSlot: 'The recall supplies a field number the stored format never declares. The printer prints it as a stray field, which the design cannot show.', lossRecallSettings: "The rows switch a setting the field reads from its data, such as the error correction level. The design keeps the first row's setting.", + lossDroppedHostCommand: 'The design does not keep this host command, so a regenerated block no longer asks it.', browserLimitTitle: 'Skipped: needs printer hardware', replayRiskTitle: 'Printer setup command: runs on the printer when exported/printed', deviceActionTitle: 'Printer device action: runs on the printer when exported/printed', @@ -1187,6 +1190,7 @@ const en = { mixedGeoDetailFmt: '{cmds} differ between blocks ({detail})', mixedJmTitle: 'Density mode differs between blocks: later pages keep their own mode, but only the first is editable', unknownTitle: 'Skipped: command not recognised', + hostQueryTitle: 'Host command: asks the printer for a reply when the document is sent', unterminatedFieldTitleFmt: 'Field not closed with {fs}: the printer discards it', hexControlTitle: 'Control character in the field data: the printer drops the rest of the field', reportHeader: 'ZPL Import Report', @@ -1259,6 +1263,13 @@ const en = { usbSetupAccess: 'Set up access', usbSetupFlatpak: 'Inside Flatpak the app cannot install the udev rule itself. Install 70-zplab.rules from the ZPLab repository on the host, then re-plug the printer.', usbNotFound: 'USB printer not found, re-plug it and refresh.', + queryButton: 'Query printer', + queryHint: 'This document asks the printer something. The reply opens here.', + queryNoChannel: 'This way carries no answer, so the reply is lost.', + queryNoRender: 'This way prints an image, so the reply has nowhere to land.', + queryMixedHint: 'The other fields print as well.', + queryAfterPrint: 'The ^HV reply comes after the print. ZPLab does not wait that long, so the document is sent without a query.', + replyTitle: 'Printer reply', }, pdfExport: { title: 'Export PDF', diff --git a/src/locales/es.ts b/src/locales/es.ts index bb6e0c690..b9b4564d9 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -1022,6 +1022,7 @@ const es = { failPermissionDenied: 'Sin acceso a la impresora USB. Concédelo en el diálogo de envío, pestaña USB.', failBusy: 'Todavía se está ejecutando otra lectura de la impresora. Inténtalo de nuevo en un momento.', failUnparsed: 'La impresora respondió, pero no se pudo leer la respuesta.', + failSilent: 'La impresora no respondió.', failIgnored: 'La impresora ignoró el comando. El objeto todavía está ahí.', rawReply: 'Respuesta sin procesar', configuration: 'Configuración', @@ -1063,6 +1064,7 @@ const es = { keepExistingPages: 'Conservar páginas existentes', importSetupTitle: 'Este ZPL contiene comandos de configuración de la impresora', importSetupBody: 'Se ejecutan en la impresora al imprimir o exportar la etiqueta. Elija cómo gestionarlos:', + importSetupDropsFmt: 'Mover o quitar también descarta {commands}, que solo los bytes de la etiqueta transportan.', importSetupKeep: 'Mantener en la etiqueta', importSetupToSetup: 'Mover al script de configuración', importSetupRemove: 'Eliminar', @@ -1177,6 +1179,7 @@ const es = { lossRecallFormat: 'Este bloque recupera un formato almacenado y permanece como página en lugar de fila de datos. Las filas necesitan un formato almacenado antes en el flujo que declare los campos, y bloques que contengan solo datos de campo.', lossRecallSlot: 'La recuperación aporta un número de campo que el formato almacenado nunca declara. La impresora lo imprime como un campo suelto que el diseño no puede mostrar.', lossRecallSettings: 'Las filas cambian un ajuste que el campo lee de sus datos, como el nivel de corrección de errores. El diseño conserva el ajuste de la primera fila.', + lossDroppedHostCommand: 'El diseño no conserva este comando de host, por eso un bloque regenerado ya no lo plantea.', browserLimitTitle: 'Omitido: requiere hardware de impresora', replayRiskTitle: 'Comando de configuración de impresora: se ejecuta en la impresora al exportar/imprimir', deviceActionTitle: 'Acción del dispositivo de impresora: se ejecuta en la impresora al exportar/imprimir', @@ -1187,6 +1190,7 @@ const es = { mixedGeoDetailFmt: '{cmds} difieren entre bloques ({detail})', mixedJmTitle: 'El modo de densidad de impresión difiere entre bloques: las páginas posteriores mantienen su propio modo, pero solo el primero es editable', unknownTitle: 'Omitido: comando no reconocido', + hostQueryTitle: 'Comando de host: pide una respuesta a la impresora al enviar el documento', unterminatedFieldTitleFmt: 'Campo no cerrado con {fs}: la impresora lo descarta', hexControlTitle: 'Carácter de control en los datos del campo: la impresora descarta el resto del campo', reportHeader: 'Informe de importación ZPL', @@ -2011,6 +2015,13 @@ const es = { usbSetupAccess: 'Configurar acceso', usbSetupFlatpak: 'Dentro de Flatpak, la aplicación no puede instalar la regla udev por sí misma. Instala 70-zplab.rules desde el repositorio de ZPLab en el host y luego vuelve a conectar la impresora.', usbNotFound: 'Impresora USB no encontrada, vuelve a conectarla y actualiza.', + queryButton: 'Consultar impresora', + queryHint: 'Este documento pregunta algo a la impresora. La respuesta se abre aquí.', + queryNoChannel: 'Esta vía no transporta ninguna respuesta, así que la respuesta se pierde.', + queryNoRender: 'Esta vía imprime una imagen, así que la respuesta no tiene adónde ir.', + queryMixedHint: 'Los demás campos también se imprimen.', + queryAfterPrint: 'La respuesta ^HV llega después de la impresión. ZPLab no espera tanto, así que el documento se envía sin consulta.', + replyTitle: 'Respuesta de la impresora', }, pdfExport: { title: 'Exportar PDF', diff --git a/src/locales/et.ts b/src/locales/et.ts index f823eb492..e5c3817d6 100644 --- a/src/locales/et.ts +++ b/src/locales/et.ts @@ -1022,6 +1022,7 @@ const et = { failPermissionDenied: 'USB-printerile puudub juurdepääs. Anna see saatmisdialoogis, vahekaart USB.', failBusy: 'Teine printeri lugemine on veel käimas. Proovi veidi hiljem uuesti.', failUnparsed: 'Printer vastas, kuid vastust ei õnnestunud lugeda.', + failSilent: 'Printer ei vastanud.', failIgnored: 'Printer ignoreeris käsku. Objekt on endiselt olemas.', rawReply: 'Toorvastus', configuration: 'Konfiguratsioon', @@ -1063,6 +1064,7 @@ const et = { keepExistingPages: 'Säilita olemasolevad lehed', importSetupTitle: 'See ZPL sisaldab printeri seadistuskäske', importSetupBody: 'Need käivituvad printeris etiketi printimisel või eksportimisel. Valige, kuidas neid käsitleda:', + importSetupDropsFmt: 'Teisaldamine või eemaldamine jätab välja ka {commands}, sest neid kannavad ainult sildi enda baidid.', importSetupKeep: 'Jäta etiketile', importSetupToSetup: 'Teisalda seadistusskripti', importSetupRemove: 'Eemalda', @@ -1177,6 +1179,7 @@ const et = { lossRecallFormat: 'See plokk kutsub esile salvestatud vormingu ja jääb leheks, mitte andmereaks. Andmeread vajavad voos varem salvestatud vormingut, mis deklareerib väljad, ja plokke, mis sisaldavad ainult väljaandmeid.', lossRecallSlot: 'Taaskutse annab väljanumbri, mida salvestatud vorming kunagi ei deklareeri. Printer trükib selle orvuks jäänud väljana, mida disain ei saa kuvada.', lossRecallSettings: 'Read vahetavad seadistust, mida väli loeb oma andmetest, näiteks veaparanduse taset. Disain säilitab esimese rea seadistuse.', + lossDroppedHostCommand: 'Kujundus ei hoia seda hostikäsku alles, seega uuesti loodud plokk seda enam ei esita.', browserLimitTitle: 'Vahele jäetud: vajab printeri riistvara', replayRiskTitle: 'Printeri seadistuskäsk: käivitub printeril eksportimisel/printimisel', deviceActionTitle: 'Printeri seadmetoiming: käivitub printeril eksportimisel/printimisel', @@ -1187,6 +1190,7 @@ const et = { mixedGeoDetailFmt: '{cmds} erinevad plokkide vahel ({detail})', mixedJmTitle: 'Prindi tiheduse režiim erineb plokkide vahel: hilisemad leheküljed säilitavad oma režiimi, kuid muudetav on ainult esimene', unknownTitle: 'Vahele jäetud: käsku ei tuntud ära', + hostQueryTitle: 'Hostikäsk: küsib dokumendi saatmisel printerilt vastust', unterminatedFieldTitleFmt: 'Väli ei ole suletud käsuga {fs}: printer jätab selle kõrvale', hexControlTitle: 'Juhtmärk välja andmetes: printer jätab ülejäänud välja kõrvale', reportHeader: 'ZPL-i importimise aruanne', @@ -2011,6 +2015,13 @@ const et = { usbSetupAccess: 'Seadista juurdepääs', usbSetupFlatpak: 'Flatpaki sees ei saa rakendus ise udev-reeglit paigaldada. Paigalda 70-zplab.rules ZPLab-i hoidlast hostsüsteemi ja ühenda printer uuesti.', usbNotFound: 'USB-printerit ei leitud. Ühenda see uuesti ja värskenda.', + queryButton: 'Küsi printerilt', + queryHint: 'See dokument küsib printerilt midagi. Vastus avaneb siin.', + queryNoChannel: 'Sel teel vastus tagasi ei tule, nii et see läheb kaotsi.', + queryNoRender: 'See viis prindib pildi, nii et vastusel pole kuhugi minna.', + queryMixedHint: 'Ülejäänud väljad trükitakse samuti.', + queryAfterPrint: '^HV vastus saabub pärast printimist. ZPLab nii kaua ei oota, seega saadetakse dokument ilma päringuta.', + replyTitle: 'Printeri vastus', }, pdfExport: { title: 'Ekspordi PDF', diff --git a/src/locales/fa.ts b/src/locales/fa.ts index 5d93c8965..905d24162 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -1022,6 +1022,7 @@ const fa = { failPermissionDenied: 'دسترسی به چاپگر USB وجود ندارد. آن را در کادر گفتگوی ارسال، برگه USB اعطا کنید.', failBusy: 'یک خوانش دیگر از چاپگر هنوز در حال اجراست. کمی بعد دوباره تلاش کنید.', failUnparsed: 'چاپگر پاسخ داد، اما خواندن پاسخ ممکن نشد.', + failSilent: 'چاپگر پاسخ نداد.', failIgnored: 'چاپگر این دستور را نادیده گرفت. شیء هنوز وجود دارد.', rawReply: 'پاسخ خام', configuration: 'پیکربندی', @@ -1063,6 +1064,7 @@ const fa = { keepExistingPages: 'حفظ صفحات موجود', importSetupTitle: 'این ZPL شامل دستورات راه‌اندازی چاپگر است', importSetupBody: 'این دستورات هنگام چاپ یا خروجی گرفتن از برچسب روی چاپگر اجرا می‌شوند. نحوه برخورد با آن‌ها را انتخاب کنید:', + importSetupDropsFmt: 'انتقال یا حذف، {commands} را نیز کنار می‌گذارد، چون فقط بایت‌های خود برچسب آنها را حمل می‌کنند.', importSetupKeep: 'نگه‌داشتن در برچسب', importSetupToSetup: 'انتقال به اسکریپت راه‌اندازی', importSetupRemove: 'حذف', @@ -1177,6 +1179,7 @@ const fa = { lossRecallFormat: 'این بلوک یک قالب ذخیره‌شده را فراخوانی می‌کند و به‌جای یک ردیف داده، یک صفحه باقی می‌ماند. ردیف‌ها به قالبی نیاز دارند که پیش‌تر در جریان داده ذخیره شده و فیلدها را تعریف می‌کند، و همچنین به بلوک‌هایی که فقط داده‌های فیلد را حمل می‌کنند.', lossRecallSlot: 'فراخوانی شماره فیلدی را ارائه می‌دهد که فرمت ذخیره‌شده هرگز آن را اعلام نکرده است. چاپگر آن را به‌صورت فیلدی سرگردان چاپ می‌کند که طرح نمی‌تواند نمایش دهد.', lossRecallSettings: 'ردیف‌ها تنظیمی را عوض می‌کنند که فیلد آن را از داده‌های خود می‌خواند، مانند سطح تصحیح خطا. طرح تنظیم ردیف نخست را حفظ می‌کند.', + lossDroppedHostCommand: 'طرح این فرمان میزبان را نگه نمی‌دارد، بنابراین بلوک بازسازی‌شده دیگر آن را نمی‌پرسد.', browserLimitTitle: 'رد شد: به سخت‌افزار چاپگر نیاز دارد', replayRiskTitle: 'دستور راه‌اندازی چاپگر: هنگام خروجی گرفتن/چاپ روی چاپگر اجرا می‌شود', deviceActionTitle: 'عملکرد دستگاه چاپگر: هنگام خروجی گرفتن/چاپ روی چاپگر اجرا می‌شود', @@ -1187,6 +1190,7 @@ const fa = { mixedGeoDetailFmt: '{cmds} بین بلوک‌ها متفاوت است ({detail})', mixedJmTitle: 'حالت تراکم چاپ بین بلوک‌ها متفاوت است: صفحات بعدی حالت خود را حفظ می‌کنند، اما فقط بلوک اول قابل ویرایش است', unknownTitle: 'رد شد: دستور شناسایی نشد', + hostQueryTitle: 'فرمان میزبان: هنگام ارسال سند از چاپگر پاسخ می‌خواهد', unterminatedFieldTitleFmt: 'فیلد با {fs} بسته نشده است: چاپگر آن را نادیده می‌گیرد', hexControlTitle: 'نویسهٔ کنترلی در دادهٔ فیلد: چاپگر بقیهٔ فیلد را نادیده می‌گیرد', reportHeader: 'گزارش وارد کردن ZPL', @@ -2011,6 +2015,13 @@ const fa = { usbSetupAccess: 'تنظیم دسترسی', usbSetupFlatpak: 'داخل Flatpak، برنامه نمی‌تواند قانون udev را خودش نصب کند. فایل 70-zplab.rules را از مخزن ZPLab روی میزبان نصب کنید، سپس چاپگر را دوباره وصل کنید.', usbNotFound: 'چاپگر USB یافت نشد، آن را دوباره وصل کنید و به‌روزرسانی کنید.', + queryButton: 'پرس‌وجو از چاپگر', + queryHint: 'این سند چیزی از چاپگر می‌پرسد. پاسخ اینجا باز می‌شود.', + queryNoChannel: 'در این مسیر پاسخی برنمی‌گردد، بنابراین پاسخ از دست می‌رود.', + queryNoRender: 'این روش یک تصویر چاپ می‌کند، پس پاسخ جایی برای رسیدن ندارد.', + queryMixedHint: 'بقیه فیلدها هم چاپ می‌شوند.', + queryAfterPrint: 'پاسخ ^HV پس از چاپ می‌رسد. ZPLab آن‌قدر منتظر نمی‌ماند، بنابراین سند بدون پرس‌وجو ارسال می‌شود.', + replyTitle: 'پاسخ چاپگر', }, pdfExport: { title: 'خروجی PDF', diff --git a/src/locales/fi.ts b/src/locales/fi.ts index 8123a5af5..fb5d778ad 100644 --- a/src/locales/fi.ts +++ b/src/locales/fi.ts @@ -1022,6 +1022,7 @@ const fi = { failPermissionDenied: 'Ei käyttöoikeutta USB-tulostimeen. Myönnä se lähetysikkunassa, välilehti USB.', failBusy: 'Toinen tulostimen luku on vielä käynnissä. Yritä hetken kuluttua uudelleen.', failUnparsed: 'Tulostin vastasi, mutta vastausta ei voitu lukea.', + failSilent: 'Tulostin ei vastannut.', failIgnored: 'Tulostin jätti komennon huomiotta. Objekti on yhä tallella.', rawReply: 'Raakavastaus', configuration: 'Asetukset', @@ -1063,6 +1064,7 @@ const fi = { keepExistingPages: 'Säilytä olemassa olevat sivut', importSetupTitle: 'Tämä ZPL sisältää tulostimen asetuskomentoja', importSetupBody: 'Ne suoritetaan tulostimessa, kun tarra tulostetaan tai viedään. Valitse, miten niitä käsitellään:', + importSetupDropsFmt: 'Siirto tai poisto pudottaa myös {commands}, koska vain etiketin omat tavut kantavat ne.', importSetupKeep: 'Säilytä tarrassa', importSetupToSetup: 'Siirrä asetuskomentosarjaan', importSetupRemove: 'Poista', @@ -1177,6 +1179,7 @@ const fi = { lossRecallFormat: 'Tämä lohko kutsuu tallennettua muotoa ja pysyy sivuna eikä datarivinä. Rivit tarvitsevat virrassa aiemmin tallennetun muodon, joka määrittelee kentät, sekä lohkoja, jotka sisältävät vain kenttätietoja.', lossRecallSlot: 'Palautus antaa kenttänumeron, jota tallennettu muoto ei koskaan ilmoita. Tulostin tulostaa sen isännättömänä kenttänä, jota design ei voi näyttää.', lossRecallSettings: 'Rivit vaihtavat asetusta, jonka kenttä lukee tiedoistaan, kuten virheenkorjaustasoa. Design säilyttää ensimmäisen rivin asetuksen.', + lossDroppedHostCommand: 'Suunnitelma ei säilytä tätä isäntäkomentoa, joten uudelleen luotu lohko ei enää esitä sitä.', browserLimitTitle: 'Ohitettu: vaatii tulostimen laitteiston', replayRiskTitle: 'Tulostimen asetuskomento: suoritetaan tulostimella vietäessä/tulostettaessa', deviceActionTitle: 'Tulostimen laitetoiminto: suoritetaan tulostimella vietäessä/tulostettaessa', @@ -1187,6 +1190,7 @@ const fi = { mixedGeoDetailFmt: '{cmds} eroavat lohkojen välillä ({detail})', mixedJmTitle: 'Tulostustiheyden tila vaihtelee lohkojen välillä: myöhemmät sivut säilyttävät oman tilansa, mutta vain ensimmäistä voi muokata', unknownTitle: 'Ohitettu: komentoa ei tunnistettu', + hostQueryTitle: 'Isäntäkomento: pyytää tulostimelta vastausta, kun asiakirja lähetetään', unterminatedFieldTitleFmt: 'Kenttä ei sulkeutunut komennolla {fs}: tulostin hylkää sen', hexControlTitle: 'Ohjausmerkki kentän tiedoissa: tulostin hylkää kentän loppuosan', reportHeader: 'ZPL-tuontiraportti', @@ -2011,6 +2015,13 @@ const fi = { usbSetupAccess: 'Määritä käyttöoikeus', usbSetupFlatpak: 'Flatpakin sisällä sovellus ei voi asentaa udev-sääntöä itse. Asenna 70-zplab.rules ZPLab-tietovarastosta isäntäjärjestelmään ja kytke tulostin uudelleen.', usbNotFound: 'USB-tulostinta ei löytynyt. Kytke se uudelleen ja päivitä.', + queryButton: 'Kysy tulostimelta', + queryHint: 'Tämä asiakirja kysyy tulostimelta jotain. Vastaus avautuu tähän.', + queryNoChannel: 'Tätä tietä ei kulje vastausta, joten vastaus katoaa.', + queryNoRender: 'Tämä tapa tulostaa kuvan, joten vastauksella ei ole paikkaa.', + queryMixedHint: 'Muut kentät tulostuvat myös.', + queryAfterPrint: '^HV-vastaus tulee tulostuksen jälkeen. ZPLab ei odota niin kauan, joten asiakirja lähetetään ilman kyselyä.', + replyTitle: 'Tulostimen vastaus', }, pdfExport: { title: 'Vie PDF', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index afa6a7ef5..5a1b453e5 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -1022,6 +1022,7 @@ const fr = { failPermissionDenied: "Aucun accès à l'imprimante USB. Accorde-le dans la boîte de dialogue d'envoi, onglet USB.", failBusy: "Une autre lecture de l'imprimante est encore en cours. Réessaie dans un instant.", failUnparsed: "L'imprimante a répondu, mais la réponse n'a pas pu être lue.", + failSilent: 'L’imprimante n’a pas répondu.', failIgnored: "L'imprimante a ignoré la commande. L'objet est toujours là.", rawReply: 'Réponse brute', configuration: 'Configuration', @@ -1063,6 +1064,7 @@ const fr = { keepExistingPages: 'Conserver les pages existantes', importSetupTitle: "Ce ZPL contient des commandes de configuration de l'imprimante", importSetupBody: "Elles s'exécutent sur l'imprimante lors de l'impression ou de l'exportation de l'étiquette. Choisissez comment les traiter :", + importSetupDropsFmt: 'Déplacer ou Supprimer abandonne aussi {commands}, que seuls les octets de l’étiquette transportent.', importSetupKeep: "Conserver dans l'étiquette", importSetupToSetup: 'Déplacer vers le script de configuration', importSetupRemove: 'Supprimer', @@ -1177,6 +1179,7 @@ const fr = { lossRecallFormat: "Ce bloc rappelle un format stocké et reste une page au lieu d'une ligne de données. Les lignes ont besoin d'un format stocké plus tôt dans le flux qui déclare des champs, et de blocs qui ne portent que des données de champ.", lossRecallSlot: "Le rappel fournit un numéro de champ que le format stocké ne déclare jamais. L'imprimante l'imprime comme un champ errant que le design ne peut pas afficher.", lossRecallSettings: "Les lignes changent un réglage que le champ lit dans ses données, comme le niveau de correction d'erreur. Le design conserve le réglage de la première ligne.", + lossDroppedHostCommand: 'Le design ne conserve pas cette commande hôte, un bloc régénéré ne la pose donc plus.', browserLimitTitle: "Ignoré : nécessite le matériel de l'imprimante", replayRiskTitle: "Commande de configuration imprimante : s'exécute sur l'imprimante à l'export/impression", deviceActionTitle: "Action de l'imprimante : s'exécute sur l'imprimante à l'export/impression", @@ -1187,6 +1190,7 @@ const fr = { mixedGeoDetailFmt: '{cmds} diffèrent entre les blocs ({detail})', mixedJmTitle: "Le mode de densité d'impression diffère entre les blocs : les pages suivantes conservent leur propre mode, mais seul le premier est modifiable", unknownTitle: 'Ignoré : commande non reconnue', + hostQueryTitle: 'Commande hôte : demande une réponse à l’imprimante à l’envoi du document', unterminatedFieldTitleFmt: "Champ non fermé par {fs} : l'imprimante l'ignore", hexControlTitle: "Caractère de contrôle dans les données du champ : l'imprimante ignore le reste du champ", reportHeader: "Rapport d'import ZPL", @@ -2011,6 +2015,13 @@ const fr = { usbSetupAccess: "Configurer l'accès", usbSetupFlatpak: "À l'intérieur de Flatpak, l'application ne peut pas installer la règle udev elle-même. Installe 70-zplab.rules depuis le dépôt ZPLab sur l'hôte, puis rebranche l'imprimante.", usbNotFound: 'Imprimante USB introuvable, rebranche-la et actualise.', + queryButton: "Interroger l'imprimante", + queryHint: "Ce document pose une question à l'imprimante. La réponse s'ouvre ici.", + queryNoChannel: 'Ce chemin ne transporte aucune réponse, donc la réponse est perdue.', + queryNoRender: 'Cette voie imprime une image, la réponse n’a donc nulle part où aller.', + queryMixedHint: "Les autres champs s'impriment aussi.", + queryAfterPrint: "La réponse ^HV arrive après l'impression. ZPLab n'attend pas aussi longtemps, le document est donc envoyé sans requête.", + replyTitle: "Réponse de l'imprimante", }, pdfExport: { title: 'Exporter PDF', diff --git a/src/locales/he.ts b/src/locales/he.ts index a5e75f2f5..13e9ccd10 100644 --- a/src/locales/he.ts +++ b/src/locales/he.ts @@ -1022,6 +1022,7 @@ const he = { failPermissionDenied: 'אין גישה למדפסת ה-USB. הענק אותה בתיבת הדו-שיח לשליחה, בלשונית USB.', failBusy: 'קריאה נוספת מהמדפסת עדיין פועלת. נסה שוב בעוד רגע.', failUnparsed: 'המדפסת הגיבה, אך לא ניתן היה לקרוא את התגובה.', + failSilent: 'המדפסת לא ענתה.', failIgnored: 'המדפסת התעלמה מהפקודה. האובייקט עדיין קיים.', rawReply: 'תגובה גולמית', configuration: 'תצורה', @@ -1063,6 +1064,7 @@ const he = { keepExistingPages: 'שמור על דפים קיימים', importSetupTitle: 'ZPL זה מכיל פקודות הגדרת מדפסת', importSetupBody: 'הן פועלות במדפסת בעת הדפסת התווית או ייצואה. בחר כיצד לטפל בהן:', + importSetupDropsFmt: 'העברה או הסרה משמיטה גם את {commands}, כי רק הבייטים של המדבקה נושאים אותם.', importSetupKeep: 'השאר בתווית', importSetupToSetup: 'העבר לסקריפט ההגדרה', importSetupRemove: 'הסר', @@ -1177,6 +1179,7 @@ const he = { lossRecallFormat: 'בלוק זה משחזר פורמט שמור ונשאר עמוד במקום שורת נתונים. שורות זקוקות לפורמט שנשמר קודם לכן בזרם ומצהיר על שדות, ולבלוקים שנושאים רק נתוני שדות.', lossRecallSlot: 'האחזור מספק מספר שדה שהתבנית השמורה אינה מצהירה עליו כלל. המדפסת מדפיסה אותו כשדה תועה שהעיצוב אינו יכול להציג.', lossRecallSettings: 'השורות מחליפות הגדרה שהשדה קורא מתוך הנתונים שלו, כגון רמת תיקון השגיאות. העיצוב שומר על ההגדרה של השורה הראשונה.', + lossDroppedHostCommand: 'העיצוב אינו שומר פקודת מארח זו, לכן בלוק שנוצר מחדש אינו שואל אותה עוד.', browserLimitTitle: 'דולג: נדרש חומרת מדפסת', replayRiskTitle: 'פקודת הגדרת מדפסת: פועלת על המדפסת בעת ייצוא/הדפסה', deviceActionTitle: 'פעולת התקן המדפסת: פועלת על המדפסת בעת ייצוא/הדפסה', @@ -1187,6 +1190,7 @@ const he = { mixedGeoDetailFmt: '{cmds} שונים בין הבלוקים ({detail})', mixedJmTitle: 'מצב צפיפות ההדפסה שונה בין הבלוקים: העמודים המאוחרים שומרים על המצב שלהם, אך ניתן לערוך רק את הראשון', unknownTitle: 'דולג: הפקודה לא זוהתה', + hostQueryTitle: 'פקודת מארח: מבקשת תשובה מהמדפסת בעת שליחת המסמך', unterminatedFieldTitleFmt: 'השדה לא נסגר באמצעות {fs}: המדפסת מתעלמת ממנו', hexControlTitle: 'תו בקרה בנתוני השדה: המדפסת מתעלמת משאר השדה', reportHeader: 'דוח ייבוא ZPL', @@ -2011,6 +2015,13 @@ const he = { usbSetupAccess: 'הגדרת גישה', usbSetupFlatpak: 'בתוך Flatpak האפליקציה לא יכולה להתקין את כלל ה-udev בעצמה. התקן את 70-zplab.rules ממאגר ZPLab על המארח, ואז חבר את המדפסת מחדש.', usbNotFound: 'מדפסת ה-USB לא נמצאה, חבר אותה מחדש ורענן.', + queryButton: 'תשאול המדפסת', + queryHint: 'המסמך הזה שואל את המדפסת משהו. התשובה נפתחת כאן.', + queryNoChannel: 'בדרך הזו לא חוזרת תשובה, ולכן התשובה אובדת.', + queryNoRender: 'דרך זו מדפיסה תמונה, ולכן לתשובה אין לאן להגיע.', + queryMixedHint: 'שאר השדות מודפסים גם כן.', + queryAfterPrint: 'תשובת ^HV מגיעה אחרי ההדפסה. ZPLab לא ממתין כל כך הרבה, ולכן המסמך נשלח ללא שאילתה.', + replyTitle: 'תשובת המדפסת', }, pdfExport: { title: 'ייצוא PDF', diff --git a/src/locales/hr.ts b/src/locales/hr.ts index 06c0fd66d..e3e6d4ae5 100644 --- a/src/locales/hr.ts +++ b/src/locales/hr.ts @@ -1022,6 +1022,7 @@ const hr = { failPermissionDenied: 'Nema pristupa USB pisaču. Odobrite ga u dijalogu slanja, kartica USB.', failBusy: 'Drugo očitavanje pisača još je u tijeku. Pokušajte ponovno za trenutak.', failUnparsed: 'Pisač je odgovorio, ali odgovor nije bilo moguće pročitati.', + failSilent: 'Pisač nije odgovorio.', failIgnored: 'Pisač je zanemario naredbu. Objekt je još tamo.', rawReply: 'Sirovi odgovor', configuration: 'Konfiguracija', @@ -1063,6 +1064,7 @@ const hr = { keepExistingPages: 'Zadrži postojeće stranice', importSetupTitle: 'Ovaj ZPL sadrži naredbe za postavljanje pisača', importSetupBody: 'Izvršavaju se na pisaču kada se naljepnica ispiše ili izveze. Odaberite kako ih obraditi:', + importSetupDropsFmt: 'Premještanje ili uklanjanje odbacuje i {commands}, jer ih nose samo bajtovi same naljepnice.', importSetupKeep: 'Zadrži u naljepnici', importSetupToSetup: 'Premjesti u skriptu za postavljanje', importSetupRemove: 'Ukloni', @@ -1177,6 +1179,7 @@ const hr = { lossRecallFormat: 'Ovaj blok poziva pohranjeni format i ostaje stranica umjesto retka podataka. Recima je potreban format pohranjen ranije u nizu koji deklarira polja, te blokovi koji nose samo podatke polja.', lossRecallSlot: 'Dozivanje isporučuje broj polja koji spremljeni format nikada ne deklarira. Pisač ga ispisuje kao napušteno polje koje dizajn ne može prikazati.', lossRecallSettings: 'Retci mijenjaju postavku koju polje čita iz svojih podataka, primjerice razinu ispravka pogrešaka. Dizajn zadržava postavku prvog retka.', + lossDroppedHostCommand: 'Dizajn ne zadržava ovu host naredbu, pa je ponovno stvoreni blok više ne postavlja.', browserLimitTitle: 'Preskočeno: zahtijeva hardver pisača', replayRiskTitle: 'Naredba za postavljanje pisača: izvodi se na pisaču pri izvozu/ispisu', deviceActionTitle: 'Radnja uređaja pisača: izvodi se na pisaču pri izvozu/ispisu', @@ -1187,6 +1190,7 @@ const hr = { mixedGeoDetailFmt: '{cmds} razlikuju se između blokova ({detail})', mixedJmTitle: 'Način gustoće ispisa razlikuje se između blokova: kasnije stranice zadržavaju svoj vlastiti način, ali samo je prvi uredljiv', unknownTitle: 'Preskočeno: naredba nije prepoznata', + hostQueryTitle: 'Naredba hosta: traži odgovor pisača pri slanju dokumenta', unterminatedFieldTitleFmt: 'Polje nije zatvoreno s {fs}: pisač ga odbacuje', hexControlTitle: 'Kontrolni znak u podacima polja: pisač odbacuje ostatak polja', reportHeader: 'Izvještaj o uvozu ZPL-a', @@ -2011,6 +2015,13 @@ const hr = { usbSetupAccess: 'Postavi pristup', usbSetupFlatpak: 'Unutar Flatpaka aplikacija ne može sama instalirati udev pravilo. Instalirajte 70-zplab.rules iz ZPLab repozitorija na glavnom sustavu, zatim ponovno priključite pisač.', usbNotFound: 'USB pisač nije pronađen. Ponovno ga priključite i osvježite.', + queryButton: 'Ispitaj pisač', + queryHint: 'Ovaj dokument nešto pita pisač. Odgovor se otvara ovdje.', + queryNoChannel: 'Tim putem odgovor ne dolazi, pa se gubi.', + queryNoRender: 'Ovaj put ispisuje sliku, pa odgovor nema kamo stići.', + queryMixedHint: 'Ostala polja se također ispisuju.', + queryAfterPrint: 'Odgovor na ^HV stiže nakon ispisa. ZPLab ne čeka toliko dugo, pa se dokument šalje bez upita.', + replyTitle: 'Odgovor pisača', }, pdfExport: { title: 'Izvoz PDF-a', diff --git a/src/locales/hu.ts b/src/locales/hu.ts index 006ff252b..0d5d54e8c 100644 --- a/src/locales/hu.ts +++ b/src/locales/hu.ts @@ -1022,6 +1022,7 @@ const hu = { failPermissionDenied: 'Nincs hozzáférés az USB-nyomtatóhoz. Adja meg azt a küldési párbeszédpanelen, USB lapon.', failBusy: 'A nyomtató egy másik olvasása még fut. Próbálja újra egy pillanat múlva.', failUnparsed: 'A nyomtató válaszolt, de a válasz nem volt olvasható.', + failSilent: 'A nyomtató nem válaszolt.', failIgnored: 'A nyomtató figyelmen kívül hagyta a parancsot. Az objektum még megvan.', rawReply: 'Nyers válasz', configuration: 'Konfiguráció', @@ -1063,6 +1064,7 @@ const hu = { keepExistingPages: 'Meglévő oldalak megtartása', importSetupTitle: 'Ez a ZPL nyomtatóbeállító parancsokat tartalmaz', importSetupBody: 'Ezek a nyomtatón futnak le a címke nyomtatásakor vagy exportálásakor. Válassza ki, hogyan kezelje őket:', + importSetupDropsFmt: 'Az áthelyezés vagy eltávolítás a {commands} elemet is elejti, mert csak a címke saját bájtjai hordozzák.', importSetupKeep: 'Megtartás a címkében', importSetupToSetup: 'Áthelyezés a beállító parancsfájlba', importSetupRemove: 'Eltávolítás', @@ -1177,6 +1179,7 @@ const hu = { lossRecallFormat: 'Ez a blokk egy tárolt formátumot hív elő, és oldalként marad meg adatsor helyett. A sorokhoz egy korábban az adatfolyamban tárolt, mezőket deklaráló formátum szükséges, valamint olyan blokkok, amelyek kizárólag mezőadatokat hordoznak.', lossRecallSlot: 'A visszahívás olyan mezőszámot ad meg, amelyet a tárolt formátum sosem deklarál. A nyomtató gazdátlan mezőként nyomtatja ki, amelyet a design nem tud megjeleníteni.', lossRecallSettings: 'A sorok olyan beállítást váltanak, amelyet a mező a saját adataiból olvas ki, például a hibajavítási szintet. A design az első sor beállítását tartja meg.', + lossDroppedHostCommand: 'A terv nem őrzi meg ezt a hoszt parancsot, ezért egy újra létrehozott blokk már nem teszi fel.', browserLimitTitle: 'Kihagyva: nyomtató hardver szükséges', replayRiskTitle: 'Nyomtatóbeállító parancs: exportáláskor/nyomtatáskor lefut a nyomtatón', deviceActionTitle: 'Nyomtató eszközművelet: exportáláskor/nyomtatáskor lefut a nyomtatón', @@ -1187,6 +1190,7 @@ const hu = { mixedGeoDetailFmt: '{cmds} eltér a blokkok között ({detail})', mixedJmTitle: 'A nyomtatási sűrűség módja eltér a blokkok között: a későbbi oldalak megtartják saját módjukat, de csak az első szerkeszthető', unknownTitle: 'Kihagyva: ismeretlen parancs', + hostQueryTitle: 'Gazdagép-parancs: a dokumentum küldésekor választ kér a nyomtatótól', unterminatedFieldTitleFmt: 'A mező nincs a {fs} paranccsal lezárva: a nyomtató eldobja', hexControlTitle: 'Vezérlőkarakter a mező adataiban: a nyomtató eldobja a mező többi részét', reportHeader: 'ZPL importjelentés', @@ -2011,6 +2015,13 @@ const hu = { usbSetupAccess: 'Hozzáférés beállítása', usbSetupFlatpak: 'A Flatpakon belül az alkalmazás nem tudja magától telepíteni az udev szabályt. Telepítse a 70-zplab.rules fájlt a ZPLab tárolóból a gazdagépre, majd csatlakoztassa újra a nyomtatót.', usbNotFound: 'Az USB-nyomtató nem található. Csatlakoztassa újra, és frissítsen.', + queryButton: 'Nyomtató lekérdezése', + queryHint: 'Ez a dokumentum kérdez valamit a nyomtatótól. A válasz itt nyílik meg.', + queryNoChannel: 'Ezen az úton nem jön vissza válasz, ezért a válasz elveszik.', + queryNoRender: 'Ez az út képet nyomtat, így a válasznak nincs hová érkeznie.', + queryMixedHint: 'A többi mező is kinyomtatódik.', + queryAfterPrint: 'A ^HV válasz a nyomtatás után érkezik. A ZPLab nem vár ennyit, ezért a dokumentum lekérdezés nélkül kerül elküldésre.', + replyTitle: 'Nyomtató válasza', }, pdfExport: { title: 'PDF exportálása', diff --git a/src/locales/it.ts b/src/locales/it.ts index 7365ffaea..790723421 100644 --- a/src/locales/it.ts +++ b/src/locales/it.ts @@ -1022,6 +1022,7 @@ const it = { failPermissionDenied: 'Nessun accesso alla stampante USB. Concedilo nella finestra di invio, scheda USB.', failBusy: "È ancora in corso un'altra lettura della stampante. Riprova in un momento.", failUnparsed: 'La stampante ha risposto, ma non è stato possibile leggere la risposta.', + failSilent: 'La stampante non ha risposto.', failIgnored: "La stampante ha ignorato il comando. L'oggetto è ancora presente.", rawReply: 'Risposta grezza', configuration: 'Configurazione', @@ -1063,6 +1064,7 @@ const it = { keepExistingPages: 'Mantieni pagine esistenti', importSetupTitle: 'Questo ZPL contiene comandi di configurazione della stampante', importSetupBody: "Vengono eseguiti sulla stampante quando l'etichetta viene stampata o esportata. Scegli come gestirli:", + importSetupDropsFmt: 'Sposta o Rimuovi scarta anche {commands}, che solo i byte dell’etichetta trasportano.', importSetupKeep: "Mantieni nell'etichetta", importSetupToSetup: 'Sposta nello script di configurazione', importSetupRemove: 'Rimuovi', @@ -1177,6 +1179,7 @@ const it = { lossRecallFormat: 'Questo blocco richiama un formato memorizzato e resta una pagina invece di una riga di dati. Le righe richiedono un formato memorizzato in precedenza nel flusso che dichiari i campi, e blocchi che contengano solo dati di campo.', lossRecallSlot: 'Il richiamo fornisce un numero di campo che il formato memorizzato non dichiara mai. La stampante lo stampa come campo estraneo che il design non può mostrare.', lossRecallSettings: "Le righe cambiano un'impostazione che il campo legge dai suoi dati, come il livello di correzione degli errori. Il design mantiene l'impostazione della prima riga.", + lossDroppedHostCommand: 'Il design non conserva questo comando host, quindi un blocco rigenerato non lo pone più.', browserLimitTitle: 'Saltato: richiede hardware della stampante', replayRiskTitle: 'Comando di setup stampante: viene eseguito sulla stampante in esportazione/stampa', deviceActionTitle: 'Azione del dispositivo stampante: viene eseguita sulla stampante in esportazione/stampa', @@ -1187,6 +1190,7 @@ const it = { mixedGeoDetailFmt: '{cmds} differiscono tra i blocchi ({detail})', mixedJmTitle: 'La modalità di densità di stampa differisce tra i blocchi: le pagine successive mantengono la propria modalità, ma solo la prima è modificabile', unknownTitle: 'Saltato: comando non riconosciuto', + hostQueryTitle: 'Comando host: chiede una risposta alla stampante all’invio del documento', unterminatedFieldTitleFmt: 'Campo non chiuso con {fs}: la stampante lo scarta', hexControlTitle: 'Carattere di controllo nei dati del campo: la stampante scarta il resto del campo', reportHeader: 'Rapporto di importazione ZPL', @@ -2011,6 +2015,13 @@ const it = { usbSetupAccess: 'Configura accesso', usbSetupFlatpak: "All'interno di Flatpak l'app non può installare la regola udev da sola. Installa 70-zplab.rules dal repository ZPLab sull'host, poi ricollega la stampante.", usbNotFound: 'Stampante USB non trovata, ricollegala e aggiorna.', + queryButton: 'Interroga stampante', + queryHint: 'Questo documento chiede qualcosa alla stampante. La risposta si apre qui.', + queryNoChannel: 'Questa via non porta nessuna risposta, quindi la risposta va perduta.', + queryNoRender: 'Questa via stampa un’immagine, quindi la risposta non ha dove arrivare.', + queryMixedHint: 'Anche gli altri campi vengono stampati.', + queryAfterPrint: 'La risposta ^HV arriva dopo la stampa. ZPLab non attende così a lungo, quindi il documento viene inviato senza interrogazione.', + replyTitle: 'Risposta della stampante', }, pdfExport: { title: 'Esporta PDF', diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 0d7abd9a9..b9c065835 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -1022,6 +1022,7 @@ const ja = { failPermissionDenied: 'USBプリンターにアクセスできません。送信ダイアログのUSBタブで許可してください。', failBusy: '別のプリンター読み取りがまだ実行中です。少し待ってから再試行してください。', failUnparsed: 'プリンターから応答がありましたが、内容を読み取れませんでした。', + failSilent: 'プリンターが応答しませんでした。', failIgnored: 'プリンターはコマンドを無視しました。オブジェクトはまだ残っています。', rawReply: '生の応答', configuration: '設定', @@ -1063,6 +1064,7 @@ const ja = { keepExistingPages: '既存のページを保持', importSetupTitle: 'このZPLにはプリンター設定コマンドが含まれています', importSetupBody: 'ラベルの印刷またはエクスポート時にプリンター上で実行されます。処理方法を選択してください:', + importSetupDropsFmt: '移動または削除すると {commands} も失われます。これらはラベル自身のバイト列だけが運ぶためです。', importSetupKeep: 'ラベルに残す', importSetupToSetup: 'セットアップスクリプトに移動', importSetupRemove: '削除', @@ -1177,6 +1179,7 @@ const ja = { lossRecallFormat: 'このブロックは保存済みフォーマットを呼び出し、データ行ではなくページのまま残ります。行にするには、ストリーム内でそれより前にフィールドを宣言した保存済みフォーマットが必要で、かつフィールドデータだけを保持するブロックである必要があります。', lossRecallSlot: '呼び出しは保存フォーマットが宣言していないフィールド番号を渡します。プリンターはそれを所属先のないフィールドとして印字しますが、デザインには表示できません。', lossRecallSettings: '行はフィールドが自身のデータから読み取る設定、たとえば誤り訂正レベルを切り替えます。デザインは最初の行の設定を保持します。', + lossDroppedHostCommand: 'このホストコマンドはデザインに保持されないため、再生成されたブロックでは問い合わせられなくなります。', browserLimitTitle: 'スキップ: プリンターのハードウェアが必要です', replayRiskTitle: 'プリンター設定コマンド: エクスポート/印刷時にプリンター上で実行されます', deviceActionTitle: 'プリンターのデバイス動作: エクスポート/印刷時にプリンター上で実行されます', @@ -1187,6 +1190,7 @@ const ja = { mixedGeoDetailFmt: 'ブロック間で{cmds}が異なります ({detail})', mixedJmTitle: 'ブロック間で印字濃度モードが異なります。後続のページはそれぞれ自身のモードを保持しますが、編集できるのは最初のブロックのみです', unknownTitle: 'スキップ: コマンドが認識されません', + hostQueryTitle: 'ホストコマンド: ドキュメント送信時にプリンターへ応答を要求します', unterminatedFieldTitleFmt: '{fs} で閉じられていないフィールド: プリンターが破棄します', hexControlTitle: 'フィールドデータ内の制御文字: プリンターがフィールドの残りを破棄します', reportHeader: 'ZPLインポートレポート', @@ -2011,6 +2015,13 @@ const ja = { usbSetupAccess: 'アクセスを設定', usbSetupFlatpak: 'Flatpak内ではアプリが自分でudevルールをインストールできません。ホスト側でZPLabリポジトリから70-zplab.rulesをインストールし、プリンターを再接続してください。', usbNotFound: 'USBプリンターが見つかりません。再接続して更新してください。', + queryButton: 'プリンターに問い合わせ', + queryHint: 'このドキュメントはプリンターに問い合わせます。応答はここに開きます。', + queryNoChannel: 'この経路では応答が返らないため、応答は失われます。', + queryNoRender: 'この方法は画像を印刷するため、応答の行き先がありません。', + queryMixedHint: 'ほかのフィールドも印刷されます。', + queryAfterPrint: '^HV の応答は印刷後に届きます。ZPLab はそこまで待たないため、文書は問い合わせなしで送信されます。', + replyTitle: 'プリンターの応答', }, pdfExport: { title: 'PDF エクスポート', diff --git a/src/locales/ko.ts b/src/locales/ko.ts index d7a09669c..82740cefe 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -1022,6 +1022,7 @@ const ko = { failPermissionDenied: 'USB 프린터에 액세스할 수 없습니다. 전송 대화 상자의 USB 탭에서 권한을 부여하세요.', failBusy: '다른 프린터 읽기가 아직 실행 중입니다. 잠시 후 다시 시도하세요.', failUnparsed: '프린터가 응답했지만 응답을 읽을 수 없었습니다.', + failSilent: '프린터가 응답하지 않았습니다.', failIgnored: '프린터가 명령을 무시했습니다. 객체가 아직 남아 있습니다.', rawReply: '원시 응답', configuration: '구성', @@ -1063,6 +1064,7 @@ const ko = { keepExistingPages: '기존 페이지 유지', importSetupTitle: '이 ZPL에는 프린터 설정 명령이 포함되어 있습니다', importSetupBody: '라벨을 인쇄하거나 내보낼 때 프린터에서 실행됩니다. 처리 방법을 선택하세요:', + importSetupDropsFmt: '이동 또는 제거 시 {commands}도 사라집니다. 라벨 자체의 바이트만 이를 담기 때문입니다.', importSetupKeep: '라벨에 유지', importSetupToSetup: '설정 스크립트로 이동', importSetupRemove: '제거', @@ -1177,6 +1179,7 @@ const ko = { lossRecallFormat: '이 블록은 저장된 포맷을 호출하며 데이터 행 대신 페이지로 남습니다. 행이 되려면 스트림 앞쪽에서 필드를 선언하는 저장된 포맷이 있어야 하고 블록이 필드 데이터만 담고 있어야 합니다.', lossRecallSlot: '호출은 저장된 포맷이 선언하지 않은 필드 번호를 제공합니다. 프린터는 이를 소속 없는 필드로 인쇄하며 디자인에서는 표시할 수 없습니다.', lossRecallSettings: '행은 필드가 자체 데이터에서 읽어오는 설정을 바꿉니다. 오류 정정 수준이 그 예입니다. 디자인은 첫 행의 설정을 유지합니다.', + lossDroppedHostCommand: '이 호스트 명령은 디자인에 유지되지 않으므로 다시 생성된 블록에서는 더 이상 질의하지 않습니다.', browserLimitTitle: '건너뜀: 프린터 하드웨어가 필요합니다', replayRiskTitle: '프린터 설정 명령: 내보내기/인쇄 시 프린터에서 실행됩니다', deviceActionTitle: '프린터 장치 동작: 내보내기/인쇄 시 프린터에서 실행됩니다', @@ -1187,6 +1190,7 @@ const ko = { mixedGeoDetailFmt: '블록 간에 {cmds}이(가) 다릅니다 ({detail})', mixedJmTitle: '블록마다 인쇄 농도 모드가 다릅니다. 이후 페이지는 각자의 모드를 유지하지만, 편집할 수 있는 것은 첫 번째 블록뿐입니다', unknownTitle: '건너뜀: 인식되지 않은 명령', + hostQueryTitle: '호스트 명령: 문서를 보낼 때 프린터에 응답을 요청합니다', unterminatedFieldTitleFmt: '{fs}(으)로 닫히지 않은 필드: 프린터가 폐기합니다', hexControlTitle: '필드 데이터의 제어 문자: 프린터가 필드의 나머지를 폐기합니다', reportHeader: 'ZPL 가져오기 보고서', @@ -2011,6 +2015,13 @@ const ko = { usbSetupAccess: '액세스 설정', usbSetupFlatpak: 'Flatpak 내부에서는 앱이 직접 udev 규칙을 설치할 수 없습니다. 호스트에서 ZPLab 저장소의 70-zplab.rules를 설치한 뒤 프린터를 다시 연결하세요.', usbNotFound: 'USB 프린터를 찾을 수 없습니다. 다시 연결한 후 새로 고침하세요.', + queryButton: '프린터에 조회', + queryHint: '이 문서는 프린터에 무언가를 묻습니다. 응답은 여기에 열립니다.', + queryNoChannel: '이 경로로는 응답이 돌아오지 않으므로 응답이 사라집니다.', + queryNoRender: '이 방법은 이미지를 인쇄하므로 응답이 도착할 곳이 없습니다.', + queryMixedHint: '다른 필드도 함께 인쇄됩니다.', + queryAfterPrint: '^HV 응답은 인쇄 후에 도착합니다. ZPLab은 그만큼 기다리지 않으므로 문서는 조회 없이 전송됩니다.', + replyTitle: '프린터 응답', }, pdfExport: { title: 'PDF 내보내기', diff --git a/src/locales/lt.ts b/src/locales/lt.ts index 1c05a9895..acef86677 100644 --- a/src/locales/lt.ts +++ b/src/locales/lt.ts @@ -1022,6 +1022,7 @@ const lt = { failPermissionDenied: 'Nėra prieigos prie USB spausdintuvo. Suteikite ją siuntimo lange, skirtukas USB.', failBusy: 'Dar vyksta kitas spausdintuvo skaitymas. Bandykite dar kartą po kurio laiko.', failUnparsed: 'Spausdintuvas atsakė, bet atsakymo nepavyko perskaityti.', + failSilent: 'Spausdintuvas neatsakė.', failIgnored: 'Spausdintuvas nepaisė komandos. Objektas tebėra.', rawReply: 'Neapdorotas atsakymas', configuration: 'Konfigūracija', @@ -1063,6 +1064,7 @@ const lt = { keepExistingPages: 'Išsaugoti esamus puslapius', importSetupTitle: 'Šiame ZPL faile yra spausdintuvo nustatymo komandų', importSetupBody: 'Jos vykdomos spausdintuve, kai etiketė spausdinama arba eksportuojama. Pasirinkite, kaip su jomis elgtis:', + importSetupDropsFmt: 'Perkėlimas arba šalinimas taip pat atmeta {commands}, nes juos neša tik pačios etiketės baitai.', importSetupKeep: 'Palikti etiketėje', importSetupToSetup: 'Perkelti į sąrankos scenarijų', importSetupRemove: 'Šalinti', @@ -1177,6 +1179,7 @@ const lt = { lossRecallFormat: 'Šis blokas iškviečia išsaugotą formatą ir lieka puslapiu, o ne duomenų eilute. Eilutėms reikia anksčiau sraute išsaugoto formato, kuris deklaruoja laukus, ir blokų, kuriuose yra tik laukų duomenys.', lossRecallSlot: 'Iškvietimas pateikia lauko numerį, kurio išsaugotas formatas niekada nedeklaruoja. Spausdintuvas jį spausdina kaip beglobį lauką, kurio dizainas negali parodyti.', lossRecallSettings: 'Eilutės perjungia nustatymą, kurį laukas nuskaito iš savo duomenų, pavyzdžiui, klaidų taisymo lygį. Dizainas išlaiko pirmos eilutės nustatymą.', + lossDroppedHostCommand: 'Dizainas šios pagrindinio kompiuterio komandos neišsaugo, todėl iš naujo sukurtas blokas jos nebepateikia.', browserLimitTitle: 'Praleista: reikalinga spausdintuvo aparatinė įranga', replayRiskTitle: 'Spausdintuvo nustatymo komanda: vykdoma spausdintuve eksportuojant/spausdinant', deviceActionTitle: 'Spausdintuvo įrenginio veiksmas: vykdomas spausdintuve eksportuojant/spausdinant', @@ -1187,6 +1190,7 @@ const lt = { mixedGeoDetailFmt: '{cmds} skiriasi tarp blokų ({detail})', mixedJmTitle: 'Spausdinimo tankio režimas skiriasi tarp blokų: vėlesni puslapiai išlaiko savo režimą, tačiau redaguoti galima tik pirmąjį', unknownTitle: 'Praleista: komanda neatpažinta', + hostQueryTitle: 'Pagrindinio kompiuterio komanda: išsiuntus dokumentą prašo spausdintuvo atsakymo', unterminatedFieldTitleFmt: 'Laukas neuždarytas naudojant {fs}: spausdintuvas jį atmeta', hexControlTitle: 'Valdymo simbolis lauko duomenyse: spausdintuvas atmeta likusią lauko dalį', reportHeader: 'ZPL importo ataskaita', @@ -2011,6 +2015,13 @@ const lt = { usbSetupAccess: 'Nustatyti prieigą', usbSetupFlatpak: 'Flatpak aplinkoje programa negali pati įdiegti udev taisyklės. Įdiekite 70-zplab.rules iš ZPLab saugyklos pagrindinėje sistemoje, tada iš naujo prijunkite spausdintuvą.', usbNotFound: 'USB spausdintuvas nerastas. Prijunkite jį iš naujo ir atnaujinkite.', + queryButton: 'Užklausti spausdintuvą', + queryHint: 'Šis dokumentas kai ko klausia spausdintuvo. Atsakymas atsiveria čia.', + queryNoChannel: 'Šiuo keliu atsakymas negrįžta, todėl jis pranyksta.', + queryNoRender: 'Šis būdas spausdina vaizdą, todėl atsakymui nėra kur nukeliauti.', + queryMixedHint: 'Kiti laukai taip pat bus išspausdinti.', + queryAfterPrint: '^HV atsakymas ateina po spausdinimo. ZPLab tiek nelaukia, todėl dokumentas siunčiamas be užklausos.', + replyTitle: 'Spausdintuvo atsakymas', }, pdfExport: { title: 'Eksportuoti PDF', diff --git a/src/locales/lv.ts b/src/locales/lv.ts index f0ee1ecf0..a24e440fb 100644 --- a/src/locales/lv.ts +++ b/src/locales/lv.ts @@ -1022,6 +1022,7 @@ const lv = { failPermissionDenied: 'Nav piekļuves USB printerim. Piešķiriet to sūtīšanas dialoglodziņā, cilne USB.', failBusy: 'Vēl notiek cits printera nolasījums. Pamēģiniet vēlreiz pēc brīža.', failUnparsed: 'Printeris atbildēja, bet atbildi nebija iespējams nolasīt.', + failSilent: 'Printeris neatbildēja.', failIgnored: 'Printeris ignorēja komandu. Objekts joprojām ir tur.', rawReply: 'Jēla atbilde', configuration: 'Konfigurācija', @@ -1063,6 +1064,7 @@ const lv = { keepExistingPages: 'Saglabāt esošās lapas', importSetupTitle: 'Šis ZPL satur printera iestatīšanas komandas', importSetupBody: 'Tās izpildās printerī, kad etiķete tiek drukāta vai eksportēta. Izvēlieties, kā ar tām rīkoties:', + importSetupDropsFmt: 'Pārvietošana vai noņemšana atmet arī {commands}, jo tos nes tikai paša marķējuma baiti.', importSetupKeep: 'Saglabāt etiķetē', importSetupToSetup: 'Pārvietot uz iestatīšanas skriptu', importSetupRemove: 'Noņemt', @@ -1177,6 +1179,7 @@ const lv = { lossRecallFormat: 'Šis bloks izsauc saglabātu formātu un paliek lapa, nevis datu rinda. Rindām nepieciešams plūsmā agrāk saglabāts formāts, kas deklarē laukus, un bloki, kas satur tikai lauku datus.', lossRecallSlot: 'Izsaukums norāda lauka numuru, kuru saglabātais formāts nekad nedeklarē. Printeris to izdrukā kā bezpiederīgu lauku, kuru dizains nevar parādīt.', lossRecallSettings: 'Rindas maina iestatījumu, ko lauks nolasa no saviem datiem, piemēram, kļūdu labošanas līmeni. Dizains saglabā pirmās rindas iestatījumu.', + lossDroppedHostCommand: 'Dizains šo resursdatora komandu nesaglabā, tāpēc no jauna izveidots bloks to vairs neuzdod.', browserLimitTitle: 'Izlaists: nepieciešama printera aparatūra', replayRiskTitle: 'Printera iestatīšanas komanda: tiek izpildīta printerī eksportējot/drukājot', deviceActionTitle: 'Printera ierīces darbība: tiek izpildīta printerī eksportējot/drukājot', @@ -1187,6 +1190,7 @@ const lv = { mixedGeoDetailFmt: '{cmds} atšķiras starp blokiem ({detail})', mixedJmTitle: 'Drukas blīvuma režīms atšķiras starp blokiem: vēlākās lapas saglabā savu režīmu, bet rediģējams ir tikai pirmais', unknownTitle: 'Izlaists: komanda nav atpazīta', + hostQueryTitle: 'Resursdatora komanda: nosūtot dokumentu, pieprasa printera atbildi', unterminatedFieldTitleFmt: 'Lauks nav aizvērts ar {fs}: printeris to atmet', hexControlTitle: 'Vadības rakstzīme lauka datos: printeris atmet atlikušo lauka daļu', reportHeader: 'ZPL importēšanas atskaite', @@ -2011,6 +2015,13 @@ const lv = { usbSetupAccess: 'Iestatīt piekļuvi', usbSetupFlatpak: 'Flatpak iekšienē lietotne nevar pati instalēt udev kārtulu. Instalējiet 70-zplab.rules no ZPLab repozitorija resursdatorā un pēc tam atkārtoti pievienojiet printeri.', usbNotFound: 'USB printeris nav atrasts. Pievienojiet to atkārtoti un atsvaidziniet.', + queryButton: 'Vaicāt printerim', + queryHint: 'Šis dokuments printerim ko jautā. Atbilde atveras šeit.', + queryNoChannel: 'Šajā ceļā atbilde neatgriežas, tāpēc tā tiek zaudēta.', + queryNoRender: 'Šis veids drukā attēlu, tāpēc atbildei nav kur nonākt.', + queryMixedHint: 'Pārējie lauki arī tiek izdrukāti.', + queryAfterPrint: '^HV atbilde pienāk pēc drukas. ZPLab tik ilgi negaida, tāpēc dokuments tiek nosūtīts bez vaicājuma.', + replyTitle: 'Printera atbilde', }, pdfExport: { title: 'Eksportēt PDF', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 5814e0f9b..2cbc75c2e 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -1022,6 +1022,7 @@ const nl = { failPermissionDenied: 'Geen toegang tot de USB-printer. Verleen deze in het verzenddialoog, tabblad USB.', failBusy: 'Er loopt nog een andere printeruitlezing. Probeer het straks opnieuw.', failUnparsed: 'De printer heeft geantwoord, maar het antwoord kon niet worden gelezen.', + failSilent: 'De printer heeft niet geantwoord.', failIgnored: 'De printer heeft de opdracht genegeerd. Het object is er nog.', rawReply: 'Ruw antwoord', configuration: 'Configuratie', @@ -1063,6 +1064,7 @@ const nl = { keepExistingPages: 'Bestaande pagina\'s behouden', importSetupTitle: 'Deze ZPL bevat printerinstellingsopdrachten', importSetupBody: 'Ze worden op de printer uitgevoerd wanneer het label wordt afgedrukt of geëxporteerd. Kies hoe ermee wordt omgegaan:', + importSetupDropsFmt: 'Verplaatsen of verwijderen laat ook {commands} vallen, want alleen de bytes van het label dragen ze.', importSetupKeep: 'Behouden in label', importSetupToSetup: 'Verplaatsen naar installatiescript', importSetupRemove: 'Verwijderen', @@ -1177,6 +1179,7 @@ const nl = { lossRecallFormat: 'Dit blok roept een opgeslagen indeling op en blijft een pagina in plaats van een gegevensrij. Rijen hebben een eerder in de stream opgeslagen indeling nodig die velden declareert, en blokken die alleen velddata bevatten.', lossRecallSlot: 'De oproep levert een veldnummer dat de opgeslagen indeling nooit declareert. De printer drukt het af als los veld dat het ontwerp niet kan tonen.', lossRecallSettings: 'De rijen wisselen een instelling die het veld uit zijn gegevens leest, zoals het foutcorrectieniveau. Het ontwerp behoudt de instelling van de eerste rij.', + lossDroppedHostCommand: 'Het ontwerp bewaart deze hostopdracht niet, dus een opnieuw gegenereerd blok stelt ze niet meer.', browserLimitTitle: 'Overgeslagen: vereist printerhardware', replayRiskTitle: 'Printer-setupopdracht: wordt op de printer uitgevoerd bij export/afdrukken', deviceActionTitle: 'Printer-apparaatactie: wordt op de printer uitgevoerd bij export/afdrukken', @@ -1187,6 +1190,7 @@ const nl = { mixedGeoDetailFmt: '{cmds} verschillen tussen blokken ({detail})', mixedJmTitle: "De afdrukdichtheidsmodus verschilt tussen blokken: latere pagina's behouden hun eigen modus, maar alleen de eerste is bewerkbaar", unknownTitle: 'Overgeslagen: opdracht niet herkend', + hostQueryTitle: 'Hostopdracht: vraagt de printer om een antwoord bij het verzenden van het document', unterminatedFieldTitleFmt: 'Veld niet gesloten met {fs}: de printer negeert het', hexControlTitle: 'Stuurteken in de veldgegevens: de printer negeert de rest van het veld', reportHeader: 'ZPL-importrapport', @@ -2011,6 +2015,13 @@ const nl = { usbSetupAccess: 'Toegang instellen', usbSetupFlatpak: 'Binnen Flatpak kan de app de udev-regel niet zelf installeren. Installeer 70-zplab.rules uit de ZPLab-repository op de host en sluit de printer vervolgens opnieuw aan.', usbNotFound: 'USB-printer niet gevonden. Sluit deze opnieuw aan en vernieuw.', + queryButton: 'Printer opvragen', + queryHint: 'Dit document vraagt de printer iets. Het antwoord opent hier.', + queryNoChannel: 'Langs deze weg komt geen antwoord terug, dus het antwoord gaat verloren.', + queryNoRender: 'Deze weg drukt een afbeelding af, dus het antwoord kan nergens terecht.', + queryMixedHint: 'De overige velden worden ook afgedrukt.', + queryAfterPrint: 'Het ^HV-antwoord komt na het afdrukken. ZPLab wacht niet zo lang, dus het document wordt zonder opvraging verzonden.', + replyTitle: 'Printerantwoord', }, pdfExport: { title: 'PDF exporteren', diff --git a/src/locales/no.ts b/src/locales/no.ts index ad976ad3f..05408cd14 100644 --- a/src/locales/no.ts +++ b/src/locales/no.ts @@ -1022,6 +1022,7 @@ const no = { failPermissionDenied: 'Ingen tilgang til USB-skriveren. Gi den i sende-dialogen, fanen USB.', failBusy: 'En annen skriveravlesning kjører fremdeles. Prøv igjen om et øyeblikk.', failUnparsed: 'Skriveren svarte, men svaret kunne ikke leses.', + failSilent: 'Skriveren svarte ikke.', failIgnored: 'Skriveren ignorerte kommandoen. Objektet er fremdeles der.', rawReply: 'Rått svar', configuration: 'Konfigurasjon', @@ -1063,6 +1064,7 @@ const no = { keepExistingPages: 'Behold eksisterende sider', importSetupTitle: 'Denne ZPL-en inneholder oppsettkommandoer for skriveren', importSetupBody: 'De kjøres på skriveren når etiketten skrives ut eller eksporteres. Velg hvordan de skal håndteres:', + importSetupDropsFmt: 'Flytt eller Fjern dropper også {commands}, som bare etikettens egne byte bærer.', importSetupKeep: 'Behold i etikett', importSetupToSetup: 'Flytt til oppsettskript', importSetupRemove: 'Fjern', @@ -1177,6 +1179,7 @@ const no = { lossRecallFormat: 'Denne blokken henter et lagret format og forblir en side i stedet for en datarad. Rader trenger et format som er lagret tidligere i strømmen og som deklarerer felt, samt blokker som kun inneholder feltdata.', lossRecallSlot: 'Tilbakekallingen oppgir et feltnummer som det lagrede formatet aldri erklærer. Skriveren skriver det ut som et herreløst felt som designet ikke kan vise.', lossRecallSettings: 'Radene bytter en innstilling som feltet leser fra dataene sine, for eksempel feilrettingsnivået. Designet beholder innstillingen fra den første raden.', + lossDroppedHostCommand: 'Designet beholder ikke denne vertskommandoen, så en regenerert blokk stiller den ikke lenger.', browserLimitTitle: 'Hoppet over: krever skriverens maskinvare', replayRiskTitle: 'Skriveroppsettkommando: kjøres på skriveren ved eksport/utskrift', deviceActionTitle: 'Skriverens enhetshandling: kjøres på skriveren ved eksport/utskrift', @@ -1187,6 +1190,7 @@ const no = { mixedGeoDetailFmt: '{cmds} skiller seg mellom blokker ({detail})', mixedJmTitle: 'Utskriftstetthetsmodus er forskjellig mellom blokker: senere sider beholder sin egen modus, men kun den første kan redigeres', unknownTitle: 'Hoppet over: kommandoen ble ikke gjenkjent', + hostQueryTitle: 'Vertskommando: ber skriveren om et svar når dokumentet sendes', unterminatedFieldTitleFmt: 'Feltet er ikke lukket med {fs}: skriveren forkaster det', hexControlTitle: 'Kontrolltegn i feltets data: skriveren forkaster resten av feltet', reportHeader: 'ZPL-importrapport', @@ -2011,6 +2015,13 @@ const no = { usbSetupAccess: 'Konfigurer tilgang', usbSetupFlatpak: 'Inne i Flatpak kan appen ikke installere udev-regelen selv. Installer 70-zplab.rules fra ZPLab-repositoriet på verten, og koble deretter skriveren til på nytt.', usbNotFound: 'Fant ikke USB-skriveren. Koble den til på nytt, og oppdater.', + queryButton: 'Spør skriveren', + queryHint: 'Dette dokumentet spør skriveren om noe. Svaret åpnes her.', + queryNoChannel: 'Den veien bærer ingen svar, så svaret går tapt.', + queryNoRender: 'Denne veien skriver ut et bilde, så svaret har ingen steder å lande.', + queryMixedHint: 'De andre feltene skrives ut også.', + queryAfterPrint: '^HV-svaret kommer etter utskriften. ZPLab venter ikke så lenge, så dokumentet sendes uten forespørsel.', + replyTitle: 'Skriversvar', }, pdfExport: { title: 'Eksporter PDF', diff --git a/src/locales/pl.ts b/src/locales/pl.ts index 8f17cf46f..40f47693c 100644 --- a/src/locales/pl.ts +++ b/src/locales/pl.ts @@ -1022,6 +1022,7 @@ const pl = { failPermissionDenied: 'Brak dostępu do drukarki USB. Przyznaj go w oknie wysyłania, karta USB.', failBusy: 'Inny odczyt drukarki jeszcze się wykonuje. Spróbuj ponownie za chwilę.', failUnparsed: 'Drukarka odpowiedziała, ale nie udało się odczytać odpowiedzi.', + failSilent: 'Drukarka nie odpowiedziała.', failIgnored: 'Drukarka zignorowała komendę. Obiekt wciąż tam jest.', rawReply: 'Surowa odpowiedź', configuration: 'Konfiguracja', @@ -1063,6 +1064,7 @@ const pl = { keepExistingPages: 'Zachowaj istniejące strony', importSetupTitle: 'Ten plik ZPL zawiera polecenia konfiguracji drukarki', importSetupBody: 'Są wykonywane na drukarce podczas drukowania lub eksportowania etykiety. Wybierz sposób ich obsługi:', + importSetupDropsFmt: 'Przeniesienie lub usunięcie odrzuca też {commands}, bo niosą je tylko własne bajty etykiety.', importSetupKeep: 'Zachowaj w etykiecie', importSetupToSetup: 'Przenieś do skryptu konfiguracji', importSetupRemove: 'Usuń', @@ -1177,6 +1179,7 @@ const pl = { lossRecallFormat: 'Ten blok przywołuje zapisany format i pozostaje stroną zamiast wiersza danych. Wiersze potrzebują formatu zapisanego wcześniej w strumieniu, który deklaruje pola, oraz bloków, które niosą wyłącznie dane pól.', lossRecallSlot: 'Przywołanie dostarcza numer pola, którego zapisany format nigdy nie deklaruje. Drukarka drukuje je jako pole sieroce, którego projekt nie może pokazać.', lossRecallSettings: 'Wiersze zmieniają ustawienie, które pole odczytuje ze swoich danych, na przykład poziom korekcji błędów. Projekt zachowuje ustawienie pierwszego wiersza.', + lossDroppedHostCommand: 'Projekt nie zachowuje tego polecenia hosta, dlatego ponownie wygenerowany blok już go nie zadaje.', browserLimitTitle: 'Pominięto: wymaga sprzętu drukarki', replayRiskTitle: 'Polecenie konfiguracji drukarki: uruchamiane na drukarce przy eksporcie/drukowaniu', deviceActionTitle: 'Akcja urządzenia drukarki: uruchamiana na drukarce przy eksporcie/drukowaniu', @@ -1187,6 +1190,7 @@ const pl = { mixedGeoDetailFmt: '{cmds} różnią się między blokami ({detail})', mixedJmTitle: 'Tryb gęstości wydruku różni się między blokami: kolejne strony zachowują własny tryb, ale tylko pierwszy jest edytowalny', unknownTitle: 'Pominięto: nierozpoznane polecenie', + hostQueryTitle: 'Polecenie hosta: prosi drukarkę o odpowiedź przy wysyłaniu dokumentu', unterminatedFieldTitleFmt: 'Pole niezamknięte za pomocą {fs}: drukarka je odrzuca', hexControlTitle: 'Znak sterujący w danych pola: drukarka odrzuca resztę pola', reportHeader: 'Raport importu ZPL', @@ -2011,6 +2015,13 @@ const pl = { usbSetupAccess: 'Skonfiguruj dostęp', usbSetupFlatpak: 'Wewnątrz Flatpaka aplikacja nie może sama zainstalować reguły udev. Zainstaluj 70-zplab.rules z repozytorium ZPLab na hoście, a następnie podłącz drukarkę ponownie.', usbNotFound: 'Nie znaleziono drukarki USB, podłącz ją ponownie i odśwież.', + queryButton: 'Zapytaj drukarkę', + queryHint: 'Ten dokument o coś pyta drukarkę. Odpowiedź otwiera się tutaj.', + queryNoChannel: 'Tą drogą nie wraca żadna odpowiedź, więc odpowiedź przepada.', + queryNoRender: 'Ta droga drukuje obraz, więc odpowiedź nie ma dokąd trafić.', + queryMixedHint: 'Pozostałe pola też zostaną wydrukowane.', + queryAfterPrint: 'Odpowiedź ^HV przychodzi po wydruku. ZPLab nie czeka tak długo, więc dokument jest wysyłany bez zapytania.', + replyTitle: 'Odpowiedź drukarki', }, pdfExport: { title: 'Eksportuj PDF', diff --git a/src/locales/pt.ts b/src/locales/pt.ts index 906f36c9a..f757eadc1 100644 --- a/src/locales/pt.ts +++ b/src/locales/pt.ts @@ -1022,6 +1022,7 @@ const pt = { failPermissionDenied: 'Sem acesso à impressora USB. Conceda-o na caixa de diálogo de envio, aba USB.', failBusy: 'Ainda está em execução outra leitura da impressora. Tente novamente dentro de instantes.', failUnparsed: 'A impressora respondeu, mas não foi possível ler a resposta.', + failSilent: 'A impressora não respondeu.', failIgnored: 'A impressora ignorou o comando. O objeto ainda está lá.', rawReply: 'Resposta em bruto', configuration: 'Configuração', @@ -1063,6 +1064,7 @@ const pt = { keepExistingPages: 'Manter páginas existentes', importSetupTitle: 'Este ZPL contém comandos de configuração da impressora', importSetupBody: 'São executados na impressora quando a etiqueta é impressa ou exportada. Escolha como tratá-los:', + importSetupDropsFmt: 'Mover ou remover também descarta {commands}, que só os bytes da própria etiqueta transportam.', importSetupKeep: 'Manter na etiqueta', importSetupToSetup: 'Mover para o script de configuração', importSetupRemove: 'Remover', @@ -1177,6 +1179,7 @@ const pt = { lossRecallFormat: 'Este bloco recupera um formato armazenado e permanece como página em vez de linha de dados. As linhas precisam de um formato armazenado antes no fluxo que declare campos, e de blocos que contenham apenas dados de campo.', lossRecallSlot: 'A chamada fornece um número de campo que o formato armazenado nunca declara. A impressora imprime-o como campo perdido que o design não consegue mostrar.', lossRecallSettings: 'As linhas alteram uma definição que o campo lê a partir dos seus dados, como o nível de correção de erros. O design mantém a definição da primeira linha.', + lossDroppedHostCommand: 'O design não conserva este comando de host, por isso um bloco regenerado deixa de o colocar.', browserLimitTitle: 'Ignorado: requer hardware da impressora', replayRiskTitle: 'Comando de configuração da impressora: executado na impressora ao exportar/imprimir', deviceActionTitle: 'Ação do dispositivo da impressora: executada na impressora ao exportar/imprimir', @@ -1187,6 +1190,7 @@ const pt = { mixedGeoDetailFmt: '{cmds} diferem entre os blocos ({detail})', mixedJmTitle: 'O modo de densidade de impressão difere entre blocos: as páginas seguintes mantêm o seu próprio modo, mas apenas a primeira é editável', unknownTitle: 'Ignorado: comando não reconhecido', + hostQueryTitle: 'Comando de host: pede uma resposta à impressora ao enviar o documento', unterminatedFieldTitleFmt: 'Campo não fechado com {fs}: a impressora o descarta', hexControlTitle: 'Caractere de controle nos dados do campo: a impressora descarta o resto do campo', reportHeader: 'Relatório de importação ZPL', @@ -2011,6 +2015,13 @@ const pt = { usbSetupAccess: 'Configurar acesso', usbSetupFlatpak: 'Dentro do Flatpak, a aplicação não pode instalar a regra udev por si própria. Instale o 70-zplab.rules a partir do repositório ZPLab no anfitrião e depois volte a ligar a impressora.', usbNotFound: 'Impressora USB não encontrada, reconecte-a e atualize.', + queryButton: 'Consultar impressora', + queryHint: 'Este documento pergunta algo à impressora. A resposta abre aqui.', + queryNoChannel: 'Este caminho não traz nenhuma resposta, por isso a resposta é perdida.', + queryNoRender: 'Esta via imprime uma imagem, por isso a resposta não tem para onde ir.', + queryMixedHint: 'Os outros campos também são impressos.', + queryAfterPrint: 'A resposta ^HV chega depois da impressão. O ZPLab não espera tanto, por isso o documento é enviado sem consulta.', + replyTitle: 'Resposta da impressora', }, pdfExport: { title: 'Exportar PDF', diff --git a/src/locales/ro.ts b/src/locales/ro.ts index dacc4d725..6e56ae5b6 100644 --- a/src/locales/ro.ts +++ b/src/locales/ro.ts @@ -1022,6 +1022,7 @@ const ro = { failPermissionDenied: 'Fără acces la imprimanta USB. Acordă-l în fereastra de trimitere, fila USB.', failBusy: 'O altă citire a imprimantei este încă în curs. Încearcă din nou peste un moment.', failUnparsed: 'Imprimanta a răspuns, dar răspunsul nu a putut fi citit.', + failSilent: 'Imprimanta nu a răspuns.', failIgnored: 'Imprimanta a ignorat comanda. Obiectul este încă acolo.', rawReply: 'Răspuns brut', configuration: 'Configurație', @@ -1063,6 +1064,7 @@ const ro = { keepExistingPages: 'Păstrează paginile existente', importSetupTitle: 'Acest ZPL conține comenzi de configurare a imprimantei', importSetupBody: 'Acestea rulează pe imprimantă atunci când eticheta este tipărită sau exportată. Alegeți cum să le gestionați:', + importSetupDropsFmt: 'Mutarea sau eliminarea renunță și la {commands}, pe care doar octeții etichetei le poartă.', importSetupKeep: 'Păstrează în etichetă', importSetupToSetup: 'Mută în scriptul de configurare', importSetupRemove: 'Elimină', @@ -1177,6 +1179,7 @@ const ro = { lossRecallFormat: 'Acest bloc recheamă un format stocat și rămâne o pagină în loc de un rând de date. Rândurile au nevoie de un format stocat mai devreme în flux care declară câmpuri, precum și de blocuri care poartă doar date de câmp.', lossRecallSlot: 'Rechemarea furnizează un număr de câmp pe care formatul stocat nu îl declară niciodată. Imprimanta îl tipărește ca un câmp rătăcit pe care designul nu îl poate afișa.', lossRecallSettings: 'Rândurile schimbă o setare pe care câmpul o citește din datele sale, precum nivelul de corecție a erorilor. Design-ul păstrează setarea primului rând.', + lossDroppedHostCommand: 'Designul nu păstrează această comandă host, așa că un bloc regenerat nu o mai adresează.', browserLimitTitle: 'Omis: necesită hardware de imprimantă', replayRiskTitle: 'Comandă de configurare a imprimantei: rulează pe imprimantă la export/tipărire', deviceActionTitle: 'Acțiune a dispozitivului imprimantei: rulează pe imprimantă la export/tipărire', @@ -1187,6 +1190,7 @@ const ro = { mixedGeoDetailFmt: '{cmds} diferă între blocuri ({detail})', mixedJmTitle: 'Modul de densitate a imprimării diferă între blocuri: paginile ulterioare își păstrează propriul mod, dar numai primul este editabil', unknownTitle: 'Omis: comandă nerecunoscută', + hostQueryTitle: 'Comandă host: cere imprimantei un răspuns la trimiterea documentului', unterminatedFieldTitleFmt: 'Câmp neînchis cu {fs}: imprimanta îl elimină', hexControlTitle: 'Caracter de control în datele câmpului: imprimanta elimină restul câmpului', reportHeader: 'Raport de import ZPL', @@ -2011,6 +2015,13 @@ const ro = { usbSetupAccess: 'Configurează accesul', usbSetupFlatpak: 'În interiorul Flatpak, aplicația nu poate instala regula udev singură. Instalează 70-zplab.rules din depozitul ZPLab pe sistemul-host, apoi reconectează imprimanta.', usbNotFound: 'Imprimanta USB nu a fost găsită, reconecteaz-o și reîmprospătează.', + queryButton: 'Interoghează imprimanta', + queryHint: 'Acest document întreabă ceva imprimanta. Răspunsul se deschide aici.', + queryNoChannel: 'Pe această cale nu vine niciun răspuns, așa că răspunsul se pierde.', + queryNoRender: 'Această cale tipărește o imagine, așa că răspunsul nu are unde să ajungă.', + queryMixedHint: 'Celelalte câmpuri se tipăresc și ele.', + queryAfterPrint: 'Răspunsul ^HV vine după imprimare. ZPLab nu așteaptă atât, așa că documentul este trimis fără interogare.', + replyTitle: 'Răspunsul imprimantei', }, pdfExport: { title: 'Exportă PDF', diff --git a/src/locales/sk.ts b/src/locales/sk.ts index c8075f371..7ac99be64 100644 --- a/src/locales/sk.ts +++ b/src/locales/sk.ts @@ -1022,6 +1022,7 @@ const sk = { failPermissionDenied: 'Žiadny prístup k tlačiarni USB. Udelíte ho v dialógu odoslania, karta USB.', failBusy: 'Iné čítanie z tlačiarne ešte prebieha. Skúste to znova o chvíľu.', failUnparsed: 'Tlačiareň odpovedala, ale odpoveď sa nepodarilo prečítať.', + failSilent: 'Tlačiareň neodpovedala.', failIgnored: 'Tlačiareň príkaz ignorovala. Objekt stále zostáva.', rawReply: 'Nespracovaná odpoveď', configuration: 'Konfigurácia', @@ -1063,6 +1064,7 @@ const sk = { keepExistingPages: 'Zachovať existujúce stránky', importSetupTitle: 'Tento súbor ZPL obsahuje príkazy na nastavenie tlačiarne', importSetupBody: 'Spustia sa na tlačiarni pri tlači alebo exporte štítku. Vyberte, ako s nimi naložiť:', + importSetupDropsFmt: 'Presunutie alebo odstránenie zahodí aj {commands}, pretože ich nesú len vlastné bajty štítku.', importSetupKeep: 'Ponechať v štítku', importSetupToSetup: 'Presunúť do inštalačného skriptu', importSetupRemove: 'Odstrániť', @@ -1177,6 +1179,7 @@ const sk = { lossRecallFormat: 'Tento blok vyvoláva uložený formát a zostáva stránkou namiesto riadku údajov. Riadky potrebujú formát uložený skôr v dátovom toku, ktorý deklaruje polia, a bloky, ktoré nesú iba údaje polí.', lossRecallSlot: 'Vyvolanie poskytuje číslo poľa, ktoré uložený formát nikdy nedeklaruje. Tlačiareň ho vytlačí ako osirotené pole, ktoré návrh nedokáže zobraziť.', lossRecallSettings: 'Riadky menia nastavenie, ktoré pole číta zo svojich dát, napríklad úroveň opravy chýb. Návrh zachová nastavenie prvého riadku.', + lossDroppedHostCommand: 'Návrh tento hostiteľský príkaz neuchováva, preto ho nanovo vytvorený blok už nepoloží.', browserLimitTitle: 'Preskočené: vyžaduje hardvér tlačiarne', replayRiskTitle: 'Nastavovací príkaz tlačiarne: spustí sa na tlačiarni pri exporte/tlači', deviceActionTitle: 'Akcia zariadenia tlačiarne: spustí sa na tlačiarni pri exporte/tlači', @@ -1187,6 +1190,7 @@ const sk = { mixedGeoDetailFmt: '{cmds} sa medzi blokmi líšia ({detail})', mixedJmTitle: 'Režim hustoty tlače sa medzi blokmi líši: neskoršie stránky si zachovávajú svoj vlastný režim, ale upraviteľný je iba prvý', unknownTitle: 'Preskočené: príkaz nerozpoznaný', + hostQueryTitle: 'Hostiteľský príkaz: pri odoslaní dokumentu si vyžiada odpoveď tlačiarne', unterminatedFieldTitleFmt: 'Pole nie je uzavreté pomocou {fs}: tlačiareň ho zahodí', hexControlTitle: 'Riadiaci znak v údajoch poľa: tlačiareň zahodí zvyšok poľa', reportHeader: 'Správa o importe ZPL', @@ -2011,6 +2015,13 @@ const sk = { usbSetupAccess: 'Nastaviť prístup', usbSetupFlatpak: 'Vo Flatpaku si aplikácia nemôže pravidlo udev nainštalovať sama. Nainštalujte 70-zplab.rules z repozitára ZPLab na hostiteľský systém a potom znova pripojte tlačiareň.', usbNotFound: 'Tlačiareň USB sa nenašla, znova ju zapojte a obnovte.', + queryButton: 'Dopytovať tlačiareň', + queryHint: 'Tento dokument sa tlačiarne na niečo pýta. Odpoveď sa otvorí tu.', + queryNoChannel: 'Touto cestou sa žiadna odpoveď nevracia, preto sa stratí.', + queryNoRender: 'Táto cesta tlačí obrázok, takže odpoveď nemá kam prísť.', + queryMixedHint: 'Ostatné polia sa vytlačia tiež.', + queryAfterPrint: 'Odpoveď ^HV prichádza až po tlači. ZPLab tak dlho nečaká, preto sa dokument odošle bez dopytu.', + replyTitle: 'Odpoveď tlačiarne', }, pdfExport: { title: 'Exportovať PDF', diff --git a/src/locales/sl.ts b/src/locales/sl.ts index a5c056345..281bf9fcc 100644 --- a/src/locales/sl.ts +++ b/src/locales/sl.ts @@ -1022,6 +1022,7 @@ const sl = { failPermissionDenied: 'Ni dostopa do tiskalnika USB. Dodelite ga v pogovornem oknu za pošiljanje, zavihek USB.', failBusy: 'Drugo branje tiskalnika še poteka. Poskusite znova čez trenutek.', failUnparsed: 'Tiskalnik je odgovoril, vendar odgovora ni bilo mogoče prebrati.', + failSilent: 'Tiskalnik ni odgovoril.', failIgnored: 'Tiskalnik je prezrl ukaz. Predmet je še vedno tam.', rawReply: 'Neobdelan odgovor', configuration: 'Konfiguracija', @@ -1063,6 +1064,7 @@ const sl = { keepExistingPages: 'Ohrani obstoječe strani', importSetupTitle: 'Ta ZPL vsebuje ukaze za nastavitev tiskalnika', importSetupBody: 'Izvedejo se na tiskalniku, ko je nalepka natisnjena ali izvožena. Izberite, kako jih obravnavati:', + importSetupDropsFmt: 'Premik ali odstranitev zavrže tudi {commands}, ker jih nosijo le lastni bajti nalepke.', importSetupKeep: 'Ohrani v nalepki', importSetupToSetup: 'Premakni v namestitveni skript', importSetupRemove: 'Odstrani', @@ -1177,6 +1179,7 @@ const sl = { lossRecallFormat: 'Ta blok prikliče shranjeno obliko in ostane stran namesto podatkovne vrstice. Vrstice potrebujejo obliko, shranjeno prej v toku, ki deklarira polja, in bloke, ki nosijo samo podatke polj.', lossRecallSlot: 'Priklic navede številko polja, ki je shranjena oblika nikoli ne deklarira. Tiskalnik ga natisne kot osirotelo polje, ki ga zasnova ne more prikazati.', lossRecallSettings: 'Vrstice spremenijo nastavitev, ki jo polje bere iz svojih podatkov, na primer raven popravljanja napak. Zasnova ohrani nastavitev prve vrstice.', + lossDroppedHostCommand: 'Oblikovanje tega gostiteljskega ukaza ne ohrani, zato ga znova ustvarjen blok ne zastavi več.', browserLimitTitle: 'Preskočeno: zahteva strojno opremo tiskalnika', replayRiskTitle: 'Nastavitveni ukaz tiskalnika: izvede se na tiskalniku ob izvozu/tiskanju', deviceActionTitle: 'Dejanje naprave tiskalnika: izvede se na tiskalniku ob izvozu/tiskanju', @@ -1187,6 +1190,7 @@ const sl = { mixedGeoDetailFmt: '{cmds} se razlikujejo med bloki ({detail})', mixedJmTitle: 'Način gostote tiskanja se med bloki razlikuje: poznejše strani ohranijo svoj lastni način, vendar je urejljiv samo prvi', unknownTitle: 'Preskočeno: ukaz ni prepoznan', + hostQueryTitle: 'Gostiteljski ukaz: ob pošiljanju dokumenta zahteva odgovor tiskalnika', unterminatedFieldTitleFmt: 'Polje ni zaprto z {fs}: tiskalnik ga zavrže', hexControlTitle: 'Krmilni znak v podatkih polja: tiskalnik zavrže preostanek polja', reportHeader: 'Poročilo o uvozu ZPL', @@ -2011,6 +2015,13 @@ const sl = { usbSetupAccess: 'Nastavi dostop', usbSetupFlatpak: 'Znotraj Flatpaka aplikacija ne more sama namestiti pravila udev. Namestite 70-zplab.rules iz repozitorija ZPLab na gostitelja in nato tiskalnik znova priključite.', usbNotFound: 'Tiskalnika USB ni mogoče najti, znova ga priključite in osvežite.', + queryButton: 'Povprašaj tiskalnik', + queryHint: 'Ta dokument tiskalnik nekaj vpraša. Odgovor se odpre tukaj.', + queryNoChannel: 'Po tej poti odgovor ne pride nazaj, zato se izgubi.', + queryNoRender: 'Ta pot natisne sliko, zato odgovor nima kam priti.', + queryMixedHint: 'Ostala polja se prav tako natisnejo.', + queryAfterPrint: 'Odgovor na ^HV prispe po tiskanju. ZPLab ne čaka tako dolgo, zato se dokument pošlje brez poizvedbe.', + replyTitle: 'Odgovor tiskalnika', }, pdfExport: { title: 'Izvozi PDF', diff --git a/src/locales/sr.ts b/src/locales/sr.ts index 3fb052e4a..de93f8d9b 100644 --- a/src/locales/sr.ts +++ b/src/locales/sr.ts @@ -1022,6 +1022,7 @@ const sr = { failPermissionDenied: 'Нема приступа USB штампачу. Омогућите га у дијалогу за слање, картица USB.', failBusy: 'Још једно читање штампача је у току. Покушајте поново за тренутак.', failUnparsed: 'Штампач је одговорио, али одговор није могао да се прочита.', + failSilent: 'Штампач није одговорио.', failIgnored: 'Штампач је игнорисао команду. Објекат је још тамо.', rawReply: 'Sirovi odgovor', configuration: 'Konfiguracija', @@ -1063,6 +1064,7 @@ const sr = { keepExistingPages: 'Задржи постојеће странице', importSetupTitle: 'Овај ZPL садржи команде за подешавање штампача', importSetupBody: 'Извршавају се на штампачу приликом штампања или извоза налепнице. Изаберите како да их обрадите:', + importSetupDropsFmt: 'Премештање или уклањање одбацује и {commands}, јер их носе само бајтови саме налепнице.', importSetupKeep: 'Задржи у налепници', importSetupToSetup: 'Премести у скрипту за подешавање', importSetupRemove: 'Уклони', @@ -1177,6 +1179,7 @@ const sr = { lossRecallFormat: 'Овај блок позива сачувани формат и остаје страница уместо реда података. Редовима је потребан формат сачуван раније у току који декларише поља, као и блокови који носе само податке поља.', lossRecallSlot: 'Позивање даје број поља који сачувани формат никада не декларише. Штампач га штампа као напуштено поље које дизајн не може да прикаже.', lossRecallSettings: 'Редови мењају поставку коју поље чита из својих података, на пример ниво исправке грешака. Дизајн задржава поставку првог реда.', + lossDroppedHostCommand: 'Дизајн не задржава ову хост команду, па је поново створени блок више не поставља.', browserLimitTitle: 'Прескочено: захтева хардвер штампача', replayRiskTitle: 'Команда за подешавање штампача: извршава се на штампачу при извозу/штампању', deviceActionTitle: 'Радња уређаја штампача: извршава се на штампачу при извозу/штампању', @@ -1187,6 +1190,7 @@ const sr = { mixedGeoDetailFmt: '{cmds} се разликују између блокова ({detail})', mixedJmTitle: 'Режим густине штампе се разликује између блокова: касније странице задржавају сопствени режим, али само први је могуће уредити', unknownTitle: 'Прескочено: команда није препозната', + hostQueryTitle: 'Хост команда: тражи одговор штампача при слању документа', unterminatedFieldTitleFmt: 'Поље није затворено са {fs}: штампач га одбацује', hexControlTitle: 'Контролни знак у подацима поља: штампач одбацује остатак поља', reportHeader: 'Извештај о увозу ZPL-а', @@ -2011,6 +2015,13 @@ const sr = { usbSetupAccess: 'Подеси приступ', usbSetupFlatpak: 'Унутар Flatpak-а апликација не може сама да инсталира udev правило. Инсталирајте 70-zplab.rules из ZPLab репозиторијума на хост систем, затим поново прикључите штампач.', usbNotFound: 'USB штампач није пронађен, поново га прикључите и освежите.', + queryButton: 'Испитај штампач', + queryHint: 'Овај документ нешто пита штампач. Одговор се отвара овде.', + queryNoChannel: 'Овим путем одговор не долази, зато се губи.', + queryNoRender: 'Овим путем се штампа слика, па одговор нема где да стигне.', + queryMixedHint: 'Остала поља се такође штампају.', + queryAfterPrint: 'Одговор на ^HV стиже након штампе. ZPLab не чека толико дуго, па се документ шаље без упита.', + replyTitle: 'Одговор штампача', }, pdfExport: { title: 'Извоз PDF-а', diff --git a/src/locales/sv.ts b/src/locales/sv.ts index 83a974d85..13b111121 100644 --- a/src/locales/sv.ts +++ b/src/locales/sv.ts @@ -1022,6 +1022,7 @@ const sv = { failPermissionDenied: 'Ingen åtkomst till USB-skrivaren. Ge den i Skicka-dialogen, fliken USB.', failBusy: 'En annan skrivaravläsning pågår fortfarande. Försök igen om en stund.', failUnparsed: 'Skrivaren svarade, men svaret kunde inte läsas.', + failSilent: 'Skrivaren svarade inte.', failIgnored: 'Skrivaren ignorerade kommandot. Objektet finns fortfarande kvar.', rawReply: 'Rått svar', configuration: 'Konfiguration', @@ -1063,6 +1064,7 @@ const sv = { keepExistingPages: 'Behåll befintliga sidor', importSetupTitle: 'Denna ZPL innehåller kommandon för skrivarinställning', importSetupBody: 'De körs på skrivaren när etiketten skrivs ut eller exporteras. Välj hur de ska hanteras:', + importSetupDropsFmt: 'Flytta eller Ta bort släpper också {commands}, som bara etikettens egna byte bär.', importSetupKeep: 'Behåll i etikett', importSetupToSetup: 'Flytta till installationsskript', importSetupRemove: 'Ta bort', @@ -1177,6 +1179,7 @@ const sv = { lossRecallFormat: 'Det här blocket återkallar ett sparat format och förblir en sida i stället för en datarad. Rader kräver ett format som sparats tidigare i strömmen och som deklarerar fält, samt block som endast innehåller fältdata.', lossRecallSlot: 'Återkallelsen anger ett fältnummer som det lagrade formatet aldrig deklarerar. Skrivaren skriver ut det som ett herrelöst fält som designen inte kan visa.', lossRecallSettings: 'Raderna byter en inställning som fältet läser från sina data, till exempel felrättningsnivån. Designen behåller inställningen från den första raden.', + lossDroppedHostCommand: 'Designen behåller inte detta värdkommando, så ett omgenererat block ställer det inte längre.', browserLimitTitle: 'Hoppades över: kräver skrivarhårdvara', replayRiskTitle: 'Skrivarinställningskommando: körs på skrivaren vid export/utskrift', deviceActionTitle: 'Skrivarens enhetsåtgärd: körs på skrivaren vid export/utskrift', @@ -1187,6 +1190,7 @@ const sv = { mixedGeoDetailFmt: '{cmds} skiljer sig mellan block ({detail})', mixedJmTitle: 'Utskriftstäthetsläget skiljer sig mellan block: senare sidor behåller sitt eget läge, men endast det första är redigerbart', unknownTitle: 'Hoppades över: kommandot kändes inte igen', + hostQueryTitle: 'Värdkommando: ber skrivaren om ett svar när dokumentet skickas', unterminatedFieldTitleFmt: 'Fältet är inte stängt med {fs}: skrivaren kasserar det', hexControlTitle: 'Kontrolltecken i fältets data: skrivaren kasserar resten av fältet', reportHeader: 'ZPL-importrapport', @@ -2011,6 +2015,13 @@ const sv = { usbSetupAccess: 'Konfigurera åtkomst', usbSetupFlatpak: 'Inuti Flatpak kan appen inte installera udev-regeln själv. Installera 70-zplab.rules från ZPLab-förrådet på värden och anslut sedan skrivaren igen.', usbNotFound: 'USB-skrivaren hittades inte. Anslut den igen och uppdatera.', + queryButton: 'Fråga skrivaren', + queryHint: 'Det här dokumentet frågar skrivaren något. Svaret öppnas här.', + queryNoChannel: 'Den vägen bär inget svar, så svaret går förlorat.', + queryNoRender: 'Den här vägen skriver ut en bild, så svaret har ingenstans att ta vägen.', + queryMixedHint: 'De övriga fälten skrivs ut också.', + queryAfterPrint: '^HV-svaret kommer efter utskriften. ZPLab väntar inte så länge, så dokumentet skickas utan förfrågan.', + replyTitle: 'Skrivarsvar', }, pdfExport: { title: 'Exportera PDF', diff --git a/src/locales/tr.ts b/src/locales/tr.ts index b25637f59..17446b90c 100644 --- a/src/locales/tr.ts +++ b/src/locales/tr.ts @@ -1022,6 +1022,7 @@ const tr = { failPermissionDenied: 'USB yazıcıya erişilemiyor. Gönderme iletişim kutusunda, USB sekmesinde izin verin.', failBusy: 'Yazıcıdan başka bir okuma hâlâ sürüyor. Birazdan tekrar deneyin.', failUnparsed: 'Yazıcı yanıt verdi, ancak yanıt okunamadı.', + failSilent: 'Yazıcı yanıt vermedi.', failIgnored: 'Yazıcı komutu görmezden geldi. Nesne hâlâ duruyor.', rawReply: 'Ham yanıt', configuration: 'Yapılandırma', @@ -1063,6 +1064,7 @@ const tr = { keepExistingPages: 'Mevcut sayfaları koru', importSetupTitle: 'Bu ZPL, yazıcı kurulum komutları içeriyor', importSetupBody: 'Etiket yazdırıldığında veya dışa aktarıldığında yazıcıda çalışırlar. Nasıl ele alınacağını seçin:', + importSetupDropsFmt: 'Taşı veya Kaldır, {commands} öğesini de bırakır, çünkü bunları yalnızca etiketin kendi baytları taşır.', importSetupKeep: 'Etikette tut', importSetupToSetup: 'Kurulum betiğine taşı', importSetupRemove: 'Kaldır', @@ -1177,6 +1179,7 @@ const tr = { lossRecallFormat: 'Bu blok kayıtlı bir biçimi çağırır ve veri satırı yerine sayfa olarak kalır. Satırların akışta daha önce kaydedilmiş ve alanları tanımlayan bir biçime, ayrıca yalnızca alan verisi taşıyan bloklara ihtiyacı vardır.', lossRecallSlot: 'Geri çağırma, saklanan formatın hiç bildirmediği bir alan numarası sağlar. Yazıcı bunu tasarımın gösteremediği başıboş bir alan olarak yazdırır.', lossRecallSettings: 'Satırlar, alanın kendi verilerinden okuduğu bir ayarı değiştirir, örneğin hata düzeltme seviyesini. Tasarım ilk satırın ayarını korur.', + lossDroppedHostCommand: 'Tasarım bu ana bilgisayar komutunu tutmaz, bu yüzden yeniden oluşturulan bir blok artık onu sormaz.', browserLimitTitle: 'Atlandı: yazıcı donanımı gerekir', replayRiskTitle: 'Yazıcı kurulum komutu: dışa aktarıldığında/yazdırıldığında yazıcıda çalışır', deviceActionTitle: 'Yazıcı cihaz eylemi: dışa aktarıldığında/yazdırıldığında yazıcıda çalışır', @@ -1187,6 +1190,7 @@ const tr = { mixedGeoDetailFmt: 'Bloklar arasında {cmds} farklılık gösteriyor ({detail})', mixedJmTitle: 'Baskı yoğunluğu modu bloklar arasında farklılık gösteriyor: sonraki sayfalar kendi modlarını korur, ancak yalnızca ilki düzenlenebilir', unknownTitle: 'Atlandı: komut tanınmadı', + hostQueryTitle: 'Ana bilgisayar komutu: belge gönderildiğinde yazıcıdan yanıt ister', unterminatedFieldTitleFmt: 'Alan {fs} ile kapatılmadı: yazıcı onu yok sayar', hexControlTitle: 'Alan verisinde kontrol karakteri: yazıcı alanın geri kalanını yok sayar', reportHeader: 'ZPL içe aktarma raporu', @@ -2011,6 +2015,13 @@ const tr = { usbSetupAccess: 'Erişimi ayarla', usbSetupFlatpak: 'Flatpak içinde uygulama udev kuralını kendi başına yükleyemez. ZPLab deposundan 70-zplab.rules dosyasını ana makineye yükleyin, ardından yazıcıyı yeniden takın.', usbNotFound: 'USB yazıcı bulunamadı, yeniden takıp yenileyin.', + queryButton: 'Yazıcıyı sorgula', + queryHint: 'Bu belge yazıcıya bir şey sorar. Yanıt burada açılır.', + queryNoChannel: 'Bu yoldan yanıt gelmez, bu yüzden yanıt kaybolur.', + queryNoRender: 'Bu yol bir görüntü yazdırır, bu yüzden yanıtın gidecek yeri yoktur.', + queryMixedHint: 'Diğer alanlar da yazdırılır.', + queryAfterPrint: '^HV yanıtı baskıdan sonra gelir. ZPLab o kadar beklemez, bu yüzden belge sorgu olmadan gönderilir.', + replyTitle: 'Yazıcı yanıtı', }, pdfExport: { title: 'PDF Dışa Aktar', diff --git a/src/locales/zh-hans.ts b/src/locales/zh-hans.ts index 5568bd6bc..bb0d3f1c1 100644 --- a/src/locales/zh-hans.ts +++ b/src/locales/zh-hans.ts @@ -1022,6 +1022,7 @@ const zhHans = { failPermissionDenied: '无法访问 USB 打印机。请在发送对话框的 USB 标签页中授予权限。', failBusy: '另一个打印机读取仍在进行。请稍后重试。', failUnparsed: '打印机已响应,但无法读取回复内容。', + failSilent: '打印机没有应答。', failIgnored: '打印机忽略了该命令。对象仍然存在。', rawReply: '原始回复', configuration: '配置', @@ -1063,6 +1064,7 @@ const zhHans = { keepExistingPages: '保留现有页面', importSetupTitle: '此 ZPL 包含打印机设置命令', importSetupBody: '标签打印或导出时会在打印机上运行。请选择处理方式:', + importSetupDropsFmt: '移动或移除也会丢弃 {commands},因为只有标签自身的字节携带它们。', importSetupKeep: '保留在标签中', importSetupToSetup: '移动到设置脚本', importSetupRemove: '移除', @@ -1177,6 +1179,7 @@ const zhHans = { lossRecallFormat: '此区块调用了已存储格式,并保持为页面而非数据行。数据行需要流中更早存储且声明字段的格式,以及只携带字段数据的区块。', lossRecallSlot: '调用提供了一个存储格式从未声明的字段编号。打印机将其作为孤立字段打印,设计无法显示它。', lossRecallSettings: '这些行会切换字段从其数据中读取的设置,例如纠错级别。设计保留第一行的设置。', + lossDroppedHostCommand: '设计不会保留此主机命令,因此重新生成的块不再发出它。', browserLimitTitle: '已跳过:需要打印机硬件', replayRiskTitle: '打印机设置命令:导出/打印时会在打印机上执行', deviceActionTitle: '打印机设备操作:导出/打印时会在打印机上执行', @@ -1187,6 +1190,7 @@ const zhHans = { mixedGeoDetailFmt: '{cmds} 在各区块之间不同({detail})', mixedJmTitle: '各区块之间的打印浓度模式不同:后续页面保留各自的模式,但只有第一个区块可编辑', unknownTitle: '已跳过:命令无法识别', + hostQueryTitle: '主机命令:发送文档时向打印机请求应答', unterminatedFieldTitleFmt: '字段未以 {fs} 闭合:打印机会将其丢弃', hexControlTitle: '字段数据中含控制字符:打印机会丢弃该字段的其余内容', reportHeader: 'ZPL 导入报告', @@ -2011,6 +2015,13 @@ const zhHans = { usbSetupAccess: '设置访问权限', usbSetupFlatpak: '在 Flatpak 中,应用无法自行安装 udev 规则。请在主机上从 ZPLab 仓库安装 70-zplab.rules,然后重新插拔打印机。', usbNotFound: '未找到 USB 打印机,请重新插入并刷新。', + queryButton: '查询打印机', + queryHint: '本文档向打印机提问。应答在此处打开。', + queryNoChannel: '这条路不带回应答,因此应答会丢失。', + queryNoRender: '此方式打印的是图像,应答无处可去。', + queryMixedHint: '其余字段也会打印。', + queryAfterPrint: '^HV 的回复在打印之后才到达。ZPLab 不会等那么久,因此文档将不带查询发送。', + replyTitle: '打印机应答', }, pdfExport: { title: '导出 PDF', diff --git a/src/locales/zh-hant.ts b/src/locales/zh-hant.ts index c11ec4132..b635362ce 100644 --- a/src/locales/zh-hant.ts +++ b/src/locales/zh-hant.ts @@ -1022,6 +1022,7 @@ const zhHant = { failPermissionDenied: '無法存取 USB 印表機。請在傳送對話方塊的 USB 分頁中授予權限。', failBusy: '另一個印表機讀取仍在進行。請稍後再試。', failUnparsed: '印表機已回應,但無法讀取回覆內容。', + failSilent: '印表機沒有回應。', failIgnored: '印表機忽略了該命令。物件仍然存在。', rawReply: '原始回應', configuration: '設定', @@ -1063,6 +1064,7 @@ const zhHant = { keepExistingPages: '保留現有頁面', importSetupTitle: '此 ZPL 包含印表機設定指令', importSetupBody: '標籤列印或匯出時會在印表機上執行。請選擇處理方式:', + importSetupDropsFmt: '移動或移除也會捨棄 {commands},因為只有標籤本身的位元組承載它們。', importSetupKeep: '保留在標籤中', importSetupToSetup: '移動到設定指令碼', importSetupRemove: '移除', @@ -1177,6 +1179,7 @@ const zhHant = { lossRecallFormat: '此區塊呼叫了已儲存格式,並維持為頁面而非資料列。資料列需要資料流中較早儲存且宣告欄位的格式,以及只帶有欄位資料的區塊。', lossRecallSlot: '呼叫提供了一個儲存格式從未宣告的欄位編號。印表機會將其列印為孤立欄位,設計無法顯示它。', lossRecallSettings: '這些列會切換欄位從其資料中讀取的設定,例如錯誤更正等級。設計保留第一列的設定。', + lossDroppedHostCommand: '設計不會保留此主機命令,因此重新產生的區塊不再發出它。', browserLimitTitle: '已略過:需要印表機硬體', replayRiskTitle: '印表機設定指令:匯出/列印時會在印表機上執行', deviceActionTitle: '印表機裝置動作:匯出/列印時會在印表機上執行', @@ -1187,6 +1190,7 @@ const zhHant = { mixedGeoDetailFmt: '{cmds} 在各區塊之間不同({detail})', mixedJmTitle: '各區塊之間的列印濃度模式不同:後續頁面保留各自的模式,但只有第一個區塊可編輯', unknownTitle: '已略過:無法辨識指令', + hostQueryTitle: '主機命令:傳送文件時向印表機請求回應', unterminatedFieldTitleFmt: '欄位未以 {fs} 結束:印表機會捨棄它', hexControlTitle: '欄位資料中含控制字元:印表機會捨棄該欄位的其餘內容', reportHeader: 'ZPL 匯入報告', @@ -2011,6 +2015,13 @@ const zhHant = { usbSetupAccess: '設定存取權限', usbSetupFlatpak: '在 Flatpak 中,應用程式無法自行安裝 udev 規則。請在主機上從 ZPLab 儲存庫安裝 70-zplab.rules,然後重新插拔印表機。', usbNotFound: '找不到 USB 印表機,請重新插入並重新整理。', + queryButton: '查詢印表機', + queryHint: '本文件向印表機提問。回應在此處開啟。', + queryNoChannel: '這條路不會帶回回應,因此回應會遺失。', + queryNoRender: '此方式列印的是影像,回應無處可去。', + queryMixedHint: '其餘欄位也會列印。', + queryAfterPrint: '^HV 的回覆在列印之後才到達。ZPLab 不會等那麼久,因此文件將不帶查詢傳送。', + replyTitle: '印表機回應', }, pdfExport: { title: '匯出 PDF', diff --git a/src/store/documentEmits.test.ts b/src/store/documentEmits.test.ts new file mode 100644 index 000000000..6d1048886 --- /dev/null +++ b/src/store/documentEmits.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { useLabelStore } from "./labelStore"; +import { selectDocumentEmits } from "./labelStore.selectors"; + +beforeEach(() => { + useLabelStore.setState({ label: { widthMm: 50, heightMm: 30, dpmm: 8 }, pages: [{ objects: [] }], currentPageIndex: 0, variables: [] }); +}); + +describe("selectDocumentEmits", () => { + it("counts a page that only asks the printer", () => { + expect(selectDocumentEmits(useLabelStore.getState())).toBe(false); + useLabelStore.setState({ pages: [{ objects: [], hostCommands: ["~HS"] }] }); + expect(selectDocumentEmits(useLabelStore.getState())).toBe(true); + }); +}); diff --git a/src/store/labelStore.selectors.ts b/src/store/labelStore.selectors.ts index bd807a150..ba73b2572 100644 --- a/src/store/labelStore.selectors.ts +++ b/src/store/labelStore.selectors.ts @@ -1,5 +1,5 @@ import type { ImportFinding } from '@zplab/core/lib/importReport'; -import { pageLabelConfig, type LabelObject, type Page } from '@zplab/core/types/Group'; +import { type LabelObject, type Page, pageEmits, pageLabelConfig, pageHostCommands } from '@zplab/core/types/Group'; import { boundColumnIndex } from '@zplab/core/lib/variableBinding'; import { isDefaultHost, resolveHost, resolveApiKey } from '../lib/labelary'; import { isDesktopShell } from '../lib/platform'; @@ -24,22 +24,23 @@ export const currentObjects = (state: PageState): LabelObject[] => // label, density) so subscribers only re-render when one of them changes. const overrideCache = new WeakMap>(); -type PageOverrides = Pick; +type PageOverrides = Pick; const resolvedPageLabel = (label: LabelConfig, page: PageOverrides | undefined): PageLabel => { const jm = page?.jmDensity; const stored = page?.storedFormatPath; + const questions = page === undefined ? undefined : pageHostCommands(page); // No divergence means the design label already IS this page's resolved label. - if ((jm === undefined || jm === label.jmDensity) && stored === undefined) return designAsPageLabel(label); + if ((jm === undefined || jm === label.jmDensity) && stored === undefined && questions === undefined) return designAsPageLabel(label); let byOverride = overrideCache.get(label); if (!byOverride) { byOverride = new Map(); overrideCache.set(label, byOverride); } - const key = JSON.stringify([jm ?? null, stored ?? null, page?.storedFormatDelivery ?? null]); + const key = JSON.stringify([jm ?? null, stored ?? null, page?.storedFormatDelivery ?? null, questions ?? null]); const cached = byOverride.get(key); if (cached) return cached; - const built = pageLabelConfig(label, { jmDensity: jm, storedFormatPath: stored, storedFormatDelivery: page?.storedFormatDelivery }); + const built = pageLabelConfig(label, { jmDensity: jm, storedFormatPath: stored, storedFormatDelivery: page?.storedFormatDelivery, hostCommands: questions }); byOverride.set(key, built); return built; }; @@ -156,11 +157,9 @@ export const selectEffectivePreviewProvider = (s: LabelState): PreviewProvider = export const selectPreviewLocksEditor = (s: LabelState): boolean => s.previewMode.status === 'loading' || s.previewMode.status === 'active'; -/** True when the document exports non-trivial ZPL: an overlay page replays - * its bytes even with zero objects. Gates panel and export/save entries; - * canvas affordances keep their own objects-only question. */ -export const selectDocumentEmits = (s: LabelState): boolean => - s.pages.some((p) => p.objects.length > 0 || p.overlay !== undefined); +/** True when the document exports non-trivial ZPL. Gates panel and export/save entries, while canvas + * affordances keep their own objects-only question. */ +export const selectDocumentEmits = (s: LabelState): boolean => s.pages.some(pageEmits); /** True while a source-edit session owns the model. Consumers that block * ONLY for source edit (the preview conflict resolves by exiting the preview diff --git a/src/store/labelStore.test.ts b/src/store/labelStore.test.ts index 09a26815a..479607592 100644 --- a/src/store/labelStore.test.ts +++ b/src/store/labelStore.test.ts @@ -1111,6 +1111,12 @@ describe('duplicatePage', () => { state().duplicatePage(0); expect(defined(state().pages[1]).jmDensity).toBe('B'); }); + + it('keeps the questions, so the clone asks what its source asked', () => { + useLabelStore.setState({ pages: [{ objects: [], hostCommands: ['~HS'] }] }); + state().duplicatePage(0); + expect(defined(state().pages[1]).hostCommands).toEqual(['~HS']); + }); }); describe('currentPageLabel', () => { diff --git a/src/store/pageKeys.tripwire.test.ts b/src/store/pageKeys.tripwire.test.ts new file mode 100644 index 000000000..90a20e7e9 --- /dev/null +++ b/src/store/pageKeys.tripwire.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, expectTypeOf } from "vitest"; +import { DESIGN_FILE_PAGE_KEYS } from "@zplab/core/lib/designFile"; +import { pageLabelConfig, type Page } from "@zplab/core/types/Group"; +import { buildBlockOverlay } from "@zplab/core/lib/zplOverlay/overlay"; +import { useLabelStore } from "./labelStore"; + +/** A page with every key set, so a new key fails the comparison below until each consumer takes a decision on it. */ +const full: Page = { + objects: [], + jmDensity: "B", + storedFormatPath: "R:A.ZPL", + storedFormatDelivery: "setup", + hostCommands: ["~HS"], + overlay: buildBlockOverlay("^XA^XZ", [], { regenSafe: true }), +}; + +describe("Page keys", () => { + it("are all persisted by the design file", () => { + expectTypeOf<(typeof DESIGN_FILE_PAGE_KEYS)[number]>().toEqualTypeOf(); + expect([...DESIGN_FILE_PAGE_KEYS].sort()).toEqual(Object.keys(full).sort()); + }); + + it("fold into the page label or are deliberately left out of it", () => { + const folded = pageLabelConfig({ widthMm: 50, heightMm: 30, dpmm: 8 }, full); + expect(folded.jmDensity).toBe("B"); + expect(folded.storedFormatPath).toBe("R:A.ZPL"); + expect(folded.storedFormatDelivery).toBe("setup"); + expect(folded.hostCommands).toEqual(["~HS"]); + expect(folded).not.toHaveProperty("overlay"); + }); + + it("travel with a duplicated page, except the provenance and the stored format", () => { + useLabelStore.setState({ label: { widthMm: 50, heightMm: 30, dpmm: 8 }, pages: [full], currentPageIndex: 0, variables: [] }); + useLabelStore.getState().duplicatePage(0); + const copy = useLabelStore.getState().pages[1] as Page; + expect(copy.jmDensity).toBe("B"); + expect(copy.hostCommands).toEqual(["~HS"]); + expect(copy.storedFormatPath).toBeUndefined(); + expect(copy.storedFormatDelivery).toBeUndefined(); + expect(copy.overlay).toBeUndefined(); + }); +}); diff --git a/src/store/slices/objectSlice.ts b/src/store/slices/objectSlice.ts index dbfec9ed3..d1f23756b 100644 --- a/src/store/slices/objectSlice.ts +++ b/src/store/slices/objectSlice.ts @@ -1,16 +1,17 @@ import type { StateCreator } from 'zustand'; import type { ObjectChanges } from '@zplab/core/types/LabelObject'; import { - isGroup, + type GroupObject, + type LabelObject, + type Page, + findObjectById, getAllLeaves, + hasLockedAncestor, + isGroup, isSelectionLocked, mapObjectById, - findObjectById, - hasLockedAncestor, + pageHostCommands, reparentNodes, - type GroupObject, - type LabelObject, - type Page, } from '@zplab/core/types/Group'; import { getEntry } from '@zplab/core/registry'; import { anchorRepin } from '../anchorRepin'; @@ -556,6 +557,8 @@ export const createObjectSlice: StateCreator = // The ^JM override travels along: the cloned dots are in its density. const cloned: Page = { objects: cloneChildrenFresh(source.objects) }; if (source.jmDensity !== undefined) cloned.jmDensity = source.jmDensity; + const questions = pageHostCommands(source); + if (questions !== undefined) cloned.hostCommands = questions; // The ^DF does not: two blocks under one name would overwrite each other on the printer. const insertPos = index + 1; const newPages = [ diff --git a/src/store/slices/previewSlice.ts b/src/store/slices/previewSlice.ts index 9b0f5ad58..faf402e6c 100644 --- a/src/store/slices/previewSlice.ts +++ b/src/store/slices/previewSlice.ts @@ -166,6 +166,7 @@ export const createPreviewSlice: StateCreator case 'unconfigured': case 'busy': case 'unparsed': + case 'silent': case 'ignored': case 'refused': case 'unreachable': diff --git a/src/store/slices/printerStateSlice.test.ts b/src/store/slices/printerStateSlice.test.ts index fa68fa8c7..61d78e5c3 100644 --- a/src/store/slices/printerStateSlice.test.ts +++ b/src/store/slices/printerStateSlice.test.ts @@ -9,6 +9,7 @@ import { ZD230_ALLCV_EXCERPT } from "@zplab/core/lib/sgd.fixture"; const readPrinterStatus = vi.fn(); const readPrinterConfiguration = vi.fn(); const readPrinterSettings = vi.fn(); +const queryDocument = vi.fn(); vi.mock("../../lib/printerStatus", () => ({ readPrinterStatus: (...args: unknown[]) => readPrinterStatus(...args), readPrinterConfiguration: (...args: unknown[]) => readPrinterConfiguration(...args), @@ -16,11 +17,13 @@ vi.mock("../../lib/printerStatus", () => ({ vi.mock("../../lib/printerSettings", () => ({ readPrinterSettings: (...args: unknown[]) => readPrinterSettings(...args), })); +vi.mock("../../lib/printerDocumentQuery", () => ({ queryDocument: (...args: unknown[]) => queryDocument(...args) })); -const report = { identity: undefined, flags: { errors: [], warnings: [] }, memory: undefined, status: undefined, withheld: false, raw: "" }; +const report = { flags: { errors: [], warnings: [] }, withheld: false, raw: "" }; beforeEach(() => { readPrinterStatus.mockReset(); + queryDocument.mockReset(); useLabelStore.setState({ printerState: { phase: "idle" }, printTarget: { ...DEFAULT_PRINT_TARGET, host: "172.17.17.175", usbId: "usb-1" } }); }); @@ -132,3 +135,24 @@ describe("adoptPrinterSettings", () => { useLabelStore.setState({ sourceEdit: { status: "off" } }); }); }); + +describe("queryDocument", () => { + it("sends the document over the named way, names the asked commands as the step and holds the channel", async () => { + let finish: (value: unknown) => void = () => undefined; + queryDocument.mockImplementation(() => new Promise((resolve) => (finish = resolve))); + const document = "^XA~HS^HWR:*.*^FO10,10^FDx^FS^XZ"; + const pending = useLabelStore.getState().queryDocument(document, ["~HS", "^HW"], "usb"); + expect(useLabelStore.getState().printerReading).toBe("~HS ^HW"); + expect(await useLabelStore.getState().checkPrinter()).toEqual({ kind: "busy" }); + finish({ kind: "ok", value: { raw: "ok", commands: ["~HS", "^HW"], configuration: false, parsed: {} } }); + expect(await pending).toMatchObject({ kind: "ok" }); + expect(queryDocument).toHaveBeenCalledWith({ kind: "usb", id: "usb-1" }, document, ["~HS", "^HW"]); + expect(useLabelStore.getState().printerReading).toBeUndefined(); + }); + + it("names a missing target without asking", async () => { + useLabelStore.setState({ printTarget: DEFAULT_PRINT_TARGET }); + expect(await useLabelStore.getState().queryDocument("^XA~HS^XZ", ["~HS"])).toEqual({ kind: "unconfigured" }); + expect(queryDocument).not.toHaveBeenCalled(); + }); +}); diff --git a/src/store/slices/printerStateSlice.ts b/src/store/slices/printerStateSlice.ts index 678c6bc6a..aae996b70 100644 --- a/src/store/slices/printerStateSlice.ts +++ b/src/store/slices/printerStateSlice.ts @@ -1,6 +1,7 @@ import type { StateCreator } from 'zustand'; import { adoptionPatches, type AdoptionRow } from '@zplab/core/lib/printerSettingsAdoption'; import { SGD_ALL_SETTINGS, sgdGetvarCommand, type SgdSetting } from '@zplab/core/lib/sgd'; +import { queryDocument as sendQueryDocument, type DocumentReply } from '../../lib/printerDocumentQuery'; import type { PrinterOutcome, PrinterQueryFailure } from '../../lib/printerQuery'; import { readPrinterSettings as querySettings } from '../../lib/printerSettings'; import { readPrinterConfiguration as queryConfiguration, readPrinterStatus, type PrinterStatusReport } from '../../lib/printerStatus'; @@ -28,6 +29,8 @@ export interface PrinterStateSlice { readPrinterSettings: () => Promise>; /** Writes the chosen rows into the label settings and the printer profile. False when the store refused them. */ adoptPrinterSettings: (rows: readonly AdoptionRow[]) => boolean; + /** The document itself over the read channel, for the host commands it carries. */ + queryDocument: (zpl: string, commands: readonly string[], transport?: PrintTransport) => Promise>; } export const createPrinterStateSlice: StateCreator = (set, get) => ({ @@ -76,4 +79,11 @@ export const createPrinterStateSlice: StateCreator 0) set(next); return true; }, + + queryDocument: (zpl, commands, transport) => { + const resolved = resolveQueryTarget(get().printTarget, transport); + return overPrinterChannel(get, set, commands.join(' '), () => + 'failure' in resolved ? Promise.resolve(resolved.failure) : sendQueryDocument(resolved.target, zpl, commands), + ); + }, });