Skip to content

Commit dc27107

Browse files
authored
feat(preflight): producer architecture for print-risk badges (#222)
Extend the preflight badge from off-label only to a multi-producer model: per-type registry preflight checks (barcode module too small for scanning, block text too narrow / overset) plus a canvas-layer encode check over all exportable leaves (encode failure incl. QR overflow, image with no bytes). All findings carry an optional detail; localised in all 32 languages.
1 parent 777d01d commit dc27107

55 files changed

Lines changed: 527 additions & 73 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎src/components/Canvas/BarcodeObject.tsx‎

Lines changed: 4 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import React, { useCallback, useEffect, useRef } from "react";
2-
import bwipjs from "bwip-js/browser";
32
import { Image as KImage, Group, Rect, Text } from "react-konva";
43
import type Konva from "konva";
54
import { BARCODE_1D_TYPES, ObjectRegistry } from "../../registry";
@@ -10,13 +9,10 @@ import { useFontCacheVersion } from "../../hooks/useFontCacheVersion";
109
import { selectionHandlers, type KonvaObjectProps } from "./konvaObjectProps";
1110
import { setMeasuredBounds, clearMeasuredBounds } from "./measuredBoundsCache";
1211
import {
13-
buildBwipOptions,
14-
cleanBwipError,
1512
getDisplaySize,
1613
get1DBwipScale,
1714
getEanUpcHriFragments,
18-
renderEanUpcRawCanvas,
19-
renderTlc39Canvas,
15+
renderBarcodeCanvas,
2016
type BarcodeDisplaySize,
2117
type EanUpcType,
2218
} from "./bwipHelpers";
@@ -91,48 +87,9 @@ export function BarcodeObject({
9187
!ObjectRegistry[obj.type]?.interpretationLocked &&
9288
!!(obj.props as { printInterpretation?: boolean }).printInterpretation;
9389

94-
let barcodeCanvas: HTMLCanvasElement | null = null;
95-
let errorMsg: string | null = null;
96-
if (obj.type === "tlc39") {
97-
// bwip-js has no native tlc39 encoder, render the Code 39 + MicroPDF417
98-
// composite ourselves. Goes straight to canvas, bypassing buildBwipOptions.
99-
barcodeCanvas = renderTlc39Canvas(obj.props, scale, dpmm);
100-
if (!barcodeCanvas) errorMsg = "TLC39 render failed";
101-
} else if (EAN_UPC_TYPES.has(obj.type)) {
102-
// EAN/UPC use bwip.raw + own fillRect pass so guard tails extend in
103-
// the same canvas as the bars (no overlay seam). The 13-dot text
104-
// zone is always reserved on the canvas so the KImage dimensions
105-
// stay constant; the tails only get drawn when HRI is on.
106-
const eanHeight = (obj.props as { height: number }).height;
107-
const modulePxInt = get1DBwipScale(moduleWidth, scale, dpmm);
108-
const barHeightPx = dotsToPx(eanHeight, scale, dpmm);
109-
const tailHeightPx = dotsToPx(EAN_TEXT_ZONE_DOTS, scale, dpmm);
110-
barcodeCanvas = renderEanUpcRawCanvas({
111-
type: obj.type as EanUpcType,
112-
text: rawContent,
113-
modulePxInt,
114-
barHeightPx,
115-
tailHeightPx,
116-
extendGuards: printInterpEnabled,
117-
});
118-
if (!barcodeCanvas) errorMsg = "EAN/UPC encode failed";
119-
} else {
120-
const opts = buildBwipOptions(obj, scale, dpmm);
121-
if (opts) {
122-
const canvas = document.createElement("canvas");
123-
try {
124-
// buildBwipOptions returns Record<string, unknown> on purpose: the
125-
// option fields differ across barcode types (ean13 vs code128 vs …)
126-
// and per-type narrowing would duplicate the switch already in
127-
// buildBwipOptions. bwip-js' toCanvas signature uses a strict
128-
// literal-string union, so the structural cast bridges the two.
129-
bwipjs.toCanvas(canvas, opts as unknown as Parameters<typeof bwipjs.toCanvas>[1]);
130-
barcodeCanvas = canvas;
131-
} catch (e) {
132-
errorMsg = cleanBwipError(e);
133-
}
134-
}
135-
}
90+
// Shared with the preflight encode check (barcodeEncodeFindings) so the
91+
// displayed placeholder and the badge can't disagree on what's codable.
92+
const { canvas: barcodeCanvas, error: errorMsg } = renderBarcodeCanvas(obj, scale, dpmm);
13693

13794
// Single object holding the full ZPL footprint (w/h) and the bar
13895
// sub-rectangle (barW/barH/barLeftPx/barTopPx). Defaults zero out

‎src/components/Canvas/LabelCanvas.tsx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { computeAlignDeltas, computeDistribute, computeTidy } from "../../lib/al
2424
import type { AlignOp, AlignBox, DistributeAxis, AlignRef } from "../../lib/align";
2525
import { objectBoundsDots, selectionUnionDots, printableRectDots } from "../../lib/objectBounds";
2626
import { computePreflight } from "../../lib/preflight";
27+
import { barcodeEncodeFindings } from "./barcodePreflight";
2728
import { rotateSelectionChanges } from "../../lib/groupRotation";
2829
import { selectTidyTargets } from "../../lib/tidyClassify";
2930
import { safeAreaRectDots } from "../../lib/safeArea";
@@ -503,7 +504,12 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(function LabelCa
503504
// during drag/preview-lock like the rest of the chrome.
504505
const preflightSuppressed = previewLocks || isDragging;
505506
const preflightLeaves = preflightSuppressed ? [] : exportableLeaves(objects);
506-
const preflightFindings = preflightSuppressed ? [] : computePreflight(preflightLeaves, frameCtx);
507+
const preflightFindings = preflightSuppressed
508+
? []
509+
: [
510+
...computePreflight(preflightLeaves, frameCtx),
511+
...barcodeEncodeFindings(preflightLeaves, scale, label.dpmm),
512+
];
507513
// Off-label marks pair each off-label finding with its on-screen bbox: a
508514
// clipped object gets a solid amber outline, a fully-outside one a dashed red
509515
// outline with a faint fill. Keyed on the off-label kind (not severity) so a

‎src/components/Canvas/PreflightOverlay.tsx‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ export function PreflightOverlay({ findings, objects, onSelect }: Props) {
3535
const kindText: Record<PreflightKind, string> = {
3636
offLabelOutside: t.preflight.offLabelOutside,
3737
offLabelClipped: t.preflight.offLabelClipped,
38+
renderFailed: t.preflight.renderFailed,
39+
blockTooNarrow: t.preflight.blockTooNarrow,
40+
barcodeTooSmall: t.preflight.barcodeTooSmall,
41+
textOverset: t.preflight.textOverset,
42+
imageMissing: t.preflight.imageMissing,
3843
};
3944
const nameOf = (id: string) => {
4045
const o = byId.get(id);
@@ -73,14 +78,18 @@ export function PreflightOverlay({ findings, objects, onSelect }: Props) {
7378
key={`${f.objectId}-${i}`}
7479
type="button"
7580
role="menuitem"
81+
title={f.detail || undefined}
7682
onClick={() => {
7783
onSelect(f.objectId);
7884
setOpen(false);
7985
}}
8086
className="flex items-center gap-2 px-3 py-1.5 text-xs text-left hover:bg-surface-2 transition-colors"
8187
>
8288
<Icon className={`w-3.5 h-3.5 shrink-0 ${tone}`} />
83-
<span className="truncate text-text">{nameOf(f.objectId)}</span>
89+
<span className="flex flex-col min-w-0">
90+
<span className="truncate text-text">{nameOf(f.objectId)}</span>
91+
{f.detail && <span className="truncate text-[10px] text-muted">{f.detail}</span>}
92+
</span>
8493
<span className="ml-auto shrink-0 text-muted">{kindText[f.kind]}</span>
8594
</button>
8695
);
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { describe, it, expect } from "vitest";
2+
import { barcodeEncodeFindings } from "./barcodePreflight";
3+
import type { LabelObject } from "../../types/Group";
4+
import type { LeafObject } from "../../registry";
5+
6+
const bar = (id: string): LeafObject =>
7+
({ id, type: "code128", x: 0, y: 0, rotation: 0,
8+
props: { content: "X", height: 50, moduleWidth: 2, printInterpretation: false, checkDigit: false, rotation: "N" } } as LabelObject as LeafObject);
9+
10+
describe("barcodeEncodeFindings", () => {
11+
it("maps an encode error to a renderFailed finding over all given leaves", () => {
12+
const leaves = [bar("a"), bar("b")];
13+
// Inject the encoder so the mapping is tested without the DOM bwip encoder;
14+
// this also proves a hidden-but-exported leaf is checked (no visibility gate).
15+
const out = barcodeEncodeFindings(leaves, 1, 8, (l) => (l.id === "a" ? "too much data" : null));
16+
expect(out).toEqual([
17+
{ objectId: "a", kind: "renderFailed", severity: "error", detail: "too much data" },
18+
]);
19+
});
20+
21+
it("returns nothing when every leaf encodes", () => {
22+
expect(barcodeEncodeFindings([bar("a")], 1, 8, () => null)).toEqual([]);
23+
});
24+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { LeafObject } from "../../registry";
2+
import { PREFLIGHT_SEVERITY, type PreflightFinding } from "../../lib/preflight";
3+
import { renderBarcodeCanvas } from "./bwipHelpers";
4+
5+
// Cache the encode verdict per object identity. The store is identity-preserving,
6+
// so an unedited barcode keeps its reference and is not re-encoded when a
7+
// sibling changes (editing one object would otherwise re-encode the whole page).
8+
const encodeCache = new WeakMap<LeafObject, { scale: number; dpmm: number; error: string | null }>();
9+
10+
function cachedEncodeError(leaf: LeafObject, scale: number, dpmm: number): string | null {
11+
const hit = encodeCache.get(leaf);
12+
if (hit && hit.scale === scale && hit.dpmm === dpmm) return hit.error;
13+
const error = renderBarcodeCanvas(leaf, scale, dpmm).error;
14+
encodeCache.set(leaf, { scale, dpmm, error });
15+
return error;
16+
}
17+
18+
/** Encode check over ALL exportable leaves, not just rendered ones, so a
19+
* hidden-but-exported barcode with an uncodable payload (QR overflow, invalid
20+
* EAN, ...) still badges. Lives at the canvas layer because the encoder does.
21+
* `encodeError` is injectable so the mapping is testable without the encoder. */
22+
export function barcodeEncodeFindings(
23+
leaves: readonly LeafObject[],
24+
scale: number,
25+
dpmm: number,
26+
encodeError: (leaf: LeafObject) => string | null = (leaf) => cachedEncodeError(leaf, scale, dpmm),
27+
): PreflightFinding[] {
28+
const findings: PreflightFinding[] = [];
29+
for (const leaf of leaves) {
30+
const error = encodeError(leaf);
31+
if (error) {
32+
findings.push({ objectId: leaf.id, kind: "renderFailed", severity: PREFLIGHT_SEVERITY.renderFailed, detail: error });
33+
}
34+
}
35+
return findings;
36+
}

‎src/components/Canvas/bwipHelpers.ts‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// canvas. Per-symbology rationale lives at each case in getUprightDisplaySize.
33

44
import bwipjs from "bwip-js/browser";
5-
import { type LeafObject } from "../../registry";
5+
import { getEntry, type LeafObject } from "../../registry";
66
import { barcodeTextZoneDots, barcodeZoneAbove } from "../../lib/barcodeHri";
77
import { upceData6FromFd } from "../../registry/hriFormatters";
88
import type { LabelObject } from "../../types/Group";
@@ -20,6 +20,7 @@ import {
2020
CODE11_QUIET_ZONE_DELTA_MODULES,
2121
CODE93_QUIET_ZONE_DELTA_MODULES,
2222
EAN_TEXT_ZONE_DOTS,
23+
EAN_UPC_TYPES,
2324
GS1_DATABAR_PADDING_ROWS,
2425
GS1_DATABAR_SPEC_HEIGHT_MODULES,
2526
LOGMARS_TEXT_ZONE_DOTS,
@@ -959,3 +960,42 @@ export function renderTlc39Canvas(
959960
ctx.drawImage(code39Src, 0, mpdfPxH, w, code39PxH);
960961
return composite;
961962
}
963+
964+
/** Single source for encoding an object as a barcode at `scale`: used by the
965+
* canvas display AND the preflight encode check. Returns the rendered canvas
966+
* (null on failure) and a cleaned error message (null on success). A
967+
* non-barcode type yields {canvas:null, error:null}. */
968+
export function renderBarcodeCanvas(
969+
obj: LeafObject,
970+
scale: number,
971+
dpmm: number,
972+
): { canvas: HTMLCanvasElement | null; error: string | null } {
973+
if (obj.type === "tlc39") {
974+
const canvas = renderTlc39Canvas(obj.props as Parameters<typeof renderTlc39Canvas>[0], scale, dpmm);
975+
return { canvas, error: canvas ? null : "TLC39 render failed" };
976+
}
977+
if (EAN_UPC_TYPES.has(obj.type)) {
978+
const moduleWidth = (obj.props as { moduleWidth?: number }).moduleWidth ?? 2;
979+
const printInterpEnabled =
980+
!getEntry(obj.type)?.interpretationLocked &&
981+
!!(obj.props as { printInterpretation?: boolean }).printInterpretation;
982+
const canvas = renderEanUpcRawCanvas({
983+
type: obj.type as EanUpcType,
984+
text: (obj.props as { content?: string }).content ?? "",
985+
modulePxInt: get1DBwipScale(moduleWidth, scale, dpmm),
986+
barHeightPx: dotsToPx((obj.props as { height: number }).height, scale, dpmm),
987+
tailHeightPx: dotsToPx(EAN_TEXT_ZONE_DOTS, scale, dpmm),
988+
extendGuards: printInterpEnabled,
989+
});
990+
return { canvas, error: canvas ? null : "EAN/UPC encode failed" };
991+
}
992+
const opts = buildBwipOptions(obj, scale, dpmm);
993+
if (!opts) return { canvas: null, error: null };
994+
const canvas = document.createElement("canvas");
995+
try {
996+
bwipjs.toCanvas(canvas, opts as unknown as Parameters<typeof bwipjs.toCanvas>[1]);
997+
return { canvas, error: null };
998+
} catch (e) {
999+
return { canvas: null, error: cleanBwipError(e) };
1000+
}
1001+
}

‎src/lib/barcodeScannability.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { PreflightCtx, PreflightProducerResult } from "../types/preflight";
2+
3+
/** Recommended minimum barcode module / X-dimension in mm for reliable general
4+
* scanning, per GS1/AIM guidance (~0.25 mm). Below this the preflight warns.
5+
* Shared floor for 1D X-dimension and 2D cell size. */
6+
export const MIN_BARCODE_MODULE_MM = 0.25;
7+
8+
/** A `barcodeTooSmall` finding when the module size (dots) is below the minimum
9+
* scannable X-dimension at the device density. Used by every 1D and 2D type. */
10+
export function moduleTooSmallFindings(moduleDots: number, dpmm: number): PreflightProducerResult[] {
11+
const mm = moduleDots / dpmm;
12+
if (!(mm < MIN_BARCODE_MODULE_MM)) return []; // also short-circuits NaN/Infinity
13+
return [{ kind: "barcodeTooSmall", detail: `${mm.toFixed(2)} mm (min ${MIN_BARCODE_MODULE_MM} mm)` }];
14+
}
15+
16+
/** Curried registry `preflight` producer: warns when the named module-size prop
17+
* (e.g. moduleWidth, magnification, dimension) is below the min X-dimension.
18+
* One source for the scannability check every 1D/2D barcode type registers. */
19+
export function moduleTooSmallPreflight<P extends object>(
20+
prop: keyof P & string,
21+
): (obj: { props: P }, ctx: PreflightCtx) => PreflightProducerResult[] {
22+
return (obj, ctx) => moduleTooSmallFindings(obj.props[prop] as number, ctx.label.dpmm);
23+
}

‎src/lib/preflight.test.ts‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it } from "vitest";
22
import { computePreflight } from "./preflight";
3+
import { getEntry } from "../registry";
34
import type { ObjectBoundsCtx } from "./objectBounds";
45
import type { LabelObject } from "../types/Group";
56
import type { LeafObject } from "../registry";
@@ -117,3 +118,77 @@ describe("computePreflight (off-label producer)", () => {
117118
]);
118119
});
119120
});
121+
122+
describe("per-type producers (registry preflight capability)", () => {
123+
const textLeaf = (props: object): LeafObject =>
124+
({ id: "t", type: "text", x: 0, y: 0, rotation: 0, props } as LabelObject as LeafObject);
125+
const bar = (moduleWidth: number): LeafObject =>
126+
({
127+
id: "bc", type: "code128", x: 100, y: 100, rotation: 0,
128+
props: { content: "X", height: 50, moduleWidth, printInterpretation: false, checkDigit: false, rotation: "N" },
129+
} as LabelObject as LeafObject);
130+
131+
it("text: a block narrower than one glyph cell flags blockTooNarrow", () => {
132+
const narrow = getEntry("text")!.preflight!(textLeaf({ content: "X", fontHeight: 30, fontWidth: 0, blockWidth: 2 }), { label });
133+
expect(narrow).toEqual([{ kind: "blockTooNarrow" }]);
134+
});
135+
136+
it("text: a wide block and a plain (normal) field flag nothing", () => {
137+
const wide = getEntry("text")!.preflight!(textLeaf({ content: "X", fontHeight: 30, fontWidth: 0, blockWidth: 200 }), { label });
138+
const normal = getEntry("text")!.preflight!(textLeaf({ content: "X", fontHeight: 30, fontWidth: 0 }), { label });
139+
expect(wide).toEqual([]);
140+
expect(normal).toEqual([]);
141+
});
142+
143+
it("barcode: a module below the min X-dimension flags barcodeTooSmall", () => {
144+
// moduleWidth 1 @ 8 dpmm = 0.125 mm < 0.25 mm.
145+
const small = getEntry("code128")!.preflight!(bar(1), { label });
146+
expect(small).toEqual([{ kind: "barcodeTooSmall", detail: "0.13 mm (min 0.25 mm)" }]);
147+
});
148+
149+
it("barcode: a module at/above the min X-dimension flags nothing", () => {
150+
// moduleWidth 2 @ 8 dpmm = 0.25 mm.
151+
expect(getEntry("code128")!.preflight!(bar(2), { label })).toEqual([]);
152+
});
153+
154+
it("computePreflight stamps objectId + severity from a producer finding", () => {
155+
expect(computePreflight([bar(1)], ctx)).toContainEqual({
156+
objectId: "bc", kind: "barcodeTooSmall", severity: "warning", detail: "0.13 mm (min 0.25 mm)",
157+
});
158+
});
159+
160+
it("2D: a QR magnification below the min cell size flags barcodeTooSmall", () => {
161+
const qr = (magnification: number): LeafObject =>
162+
({ id: "q", type: "qrcode", x: 0, y: 0, rotation: 0,
163+
props: { content: "x", magnification, errorCorrection: "Q", model: 2, rotation: "N" } } as LabelObject as LeafObject);
164+
// magnification 1 @ 8 dpmm = 0.125 mm < 0.25; magnification 2 = 0.25 mm (ok).
165+
expect(getEntry("qrcode")!.preflight!(qr(1), { label })).toEqual([
166+
{ kind: "barcodeTooSmall", detail: "0.13 mm (min 0.25 mm)" },
167+
]);
168+
expect(getEntry("qrcode")!.preflight!(qr(2), { label })).toEqual([]);
169+
});
170+
171+
it("text: ^FB content wrapping past the line cap flags textOverset", () => {
172+
const fb = textLeaf({ content: "AAA BBB CCC", fontHeight: 30, fontWidth: 0, blockWidth: 40, blockLines: 1 });
173+
expect(getEntry("text")!.preflight!(fb, { label })).toEqual([{ kind: "textOverset" }]);
174+
});
175+
176+
it("text: ^TB content taller than the block height flags textOverset", () => {
177+
const tb = textLeaf({ content: "AAA", fontHeight: 30, fontWidth: 0, blockWidth: 200, blockHeight: 10, textMode: "tb" });
178+
expect(getEntry("text")!.preflight!(tb, { label })).toEqual([{ kind: "textOverset" }]);
179+
});
180+
181+
it("text: a block with room to spare flags nothing", () => {
182+
const ok = textLeaf({ content: "AAA", fontHeight: 30, fontWidth: 0, blockWidth: 400, blockLines: 5 });
183+
expect(getEntry("text")!.preflight!(ok, { label })).toEqual([]);
184+
});
185+
186+
it("image: no resolvable bytes flags imageMissing; rawGf resolves", () => {
187+
const img = (props: object): LeafObject =>
188+
({ id: "i", type: "image", x: 0, y: 0, rotation: 0, props } as LabelObject as LeafObject);
189+
expect(getEntry("image")!.preflight!(img({ imageId: "nope", widthDots: 200, threshold: 128 }), { label })).toEqual([
190+
{ kind: "imageMissing" },
191+
]);
192+
expect(getEntry("image")!.preflight!(img({ imageId: "nope", widthDots: 200, threshold: 128, rawGf: "^GFA,1,1,1,00" }), { label })).toEqual([]);
193+
});
194+
});

0 commit comments

Comments
 (0)