Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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: 8 additions & 0 deletions src/components/Canvas/LabelCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { Ruler, RULER_SIZE } from "./Ruler";
import { ObjectRegistry } from "../../registry";
import type { LabelObject } from "../../registry";
import { useColorScheme } from "../../lib/useColorScheme";
import { useT } from "../../lib/useT";
import { useCanvasPanZoom } from "./hooks/useCanvasPanZoom";
import { useCanvasLasso } from "./hooks/useCanvasLasso";
import { useKonvaTransformer } from "./hooks/useKonvaTransformer";
Expand Down Expand Up @@ -83,6 +84,7 @@ export function LabelCanvas({
}, []);

const colors = useColorScheme();
const t = useT();

const {
label,
Expand Down Expand Up @@ -459,6 +461,12 @@ export function LabelCanvas({
>
<PaginationControl />

{label.printOrientation === "I" && (
<div className="absolute top-3 right-3 z-10 bg-surface border border-border rounded px-2 py-0.5 text-[10px] font-mono text-muted">
{t.label.printOrientationIndicator}
</div>
)}

{/* Bottom-right controls: view options + zoom */}
<div className="absolute bottom-3 right-3 z-10 flex items-center gap-1 bg-surface border border-border rounded px-1 py-0.5">
<button
Expand Down
134 changes: 133 additions & 1 deletion src/components/Properties/PropertiesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ function LabelConfigPanel({
})
}
>
<option value="">{t.label.presetCustom}</option>
<option value="">{t.label.printerDefault}</option>
<option value="T">{t.label.mediaModeT}</option>
<option value="V">{t.label.mediaModeV}</option>
<option value="D">{t.label.mediaModeD}</option>
Expand Down Expand Up @@ -353,7 +353,139 @@ function LabelConfigPanel({
}
/>
</div>

<div className="border-t border-border" />

<p className={labelCls}>{t.label.printerSettingsHeading}</p>

<div className="flex flex-col gap-1">
<label className={labelCls}>{t.label.printSpeed}</label>
<input
type="number"
className={inputCls}
value={label.printSpeed ?? ""}
min={2}
max={14}
onChange={(e) =>
onUpdate({ printSpeed: parseIntOrUndef(e.target.value) })
}
/>
<p className="text-[10px] text-muted">{t.label.printSpeedHint}</p>
</div>

<div className="flex flex-col gap-1">
<label className={labelCls}>{t.label.darkness}</label>
<input
type="number"
className={inputCls}
value={label.darkness ?? ""}
min={-30}
max={30}
onChange={(e) =>
onUpdate({ darkness: parseIntOrUndef(e.target.value) })
}
/>
<p className="text-[10px] text-muted">{t.label.darknessHint}</p>
</div>

<div className="flex flex-col gap-1">
<label className={labelCls}>{t.label.mediaType}</label>
<select
className={inputCls}
value={label.mediaType ?? ""}
onChange={(e) =>
onUpdate({
mediaType:
(e.target.value as LabelConfig["mediaType"]) || undefined,
})
}
>
<option value="">{t.label.printerDefault}</option>
<option value="T">{t.label.mediaTypeT}</option>
<option value="D">{t.label.mediaTypeD}</option>
</select>
</div>

<div className="flex flex-col gap-1">
<label className={labelCls}>{t.label.printOrientation}</label>
<select
className={inputCls}
value={label.printOrientation ?? ""}
onChange={(e) =>
onUpdate({
printOrientation:
(e.target.value as LabelConfig["printOrientation"]) ||
undefined,
})
}
>
<option value="">{t.label.printerDefault}</option>
<option value="N">{t.label.printOrientationN}</option>
<option value="I">{t.label.printOrientationI}</option>
</select>
</div>

<div className="flex flex-col gap-1">
<label className={labelCls}>{t.label.defaultFont}</label>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-1">
<label className="text-[10px] text-muted">
{t.label.defaultFontId}
</label>
<input
type="text"
className={inputCls}
maxLength={2}
value={label.defaultFont?.fontId ?? ""}
onChange={(e) =>
onUpdate({
defaultFont: mergeDefaultFont(label.defaultFont, {
fontId: e.target.value,
}),
})
}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] text-muted">
{t.label.defaultFontHeight}
</label>
<input
type="number"
className={inputCls}
min={1}
value={label.defaultFont?.height ?? ""}
onChange={(e) =>
onUpdate({
defaultFont: mergeDefaultFont(label.defaultFont, {
height: parseIntOrUndef(e.target.value),
}),
})
}
/>
</div>
</div>
</div>
</div>
</div>
);
}

function parseIntOrUndef(raw: string): number | undefined {
if (raw.trim() === "") return undefined;
const n = parseInt(raw, 10);
return isNaN(n) ? undefined : n;
}

