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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ See [docs/zpl-roadmap.md](docs/zpl-roadmap.md) for ZPL II command coverage.
## Limitations

- The canvas is a design preview, not a pixel-perfect simulation. Shapes, spacing, and positions match the print; text approximates Zebra's built-in font to within a few dots, but exact letterforms and anti-aliasing differ. For a faithful render, use the **Preview** in the bottom-right panel (powered by Labelary).
- Label preview requires a connection to `api.labelary.com`.
- Label preview is rendered by Labelary. The default build calls `api.labelary.com`; self-hosters can point at a private endpoint or turn it off.
- The Labelary preview doesn't render every ZPL feature. Some less common elements (e.g. Codablock F barcodes) may be missing or wrong in the preview even when the actual print is fine.
- The Labelary preview shows only the current page; the printed/exported ZPL still contains every page.

Expand Down
3 changes: 2 additions & 1 deletion src/components/Output/ZplImportModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { useRef, useState } from 'react';
import { XMarkIcon, ClipboardDocumentIcon, CheckIcon, FolderOpenIcon } from '@heroicons/react/16/solid';
import { importZplText } from '../../lib/zplImportService';
import { readFileAsText } from '../../lib/readFile';
import { useLabelStore, type Page } from '../../store/labelStore';
import { useLabelStore } from '../../store/labelStore';
import type { Page } from '../../types/Group';
import type { LabelConfig } from '../../types/ObjectType';
import type { Variable } from '../../types/Variable';
import { formatReportAsText, type ImportReport, type ImportResult } from '../../lib/importReport';
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useCsvImportActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ export function useCsvImportActions() {
function applyImport(p: ParsedImport, opts: { keepMapping: boolean }): void {
const { loadCsv, setCsvMapping, openCsvMappingModal, csvMapping } =
useLabelStore.getState();
rememberImport(p.file, p.bytes, p.text);
rememberImport(p.bytes, p.text);
const effectiveMapping: CsvMapping | null = opts.keepMapping ? csvMapping : null;
if (!opts.keepMapping) setCsvMapping(null);
loadCsv(p.result);
Expand Down
61 changes: 35 additions & 26 deletions src/hooks/useZplImportExport.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,42 @@
import { useState } from "react";
import { useLabelStore, useCurrentObjects } from "../store/labelStore";
import {
currentObjects,
selectBatchInputs,
selectCanBatchExport,
useLabelStore,
} from "../store/labelStore";
import { generateMultiPageZPL, generateBatchZpl } from "../lib/zplGenerator";
import { printLabel } from "../lib/printPreview";
import { triggerDownload } from "../lib/triggerDownload";
import { labelaryErrorMessage } from "../lib/labelary";
import { buildActiveCsvRow } from "../lib/variableBinding";

export function useZplImportExport() {
const label = useLabelStore((s) => s.label);
const pages = useLabelStore((s) => s.pages);
const variables = useLabelStore((s) => s.variables);
const csvDataset = useLabelStore((s) => s.csvDataset);
const csvMapping = useLabelStore((s) => s.csvMapping);
const objects = useCurrentObjects();
// Reactive: only what the UI rendering needs (menu enable +
// label-count text). Event handlers below all read a fresh
// snapshot via `useLabelStore.getState()` so generator inputs
// come from the same point in time and the hook doesn't re-
// render on every label / page / variable edit.
const canBatchExport = useLabelStore(selectCanBatchExport);
const batchRowCount = useLabelStore((s) => s.csvDataset?.rows.length ?? 0);

const [showZplImport, setShowZplImport] = useState(false);
const [showZebraPrint, setShowZebraPrint] = useState(false);
const [printError, setPrintError] = useState<string | null>(null);

// Batch export is only meaningful with both a dataset and at least one
// mapped variable. Without either, the output would be identical to a
// single label and surfacing the action just clutters the menu.
const canBatchExport =
csvDataset !== null &&
csvDataset.rows.length > 0 &&
csvMapping !== null &&
Object.keys(csvMapping.bindings).length > 0;

const handleDownload = () => {
const zpl = generateMultiPageZPL(label, pages, variables);
const s = useLabelStore.getState();
const zpl = generateMultiPageZPL(s.label, s.pages, s.variables);
triggerDownload(new Blob([zpl], { type: "text/plain" }), "label.zpl");
};

const handleExportBatch = () => {
if (!canBatchExport) return;
const zpl = generateBatchZpl(label, objects, variables, csvDataset, csvMapping);
const s = useLabelStore.getState();
const batch = selectBatchInputs(s);
if (!batch) return;
const zpl = generateBatchZpl(
s.label, currentObjects(s), s.variables, batch.dataset, batch.mapping,
);
triggerDownload(new Blob([zpl], { type: "text/plain" }), "label-batch.zpl");
};

Expand All @@ -42,9 +45,10 @@ export function useZplImportExport() {
// active CSV row (if any) is substituted into bound fields so the
// preview reflects what would actually print for the selected row.
const handlePrint = async () => {
const s = useLabelStore.getState();
try {
const active = buildActiveCsvRow(csvDataset, csvMapping);
await printLabel(label, objects, variables, active);
const active = buildActiveCsvRow(s.csvDataset, s.csvMapping);
await printLabel(s.label, currentObjects(s), s.variables, active);
} catch (e) {
setPrintError(labelaryErrorMessage(e));
}
Expand All @@ -53,10 +57,15 @@ export function useZplImportExport() {
// ZPL surfaced to direct-print: batch form when a CSV is in play (so
// sending to the printer produces N labels), otherwise the same
// template the editor displays.
const currentZpl = () =>
canBatchExport
? generateBatchZpl(label, objects, variables, csvDataset, csvMapping)
: generateMultiPageZPL(label, pages, variables);
const currentZpl = () => {
const s = useLabelStore.getState();
const batch = selectBatchInputs(s);
return batch
? generateBatchZpl(
s.label, currentObjects(s), s.variables, batch.dataset, batch.mapping,
)
: generateMultiPageZPL(s.label, s.pages, s.variables);
};

return {
showZplImport,
Expand All @@ -71,7 +80,7 @@ export function useZplImportExport() {
handleDownload,
handleExportBatch,
canBatchExport,
batchRowCount: csvDataset?.rows.length ?? 0,
batchRowCount,
handlePrint,
};
}
9 changes: 3 additions & 6 deletions src/lib/csvImport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,28 +138,25 @@ describe('encoding cache', () => {
});

it('rememberImport + decodeImportedText round-trips UTF-8', () => {
const file = new File([''], 't.csv', { type: 'text/csv' });
const bytes = new TextEncoder().encode('sku,qty\n');
rememberImport(file, bytes, new TextDecoder('utf-8').decode(bytes));
rememberImport(bytes, new TextDecoder('utf-8').decode(bytes));
expect(getImportedBytes()).toBe(bytes);
expect(decodeImportedText('utf-8')).toBe('sku,qty\n');
forgetImport();
});

it('decodeImportedText with windows-1252 turns 0xE4 into ä', () => {
const file = new File([''], 't.csv', { type: 'text/csv' });
// 0xE4 in windows-1252 (ANSI) is "ä". The same byte in UTF-8 is
// a continuation byte (invalid as a standalone), so the two
// decodings of the same bytes should differ.
const bytes = new Uint8Array([0x73, 0xE4, 0x6F]); // s ä o (CP1252)
rememberImport(file, bytes, new TextDecoder('utf-8').decode(bytes));
rememberImport(bytes, new TextDecoder('utf-8').decode(bytes));
expect(decodeImportedText('windows-1252')).toBe('säo');
forgetImport();
});

it('forgetImport clears the cache', () => {
const file = new File([''], 't.csv', { type: 'text/csv' });
rememberImport(file, new Uint8Array([1, 2, 3]), 'abc');
rememberImport(new Uint8Array([1, 2, 3]), 'abc');
forgetImport();
expect(getImportedBytes()).toBeNull();
expect(decodeImportedText('utf-8')).toBeNull();
Expand Down
9 changes: 1 addition & 8 deletions src/lib/csvImport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,26 +154,19 @@ export function parseCsvText(
* mapping modal needs synchronous re-parse on every option-change
* keystroke. Mirrors the previewCache pattern in labelStore.ts.
*/
let lastImportedFile: File | null = null;
let lastImportedBytes: Uint8Array | null = null;
let lastImportedText: string | null = null;

export function rememberImport(file: File, bytes: Uint8Array, text: string): void {
lastImportedFile = file;
export function rememberImport(bytes: Uint8Array, text: string): void {
lastImportedBytes = bytes;
lastImportedText = text;
}

export function forgetImport(): void {
lastImportedFile = null;
lastImportedBytes = null;
lastImportedText = null;
}

export function getImportedFile(): File | null {
return lastImportedFile;
}

export function getImportedText(): string | null {
return lastImportedText;
}
Expand Down
11 changes: 9 additions & 2 deletions src/lib/zplCommandSupport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@ export const ZPL_COMMANDS: readonly ZplCommandInfo[] = [
{ cmd: 'FC', status: 'unsupported', description: 'Field clock — inserts date/time into field data' },
{ cmd: 'FE', status: 'unsupported', description: 'Field concatenation — appends data to the current field' },
{ cmd: 'FM', status: 'unsupported', description: 'Multiple field origin locations' },
{ cmd: 'FN', status: 'unsupported', description: 'Field number — variable field placeholder for recall/merge' },
{ cmd: 'FN', status: 'supported', description: 'Field number — variable field placeholder, lands in the Variables tab on import and emits as ^FN{slot} on export' },
{ cmd: 'FP', status: 'unsupported', description: 'Field parameter — sets character-by-character text direction' },
{ cmd: 'FV', status: 'unsupported', description: 'Field variable — supplies data for a ^FN field at print time' },
{ cmd: 'FV', status: 'supported', description: 'Field variable — supplies data for a ^FN field at print time; populates the bound Variable\'s default on import' },

// ── Fonts & text ──────────────────────────────────────────────────────────
{ cmd: 'A0', status: 'supported', description: 'Scalable/bitmap font 0 — primary designer font' },
Expand Down Expand Up @@ -171,6 +171,13 @@ export const ZPL_COMMANDS: readonly ZplCommandInfo[] = [
description: 'Download graphic to printer storage (~DG)',
loss: 'Stores data on the physical printer; not relevant for canvas label design',
},

// ── Templates & batch merge (round-trip on parse; emit as ^DFR/^XFR
// for CSV-driven batch printing) ──────────────────────────────────────────
{ cmd: 'DF', status: 'supported', description: 'Download format — used as ^DFR:LBL.ZPL to store the design template once, recalled per CSV row during batch export' },
{ cmd: 'XF', status: 'supported', description: 'Recall format — pulled per row in batch export, paired with ^FN overrides' },
{ cmd: 'XG', status: 'supported', description: 'Recall graphic — used together with ~DY for printer-resident images' },
{ cmd: 'DY', status: 'supported', description: 'Download object (~DY) — embeds custom fonts and graphics so the printer can resolve ^A aliases and ^XG recalls' },
] as const;

/** O(1) lookup map: command code → info */
Expand Down
41 changes: 18 additions & 23 deletions src/lib/zplGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@ import { mmToDots } from './coordinates';
import { ObjectRegistry } from '../registry';
import { fdField, stripZplCommandChars } from '../registry/zplHelpers';
import type { CustomFontMapping, LabelConfig } from '../types/ObjectType';
import type { Page } from '../store/labelStore';
import type { Variable } from '../types/Variable';
import { isGroup, type LabelObject } from '../types/Group';
import { isGroup, type LabelObject, type Page } from '../types/Group';
import { getFontBytes } from './fontCache';
import type { ImageProps } from '../registry/image';
import { formatStoragePath } from './storagePath';
Expand Down Expand Up @@ -91,30 +90,23 @@ export function generateMultiPageZPL(
return pages.map((p) => generateZPL(label, p.objects, variables)).join('\n');
}

/** Drive + filename used to stash the batch template on the printer.
* R: is RAM (volatile, dropped on power cycle), which matches a
* single-run batch scope. 8.3 filename avoids stomping on
* user-managed stored forms. */
const BATCH_TEMPLATE_PATH = 'R:LBL.ZPL';

/**
* Batch-print form for a single page-design driven by a CSV dataset.
* Emits two parts:
*
* 1. The template once, wrapped in `^DFR:LBL.ZPL` so the printer
* stores it under R:LBL.ZPL. The body keeps its `^FN` slots,
* so the printer treats it as a reusable form file.
* 2. One small `^XA^XFR:LBL.ZPL^FN…^FD…^FS…^XZ` block per CSV row
* that recalls the stored format and supplies per-row values
* via `^FN` overrides.
*
* This is the idiomatic ZPL data-merge pattern: the printer parses
* the heavy template once and applies cheap field overrides per
* label, so a 10k-row batch isn't 10k full copies of the design.
* Emits the template once (wrapped in `^DF{path}` so the printer
* stores it under `BATCH_TEMPLATE_PATH`) and then one small recall
* block per CSV row: `^XA^XF{path}^FN..^FD..^FS..^XZ`. This is the
* idiomatic ZPL data-merge pattern, so a 10k-row batch isn't 10k
* full copies of the design.
*
* Variables with no binding (or whose bound header is missing from
* the current dataset) emit no override; the printer falls back to
* the variable's `^FD` default that's baked into the stored template.
*
* The drive choice is R: (volatile RAM) so the stored format gets
* dropped on printer power-cycle — a single print run is the
* intended scope, not permanent provisioning. Filename `LBL.ZPL`
* keeps the printer's 8.3 limit and avoids stomping on user
* filenames that follow design conventions.
* the variable's `^FD` default baked into the stored template.
*/
export function generateBatchZpl(
label: LabelConfig,
Expand All @@ -132,7 +124,10 @@ export function generateBatchZpl(
// darkness) emit before ^XA, and a start-anchored regex would silently
// skip the inject — recall blocks below would then reference a form
// file the printer never stored.
const templateStored = baseZpl.replace(/\^XA\n/, '^XA\n^DFR:LBL.ZPL\n');
const templateStored = baseZpl.replace(
/\^XA\r?\n/,
`^XA\n^DF${BATCH_TEMPLATE_PATH}\n`,
);
Comment thread
u8array marked this conversation as resolved.

// Pre-compute (variable.fnNumber, columnIdx) per mapped variable so
// the per-row loop is a tight zip. Variables with no binding or whose
Expand All @@ -149,7 +144,7 @@ export function generateBatchZpl(
}

const recallBlocks = csvDataset.rows.map((row) => {
const lines: string[] = ['^XA', '^XFR:LBL.ZPL'];
const lines: string[] = ['^XA', `^XF${BATCH_TEMPLATE_PATH}`];
for (const { fn, colIdx } of overrides) {
const value = row[colIdx] ?? '';
// Route through `fdField` so values containing `^`/`~` get the
Expand Down
24 changes: 20 additions & 4 deletions src/store/labelStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
stripVariableIdFromObjects,
type GroupObject,
type LabelObject,
type Page,
} from '../types/Group';
import { locales } from '../locales';
import type { LocaleCode } from '../locales';
Expand Down Expand Up @@ -46,10 +47,6 @@ export interface CsvDataset {
export type { ObjectChanges };
export type { Variable, VariableInput };

export interface Page {
objects: LabelObject[];
}

/** Meta fields that remain editable on a locked object so the user can
* release the lock or annotate without unlocking first. Everything else
* (position, props, rotation, positionType) is blocked. */
Expand Down Expand Up @@ -348,6 +345,25 @@ export const selectLabelaryNoticeRequired = (s: LabelState): boolean =>
export const selectPreviewLocksEditor = (s: LabelState): boolean =>
s.previewMode.status === 'loading' || s.previewMode.status === 'active';

/** The dataset + mapping pair that batch emit needs, or null when
* batch emit would produce nothing different from a single label.
* Requires a loaded CSV with rows and at least one mapped Variable.
* Co-narrows the two store fields so callers don't repeat the
* null-checks for TS. */
export const selectBatchInputs = (
s: LabelState,
): { dataset: CsvDataset; mapping: CsvMapping } | null => {
const { csvDataset, csvMapping } = s;
if (!csvDataset || csvDataset.rows.length === 0) return null;
if (!csvMapping || Object.keys(csvMapping.bindings).length === 0) return null;
return { dataset: csvDataset, mapping: csvMapping };
};

/** Boolean form of {@link selectBatchInputs} — feeds the File menu
* enable state without triggering re-renders on the inner refs. */
export const selectCanBatchExport = (s: LabelState): boolean =>
selectBatchInputs(s) !== null;

function updateCurrentObjects(
state: PageState,
fn: (objects: LabelObject[]) => LabelObject[]
Expand Down
8 changes: 8 additions & 0 deletions src/types/Group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ export type GroupObject = LabelObjectBase & {
* consumers — keeping it here breaks the registry ↔ types cycle. */
export type LabelObject = LeafObject | GroupObject;

/** One page of label objects. The store holds a list of these; consumers
* that emit ZPL per-page (zplGenerator, design-file save) take the
* `Page` shape directly so the lib layer doesn't have to depend on
* the store. */
export interface Page {
objects: LabelObject[];
}

export function isGroup(obj: LabelObject): obj is GroupObject {
return obj.type === 'group';
}
Expand Down