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
34 changes: 34 additions & 0 deletions src/lib/storagePath.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
50 changes: 50 additions & 0 deletions src/lib/storagePath.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
53 changes: 53 additions & 0 deletions src/lib/zplGenerator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
57 changes: 57 additions & 0 deletions src/lib/zplGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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.
Expand All @@ -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<string>();
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.
Expand Down
53 changes: 53 additions & 0 deletions src/lib/zplParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading