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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,14 @@ The print resolution (dpmm = dots per millimeter) must match your printer. Commo

Drag items from the left panel onto the canvas, or double-click them to add at the center.

Available objects: text, serial (auto-incrementing number fields), barcodes (27 symbologies including Code 128, QR, DataMatrix, PDF417, Maxicode), shapes (box, line, ellipse), and images.
Available objects: text, serial (auto-incrementing number fields), barcodes (28 symbologies including Code 128, QR, DataMatrix, PDF417, Maxicode), shapes (box, line, ellipse), and images.

<details>
<summary>Full list of supported barcode symbologies</summary>

**1D linear:** Code 128, Code 39, Code 93, Code 11, Interleaved 2 of 5, Standard 2 of 5, Industrial 2 of 5, Codabar, LOGMARS, MSI, Plessey, GS1 Databar, Planet Code, Postal/POSTNET, EAN-13, EAN-8, UPC-A, UPC-E, UPC/EAN 2- or 5-digit supplement, Code 49

**2D matrix:** QR Code, DataMatrix, PDF417, MicroPDF417, Aztec, Codablock F, Maxicode
**2D matrix:** QR Code, DataMatrix, PDF417, MicroPDF417, Aztec, Codablock F, Maxicode, TLC39

</details>

Expand Down Expand Up @@ -116,13 +116,13 @@ Both `.zpl` and `.json` round-trip cleanly. `.zpl` preserves all printable conte

## Coverage

93 of the 202 ZPL II commands tracked in the [roadmap](docs/zpl-roadmap.md) are supported today. Categorical breakdown:
94 of the 202 ZPL II commands tracked in the [roadmap](docs/zpl-roadmap.md) are supported today. Categorical breakdown:

| Area | Supported |
|---|---|
| Layout & flow | 16 / 16 |
| Templates & variables | 4 / 4 |
| Barcodes | 27 / 28 |
| Barcodes | 28 / 28 |
| Fields | 15 / 20 |
| Encoding & language | 3 / 4 |
| Clock & time | 2 / 3 |
Expand Down
2 changes: 1 addition & 1 deletion docs/zpl-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ The Printer Settings Modal (Media & Feed / Print Quality / Clock & Time / Encodi
| `[x]` | `^B0` / `^BO` | Aztec | |
| `[x]` | `^BB` | CODABLOCK F | |
| `[x]` | `^BV` | UPS MaxiCode (also accepts `^BD` on some firmware generations as an alias) | |
| `[ ]` | `^BT` | TLC39 | `Coming soon` |
| `[x]` | `^BT` | TLC39 | |

## Graphics

Expand Down
34 changes: 21 additions & 13 deletions src/components/Canvas/BarcodeObject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
getDisplaySize,
get1DBwipScale,
getEanUpcLayout,
renderTlc39Canvas,
type BarcodeDisplaySize,
type EanUpcType,
} from "./bwipHelpers";
Expand Down Expand Up @@ -61,21 +62,28 @@ export function BarcodeObject({
}
}, []);

