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
69 changes: 69 additions & 0 deletions packages/core/src/lib/anchorRepin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { LabelObject } from "../types/Group";
import type { ObjectChanges } from "../types/LabelObject";
import { BARCODE_1D_TYPES, getEntry } from "../registry";
import { isAxisSwapped, objectRotation } from "../registry/rotation";
import { valueAnchorShift } from "./valueAnchor";
import type { Footprint as BarcodeFootprint } from "./footprintProber";

export type { BarcodeFootprint };

/** ^FT+I/B inverts the anchor math (see valueAnchorShift). */
function hasFtFlip(o: LabelObject): boolean {
const rot = objectRotation((o as { props: object }).props);
return (o as { positionType?: string }).positionType === "FT" && (rot === "I" || rot === "B");
}

/** Re-pins justified 1D barcodes so a width-changing edit keeps the justified edge fixed. */
export function anchorRepin(
obj: LabelObject,
changes: ObjectChanges,
next: LabelObject,
probe: (o: LabelObject) => BarcodeFootprint | null,
): LabelObject {
// 1D-only: the ftFlip math matches barcodeFtAnchorOffset only there (QR
// graphics use an "N" offset + module shift the re-pin doesn't model);
// graphics have static extents, so fieldJustify never re-pins them.
if (!BARCODE_1D_TYPES.has(next.type)) return next;
// Absent means L (schema contract), and L participates under the FT flip.
const justify = next.fieldJustify ?? "L";
const rot = objectRotation((next as { props: object }).props);
const ftFlip = hasFtFlip(next);
if (justify === "L" && !ftFlip) return next;
// `in`, not value-check: an explicit x/y key marks a positioning edit, and
// x: undefined is already illegal (the merge spread would clobber obj.x).
if (!changes.props || "x" in changes || "y" in changes) return next;
if ("rotation" in changes.props) return next;
// Re-pinning presumes the anchored edge was already in force: an op that
// introduces the justify/flip itself has no pinned edge to keep, so shifting
// by the width delta would move it off the x the caller just set.
if ((obj.fieldJustify ?? "L") !== justify || hasFtFlip(obj) !== ftFlip) return next;
// Both widths from the same synchronous probe: width-neutral edit = exact no-op.
const before = probe(obj);
const after = probe(next);
if (!before || !after) return next;
const swapped = isAxisSwapped(rot);
const delta = swapped ? before.h - after.h : before.w - after.w;
const shift = valueAnchorShift(justify, delta, ftFlip);
if (shift === 0) return next;
return swapped ? { ...next, y: next.y + shift } : { ...next, x: next.x + shift };
}

