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: 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
115 changes: 114 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,120 @@ 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.defaultFontId ?? ""}
onChange={(e) =>
onUpdate({ defaultFontId: e.target.value || undefined })
}
/>
</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.defaultFontHeight ?? ""}
onChange={(e) =>
onUpdate({
defaultFontHeight: 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;
}
116 changes: 116 additions & 0 deletions src/lib/zplGenerator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,88 @@ 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 both defaultFontId and defaultFontHeight are set', () => {
const zpl = generateZPL(
{ ...BASE_LABEL, defaultFontId: '0', defaultFontHeight: 30 },
[],
);
expect(zpl).toContain('^CF0,30');
});

it('emits ^CF{id} when only defaultFontId is set', () => {
const zpl = generateZPL({ ...BASE_LABEL, defaultFontId: '0' }, []);
expect(zpl).toContain('^CF0');
expect(zpl).not.toContain('^CF0,');
});

it('emits ^CF,{height} when only defaultFontHeight is set', () => {
expect(generateZPL({ ...BASE_LABEL, defaultFontHeight: 30 }, []))
.toContain('^CF,30');
});

it('omits ^CF when neither defaultFont field is set', () => {
expect(generateZPL(BASE_LABEL, [])).not.toContain('^CF');
});

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',
defaultFontId: '0',
defaultFontHeight: 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 +223,38 @@ 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',
defaultFontId: '0',
defaultFontHeight: 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.defaultFontId).toBe('0');
expect(labelConfig.defaultFontHeight).toBe(30);
});

it('preserves partial ^CF (id only) through generate -> parse', () => {
const regenerated = generateZPL({ ...BASE_LABEL, defaultFontId: 'A' }, []);
const { labelConfig } = parseZPL(regenerated, BASE_LABEL.dpmm);
expect(labelConfig.defaultFontId).toBe('A');
expect(labelConfig.defaultFontHeight).toBeUndefined();
});

it('preserves partial ^CF (height only) through generate -> parse', () => {
const regenerated = generateZPL({ ...BASE_LABEL, defaultFontHeight: 25 }, []);
const { labelConfig } = parseZPL(regenerated, BASE_LABEL.dpmm);
expect(labelConfig.defaultFontId).toBeUndefined();
expect(labelConfig.defaultFontHeight).toBe(25);
});
});
14 changes: 14 additions & 0 deletions src/lib/zplGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,20 @@ 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}`);
// ^CF parameters are individually optional per Zebra spec: ^CF0 sets the
// font only, ^CF,30 sets the height only. Preserves round-trip fidelity
// when an imported label used a partial command.
if (label.defaultFontId || label.defaultFontHeight !== undefined) {
const id = label.defaultFontId ?? "";
const height =
label.defaultFontHeight !== undefined ? `,${label.defaultFontHeight}` : "";
lines.push(`^CF${id}${height}`);
}
if (label.labelShift) lines.push(`^LS${label.labelShift}`);

lines.push(...objects.map((obj) => {
Expand Down
39 changes: 39 additions & 0 deletions src/lib/zplParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,45 @@ 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 into defaultFontId and defaultFontHeight', () => {
const { labelConfig } = parseZPL('^XA^CF0,40^XZ', 8);
expect(labelConfig.defaultFontId).toBe('0');
expect(labelConfig.defaultFontHeight).toBe(40);
});
});

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

describe('parseZPL — edge cases', () => {
Expand Down
Loading