function mergeDefaultFont(
current: LabelConfig["defaultFont"],
patch: { fontId?: string; height?: number },
): LabelConfig["defaultFont"] {
// `in` distinguishes "key absent from patch" (keep current value) from
// "key set to undefined" (user just cleared the input). `??` would conflate
// the two and silently keep the old value when the user clears a field.
const fontId = "fontId" in patch ? patch.fontId : current?.fontId;
const height = "height" in patch ? patch.height : current?.height;
if (!fontId || height === undefined) return undefined;
Comment thread
u8array marked this conversation as resolved.
Outdated
return { fontId, height };
}
84 changes: 84 additions & 0 deletions src/lib/zplGenerator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,72 @@ describe('generateZPL — structure', () => {
});
});

describe('generateZPL — printer params', () => {
it('emits ^PR when printSpeed is set', () => {
const zpl = generateZPL({ ...BASE_LABEL, printSpeed: 6 }, []);
expect(zpl).toContain('^PR6');
});

it('omits ^PR when printSpeed is absent', () => {
expect(generateZPL(BASE_LABEL, [])).not.toContain('^PR');
});

it('emits ^MD for darkness boundaries including 0', () => {
expect(generateZPL({ ...BASE_LABEL, darkness: 0 }, [])).toContain('^MD0');
expect(generateZPL({ ...BASE_LABEL, darkness: 30 }, [])).toContain('^MD30');
expect(generateZPL({ ...BASE_LABEL, darkness: -30 }, [])).toContain('^MD-30');
});

it('omits ^MD when darkness is absent', () => {
expect(generateZPL(BASE_LABEL, [])).not.toContain('^MD');
});

it('emits ^MT for thermal transfer and direct thermal', () => {
expect(generateZPL({ ...BASE_LABEL, mediaType: 'T' }, [])).toContain('^MTT');
expect(generateZPL({ ...BASE_LABEL, mediaType: 'D' }, [])).toContain('^MTD');
});

it('emits ^PO for both orientations when explicitly set', () => {
expect(generateZPL({ ...BASE_LABEL, printOrientation: 'I' }, [])).toContain('^POI');
expect(generateZPL({ ...BASE_LABEL, printOrientation: 'N' }, [])).toContain('^PON');
});

it('omits ^PO when print orientation is absent', () => {
expect(generateZPL(BASE_LABEL, [])).not.toContain('^PO');
});

it('emits ^CF when defaultFont is set', () => {
const zpl = generateZPL(
{ ...BASE_LABEL, defaultFont: { fontId: '0', height: 30 } },
[],
);
expect(zpl).toContain('^CF0,30');
});

it('emits printer params in canonical header order before ^LS', () => {
const zpl = generateZPL(
{
...BASE_LABEL,
mediaMode: 'T',
mediaType: 'T',
printSpeed: 6,
darkness: 10,
printOrientation: 'I',
defaultFont: { fontId: '0', height: 30 },
labelShift: 5,
},
[],
);
const idx = (cmd: string) => zpl.indexOf(cmd);
expect(idx('^MMT')).toBeLessThan(idx('^MTT'));
expect(idx('^MTT')).toBeLessThan(idx('^PR6'));
expect(idx('^PR6')).toBeLessThan(idx('^MD10'));
expect(idx('^MD10')).toBeLessThan(idx('^POI'));
expect(idx('^POI')).toBeLessThan(idx('^CF0,30'));
expect(idx('^CF0,30')).toBeLessThan(idx('^LS5'));
});
});