/** Shared leaf edit pipeline: normalize, replace, merge props, re-pin, in that order. */
export function applyChanges(
obj: LabelObject,
changes: ObjectChanges,
probe: (o: LabelObject) => BarcodeFootprint | null,
): LabelObject {
const normalize = getEntry(obj.type)?.normalizeChanges;
const normalized = normalize ? normalize(obj as never, changes as never) : changes;
const current = (obj as { props?: object }).props ?? {};
const next = {
...obj,
...normalized,
// Always written, never conditionally spread: `normalized` may carry an
// explicit `props: undefined`, which the spread above would leave in place
// and hand every renderer and emitter a propless object.
props: normalized.props ? { ...current, ...normalized.props } : (obj as { props?: object }).props,
} as LabelObject;
return anchorRepin(obj, normalized as ObjectChanges, next, probe);
}
57 changes: 55 additions & 2 deletions packages/core/src/lib/barcodeDims.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import type { LeafObject } from "../registry";
import type { LabelObject } from "../types/Group";
import { errorMessage } from "./errorMessage";
import { clampCodablockColumns, CODABLOCK_PREVIEW_COLUMNS_MIN } from "../registry/codablock";
import { EC_PERCENT_MIN, EC_PERCENT_MAX } from "../registry/aztec";
import { upceData6FromFd } from "../registry/hriFormatters";
Expand Down Expand Up @@ -690,7 +691,8 @@ function getUprightDisplaySize(
const modulePx = dotsToPx(obj.props.moduleWidth, scale, dpmm);
const bwipSc = get1DBwipScale(obj.props.moduleWidth, scale, dpmm);
const w = (cw / bwipSc) * modulePx;
const h = dotsToPx(obj.props.height, scale, dpmm);
const zone = barcodeTextZoneDots(obj);
const h = dotsToPx(obj.props.height + zone, scale, dpmm);
return { w, h };
}
case "ean13":
Expand Down Expand Up @@ -751,7 +753,7 @@ function getUprightDisplaySize(
const bwipSc = get1DBwipScale(obj.props.moduleWidth, scale, dpmm);
const extraPx = bwipSc === 1 ? 1 : 0;
const w = ((cw - extraPx) / bwipSc) * modulePx;
const h = dotsToPx(obj.props.height, scale, dpmm);
const h = dotsToPx(obj.props.height + barcodeTextZoneDots(obj), scale, dpmm);
return { w, h };
}
case "pdf417": {
Expand Down Expand Up @@ -1139,6 +1141,57 @@ function measureDisplayWith(
return dim.w > 0 && dim.h > 0 ? dim : null;
}

/** Why the leaf's OWN content does not encode, or null when it does.
* measureDisplayWith falls back to sample content, so a measured footprint
* proves nothing. Blank is not a failure: emptyContent owns that signal. */
export function barcodeEncodeIssueWith(
bwip: BwipEngine,
obj: LeafObject,
dpmm: number,
): string | null {
if ((getObjectStringContent(obj) ?? "").trim() === "") return null;
try {
if (barcodeDimsPx(bwip, obj, dpmm, dpmm) !== null) return null;
} catch {
// The diagnostic run below reports the throw instead of hiding it.
}
return encodeFailureReason(bwip, obj, dpmm);
}

/** Re-run the encoder with its errors uncaught, so the caller can say WHAT is
* wrong. The dims path deliberately swallows these to keep measuring. */
function encodeFailureReason(bwip: BwipEngine, obj: LeafObject, dpmm: number): string {
try {
if (EAN_UPC_TYPES.has(obj.type)) {
const text = getObjectStringContent(obj) ?? "";
const encoded = obj.type === "upce" ? `0${upceData6FromFd(text)}` : text;
bwip.raw({ bcid: obj.type, text: encoded, includetext: true });
return "the encoder produced no symbol for this payload";
}
// barcodeDimsPx routes these past buildBwipOptions, so their missing BCID
// entry says nothing about them: re-run their own encoder for the reason.
if (ZEBRA_WIDTH_BAR_TYPES.has(obj.type)) {
const t = obj.type as ZebraWidthBarType;
const text = zebraWidthBarText(t, getObjectStringContent(obj) ?? "");
bwip.raw({ bcid: ZEBRA_WIDTH_BCID[t], text });
return "the encoder produced no symbol for this payload";
}
if (obj.type === "tlc39") return "the encoder produced no symbol for this payload";
const opts = buildBwipOptions(obj, dpmm, dpmm);
// Only ^BF refuses on capacity; every other null is a type with no encoder
// path, which must not be reported to the caller as a payload problem.
if (!opts) {
return obj.type === "micropdf417"
? "the payload exceeds what this symbology can carry"
: "this symbology has no encoder";
}
bwip.render(opts, dimsDrawing());
return "the encoder produced no symbol for this payload";
} catch (e) {
return errorMessage(e);
}
}

export function measureBarcodeFootprintDotsWith(
bwip: BwipEngine,
obj: LeafObject,
Expand Down
88 changes: 88 additions & 0 deletions packages/core/src/lib/barcodeEncodePreflight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Single decision tree for encode findings, shared by editor and MCP sidecar so the two reports cannot drift.

import { ctrlParityFor, gs1StaticUnparsed, type LeafObject } from "../registry";
import { maxicodeScmOwnedByPreflight, type MaxicodeProps } from "../registry/maxicode";
import { isBarcode } from "./objectBounds";
import { PREFLIGHT_SEVERITY, type PreflightFinding } from "./preflight";
import type { Variable } from "../types/Variable";
import {
applyBindingToObject,
getObjectStringContent,
type ActiveRow,
type ClockResolveCtx,
} from "./variableBinding";

/** Binding context so the check encodes what PRINTS: `«marker»` content is
* resolved exactly like the caller's render. */
export interface EncodeEnv {
variables: readonly Variable[];
active: ActiveRow | null;
clock?: ClockResolveCtx;
}

export interface EncodeVerdict {
error: string | null;
approximated: boolean;
}

/** Preview-resolved leaf for the encoder (identity-preserving when unbound). */
export function resolveForEncode(leaf: LeafObject, env: EncodeEnv): LeafObject {
return applyBindingToObject(
leaf,
env.variables,
env.active,
// Always the resolved values: a schema render substitutes placeholders,
// and encoding those would clear a barcode whose real payload cannot code.
"preview",
env.clock,
ctrlParityFor(leaf),
);
}

/** Encode check over ALL exportable leaves, not just rendered ones, so a
* hidden-but-exported barcode with an uncodable payload still reports.
* `encode` is the caller's encoder seam (canvas or headless bwip). */
export function barcodeEncodeFindingsCore(
leaves: readonly LeafObject[],
env: EncodeEnv,
encode: (leaf: LeafObject, resolved: LeafObject) => EncodeVerdict,
): PreflightFinding[] {
const findings: PreflightFinding[] = [];
for (const leaf of leaves) {
// Barcode-only producer: text and shapes never encode, and a bound TEXT
// field resolving empty stays quiet (configured field, and the canvas
// shows an honest empty box there, unlike the barcode's sample bars).
if (!isBarcode(leaf)) continue;
const resolved = resolveForEncode(leaf, env);
if ((getObjectStringContent(resolved) ?? "").trim() === "") {
// A literal-blank field is already owned by computePreflight's
// emptyContent (raw content ""); a BARCODE whose marker resolves empty
// is raw-nonempty there, yet renders as sample bars, so surface it here.
if ((getObjectStringContent(leaf) ?? "").trim() !== "") {
findings.push({ objectId: leaf.id, kind: "emptyContent", severity: PREFLIGHT_SEVERITY.emptyContent });
}
continue;
}
// A literal mode 2/3 MaxiCode without a carrier message is owned by
// maxicodeModeMissingScm (computePreflight); skip renderFailed to avoid a
// double report. Marker content isn't skipped: the producer guards it out.
if (
resolved.type === "maxicode" &&
maxicodeScmOwnedByPreflight(getObjectStringContent(leaf) ?? "", resolved.props as MaxicodeProps)
) {
continue;
}
// Static unparsed GS1 is owned by gs1ContentUnparsed (see
// gs1StaticUnparsed); a second renderFailed would contradict it.
if (gs1StaticUnparsed(leaf.type, leaf.props, getObjectStringContent(leaf) ?? "")) {
continue;
}
const verdict = encode(leaf, resolved);
if (verdict.error) {
findings.push({ objectId: leaf.id, kind: "renderFailed", severity: PREFLIGHT_SEVERITY.renderFailed, detail: verdict.error });
} else if (verdict.approximated) {
findings.push({ objectId: leaf.id, kind: "previewApproximate", severity: PREFLIGHT_SEVERITY.previewApproximate });
}
}
return findings;
}
88 changes: 88 additions & 0 deletions packages/core/src/lib/barcodeHri.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, it, expect } from "vitest";

import { BARCODE_1D_TYPES, ObjectRegistry, type LeafObject } from "../registry";
import { barcodeTextZoneDots, hriZoneDots } from "./barcodeHri";

describe("hriZoneDots", () => {
// Labelary, 6 and 8 dpmm, ^BC and ^B3: total ink height minus the bar height.
it("matches the measured line height per module width", () => {
expect([1, 2, 3, 4, 5].map(hriZoneDots)).toEqual([14, 21, 28, 35, 42]);
});

it("treats a fractional module like the dot grid does", () => {
expect(hriZoneDots(2.4)).toBe(21);
expect(hriZoneDots(0)).toBe(14);
});
});

/** 1D symbologies whose firmware prints an interpretation line that no zone is
* reserved for yet. Shrinking this set needs measurements, never a copied
* formula: a guessed band moves every box by an invented number. */
const UNMEASURED_HRI_ZONE: ReadonlySet<string> = new Set([
"plessey",
"planet",
"postal",
"code49",
"gs1databar",
]);

describe("HRI zone coverage", () => {
// The zone tests iterate HRI_LINE_TYPES itself, so only an outside-in sweep
// catches a symbology that was never added to it.
it("classifies every 1D symbology, or names it as unmeasured", () => {
for (const type of BARCODE_1D_TYPES) {
const entry = ObjectRegistry[type as keyof typeof ObjectRegistry];
if (!entry) continue;
const leaf = {
id: type,
type,
x: 0,
y: 0,
props: { ...(entry.defaultProps as object), printInterpretation: true, moduleWidth: 2 },
} as LeafObject;
const zone = barcodeTextZoneDots(leaf);
if (UNMEASURED_HRI_ZONE.has(type)) {
expect(zone, `${type} is listed as unmeasured but now reserves a zone`).toBe(0);
} else {
expect(zone, `${type} prints an HRI line with no zone reserved`).toBeGreaterThan(0);
}
}
});
});

describe("a GS1-128's interpretation band", () => {
const leaf = (gs1: boolean, moduleWidth: number, content = "(01)09501101530003") =>
({
id: "b", type: "code128", x: 0, y: 0, rotation: 0,
props: { content, height: 60, moduleWidth, printInterpretation: true, gs1 },
}) as never;

it("is taller than the plain one, because the HRI font is scaled up", () => {
// The renderer draws GS1 HRI at up to GS1_HRI_FONT_SCALE of the plain em,
// so reserving the plain band let the line run outside the published bbox.
for (const mw of [2, 5]) {
expect(barcodeTextZoneDots(leaf(true, mw))).toBeGreaterThan(barcodeTextZoneDots(leaf(false, mw)));
}
});

it("covers the band Labelary prints, at every measured module width", () => {
// Measured at 8 dpmm as ink below the bars: 21 / 34 / 52 / 66 / 82.
// The reserved band must never read short, or the HRI runs off the media
// while the report calls the field clean.
const measured: Record<number, number> = { 1: 21, 2: 34, 3: 52, 4: 66, 5: 82 };
for (const [mw, dots] of Object.entries(measured)) {
expect(barcodeTextZoneDots(leaf(true, Number(mw))), `mw ${mw}`).toBeGreaterThanOrEqual(dots);
}
});

it("does not read the content", () => {
// Reading it meant measuring it, and the measure falls back to a per-glyph
// estimate without a canvas, so headless and browser reserved differently.
const long = leaf(true, 3, "(01)09501101020917(10)ABC123(21)SERIAL987654(11)260101");
expect(barcodeTextZoneDots(long)).toBe(barcodeTextZoneDots(leaf(true, 3)));
});

it("leaves a non-GS1 code128 exactly where it was", () => {
expect(barcodeTextZoneDots(leaf(false, 3))).toBe(hriZoneDots(3));
});
});
Loading
Loading