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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -188,7 +189,7 @@ On desktop, **File → Settings… → App → MCP** starts a local MCP server,
## Coverage

<!-- coverage:start (generated from the command catalog by scripts/gen-coverage.mjs; run `pnpm coverage:gen`) -->
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 |
|---|---|
Expand Down
18 changes: 9 additions & 9 deletions docs/zpl-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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

Expand Down
16 changes: 15 additions & 1 deletion packages/core/src/catalog/catalog.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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();
Expand Down
18 changes: 9 additions & 9 deletions packages/core/src/catalog/commands.json
Original file line number Diff line number Diff line change
Expand Up @@ -6145,7 +6145,8 @@
},
"support": {
"web": "no",
"desktop": "no"
"desktop": "planned",
"docker": "planned"
}
},
{
Expand All @@ -6163,7 +6164,7 @@
},
"support": {
"web": "no",
"desktop": "planned",
"desktop": "yes",
"docker": "planned"
}
},
Expand Down Expand Up @@ -6201,7 +6202,7 @@
},
"support": {
"web": "no",
"desktop": "planned",
"desktop": "yes",
"docker": "planned"
}
},
Expand Down Expand Up @@ -6321,7 +6322,7 @@
},
"support": {
"web": "no",
"desktop": "planned",
"desktop": "yes",
"docker": "planned"
}
},
Expand Down Expand Up @@ -6359,10 +6360,9 @@
},
"support": {
"web": "no",
"desktop": "planned",
"desktop": "yes",
"docker": "planned"
},
"loss": "printerComms"
}
},
{
"cmd": "HU",
Expand All @@ -6379,7 +6379,7 @@
},
"support": {
"web": "no",
"desktop": "planned",
"desktop": "yes",
"docker": "planned"
}
},
Expand Down Expand Up @@ -6535,7 +6535,7 @@
},
"support": {
"web": "no",
"desktop": "planned",
"desktop": "yes",
"docker": "planned"
}
},
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/catalog/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/catalog/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/lib/designFile.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand All @@ -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[];
Expand Down Expand Up @@ -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.
Expand Down
77 changes: 77 additions & 0 deletions packages/core/src/lib/hostCommands.test.ts
Original file line number Diff line number Diff line change
@@ -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:*.*"]);
});
});
Loading
Loading