diff --git a/README.md b/README.md index dc63d617..d7bc91bf 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/components/Output/ZplImportModal.tsx b/src/components/Output/ZplImportModal.tsx index 5b6d5df7..31003176 100644 --- a/src/components/Output/ZplImportModal.tsx +++ b/src/components/Output/ZplImportModal.tsx @@ -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'; diff --git a/src/hooks/useCsvImportActions.ts b/src/hooks/useCsvImportActions.ts index e851eed9..d9b2c658 100644 --- a/src/hooks/useCsvImportActions.ts +++ b/src/hooks/useCsvImportActions.ts @@ -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); diff --git a/src/hooks/useZplImportExport.ts b/src/hooks/useZplImportExport.ts index 3efb0fe5..0e046a0b 100644 --- a/src/hooks/useZplImportExport.ts +++ b/src/hooks/useZplImportExport.ts @@ -1,5 +1,10 @@ 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"; @@ -7,33 +12,31 @@ 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(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"); }; @@ -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)); } @@ -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, @@ -71,7 +80,7 @@ export function useZplImportExport() { handleDownload, handleExportBatch, canBatchExport, - batchRowCount: csvDataset?.rows.length ?? 0, + batchRowCount, handlePrint, }; } diff --git a/src/lib/csvImport.test.ts b/src/lib/csvImport.test.ts index d2297073..02c7ef4d 100644 --- a/src/lib/csvImport.test.ts +++ b/src/lib/csvImport.test.ts @@ -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(); diff --git a/src/lib/csvImport.ts b/src/lib/csvImport.ts index 2b83e6b9..e08aacb6 100644 --- a/src/lib/csvImport.ts +++ b/src/lib/csvImport.ts @@ -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; } diff --git a/src/lib/zplCommandSupport.ts b/src/lib/zplCommandSupport.ts index d503080c..31d921fa 100644 --- a/src/lib/zplCommandSupport.ts +++ b/src/lib/zplCommandSupport.ts @@ -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' }, @@ -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 */ diff --git a/src/lib/zplGenerator.ts b/src/lib/zplGenerator.ts index 6210ea86..b91929c5 100644 --- a/src/lib/zplGenerator.ts +++ b/src/lib/zplGenerator.ts @@ -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'; @@ -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, @@ -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`, + ); // Pre-compute (variable.fnNumber, columnIdx) per mapped variable so // the per-row loop is a tight zip. Variables with no binding or whose @@ -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 diff --git a/src/store/labelStore.ts b/src/store/labelStore.ts index cf2f19cf..e4d6a696 100644 --- a/src/store/labelStore.ts +++ b/src/store/labelStore.ts @@ -15,6 +15,7 @@ import { stripVariableIdFromObjects, type GroupObject, type LabelObject, + type Page, } from '../types/Group'; import { locales } from '../locales'; import type { LocaleCode } from '../locales'; @@ -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. */ @@ -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[] diff --git a/src/types/Group.ts b/src/types/Group.ts index d205593c..87c2851d 100644 --- a/src/types/Group.ts +++ b/src/types/Group.ts @@ -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'; }