describe('generateZPL — text object', () => {
it('emits ^FO, ^A0 and ^FD for a text object', () => {
const { objects } = parseZPL('^XA^FO10,20^A0N,30,0^FDHello^FS^XZ', 8);
Expand Down Expand Up @@ -141,4 +207,22 @@ describe('generateZPL — parse/generate roundtrip', () => {
expect(props(barcode).content).toBe('987654');
expect(props(barcode).height).toBe(150);
});

it('preserves printer params through generate -> parse', () => {
const label: LabelConfig = {
...BASE_LABEL,
printSpeed: 8,
darkness: 0,
mediaType: 'D',
printOrientation: 'I',
defaultFont: { fontId: '0', height: 30 },
};
const regenerated = generateZPL(label, []);
const { labelConfig } = parseZPL(regenerated, BASE_LABEL.dpmm);
expect(labelConfig.printSpeed).toBe(8);
expect(labelConfig.darkness).toBe(0);
expect(labelConfig.mediaType).toBe('D');
expect(labelConfig.printOrientation).toBe('I');
expect(labelConfig.defaultFont).toEqual({ fontId: '0', height: 30 });
});
});
8 changes: 8 additions & 0 deletions src/lib/zplGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ export function generateZPL(label: LabelConfig, objects: LabelObject[]): string
];

if (label.mediaMode) lines.push(`^MM${label.mediaMode}`);
if (label.mediaType) lines.push(`^MT${label.mediaType}`);
if (label.printSpeed !== undefined) lines.push(`^PR${label.printSpeed}`);
// darkness=0 is a valid value (printer baseline), so check undefined explicitly.
if (label.darkness !== undefined) lines.push(`^MD${label.darkness}`);
if (label.printOrientation) lines.push(`^PO${label.printOrientation}`);
if (label.defaultFont) {
Comment thread
u8array marked this conversation as resolved.
Outdated
lines.push(`^CF${label.defaultFont.fontId},${label.defaultFont.height}`);
}
if (label.labelShift) lines.push(`^LS${label.labelShift}`);

lines.push(...objects.map((obj) => {
Expand Down
38 changes: 38 additions & 0 deletions src/lib/zplParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,44 @@ describe('parseZPL — ^MM and ^LS', () => {
});
});

describe('parseZPL — printer params', () => {
it('parses ^PR print speed within range', () => {
const { labelConfig } = parseZPL('^XA^PR6^XZ', 8);
expect(labelConfig.printSpeed).toBe(6);
});

it('ignores ^PR with out-of-range value', () => {
const { labelConfig } = parseZPL('^XA^PR1^XZ', 8);
expect(labelConfig.printSpeed).toBeUndefined();
});

it('parses ^MD darkness including 0', () => {
expect(parseZPL('^XA^MD0^XZ', 8).labelConfig.darkness).toBe(0);
expect(parseZPL('^XA^MD15^XZ', 8).labelConfig.darkness).toBe(15);
expect(parseZPL('^XA^MD-10^XZ', 8).labelConfig.darkness).toBe(-10);
});

it('ignores ^MD outside the supported range', () => {
const { labelConfig } = parseZPL('^XA^MD99^XZ', 8);
expect(labelConfig.darkness).toBeUndefined();
});

it('parses ^MT media type', () => {
expect(parseZPL('^XA^MTT^XZ', 8).labelConfig.mediaType).toBe('T');
expect(parseZPL('^XA^MTD^XZ', 8).labelConfig.mediaType).toBe('D');
});

it('parses ^PO print orientation', () => {
expect(parseZPL('^XA^PON^XZ', 8).labelConfig.printOrientation).toBe('N');
expect(parseZPL('^XA^POI^XZ', 8).labelConfig.printOrientation).toBe('I');
});

it('parses ^CF default font with id and height', () => {
const { labelConfig } = parseZPL('^XA^CF0,40^XZ', 8);
expect(labelConfig.defaultFont).toEqual({ fontId: '0', height: 40 });
});
});

// ── edge cases ────────────────────────────────────────────────────────────────

describe('parseZPL — edge cases', () => {
Expand Down
29 changes: 26 additions & 3 deletions src/lib/zplParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -645,8 +645,13 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL {
// ── Change alphanumeric default font ────────────────────────────────────
// ^CF{font},{height},{width} → sets default for fields without ^A
CF(p) {
cfHeight = int(p[1], cfHeight);
const fontId = (p[0] ?? "").trim();
const explicitHeight = parseInt(p[1] ?? "", 10);
cfHeight = isNaN(explicitHeight) ? cfHeight : explicitHeight;
cfWidth = int(p[2], cfWidth);
if (fontId && !isNaN(explicitHeight) && explicitHeight > 0) {
labelConfig.defaultFont = { fontId, height: explicitHeight };
}
},

// ── Field-wide default rotation ─────────────────────────────────────────
Expand Down Expand Up @@ -1097,6 +1102,26 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL {
const shift = int(rest, 0);
if (shift !== 0) labelConfig.labelShift = shift;
},
PR(p) {
const speed = int(p[0], 0);
if (speed >= 2 && speed <= 14) labelConfig.printSpeed = speed;
},
MD(_, rest) {
// Direct parse: int() falls back to 0 on NaN, which would conflate
// "absent" with the valid darkness value 0.
const parsed = parseInt(rest, 10);
if (!isNaN(parsed) && parsed >= -30 && parsed <= 30) {
labelConfig.darkness = parsed;
}
},
MT(_, rest) {
const mt = (rest[0] ?? "").toUpperCase();
Comment thread
u8array marked this conversation as resolved.
if (mt === "T" || mt === "D") labelConfig.mediaType = mt;
},
PO(_, rest) {
const po = (rest[0] ?? "").toUpperCase();
Comment thread
u8array marked this conversation as resolved.
if (po === "N" || po === "I") labelConfig.printOrientation = po;
},
Comment thread
u8array marked this conversation as resolved.

// ── Browser-limit: printer-specific features ────────────────────────────
CW: mkBrowserLimit("CW"), // font identifier — assigns alias to printer-resident font
Expand Down Expand Up @@ -1153,7 +1178,6 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL {
FE: noop, // field concatenation — appends data to current field
FM: noop, // multiple field origin locations
FP: noop, // field parameter — per-character text direction
MT: noop, // media type
MN: noop, // media handling / notch tracking
JA: noop, // applicator / configuration recall
JM: noop, // darkness / print settings
Expand All @@ -1164,7 +1188,6 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL {
JR: noop, // restore factory defaults
JS: noop, // change darkness
JU: noop, // update firmware
PR: noop, // print rate / speed
PM: noop, // part of message
PP: noop, // presentation position
};
Expand Down
Loading