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
4 changes: 4 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,9 @@ THIRD-PARTY-LICENSES-RUST.md text eol=lf
scripts/*.mjs text eol=lf
scripts/**/*.mjs text eol=lf

# Rust manifests: pin LF so a Windows checkout stops flagging Cargo.toml as
# perpetually modified (autocrlf phantom, empty content diff).
src-tauri/Cargo.toml text eol=lf

# Raw printer-capture fixtures: byte-exact, never eol-converted.
*.prn binary
2 changes: 2 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export default defineConfig([
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-non-null-assertion': 'warn',
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
// Omit-via-rest (`const { drop, ...keep } = obj`) is the intended pattern.
'@typescript-eslint/no-unused-vars': ['error', { ignoreRestSiblings: true }],
},
},
{
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
},
"scripts": {
"dev": "vite",
"build": "tsc -b && tsc -p tsconfig.test.json --noEmit && vite build",
"build": "tsc -b && tsc -p tsconfig.test.json --noEmit && tsc -p packages/mcp-server/tsconfig.json --noEmit && vite build",
"typecheck:test": "tsc -p tsconfig.test.json --noEmit",
"test": "vitest run",
"test:watch": "vitest",
Expand Down
4 changes: 4 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
"version": "0.0.0",
"type": "module",
"exports": {
"./registry": {
"types": "./src/registry/index.ts",
"default": "./src/registry/index.ts"
},
"./*": {
"types": "./src/*.ts",
"default": "./src/*.ts"
Expand Down
51 changes: 51 additions & 0 deletions packages/core/src/lib/importReport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { ImportFinding, ImportFindingKind, ImportReport } from './zplParser';

// Re-export the parser-side domain types so UI code talks to the report
// surface through this module exclusively instead of reaching into the
// parser for type imports.
export type { ImportFinding, ImportFindingKind, ImportReport };

/** Distinct command codes of one finding kind; backs the report's bucket views. */
export function dedupCommandsByKind(
findings: readonly ImportFinding[],
kind: ImportFindingKind,
): string[] {
return [...new Set(findings.filter((f) => f.kind === kind).map((f) => f.command))];
}

/** Routable setup-script findings; drives the import routing prompt. */
export function replayRiskFindings(report: ImportReport): ImportFinding[] {
return report.findings.filter((f) => f.kind === 'replayRisk');
}

/** Routable setup commands plus non-routable device actions, for the prompt list. */
export function printerCommandFindings(report: ImportReport): ImportFinding[] {
return report.findings.filter((f) => f.kind === 'replayRisk' || f.kind === 'deviceAction');
}

/** Report after routing setup commands out of the label: replayRisk resolved,
* overlay-dependent findings on routed pages moot, findings on dropped pages
* removed and survivors remapped to `keptPageIndexes` order. */
export function resolveRoutedReport(
report: ImportReport,
keptPageIndexes: readonly number[],
): ImportReport {
const riskPages = new Set(replayRiskFindings(report).map((f) => f.pageIndex));
const newIndexOf = new Map(keptPageIndexes.map((orig, i) => [orig, i]));
const findings = report.findings.flatMap((f) => {
if (f.kind === 'replayRisk') return [];
if ((f.kind === 'lossyEdit' || f.kind === 'deviceAction') && riskPages.has(f.pageIndex)) {
return [];
}
const newIndex = newIndexOf.get(f.pageIndex);
return newIndex === undefined ? [] : [{ ...f, pageIndex: newIndex }];
});
return {
findings,
partial: dedupCommandsByKind(findings, 'partial'),
browserLimit: dedupCommandsByKind(findings, 'browserLimit'),
unknown: dedupCommandsByKind(findings, 'unknown'),
replayRisk: dedupCommandsByKind(findings, 'replayRisk'),
deviceAction: dedupCommandsByKind(findings, 'deviceAction'),
};
}
6 changes: 5 additions & 1 deletion packages/core/src/lib/localStorageBucket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,9 @@ export function safeLocalStorageSet(key: string, value: string): void {

export function safeLocalStorageRemove(key: string): void {
if (!hasLocalStorage()) return;
localStorage.removeItem(key);
try {
localStorage.removeItem(key);
} catch {
// extension/policy interception can make removeItem throw; module must not
}
}
11 changes: 11 additions & 0 deletions packages/core/src/lib/objectBounds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,17 @@ export function isBarcode(obj: { type: string }): boolean {
return BARCODE_TYPES.has(obj.type);
}

/** True when objectBoundsDots estimates this leaf headlessly (no measured
* footprint): barcode registry footprint, single-line-text font estimate,
* image-without-heightDots square guess. Everything else is exact. */
export function boundsAreApprox(obj: LabelObject): boolean {
if (isBarcode(obj)) return true;
if (obj.type === "image") return obj.props.heightDots === undefined;
if (obj.type !== "text") return false;
const p = obj.props;
return !(resolveTextMode(p) !== "normal" && !!p.blockWidth && p.blockWidth > 0);
}

/** Axis-aligned model-space bbox (dots) for one object. Always the VISUAL
* top-left regardless of FO/FT, so align/distribute can use min/max edges. */
export function objectBoundsDots(obj: LabelObject, ctx: ObjectBoundsCtx): BoundingBoxDots {
Expand Down
76 changes: 76 additions & 0 deletions packages/core/src/lib/objectOverlap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Axis-aligned bbox intersections between leaf objects, as neutral geometric
// facts: intent is not in the model (text on a reverse box overlaps on
// purpose), so callers judge. Headless barcode bboxes are estimates (`approx`).

import type { LeafObject } from "../registry";
import {
boundsAreApprox,
objectBoundsDots,
type BoundingBoxDots,
type ObjectBoundsCtx,
} from "./objectBounds";

/** One leaf's bbox plus the approx flag; the shared input for bounds
* reporting and overlap detection so both derive from the same boxes. */
export interface LeafBoxDots {
id: string;
box: BoundingBoxDots;
/** Bbox is a headless estimate (barcode footprint or single-line text), not
* render-exact. See boundsAreApprox. */
approx: boolean;
}

export function leafBoxesDots(
leaves: readonly LeafObject[],
ctx: ObjectBoundsCtx,
): LeafBoxDots[] {
return leaves.map((l) => ({
id: l.id,
box: objectBoundsDots(l, ctx),
approx: boundsAreApprox(l),
}));
}

export interface OverlapDots {
a: string;
b: string;
/** Axis-aligned intersection rect in dots. */
x: number;
y: number;
width: number;
height: number;
/** Either bbox is approximate, so the rect isn't render-exact. */
approx: boolean;
}

function intersect(a: BoundingBoxDots, b: BoundingBoxDots): BoundingBoxDots | null {
const x = Math.max(a.x, b.x);
const y = Math.max(a.y, b.y);
const width = Math.min(a.x + a.width, b.x + b.width) - x;
const height = Math.min(a.y + a.height, b.y + b.height) - y;
return width > 0 && height > 0 ? { x, y, width, height } : null;
}

/** Overlap-count cap. The pairwise scan is O(n²); an unbounded result also
* bloats the agent payload. Past this the layout is degenerate anyway, so
* stop and let the caller flag truncation. */
export const MAX_OVERLAPS = 500;

/** Index loop (no per-row slice allocation) with an early exit at `cap`. */
export function computeOverlaps(
boxes: readonly LeafBoxDots[],
cap: number = MAX_OVERLAPS,
): OverlapDots[] {
const out: OverlapDots[] = [];
for (let i = 0; i < boxes.length && out.length < cap; i++) {
const bi = boxes[i];
if (!bi) continue;
for (let j = i + 1; j < boxes.length && out.length < cap; j++) {
const bj = boxes[j];
if (!bj) continue;
const rect = intersect(bi.box, bj.box);
if (rect) out.push({ a: bi.id, b: bj.id, ...rect, approx: bi.approx || bj.approx });
}
}
return out;
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Page } from "@zplab/core/types/Group";
import type { Page } from "../types/Group";

/** Strip overlays (all pages, or those matching `shouldDrop`) so they
* regenerate from the model instead of replaying stale bytes.
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/lib/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,12 @@ export function computePreflight(
): PreflightFinding[] {
const findings: PreflightFinding[] = [];
for (const leaf of leaves) {
// An unregistered type has no emitter/bounds producer, so it prints
// nothing; flag it and skip the checks that dereference the entry.
if (getEntry(leaf.type) === undefined) {
findings.push({ objectId: leaf.id, kind: "unknownType", severity: PREFLIGHT_SEVERITY.unknownType });
continue;
}
const content = getObjectStringContent(leaf);
const box = objectBoundsDots(leaf, ctx);
// A blank text field draws a placeholder (its bounds) but emits an empty
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { parseZPL, type ImportFinding, type ImportReport } from "@zplab/core/lib/zplParser";
import { parseZPL, type ImportFinding, type ImportReport } from "./zplParser";
import { replayRiskFindings, dedupCommandsByKind } from "./importReport";
import { dropPageOverlays } from "./pageOverlay";
import { pruneUndefined } from "./pruneUndefined";
import { stripDrivePrefix } from "@zplab/core/lib/customFonts";
import { renameTemplateMarkers } from "@zplab/core/lib/fnTemplate";
import type { CustomFontMapping, LabelConfig } from "@zplab/core/types/LabelConfig";
import type { PrinterProfile } from "@zplab/core/types/PrinterProfile";
import type { LabelObject, Page } from "@zplab/core/types/Group";
import { nextFreeFnNumber, uniqueVariableName, type Variable } from "@zplab/core/types/Variable";
import { stripDrivePrefix } from "./customFonts";
import { renameTemplateMarkers } from "./fnTemplate";
import type { CustomFontMapping, LabelConfig } from "../types/LabelConfig";
import type { PrinterProfile } from "../types/PrinterProfile";
import type { LabelObject, Page } from "../types/Group";
import { nextFreeFnNumber, uniqueVariableName, type Variable } from "../types/Variable";

export interface ZplImportResult {
labelConfig: Partial<LabelConfig>;
Expand All @@ -20,6 +20,10 @@ export interface ZplImportResult {
pages: Page[];
variables: Variable[];
report: ImportReport;
/** True when ^XA blocks set different ^PW/^LL: the single-label design keeps
* only block 0's size, so later pages would render/preflight at the wrong
* size. Interactive import ignores it; the MCP tools reject on it. */
mixedPageGeometry: boolean;
}

interface SplitBlocks {
Expand Down Expand Up @@ -60,6 +64,7 @@ export function importZplText(zpl: string, dpmm: number): ZplImportResult {
pages: [],
variables: [],
report: { findings: [], partial: [], browserLimit: [], unknown: [], replayRisk: [], deviceAction: [] },
mixedPageGeometry: false,
};
}

Expand Down Expand Up @@ -230,7 +235,24 @@ export function importZplText(zpl: string, dpmm: number): ZplImportResult {
deviceAction: dedupCommandsByKind(findings, 'deviceAction'),
};

return { labelConfig, printerProfile, pages, variables, report };
// ^PW/^LL persist across ^XA on a printer, so only an explicit re-statement
// to a different size is a real divergence.
const explicitSizes = new Set(
parsedBlocks
.map((r) => r.labelConfig)
.filter((lc) => lc.widthMm !== undefined || lc.heightMm !== undefined)
.map((lc) => `${lc.widthMm ?? ''}x${lc.heightMm ?? ''}`),
);
const mixedPageGeometry = explicitSizes.size > 1;
if (mixedPageGeometry) {
report.findings.push({
kind: 'mixedPageGeometry',
command: [...explicitSizes].join(', '),
pageIndex: 0,
});
}

return { labelConfig, printerProfile, pages, variables, report, mixedPageGeometry };
}

/** Additive setup-font merge (dedupe by normalized path): a stream lists only
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/lib/zplParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,10 @@ export function parseZPL(
}

if (rest.trim() || cmd.trim()) {
const token = `^${cmd}${rest}`;
// Source prefix from `start`: tilde commands must not surface as `^`.
// trimEnd: `rest` runs to the next command, dragging the line break in
// multi-line ZPL into the surfaced token.
const token = `${zpl[start] ?? "^"}${cmd}${rest}`.trimEnd();
skipped.push(token);
unknown.push(token);
}
Expand Down
9 changes: 6 additions & 3 deletions packages/core/src/lib/zplParser/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,10 +219,13 @@ export interface ParserState {
serialStrippedFns: Set<number>;
}

/** Append to `skipped` and `browserLimit` (invariant: browserLimit ⊆ skipped). */
/** Append to `skipped` and `browserLimit` (invariant: browserLimit ⊆ skipped).
* trimEnd: `token` carries `rest` up to the next command, which in multi-line
* ZPL includes the trailing newline (noise in this diagnostic surface). */
export function pushBrowserLimit(result: ParserResult, token: string): void {
result.skipped.push(token);
result.browserLimit.push(token);
const clean = token.trimEnd();
result.skipped.push(clean);
result.browserLimit.push(clean);
}

/** Default text height: ^CF override, else ZPL baseline 30. */
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/lib/zplParser/handlers/graphics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,9 @@ export function createGraphicsHandlers(
const size = parseInt(rest.slice(c2 + 1, c3), 10);
const dyBytesPerRow = parseInt(rest.slice(c3 + 1, c4), 10);
const data = rest.slice(c4 + 1);
const dySummary = `~DY${rest.slice(0, IMPORT_FINDING_PAYLOAD_LIMIT)}…`;
// trimEnd before slicing: a short rest ends with the line break, which
// would land mid-token where pushBrowserLimit's trim cannot reach.
const dySummary = `~DY${rest.trimEnd().slice(0, IMPORT_FINDING_PAYLOAD_LIMIT)}…`;

// Graphic uploads (~DY ...,A/B/C,G,...): decode via the same payload
// pipeline as ^GF, register the resulting image under the full
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/lib/zplParser/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ export type ImportFindingKind =
| "deviceAction"
| "lossyEdit"
| "fnRenumbered"
| "fnDefaultDropped";
| "fnDefaultDropped"
| "mixedPageGeometry";

/**
* One import finding. Created per-occurrence so each entry can be navigated
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/types/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ export type PreflightKind =
| 'emptyContent'
| 'printerSupportLimited'
| 'qrRotatedStatic'
| 'qrRotatedModel2';
| 'qrRotatedModel2'
| 'unknownType';

export interface PreflightFinding {
objectId: string;
Expand Down Expand Up @@ -65,6 +66,9 @@ export const PREFLIGHT_SEVERITY: Record<PreflightKind, PreflightSeverity> = {
// The graphic encoder only produces Model 2 symbols, so a rotated Model 1
// QR prints as Model 2 (legacy scanners may reject it).
qrRotatedModel2: 'warning',
// A leaf whose type isn't in the registry: it has no emitter, so it prints
// nothing. Only reachable via an imported/foreign design file.
unknownType: 'error',
};

/** What a per-type `preflight` producer receives. Minimal on purpose (the
Expand Down
Loading
Loading