const opts = buildBwipOptions(obj, scale, dpmm);
let barcodeCanvas: HTMLCanvasElement | null = null;
let errorMsg: string | null = null;
if (opts) {
const canvas = document.createElement("canvas");
try {
// buildBwipOptions returns Record<string, unknown> on purpose: the
// option fields differ across barcode types (ean13 vs code128 vs …)
// and per-type narrowing would duplicate the switch already in
// buildBwipOptions. bwip-js' toCanvas signature uses a strict
// literal-string union, so the structural cast bridges the two.
bwipjs.toCanvas(canvas, opts as unknown as Parameters<typeof bwipjs.toCanvas>[1]);
barcodeCanvas = canvas;
} catch (e) {
errorMsg = cleanBwipError(e);
if (obj.type === "tlc39") {
// bwip-js has no native tlc39 encoder; render the Code 39 + MicroPDF417
// composite ourselves. Goes straight to canvas, bypassing buildBwipOptions.
barcodeCanvas = renderTlc39Canvas(obj.props, scale, dpmm);
if (!barcodeCanvas) errorMsg = "TLC39 render failed";
Comment thread
u8array marked this conversation as resolved.
} else {
const opts = buildBwipOptions(obj, scale, dpmm);
if (opts) {
const canvas = document.createElement("canvas");
try {
// buildBwipOptions returns Record<string, unknown> on purpose: the
// option fields differ across barcode types (ean13 vs code128 vs …)
// and per-type narrowing would duplicate the switch already in
// buildBwipOptions. bwip-js' toCanvas signature uses a strict
// literal-string union, so the structural cast bridges the two.
bwipjs.toCanvas(canvas, opts as unknown as Parameters<typeof bwipjs.toCanvas>[1]);
barcodeCanvas = canvas;
} catch (e) {
errorMsg = cleanBwipError(e);
}
}
}

Expand Down
1 change: 1 addition & 0 deletions src/components/Canvas/KonvaObject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ const BARCODE_TYPES = new Set([
"codablock",
"upcEanExtension",
"code49",
"tlc39",
]);

export function KonvaObject(props_: Props) {
Expand Down
3 changes: 3 additions & 0 deletions src/components/Canvas/bwipConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ export const LOGMARS_TEXT_ZONE_DOTS = 20;
// bwip-js adds 3 quiet-zone rows to MicroPDF417 canvas output.
export const MICROPDF417_QUIET_ZONE_ROWS = 3;

// bwip-js renders MicroPDF417 at 2 internal px per data row, independent of `rowheight`.
export const MICROPDF417_PX_PER_ROW = 2;

/**
* bwip-vs-Zebra width-correction constants for symbologies whose bar
* pattern in bwip-js diverges from Zebra firmware.
Expand Down
148 changes: 145 additions & 3 deletions src/components/Canvas/bwipHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
GS1_DATABAR_PADDING_ROWS,
GS1_DATABAR_SPEC_HEIGHT_MODULES,
LOGMARS_TEXT_ZONE_DOTS,
MICROPDF417_PX_PER_ROW,
MICROPDF417_QUIET_ZONE_ROWS,
PLESSEY_BWIP_TO_ZEBRA_WIDTH_RATIO,
UPC_SUPP_TEXT_ZONE_DOTS,
Expand Down Expand Up @@ -965,9 +966,7 @@ function getUprightDisplaySize(
}
case "micropdf417": {
const p = obj.props;
// bwip-js ignores rowheight for micropdf417 and always uses 2 internal pixels per row.
// It also adds MICROPDF417_QUIET_ZONE_ROWS quiet-zone rows (top+bottom) to the canvas.
const numRows = Math.max(0, ch / (BWIP_SCALE * 2) - MICROPDF417_QUIET_ZONE_ROWS);
const numRows = micropdfDataRows(ch);
const w =
(cw / BWIP_SCALE) * dotsToPx(p.moduleWidth, scale, dpmm);
const h = numRows * dotsToPx(p.rowHeight, scale, dpmm);
Expand All @@ -991,3 +990,146 @@ function getUprightDisplaySize(
}
}
}

/** Valid MicroPDF417 row counts in TLC39's linked 4-column geometry. */
export const TLC39_MICROPDF_ROW_COUNTS = [4, 6, 8, 10] as const;

/** Snap to the nearest valid row count; bwip-js throws on any other value. */
export function snapTlc39MicroPdfRows(requested: number): number {
if (!Number.isFinite(requested)) return 4;
for (const r of TLC39_MICROPDF_ROW_COUNTS) if (requested <= r) return r;
return 10;
}

/** Data-row count from a bwip-js MicroPDF417 canvas height (assumes scale=BWIP_SCALE). */
function micropdfDataRows(canvasHeight: number): number {
return Math.max(
0,
canvasHeight / (BWIP_SCALE * MICROPDF417_PX_PER_ROW)
- MICROPDF417_QUIET_ZONE_ROWS,
);
}

/** TLC39 spec splits content on the first comma: ECI (6 digits) for
* the Code 39 base line, serial (≤25 alphanum) for the MicroPDF417
* block stacked on top. The "T" linkage flag is appended to the
* Code 39 data when a MicroPDF417 follows; the leading "S" data
* identifier (if any) is stripped from the serial before MicroPDF
* encoding. */
export function splitTlc39Content(content: string): { eci: string; serial: string } {
if (!content) return { eci: "", serial: "" };
const comma = content.indexOf(",");
if (comma < 0) return { eci: content, serial: "" };
const eci = content.slice(0, comma);
let serial = content.slice(comma + 1);
if (serial.startsWith("S")) serial = serial.slice(1);
return { eci, serial };
}
Comment thread
u8array marked this conversation as resolved.

interface Tlc39RenderProps {
content: string;
moduleWidth: number;
height: number;
microPdfRowHeight: number;
microPdfRows: number;
}

/** TLC39 composite (MicroPDF417 on top, Code 39 base below). bwip-js has
* no native encoder; composes two encoders at dpmm-aware display sizes
* per the TCIF spec (shared width, no separator). */
export function renderTlc39Canvas(
props: Tlc39RenderProps,
scale: number,
dpmm: number,
): HTMLCanvasElement | null {
const { eci, serial } = splitTlc39Content(props.content);
const bwipScale = get1DBwipScale(props.moduleWidth, scale, dpmm);
const modulePx = dotsToPx(props.moduleWidth, scale, dpmm);
const code39H = dotsToPx(props.height, scale, dpmm);

const renderCode39 = (text: string): HTMLCanvasElement | null => {
const c = document.createElement("canvas");
try {
bwipjs.toCanvas(c, {
Comment thread
u8array marked this conversation as resolved.
bcid: "code39",
text: text || " ",
scale: bwipScale,
// bwip `height` is in mm at 1x; constant source height
// (stretched to dpmm-correct dots below) matches the standalone
// 1D pattern.
height: 10,
includetext: false,
} as unknown as Parameters<typeof bwipjs.toCanvas>[1]);
} catch {
return null;
}
return c;
};

const stretchTo = (
src: HTMLCanvasElement,
targetW: number,
targetH: number,
): HTMLCanvasElement | null => {
const out = document.createElement("canvas");
out.width = Math.max(1, Math.round(targetW));
out.height = Math.max(1, Math.round(targetH));
const c = out.getContext("2d");
if (!c) return null;
c.fillStyle = "white";
c.fillRect(0, 0, out.width, out.height);
c.imageSmoothingEnabled = false;
c.drawImage(src, 0, 0, out.width, out.height);
return out;
};

if (!serial) {
const src = renderCode39(eci);
if (!src) return null;
const w = (src.width / bwipScale) * modulePx;
return stretchTo(src, w, code39H);
}

// Render MicroPDF first; the "T" linkage flag is only appended to
// Code 39 when the linked MicroPDF actually rendered, otherwise the
// flag would claim a link that does not exist.
const snappedRows = snapTlc39MicroPdfRows(props.microPdfRows);
const mpdfSrc = document.createElement("canvas");
let mpdfOk = true;
try {
bwipjs.toCanvas(mpdfSrc, {
bcid: "micropdf417",
text: serial,
scale: BWIP_SCALE,
rows: snappedRows,
// TLC39 spec: linked MicroPDF417 is fixed at 4 columns.
columns: 4,
} as unknown as Parameters<typeof bwipjs.toCanvas>[1]);
} catch {
mpdfOk = false;
}

const code39Src = renderCode39(mpdfOk ? `${eci}T` : eci);
if (!code39Src) return null;
const code39W = (code39Src.width / bwipScale) * modulePx;

if (!mpdfOk) return stretchTo(code39Src, code39W, code39H);

const mpdfW = (mpdfSrc.width / BWIP_SCALE) * modulePx;
const mpdfH = snappedRows * dotsToPx(props.microPdfRowHeight, scale, dpmm);

const w = Math.max(1, Math.round(Math.max(code39W, mpdfW)));
const mpdfPxH = Math.max(1, Math.round(mpdfH));
const code39PxH = Math.max(1, Math.round(code39H));
const composite = document.createElement("canvas");
composite.width = w;
composite.height = mpdfPxH + code39PxH;
const ctx = composite.getContext("2d");
if (!ctx) return null;
ctx.fillStyle = "white";
ctx.fillRect(0, 0, composite.width, composite.height);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(mpdfSrc, 0, 0, w, mpdfPxH);
ctx.drawImage(code39Src, 0, mpdfPxH, w, code39PxH);
Comment thread
u8array marked this conversation as resolved.
return composite;
}
111 changes: 111 additions & 0 deletions src/lib/zplParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1388,6 +1388,117 @@ describe('parseZPL — change caret / tilde / delimiter (^CC ^CT ^CD)', () => {
});
});

// ── ^BT TLC39 ─────────────────────────────────────────────────────────────────

describe('parseZPL — ^BT TLC39', () => {
it('parses ^BT with full param set into a tlc39 object', () => {
const zpl = '^XA^FO50,50^BTN,3,3,60,5,3^FD123456,ABC^FS^XZ';
const { objects } = parseZPL(zpl, 8);
expect(objects).toHaveLength(1);
expect(objects[0]?.type).toBe('tlc39');
const p = props(objects[0]);
expect(p.content).toBe('123456,ABC');
expect(p.moduleWidth).toBe(3);
expect(p.height).toBe(60);
expect(p.microPdfRowHeight).toBe(5);
expect(p.microPdfRows).toBe(3);
expect(p.rotation).toBe('N');
Comment thread
u8array marked this conversation as resolved.
});

it('falls back to ^BY moduleWidth when ^BT w1 is empty', () => {
const zpl = '^XA^FO0,0^BY5^BTN,,3,40,4,4^FD123456,X^FS^XZ';
const { objects } = parseZPL(zpl, 8);
expect(props(objects[0]).moduleWidth).toBe(5);
});

it('preserves microPdfRows mid-range', () => {
const zpl = '^XA^FO0,0^BTN,2,3,40,4,6^FD123456,X^FS^XZ';
const { objects } = parseZPL(zpl, 8);
expect(props(objects[0]).microPdfRows).toBe(6);
});

it('accepts microPdfRows=1 and =10 (^BT spec bounds)', () => {
const z1 = parseZPL('^XA^FO0,0^BTN,2,3,40,4,1^FD123456,X^FS^XZ', 8).objects;
expect(props(z1[0]).microPdfRows).toBe(1);
const z10 = parseZPL('^XA^FO0,0^BTN,2,3,40,4,10^FD123456,X^FS^XZ', 8).objects;
expect(props(z10[0]).microPdfRows).toBe(10);
});

it('rejects microPdfRows outside ^BT spec range (-1, 0, 11, 20) and falls back to default 4', () => {
for (const v of [-1, 0, 11, 20]) {
const zpl = `^XA^FO0,0^BTN,2,3,40,4,${v}^FD123456,X^FS^XZ`;
const { objects } = parseZPL(zpl, 8);
expect(props(objects[0]).microPdfRows, `rows=${v}`).toBe(4);
}
});

it('drops non-canonical r1 on round-trip (re-emits as 2)', async () => {
const { ObjectRegistry } = await import('../registry');
const { objects } = parseZPL('^XA^FO0,0^BTN,2,3,40,4,4^FD123,X^FS^XZ', 8);
const emitted = ObjectRegistry.tlc39.toZPL(objects[0] as never);
expect(emitted).toContain('^BTN,2,2,40,4,4');
});

it('round-trips ^BT without serial (no MicroPDF block, no trailing comma)', async () => {
const { ObjectRegistry } = await import('../registry');
const original = '^XA^FO10,20^BY2^BTN,2,2,40,4,4^FD123456^FS^XZ';
const { objects } = parseZPL(original, 8);
expect(objects[0]?.type).toBe('tlc39');
expect(props(objects[0]).content).toBe('123456');
const emitted = ObjectRegistry.tlc39.toZPL(objects[0] as never);
expect(emitted).toMatch(/\^FD123456\^FS/);
const { objects: round2 } = parseZPL(`^XA${emitted}^XZ`, 8);
expect(props(round2[0]).content).toBe('123456');
});

it('round-trips ^BT via parse → toZPL → parse', async () => {
const { ObjectRegistry } = await import('../registry');
const original = '^XA^FO50,60^BY3^BTN,3,2,80,5,8^FD654321,ABCDEF^FS^XZ';
const { objects } = parseZPL(original, 8);
expect(objects).toHaveLength(1);
const emitted = ObjectRegistry.tlc39.toZPL(objects[0] as never);
expect(emitted).toContain('^BTN,3,2,80,5,8');
expect(emitted).toContain('^FD654321,ABCDEF');
const { objects: round2 } = parseZPL(`^XA${emitted}^XZ`, 8);
expect(round2[0]?.type).toBe('tlc39');
expect(props(round2[0])).toMatchObject(props(objects[0]));
});
});

describe('snapTlc39MicroPdfRows', () => {
it('snaps in-range requests up to the nearest valid {4,6,8,10}', async () => {
const { snapTlc39MicroPdfRows } = await import('../components/Canvas/bwipHelpers');
expect(snapTlc39MicroPdfRows(1)).toBe(4);
expect(snapTlc39MicroPdfRows(4)).toBe(4);
expect(snapTlc39MicroPdfRows(5)).toBe(6);
expect(snapTlc39MicroPdfRows(7)).toBe(8);
expect(snapTlc39MicroPdfRows(9)).toBe(10);
expect(snapTlc39MicroPdfRows(10)).toBe(10);
});

it('clamps above-range to the highest valid count', async () => {
const { snapTlc39MicroPdfRows } = await import('../components/Canvas/bwipHelpers');
expect(snapTlc39MicroPdfRows(99)).toBe(10);
});
});

describe('splitTlc39Content', () => {
it('splits on first comma; ECI before, serial after', async () => {
const { splitTlc39Content } = await import('../components/Canvas/bwipHelpers');
expect(splitTlc39Content('123456,ABC123')).toEqual({ eci: '123456', serial: 'ABC123' });
});

it('strips a leading S data identifier from the serial', async () => {
const { splitTlc39Content } = await import('../components/Canvas/bwipHelpers');
expect(splitTlc39Content('123456,SXYZ789')).toEqual({ eci: '123456', serial: 'XYZ789' });
});

it('returns empty serial when no comma is present', async () => {
const { splitTlc39Content } = await import('../components/Canvas/bwipHelpers');
expect(splitTlc39Content('123456')).toEqual({ eci: '123456', serial: '' });
});
});

// ── ^IM image reference ───────────────────────────────────────────────────────

describe('parseZPL — ^IM image reference', () => {
Expand Down
Loading