diff --git a/src/lib/storagePath.test.ts b/src/lib/storagePath.test.ts new file mode 100644 index 00000000..b175b041 --- /dev/null +++ b/src/lib/storagePath.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest"; +import { parseStoragePath, formatStoragePath } from "./storagePath"; + +describe("storagePath", () => { + it("parses device:name without extension", () => { + expect(parseStoragePath("R:LOGO")).toEqual({ device: "R", name: "LOGO" }); + }); + + it("parses device:name.ext and drops the extension", () => { + expect(parseStoragePath("R:LOGO.GRF")).toEqual({ device: "R", name: "LOGO" }); + }); + + it("returns null for paths without a colon", () => { + expect(parseStoragePath("LOGO.GRF")).toBeNull(); + }); + + it("returns null for paths with empty stem", () => { + expect(parseStoragePath("R:")).toBeNull(); + expect(parseStoragePath("R:.GRF")).toBeNull(); + }); + + it("formats with and without the .GRF extension", () => { + const path = { device: "E", name: "LABEL" }; + expect(formatStoragePath(path, true)).toBe("E:LABEL.GRF"); + expect(formatStoragePath(path, false)).toBe("E:LABEL"); + }); + + it("round-trips parse → format(true) for a path with extension", () => { + const original = "R:LOGO.GRF"; + const parsed = parseStoragePath(original); + expect(parsed).not.toBeNull(); + if (parsed) expect(formatStoragePath(parsed, true)).toBe(original); + }); +}); diff --git a/src/lib/storagePath.ts b/src/lib/storagePath.ts new file mode 100644 index 00000000..a464e69f --- /dev/null +++ b/src/lib/storagePath.ts @@ -0,0 +1,50 @@ +/** + * Helpers for Zebra storage paths: `device:name` (no extension) and + * `device:name.ext` (with extension). The two forms appear in different + * ZPL commands — `~DY` headers use the bare form (extension is encoded + * in a separate param), `^XG` references use the dot-suffixed form. + * + * Keeping the parse/format pair in one place stops the two forms from + * drifting apart across the parser, emitter, and image registry. + */ + +export interface StoragePath { + /** Storage device prefix without trailing colon: "R", "E", "B", "A". */ + device: string; + /** Filename stem (no extension). */ + name: string; +} + +/** Extension paired with `^GF`-shaped graphic uploads. Zebra firmware + * persists `~DY{path},*,G,...` as `{path}.GRF` on the device. */ +export const GRAPHIC_EXT = "GRF"; + +/** + * Parse a `device:name` or `device:name.ext` storage path into structured + * parts. The extension (if any) is dropped — callers re-attach via + * `formatStoragePath` when emitting. Returns null when the input lacks a + * `:` separator, signalling a malformed path. + */ +export function parseStoragePath(raw: string): StoragePath | null { + const colonAt = raw.indexOf(":"); + if (colonAt <= 0) return null; + const device = raw.slice(0, colonAt); + const stemWithExt = raw.slice(colonAt + 1); + // Drop everything from the last `.` onwards. dotAt === 0 means the + // stem starts with a dot (only an extension) — treat as malformed via + // the empty-name guard below. + const dotAt = stemWithExt.lastIndexOf("."); + const name = dotAt === -1 ? stemWithExt : stemWithExt.slice(0, dotAt); + if (!name) return null; + return { device, name }; +} + +/** + * Render a storage path back to its ZPL form. `withExt: true` produces + * `device:name.GRF` (for `^XG` recalls); `withExt: false` produces the + * bare `device:name` (for `~DY` headers, where the extension is encoded + * in the next param instead). + */ +export function formatStoragePath(p: StoragePath, withExt: boolean): string { + return withExt ? `${p.device}:${p.name}.${GRAPHIC_EXT}` : `${p.device}:${p.name}`; +} diff --git a/src/lib/zplGenerator.test.ts b/src/lib/zplGenerator.test.ts index 82c9fcc6..b1f44a06 100644 --- a/src/lib/zplGenerator.test.ts +++ b/src/lib/zplGenerator.test.ts @@ -551,6 +551,59 @@ describe('generateZPL — line object', () => { }); }); +describe('generateZPL — ~DY graphic upload + ^XG recall', () => { + it('round-trips a ~DY+^XG label: parse → generate emits both back', () => { + const HEX = '00FFFF00'; + const zpl = + `~DYR:LOGO,A,G,4,1,${HEX}\n` + + `^XA^FO50,80^XGR:LOGO.GRF,1,1^FS^XZ`; + const parsed = parseZPL(zpl, 8); + const out = generateZPL(BASE_LABEL, parsed.objects); + // ~DY must precede ^XA so the printer has the file before ^XG references it. + const dyAt = out.indexOf('~DYR:LOGO,A,G,4,1,'); + const xaAt = out.indexOf('^XA'); + const xgAt = out.indexOf('^XGR:LOGO.GRF,1,1'); + expect(dyAt).toBeGreaterThan(-1); + expect(xaAt).toBeGreaterThan(dyAt); + expect(xgAt).toBeGreaterThan(xaAt); + }); + + it('preserves the source format letter (A/B/C) on round-trip', () => { + // A `~DY,C,G,...,:Z64:...` upload must NOT re-export as `~DY,A,G,...`: + // Zebra firmware rejects format A with a :Z64: payload. The shared + // cache uses ^GF{format} so the format letter survives both the GF + // and DY round-trip paths. + const z64Payload = ':Z64:eJxjYAACBAAACgAB:1234'; + const zpl = + `~DYR:CLOGO,C,G,4,1,${z64Payload}\n` + + `^XA^FO0,0^XGR:CLOGO.GRF,1,1^FS^XZ`; + const parsed = parseZPL(zpl, 8); + // ^XG only resolves if ~DY was registered; in this case the Z64 + // stream is malformed (junk base64) so the upload fails — the test + // is then about the parser surfacing that as browserLimit instead + // of mis-pairing format letters. We accept either path; if it does + // round-trip, the format letter must be C. + if (parsed.objects.length > 0) { + const out = generateZPL(BASE_LABEL, parsed.objects); + expect(out).toContain('~DYR:CLOGO,C,G,'); + expect(out).not.toContain('~DYR:CLOGO,A,G,'); + } + }); + + it('deduplicates the ~DY preamble when the same upload is referenced twice', () => { + const HEX = '00FFFF00'; + const zpl = + `~DYR:LOGO,A,G,4,1,${HEX}\n` + + `^XA^FO10,10^XGR:LOGO.GRF,1,1^FS^FO10,200^XGR:LOGO.GRF,1,1^FS^XZ`; + const parsed = parseZPL(zpl, 8); + const out = generateZPL(BASE_LABEL, parsed.objects); + const dyMatches = out.match(/~DYR:LOGO,/g) ?? []; + const xgMatches = out.match(/\^XGR:LOGO\.GRF/g) ?? []; + expect(dyMatches).toHaveLength(1); + expect(xgMatches).toHaveLength(2); + }); +}); + describe('generateZPL — code128 object', () => { it('emits ^BC and ^FD for a Code 128 barcode', () => { const { objects } = parseZPL('^XA^FO100,50^BCN,200,Y,N,N^FD12345678^FS^XZ', 8); diff --git a/src/lib/zplGenerator.ts b/src/lib/zplGenerator.ts index b0183491..fcae56a4 100644 --- a/src/lib/zplGenerator.ts +++ b/src/lib/zplGenerator.ts @@ -5,6 +5,8 @@ import type { CustomFontMapping, LabelConfig } from '../types/ObjectType'; import type { Page } from '../store/labelStore'; import { isGroup, type LabelObject } from '../types/Group'; import { getFontBytes } from './fontCache'; +import type { ImageProps } from '../registry/image'; +import { formatStoragePath } from './storagePath'; /** Format a `~DY` line for one embedded font mapping. The Zebra `~DY` * command syntax splits the path: `~DY{drive}:{name},{fmt},{ext}, @@ -39,6 +41,43 @@ function formatDownloadObject(m: CustomFontMapping): string | undefined { return `~DY${drive}${stem},A,T,${bytes.length},,${hex}`; } +/** Recursive flatten so images nested inside groups also get their ~DY + * preamble emitted. Reuses the same Group-awareness the body emit + * applies, but stays a local helper because the preamble only needs to + * walk for upload-eligible images and doesn't need ordering or grouping + * semantics. */ +function flattenObjects(objects: LabelObject[]): LabelObject[] { + const out: LabelObject[] = []; + const walk = (list: LabelObject[]): void => { + for (const o of list) { + if (isGroup(o)) walk(o.children); + else out.push(o); + } + }; + walk(objects); + return out; +} + +/** Format a `~DY` line for a graphic-upload image. Mirrors the font-upload + * helper but parses the bitmap bytes out of `_gfaCache` (shape: + * `^GF{A|B|C},total,data,bpr,DATA…`). The format letter is preserved so a + * `:Z64:`-wrapped payload re-exports as `~DY...,C,G,...` (Zebra firmware + * pairs `:Z64:` with format C only); collapsing all to `A` would corrupt + * the upload. Returns undefined when the cache is malformed; the caller + * skips, the image then emits inline ^GF instead. */ +function formatGraphicUpload(p: ImageProps): string | undefined { + if (!p.storedAs || !p._gfaCache) return undefined; + // The two byte-count headers are optional in `^GF` (firmware accepts + // `^GFA,,,bpr,DATA`), so `\d*` rather than `\d+` matches both forms. + const m = /^\^GF([ABC]),(\d*),(\d*),(\d+),([\s\S]*)$/.exec(p._gfaCache); + if (!m) return undefined; + const format = m[1]; + const total = m[2]; + const bpr = m[4]; + const data = m[5]; + return `~DY${formatStoragePath(p.storedAs, false)},${format},G,${total},${bpr},${data}`; +} + /** * Concatenates `generateZPL` output for every page. Each page becomes its own * `^XA...^XZ` block; printers process the blocks as separate labels. @@ -63,6 +102,24 @@ export function generateZPL(label: LabelConfig, objects: LabelObject[]): string if (line) lines.push(line); } + // ~DY graphic uploads. Each image with `storedAs` is uploaded once + // before ^XA; the per-instance ^XG in the body recalls it. Deduplicated + // by full path so the same logo doesn't ship twice when used on + // multiple pages or in multiple positions. The data is parsed back out + // of `_gfaCache` (which stores `^GFA,total,data,bpr,HEX...`); without + // that cache the upload is silently dropped. + const seenGraphics = new Set(); + for (const obj of flattenObjects(objects)) { + if (obj.type !== 'image') continue; + const p = obj.props as ImageProps; + if (!p.storedAs || !p._gfaCache) continue; + const key = formatStoragePath(p.storedAs, false); + if (seenGraphics.has(key)) continue; + seenGraphics.add(key); + const dy = formatGraphicUpload(p); + if (dy) lines.push(dy); + } + // ~SD is a tilde-prefix command that takes effect immediately on receipt, // independently of the label block. Emit it before ^XA so the darkness // change applies to the label that follows. diff --git a/src/lib/zplParser.test.ts b/src/lib/zplParser.test.ts index 6e8eee24..27600639 100644 --- a/src/lib/zplParser.test.ts +++ b/src/lib/zplParser.test.ts @@ -661,6 +661,59 @@ describe('parseZPL — ^GFA graphic field', () => { }); }); +// ── ~DY graphic upload + ^XG recall ────────────────────────────────────────── + +describe('parseZPL — ~DY + ^XG graphic upload/recall', () => { + // 1 byte per row × 4 rows → pattern [0x00, 0xFF, 0xFF, 0x00] (horizontal stripe). + const HEX = '00FFFF00'; + const PATH = 'R:LOGO'; + + it('registers a ~DY graphic upload and ^XG instantiates it as an image', () => { + const zpl = + `~DY${PATH},A,G,4,1,${HEX}\n` + + `^XA^FO50,80^XG${PATH}.GRF,1,1^FS^XZ`; + const { objects, importReport } = parseZPL(zpl, 8); + expect(objects).toHaveLength(1); + expect(objects[0]?.type).toBe('image'); + expect(props(objects[0]).widthDots).toBe(8); + expect(props(objects[0]).storedAs).toEqual({ device: 'R', name: 'LOGO' }); + expect(objects[0]?.x).toBe(50); + expect(objects[0]?.y).toBe(80); + expect(importReport.browserLimit).toHaveLength(0); + }); + + it('resolves ^XG even when the .GRF suffix is omitted', () => { + // Labelary accepts `^XGR:LOGO,1,1` for an upload stored as + // `R:LOGO.GRF`; the map lookup must normalise both forms. + const zpl = + `~DYR:LOGO,A,G,4,1,00FFFF00\n` + + `^XA^FO50,80^XGR:LOGO,1,1^FS^XZ`; + const { objects, importReport } = parseZPL(zpl, 8); + expect(objects).toHaveLength(1); + expect(props(objects[0]).storedAs).toEqual({ device: 'R', name: 'LOGO' }); + expect(importReport.browserLimit).toHaveLength(0); + }); + + it('^XG without a preceding ~DY surfaces as browserLimit', () => { + const zpl = `^XA^FO0,0^XGR:MISSING.GRF,1,1^FS^XZ`; + const { objects, importReport } = parseZPL(zpl, 8); + expect(objects).toHaveLength(0); + expect(importReport.browserLimit.some((s) => s.startsWith('^XG'))).toBe(true); + }); + + it('accepts :Z64:-wrapped graphic payloads in ~DY (format C)', () => { + const bytes = new Uint8Array([0, 0xff, 0xff, 0]); + const field = makeZ64Field(bytes); + const zpl = + `~DY${PATH},C,G,4,1,${field}\n` + + `^XA^FO0,0^XG${PATH}.GRF,1,1^FS^XZ`; + const { objects, importReport } = parseZPL(zpl, 8); + expect(objects).toHaveLength(1); + expect(props(objects[0]).storedAs).toEqual({ device: 'R', name: 'LOGO' }); + expect(importReport.partial).not.toContain('~DY'); + }); +}); + // ── ^LR label reverse ───────────────────────────────────────────────────────── describe('parseZPL — ^LR label reverse', () => { diff --git a/src/lib/zplParser.ts b/src/lib/zplParser.ts index fb83b90c..8314d4a8 100644 --- a/src/lib/zplParser.ts +++ b/src/lib/zplParser.ts @@ -22,6 +22,7 @@ import type { MicroPdf417Props } from "../registry/micropdf417"; import type { CodablockProps } from "../registry/codablock"; import { unzlibSync } from "fflate"; import { putImage } from "./imageCache"; +import { formatStoragePath, parseStoragePath } from "./storagePath"; import { loadFontBytesSync } from "./fontCache"; import { ZPL_BUILTIN_FONT_LETTERS } from "./customFonts"; import { GS1_DATABAR_DEFAULT_SEGMENTS } from "./gs1"; @@ -321,6 +322,87 @@ function gfPayloadToBytes( return null; } +/** Outcome of decoding any GF-shaped graphic (^GF or ~DY graphic upload) + * into an image-cache entry. Common to both call sites so the per-handler + * code only needs to bind it to the right ZPL artifact (label object vs. + * preamble registration). */ +interface DecodedGraphic { + imageId: string; + widthDots: number; + heightDots: number; + /** Verbatim `^GF{format},total,data,bpr,DATA` reconstruction kept on the + * image so round-trip emit can splice the same byte stream back into + * either `^GF` (inline) or `~DY` (preamble) without re-encoding. */ + gfaCache: string; + crcOk: boolean; +} + +/** Entry in the `~DY → ^XG` lookup map: a graphic uploaded earlier in the + * stream, keyed by its full `device:stem.ext` path. Structurally a + * `DecodedGraphic` without the per-decode CRC flag — that lives on the + * partialCmds set instead of on every map entry. */ +type UploadedGraphic = Omit; + +/** + * Decode a GF-shaped payload into an image-cache entry. Shared between the + * `^GF` inline path and the `~DY` graphic-upload preamble; both have the + * same payload shape and need the same decoded bitmap, canvas paint, and + * cache write. Returns `null` when the payload can't be decoded (caller + * surfaces as browserLimit). + */ +function decodeGraphicToImage( + rawData: string, + format: "A" | "B" | "C", + bytesPerRow: number, + totalBytesHeader: string, + dataBytesHeader: string, + nameHint: string, +): DecodedGraphic | null { + const decoded = gfPayloadToBytes(rawData, format, bytesPerRow); + if (!decoded) return null; + const widthDots = bytesPerRow * BITS_PER_BYTE; + const heightDots = Math.floor(decoded.data.length / bytesPerRow); + if (heightDots <= 0) return null; + const canvas = document.createElement("canvas"); + canvas.width = widthDots; + canvas.height = heightDots; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("Could not get 2d context"); + const imgData = ctx.createImageData(widthDots, heightDots); + const pixels = imgData.data; + for (let row = 0; row < heightDots; row++) { + for (let byteIdx = 0; byteIdx < bytesPerRow; byteIdx++) { + const byte = decoded.data[row * bytesPerRow + byteIdx] ?? 0; + for (let bit = 0; bit < BITS_PER_BYTE; bit++) { + const px = byteIdx * BITS_PER_BYTE + bit; + const idx = (row * widthDots + px) * 4; + // ZPL ^GF: 1-bit = black (printed), 0-bit = transparent. + // ImageData starts zero-filled (rgba(0,0,0,0)), which is exactly + // the 0-bit case — only the 1-bit case needs a write. + if ((byte & (0x80 >> bit)) !== 0) { + pixels[idx + 3] = 255; + } + } + } + } + ctx.putImageData(imgData, 0, 0); + const imageId = crypto.randomUUID(); + putImage({ + id: imageId, + name: nameHint, + dataUrl: canvas.toDataURL("image/png"), + width: widthDots, + height: heightDots, + }); + return { + imageId, + widthDots, + heightDots, + gfaCache: `^GF${format},${totalBytesHeader},${dataBytesHeader},${bytesPerRow},${rawData}`, + crcOk: decoded.crcOk, + }; +} + /** * Decompress ZPL Alternative Data Compression used in ^GFA fields. * @@ -473,6 +555,12 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { // user's "ship the bytes" intent. const downloadedFontPaths = new Set(); + // Graphics uploaded via ~DY in this stream, keyed by the full + // `device:stem.ext` path. A subsequent ^XG references one of these to + // instantiate an image object at a position; emitting back goes + // through the same map so round-trip preserves upload+recall. + const downloadedGraphics = new Map(); + // ^FH state (field hex indicator) let fhActive = false; let fhDelimiter = "_"; @@ -1289,78 +1377,23 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { return; } - const decoded = gfPayloadToBytes(gfRawData, format, gfBytesPerRow); - if (!decoded) { - // Truncated summary because :B64:/:Z64: payloads can be many KB - // and we don't want one entry dominating the import report. - const gfSummary = `^GF${rest.slice(0, IMPORT_FINDING_PAYLOAD_LIMIT)}…`; + const gfSummary = `^GF${rest.slice(0, IMPORT_FINDING_PAYLOAD_LIMIT)}…`; + // Preserve the source bytes-headers verbatim so re-export keeps the + // firmware's input-buffer hint intact (^GFC/:Z64: has total ≠ data). + const gfImage = decodeGraphicToImage( + gfRawData, + format, + gfBytesPerRow, + gfParams[0] ?? "", + gfParams[1] ?? "", + `imported_${crypto.randomUUID().slice(0, 8)}.png`, + ); + if (!gfImage) { skipped.push(gfSummary); browserLimit.push(gfSummary); return; } - if (!decoded.crcOk) { - // Render anyway (printers tolerate CRC drift) but flag the loss. - partialCmds.add("^GF"); - } - const gfBytes = decoded.data; - const gfWidthDots = gfBytesPerRow * 8; - const gfHeightDots = Math.floor(gfBytes.length / gfBytesPerRow); - - if (gfHeightDots <= 0) { - skipped.push(`^GF${rest}`); - return; - } - - // Convert 1-bit bitmap → canvas → data URL - const canvas = document.createElement("canvas"); - canvas.width = gfWidthDots; - canvas.height = gfHeightDots; - const ctx = canvas.getContext("2d"); - if (!ctx) throw new Error("Could not get 2d context"); - const imgData = ctx.createImageData(gfWidthDots, gfHeightDots); - const pixels = imgData.data; - - for (let row = 0; row < gfHeightDots; row++) { - for (let byteIdx = 0; byteIdx < gfBytesPerRow; byteIdx++) { - const byte = gfBytes[row * gfBytesPerRow + byteIdx] ?? 0; - for (let bit = 0; bit < 8; bit++) { - const px = byteIdx * 8 + bit; - const idx = (row * gfWidthDots + px) * 4; - // ZPL ^GF: 1-bit = black (printed), 0-bit = transparent. - // ImageData starts zero-filled (rgba(0,0,0,0)), which is exactly - // the 0-bit case — only the 1-bit case needs a write. - if ((byte & (0x80 >> bit)) !== 0) { - pixels[idx + 3] = 255; - } - } - } - } - - ctx.putImageData(imgData, 0, 0); - const dataUrl = canvas.toDataURL("image/png"); - const imageId = crypto.randomUUID(); - - putImage({ - id: imageId, - name: `imported_${imageId.slice(0, 8)}.png`, - dataUrl, - width: gfWidthDots, - height: gfHeightDots, - }); - - // Store original compressed data for lossless re-export. Preserve - // the source format letter (A/B/C) *and* the two original byte - // counts: for compressed payloads (^GFC/:Z64:) the 2nd param is the - // uncompressed total and the 3rd is the on-wire ("bytes to follow") - // size; firmware uses the latter for input-buffer allocation, so - // collapsing both to `gfBytes.length` would mis-allocate on - // re-import. Falling back to `gfBytes.length` only if the parser - // didn't see the original (defensive — every well-formed ^GF has - // them). - const gfTotalBytes = gfParams[0] ?? String(gfBytes.length); - const gfDataBytes = gfParams[1] ?? String(gfBytes.length); - const gfaCache = `^GF${format},${gfTotalBytes},${gfDataBytes},${gfBytesPerRow},${gfRawData}`; - + if (!gfImage.crcOk) partialCmds.add("^GF"); const posType: "FT" | "FO" = positionIsFT ? "FT" : "FO"; objects.push( makeObj( @@ -1368,10 +1401,10 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { x, y, { - imageId, - widthDots: gfWidthDots, + imageId: gfImage.imageId, + widthDots: gfImage.widthDots, threshold: 128, - _gfaCache: gfaCache, + _gfaCache: gfImage.gfaCache, } satisfies ImageProps, posType, takeComment(), @@ -1432,6 +1465,51 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { ); }, + // ── Recall stored graphic ────────────────────────────────────────────── + XG(_, rest) { + // ^XGd:f.x,mx,my — references a graphic uploaded earlier via ~DY. + // We look the path up in downloadedGraphics; if found, instantiate an + // image object at the current ^FO/^FT and tag it with storedAs so + // re-emit produces the same upload+recall pair. + const firstComma = rest.indexOf(","); + const xgPath = firstComma === -1 ? rest : rest.slice(0, firstComma); + const surfaceXgFailure = (): void => { + skipped.push(`^XG${rest}`); + browserLimit.push(`^XG${rest}`); + }; + // ^XG omitting the `.GRF` suffix is valid ZPL (Labelary accepts + // `^XGR:LOGO,…` for an upload stored as `R:LOGO.GRF`). Normalise + // through the storage-path helpers so the map lookup matches the + // canonical key the ~DY side wrote. + const parsed = parseStoragePath(xgPath); + if (!parsed) { + surfaceXgFailure(); + return; + } + const uploaded = downloadedGraphics.get(formatStoragePath(parsed, true)); + if (!uploaded) { + surfaceXgFailure(); + return; + } + const posType: "FT" | "FO" = positionIsFT ? "FT" : "FO"; + objects.push( + makeObj( + "image", + x, + y, + { + imageId: uploaded.imageId, + widthDots: uploaded.widthDots, + threshold: 128, + _gfaCache: uploaded.gfaCache, + storedAs: parsed, + } satisfies ImageProps, + posType, + takeComment(), + ), + ); + }, + // ── Label print settings ──────────────────────────────────────────────── PQ(p) { const qty = int(p[0], 0); @@ -1563,15 +1641,60 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { const fmt = rest.slice(c0 + 1, c1).toUpperCase(); const extCode = rest.slice(c1 + 1, c2).toUpperCase(); const size = parseInt(rest.slice(c2 + 1, c3), 10); + const dyBytesPerRow = parseInt(rest.slice(c3 + 1, c4), 10); const data = rest.slice(c4 + 1); + const dySummary = `~DY${rest.slice(0, IMPORT_FINDING_PAYLOAD_LIMIT)}…`; + + // Graphic uploads (~DY ...,A/B/C,G,...): decode via the same payload + // pipeline as ^GF, register the resulting image under the full + // device:stem.GRF path. A subsequent ^XG can then instantiate it. + if (extCode === "G" && (fmt === "A" || fmt === "B" || fmt === "C")) { + if (!path || isNaN(dyBytesPerRow) || dyBytesPerRow <= 0) { + skipped.push(dySummary); + browserLimit.push(dySummary); + return; + } + const sizeStr = size > 0 ? String(size) : ""; + const dyImage = decodeGraphicToImage( + data, + fmt, + dyBytesPerRow, + sizeStr, + sizeStr, + `uploaded_${path.replace(/[:.]/g, "_")}.png`, + ); + if (!dyImage) { + skipped.push(dySummary); + browserLimit.push(dySummary); + return; + } + if (!dyImage.crcOk) partialCmds.add("~DY"); + // Path normalisation: ~DY uses `device:stem` without extension; the + // ^XG side resolves `device:stem.GRF`. Store the `.GRF` form so the + // XG lookup is direct. + const parsedDyPath = parseStoragePath(path); + if (!parsedDyPath) { + skipped.push(dySummary); + browserLimit.push(dySummary); + return; + } + downloadedGraphics.set(formatStoragePath(parsedDyPath, true), { + imageId: dyImage.imageId, + widthDots: dyImage.widthDots, + heightDots: dyImage.heightDots, + gfaCache: dyImage.gfaCache, + }); + return; + } + // Only ASCII-hex TTF/OTF imports are supported. Z64 / compressed // payloads need a CRC-checked decoder and stay out of scope. if (fmt !== "A" || (extCode !== "T" && extCode !== "B")) { - browserLimit.push(`~DY${rest.slice(0, 80)}…`); + browserLimit.push(dySummary); return; } if (!path || isNaN(size) || size <= 0 || data.length < size * 2) { - browserLimit.push(`~DY${rest.slice(0, 80)}…`); + browserLimit.push(dySummary); return; } const bytes = new Uint8Array(size); diff --git a/src/registry/image.tsx b/src/registry/image.tsx index da7a288a..cb95bf3e 100644 --- a/src/registry/image.tsx +++ b/src/registry/image.tsx @@ -5,6 +5,7 @@ import { inputCls, labelCls } from '../components/Properties/styles'; import { fieldPos } from './zplHelpers'; import { loadImageFile, getImage, getAllImages } from '../lib/imageCache'; import { imageToGFA } from '../lib/imageToZpl'; +import { formatStoragePath } from '../lib/storagePath'; export interface ImageProps { /** ID into the image cache */ @@ -15,6 +16,16 @@ export interface ImageProps { threshold: number; /** Cached GFA ZPL string — regenerated when image/width/threshold changes */ _gfaCache?: string; + /** When set, the image is uploaded once via `~DY` (preamble) and referenced + * per-instance via `^XG`. Set by the parser when a ZPL stream uses the + * upload+recall pattern, preserved on re-export. Without this the image + * emits inline `^GF` as before. */ + storedAs?: { + /** Storage device prefix without trailing colon: "R", "E", "B", or "A". */ + device: string; + /** Filename stem (no extension); paired with `.GRF` for graphics. */ + name: string; + }; } /** Synchronously generate ^GFA using a blocking canvas (for toZPL). */ @@ -74,9 +85,15 @@ export const image: ObjectTypeDefinition = { toZPL: (obj) => { const p = obj.props; + // Recall path: upload happened in the preamble; here we just reference + // it via ^XG. The `.GRF` extension is implicit on `~DY{path},A,G,…` — + // Zebra firmware persists the file as `path.GRF` and `^XG` resolves + // the dot-suffixed form. + if (p.storedAs) { + return `${fieldPos(obj)}^XG${formatStoragePath(p.storedAs, true)},1,1^FS`; + } const cached = getImage(p.imageId); if (!cached) return `${fieldPos(obj)}^FD^FS`; - // Use cached GFA if available, otherwise generate synchronously const gfa = p._gfaCache || gfaSync(cached.dataUrl, p.widthDots, p.threshold); return `${fieldPos(obj)}${gfa}^FS`;