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
7 changes: 5 additions & 2 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
# Generated by scripts/gen-attribution.mjs (always LF) so `attribution:check`
# diffs cleanly regardless of the checkout's core.autocrlf. The scripts emit LF,
# so they must be checked out LF too or a fresh Windows clone would taint output.
THIRD-PARTY-LICENSES.md text eol=lf
THIRD-PARTY-LICENSES-RUST.md text eol=lf
# -whitespace: these reproduce crate/npm license bodies verbatim, so trailing
# whitespace inside them is source text, not ours to strip (stripping would
# also break the release regeneration diff). Exempt from `git diff --check`.
THIRD-PARTY-LICENSES.md text eol=lf -whitespace
THIRD-PARTY-LICENSES-RUST.md text eol=lf -whitespace
scripts/*.mjs text eol=lf
scripts/**/*.mjs text eol=lf

Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,12 @@ Byte capture at import is deliberately conservative: when a field can't be mappe

File menu → **Add page** creates a new page. With multiple pages, the control at the bottom-center of the canvas switches between them and removes them. All pages share the same dimensions; export and import handle each page as a separate label.

### Batch printing from CSV
### Batch printing from data

File menu → **Import CSV data** loads a CSV. The mapping dialog pairs each Variable with a column, saved with the design. **Export batch ZPL** or **Send to Zebra Printer** then outputs one label per row.

On desktop, **Import Excel data** reads a worksheet and **Printer settings… → Data sources** pulls from a read-only SQLite/PostgreSQL/MySQL database (password in the OS keychain), through the same mapping and batch output. A design remembers its database link for one-click reload.

### Printer settings

File menu → **Printer settings…** configures label-level media and print quality, plus a Setup Script for clock, locale, encoding, and printer identity (meant to be sent once when first setting up a printer). Setup-Script values stay out of saved designs so sharing a `.zpl` or `.json` doesn't leak your printer name or locale.
Expand Down Expand Up @@ -137,7 +139,7 @@ Both `.zpl` and `.json` round-trip cleanly. `.zpl` preserves all printable conte
- Layers panel with reordering
- Lossless ZPL round-trip: imported ZPL re-exports byte-for-byte, regenerating only what you edit ([import guarantees](#import-guarantees))
- Variables: bind text and barcode fields to named defaults that emit as `^FN` slots (or `^FE` inline embeds when one field references multiple variables), round-tripping with printer-side templates
- CSV batch printing: import a CSV, map columns to Variables, print or export with efficient printer-side data merge (template ships once, each row sends only its overrides)
- Batch printing: map columns to Variables from a CSV, an Excel worksheet, or a read-only database (desktop), then print or export with efficient printer-side data merge (template ships once, each row sends only its overrides)
- GS1 content builder: assemble GS1 content from Application Identifiers (DataBar Expanded, GS1-128, and GS1 DataMatrix), validated per field and against GS1 combination rules
- Content builder: generate typed QR/DataMatrix/Aztec content (URL, WiFi, contact, email, phone, SMS, geo) with the right encoding and escaping
- EAN/UPC inline validation: live length counter, computed check-digit preview, and a GS1 prefix hint right under the content field
Expand Down
32 changes: 22 additions & 10 deletions packages/core/src/lib/designFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import { labelConfigSchema, type LabelConfig } from "../types/LabelConfig";
import { labelObjectBaseSchema } from "../types/LabelObject";
import {
variableSchema,
csvMappingSchema,
columnMappingSchema,
type Variable,
type CsvMapping,
type ColumnMapping,
} from "../types/Variable";
import { dbSourceRefSchema, type DbSourceRef } from "../types/DataSource";
import type { LabelObject } from "../types/Group";
import { blockOverlaySchema, type BlockOverlay } from "./zplOverlay/overlay";
import { visitLeavesInPages, foldSerialLeaf, bindSingleMarkerLeaf, sanitiseVariableNames, safeUniqueNameById } from "./objectTree";
Expand All @@ -26,10 +27,14 @@ export interface DesignFile {
label: LabelConfig;
pages: DesignFilePage[];
variables: Variable[];
/** Optional: present only when the user has imported a CSV and set
/** Optional: present only when the user has loaded a dataset and set
* up a mapping for the current design. Round-trips with the design;
* rows themselves are session-only and not part of the save. */
csvMapping: CsvMapping | null;
* rows themselves are session-only and not part of the save. On disk
* the key stays `csvMapping` (pre-rename files keep loading). */
columnMapping: ColumnMapping | null;
/** Optional pointer to the database the rows came from, so a reopened
* design can offer a one-click re-fetch. */
dataSource: DbSourceRef | null;
}

// Two distinct shapes share the base fields:
Expand Down Expand Up @@ -66,7 +71,10 @@ const designFileSchema = z.object({
label: labelConfigSchema,
pages: z.array(pageSchema),
variables: z.array(variableSchema).optional(),
csvMapping: csvMappingSchema.optional(),
csvMapping: columnMappingSchema.optional(),
// Comfort metadata only: a malformed pointer drops instead of rejecting
// the whole design (same policy as a broken overlay).
dataSource: dbSourceRefSchema.optional().catch(undefined),
});

export function parseDesignFile(text: string): Result<DesignFile, DesignFileError> {
Expand All @@ -93,7 +101,8 @@ export function parseDesignFile(text: string): Result<DesignFile, DesignFileErro
label: parsed.data.label,
pages,
variables,
csvMapping: parsed.data.csvMapping ?? null,
columnMapping: parsed.data.csvMapping ?? null,
dataSource: parsed.data.dataSource ?? null,
});
}

Expand Down Expand Up @@ -183,22 +192,25 @@ interface SerializedDesign {
label: LabelConfig;
pages: DesignFilePage[];
variables?: Variable[];
csvMapping?: CsvMapping;
csvMapping?: ColumnMapping;
dataSource?: DbSourceRef;
}

export function serializeDesign(
label: LabelConfig,
pages: DesignFilePage[],
variables: Variable[] = [],
csvMapping: CsvMapping | null = null,
columnMapping: ColumnMapping | null = null,
dataSource: DbSourceRef | null = null,
): string {
const payload: SerializedDesign = {
schemaVersion: CURRENT_DESIGN_SCHEMA_VERSION,
label,
pages,
};
if (variables.length > 0) payload.variables = variables;
if (csvMapping) payload.csvMapping = csvMapping;
if (columnMapping) payload.csvMapping = columnMapping;
if (dataSource) payload.dataSource = dataSource;
return JSON.stringify(payload, null, 2);
}

Expand Down
31 changes: 18 additions & 13 deletions packages/core/src/lib/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { getObjectStringContent, resolveForRow, variableSubstitutions } from "./
import { gs1ModeDSharedFns, isModeDLeaf } from "./gs1ModeDFns";
import { isBlankText } from "./zebraTextLayout";
import { resolveTextMode } from "../registry/text";
import type { CsvMapping, Variable } from "../types/Variable";
import type { ColumnMapping, Variable } from "../types/Variable";
import type { Unit } from "./units";
import {
PREFLIGHT_SEVERITY,
Expand All @@ -38,8 +38,8 @@ export function suppressPristineEmpty(

interface MarkerValueDeps {
variables: readonly Variable[];
csvDataset: { headers: readonly string[]; rows: readonly (readonly string[])[] } | null;
csvMapping: CsvMapping | null;
dataset: { headers: readonly string[]; rows: readonly (readonly string[])[] } | null;
columnMapping: ColumnMapping | null;
}

// Per-leaf cache: the row-walk over a large CSV must not rerun every render.
Expand Down Expand Up @@ -79,8 +79,8 @@ export function markerValueFindings(
if (
hit &&
hit.variables === deps.variables &&
hit.csvDataset === deps.csvDataset &&
hit.csvMapping === deps.csvMapping
hit.dataset === deps.dataset &&
hit.columnMapping === deps.columnMapping
) {
out.push(...hit.findings);
continue;
Expand All @@ -92,10 +92,10 @@ export function markerValueFindings(
// badge already covers the ACTIVE substitution (defaults, or the
// active row), so only the OTHER CSV rows need checking here; without
// a dataset there is nothing the badge doesn't see.
const rows = deps.csvDataset && deps.csvMapping ? deps.csvDataset.rows.map((_, i) => i) : [];
const rows = deps.dataset && deps.columnMapping ? deps.dataset.rows.map((_, i) => i) : [];
const details: string[] = [];
for (const rowIdx of rows) {
const resolved = resolveForRow(content, rowIdx, deps.variables, deps.csvDataset, deps.csvMapping);
const resolved = resolveForRow(content, rowIdx, deps.variables, deps.dataset, deps.columnMapping);
const at = rowIdx < 0 ? "defaults" : `row ${rowIdx + 1}`;
const segs = parseGs1ToSegments(resolved);
if (segs === null || segs.length === 0) {
Expand All @@ -120,12 +120,17 @@ export function markerValueFindings(
// row, else the defaults) per marker segment against the AI's
// length/charset/date rules. The resolved value IS the runtime
// value here, so an empty variable-AI value is a real error.
const rows = deps.csvDataset && deps.csvMapping ? deps.csvDataset.rows.map((_, i) => i) : [-1];
// A 0-row dataset prints the defaults too, so validate them like the
// no-dataset case rather than iterating an empty row set.
const rows =
deps.dataset && deps.columnMapping && deps.dataset.rows.length > 0
? deps.dataset.rows.map((_, i) => i)
: [-1];
const details: string[] = [];
outer: for (const rowIdx of rows) {
for (const seg of segments) {
if (!hasTemplateMarkers(seg.value)) continue;
const resolved = resolveForRow(seg.value, rowIdx, deps.variables, deps.csvDataset, deps.csvMapping);
const resolved = resolveForRow(seg.value, rowIdx, deps.variables, deps.dataset, deps.columnMapping);
const at = rowIdx < 0 ? "defaults" : `row ${rowIdx + 1}`;
const err = validateGs1SegmentResolved(seg.ai, seg.value, resolved, false);
if (err) details.push(`${at}: (${seg.ai}) ${err}`);
Expand All @@ -147,13 +152,13 @@ export function markerValueFindings(
} else if (typed) {
const parsed = parseContent(content);
const errors = typedContentMarkerFindings(
parsed.type, parsed.fields, deps.variables, deps.csvDataset, deps.csvMapping,
parsed.type, parsed.fields, deps.variables, deps.dataset, deps.columnMapping,
);
for (const [field, chars] of Object.entries(errors)) {
findings.push(finding(leaf, "markerValueUnsafe", `${field}: ${chars}`));
}
const rows = typedContentIncompleteRows(
parsed.type, parsed.fields, deps.variables, deps.csvDataset, deps.csvMapping,
parsed.type, parsed.fields, deps.variables, deps.dataset, deps.columnMapping,
);
if (rows.length > 0) {
const shown = rows.slice(0, 5).join(", ") + (rows.length > 5 ? ", …" : "");
Expand All @@ -169,7 +174,7 @@ export function markerValueFindings(
for (const name of new Set(extractTemplateRefs(content))) {
const v = byName.get(name);
if (!v) continue;
if (variableSubstitutions(v, deps.csvDataset, deps.csvMapping).some((val) => val.includes(blockHazard))) {
if (variableSubstitutions(v, deps.dataset, deps.columnMapping).some((val) => val.includes(blockHazard))) {
dirty.push(v.name);
}
}
Expand Down Expand Up @@ -197,7 +202,7 @@ export function markerValueFindings(
for (const name of new Set(extractTemplateRefs(content))) {
const v = byName.get(name);
if (!v || !shared.has(v.fnNumber)) continue;
if (variableSubstitutions(v, deps.csvDataset, deps.csvMapping).some((val) => val.includes(">"))) {
if (variableSubstitutions(v, deps.dataset, deps.columnMapping).some((val) => val.includes(">"))) {
dirty.push(v.name);
}
}
Expand Down
22 changes: 11 additions & 11 deletions packages/core/src/lib/typedContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

import { extractTemplateRefs, hasTemplateMarkers, mapLiteralSpans } from "./fnTemplate";
import { resolveForRow, variableSubstitutions } from "./variableBinding";
import type { CsvMapping, Variable } from "../types/Variable";
import type { ColumnMapping, Variable } from "../types/Variable";

// The array is the single source; the union derives from it so the two can't
// drift (the encode/complete switches and the modal's field Record stay
Expand Down Expand Up @@ -95,8 +95,8 @@ export function typedContentMarkerFindings(
type: ContentType,
fields: ContentFields,
variables: readonly Variable[],
csvDataset: { headers: readonly string[]; rows: readonly (readonly string[])[] } | null,
csvMapping: CsvMapping | null,
dataset: { headers: readonly string[]; rows: readonly (readonly string[])[] } | null,
columnMapping: ColumnMapping | null,
): Record<string, string> {
const byName = new Map(variables.map((v) => [v.name, v]));
const out: Record<string, string> = {};
Expand All @@ -106,7 +106,7 @@ export function typedContentMarkerFindings(
for (const name of new Set(extractTemplateRefs(raw))) {
const variable = byName.get(name);
if (!variable) continue;
for (const value of variableSubstitutions(variable, csvDataset, csvMapping)) {
for (const value of variableSubstitutions(variable, dataset, columnMapping)) {
const chars = markerUnsafeChars(type, key, value);
if (chars) for (const c of chars.split(" ")) hits.add(c);
}
Expand All @@ -117,24 +117,24 @@ export function typedContentMarkerFindings(
}

/** Rows whose print-time substitution yields an incomplete payload (e.g. an
* empty CSV cell blanking a required WiFi SSID). With a dataset every row is
* checked; without one only the defaults ([0] returned on failure). Clock
* markers are never empty. */
* empty CSV cell blanking a required WiFi SSID). Each row is checked, but with
* no dataset OR a 0-row one the defaults print, so those validate `-1` instead.
* Clock markers are never empty. */
export function typedContentIncompleteRows(
type: ContentType,
fields: ContentFields,
variables: readonly Variable[],
csvDataset: { headers: readonly string[]; rows: readonly (readonly string[])[] } | null,
csvMapping: CsvMapping | null,
dataset: { headers: readonly string[]; rows: readonly (readonly string[])[] } | null,
columnMapping: ColumnMapping | null,
): number[] {
if (!Object.values(fields).some(hasTemplateMarkers)) return [];
const rows = csvDataset && csvMapping ? csvDataset.rows.map((_, i) => i) : [-1];
const rows = dataset && columnMapping && dataset.rows.length > 0 ? dataset.rows.map((_, i) => i) : [-1];
const bad: number[] = [];
for (const rowIdx of rows) {
const resolved = Object.fromEntries(
Object.entries(fields).map(([k, v]) => [
k,
hasTemplateMarkers(v) ? resolveForRow(v, rowIdx, variables, csvDataset, csvMapping) : v,
hasTemplateMarkers(v) ? resolveForRow(v, rowIdx, variables, dataset, columnMapping) : v,
]),
);
if (!isContentComplete(type, resolved)) bad.push(rowIdx + 1);
Expand Down
Loading