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
21 changes: 15 additions & 6 deletions packages/core/src/lib/barcodeDims.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { EC_PERCENT_MIN, EC_PERCENT_MAX } from "../registry/aztec";
import { upceData6FromFd } from "../registry/hriFormatters";
import type { Gs1DatabarProps } from "../registry/gs1databar";
import { isAxisSwapped, objectRotation } from "../registry/rotation";
import { dotsToPx, pxToDots } from "./coordinates";
import { dotsToPx, mmToDots, pxToDots } from "./coordinates";
import {
GS1_DATABAR_DEFAULT_SEGMENTS,
GS1_DATABAR_EXPANDED_SYMBOLOGIES,
Expand All @@ -34,6 +34,7 @@ import {
GS1_DATABAR_PADDING_ROWS,
GS1_DATABAR_SPEC_HEIGHT_MODULES,
LOGMARS_TEXT_ZONE_DOTS,
MAXICODE_INK_MARGIN_PX,
MICROPDF417_PX_PER_ROW,
MICROPDF417_QUIET_ZONE_ROWS,
upcSuppTextZoneDots,
Expand Down Expand Up @@ -595,6 +596,14 @@ export function getDisplaySize(
height: canvas.height - 2 * padPx,
};
}
} else if (obj.type === "maxicode") {
const m = MAXICODE_INK_MARGIN_PX;
bitmapCrop = {
x: m.left,
y: m.top,
width: canvas.width - m.left - m.right,
height: canvas.height - m.top - m.bottom,
};
}

const uprightView = {
Expand Down Expand Up @@ -750,11 +759,11 @@ function getUprightDisplaySize(
return { w: size, h: size };
}
case "maxicode": {
// Fixed physical symbol: size tracks dpmm, unlike bwip's dpmm-independent
// pixel canvas (which rendered ~half the printed size). The ink-only bwip
// bitmap fills this footprint, so drawn ink matches the printed extent.
const w = dotsToPx(MAXICODE_WIDTH_MM * dpmm, scale, dpmm);
const h = dotsToPx(MAXICODE_HEIGHT_MM * dpmm, scale, dpmm);
// Fixed physical symbol: size tracks dpmm. Quantise through mmToDots, the
// path resolveDefaultSizeDots takes, so both round half-dot ties alike and
// the footprint can't flip with the zoom level.
const w = dotsToPx(mmToDots(MAXICODE_WIDTH_MM, dpmm), scale, dpmm);
const h = dotsToPx(mmToDots(MAXICODE_HEIGHT_MM, dpmm), scale, dpmm);
return { w, h };
}
case "micropdf417": {
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/lib/bwipConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,11 @@ export const GS1_DATABAR_SPEC_HEIGHT_MODULES: Partial<
/** bwip's paddingheight rows; shared with bitmap-crop logic. */
export const GS1_DATABAR_PADDING_ROWS = 2;

/** Dead pixels around bwip's maxicode ink at BWIP_SCALE (canvas 210x200, ink
* 209x198 at (0,1)); cropping them keeps the drawn ink filling the footprint
* box instead of stopping ~1 dot short per axis. Pinned by maxicodeBitmap.test.ts. */
export const MAXICODE_INK_MARGIN_PX = { left: 0, top: 1, right: 1, bottom: 1 } as const;

export const EAN_UPC_TYPES = new Set<string>([
"ean13",
"ean8",
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/lib/zplParser/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,8 @@ export interface FieldState {
pdfColumns: number;
aztecMag: number;
maxicodeMode: MaxicodeProps["mode"];
maxicodeNumber: number;
maxicodeTotal: number;
mpdfRowHeight: number;
cbRowHeight: number;
cbColumns: number;
Expand Down Expand Up @@ -419,7 +421,9 @@ export function freshFieldState(): FieldState {
pdfSecurity: 0,
pdfColumns: 0,
aztecMag: 4,
maxicodeMode: 4,
maxicodeMode: 2,
maxicodeNumber: 1,
maxicodeTotal: 1,
mpdfRowHeight: 10,
cbRowHeight: 10,
cbColumns: CODABLOCK_DEFAULT_COLUMNS,
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/lib/zplParser/flushField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,8 @@ export function createFlushField(
{
content,
mode: s.field.maxicodeMode,
symbolNumber: s.field.maxicodeNumber,
symbolTotal: s.field.maxicodeTotal,
} satisfies MaxicodeProps,
posType,
comment,
Expand Down
14 changes: 8 additions & 6 deletions packages/core/src/lib/zplParser/handlers/barcodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Code49Props } from "../../../registry/code49";
import { clampCodablockColumns } from "../../../registry/codablock";
import { isDmRectPair, type DataMatrixProps } from "../../../registry/datamatrix";
import type { Gs1DatabarProps } from "../../../registry/gs1databar";
import type { MaxicodeProps } from "../../../registry/maxicode";
import { clampMaxicodeAppend, type MaxicodeProps } from "../../../registry/maxicode";
import { GS1_DATABAR_DEFAULT_SEGMENTS } from "../../gs1";
import type { ParserState } from "../context";
import { dotsFor, int, readRotation } from "../helpers";
Expand Down Expand Up @@ -156,13 +156,15 @@ export function createBarcodeHandlers(s: ParserState): Record<string, Handler> {
B0: handleAztec,
BO: handleAztec,

// ^BDm,{symbolNumber},{totalSymbols}: Maxicode (spec p106; fixed
// physical size, no orientation slot). Structured-append params are
// read but pinned to (1,1) on emit.
// ^BDm,n,t: Maxicode (spec p106; fixed physical size, no orientation slot).
// 2-6 is the whole value list, so an out-of-range m falls back to the same
// 2 the firmware reads for an omitted one.
BD(p) {
s.field.fieldType = "maxicode";
const m = int(p[0], 4);
s.field.maxicodeMode = (m >= 2 && m <= 6 ? m : 4) as MaxicodeProps["mode"];
const m = int(p[0], 2);
s.field.maxicodeMode = (m >= 2 && m <= 6 ? m : 2) as MaxicodeProps["mode"];
s.field.maxicodeNumber = clampMaxicodeAppend(int(p[1], 1));
s.field.maxicodeTotal = clampMaxicodeAppend(int(p[2], 1));
},

// ^BFN,{rowHeight}: MicroPDF417
Expand Down
32 changes: 25 additions & 7 deletions packages/core/src/registry/maxicode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@ import { describe, expect, it } from "vitest";
import bwipjs from "bwip-js/generic";
import { maxicodeMissingScm, maxicodeScmOwnedByPreflight } from "./maxicode";
import { getEntry, type LeafObject } from ".";
import { measureBarcodeFootprintDotsWith, type BwipEngine } from "../lib/barcodeDims";
import { getDisplaySize, measureBarcodeFootprintDotsWith, type BwipEngine } from "../lib/barcodeDims";
import { pxToDots } from "../lib/coordinates";
import { resolveDefaultSizeDots } from "../lib/resolveDefaultSize";
import type { LabelObject } from "../types/Group";
import type { LabelConfig } from "../types/LabelConfig";

const engine = bwipjs as unknown as BwipEngine;
/** bwip's maxicode bitmap at BWIP_SCALE; pinned by src/test/maxicodeBitmap.test.ts. */
const BWIP_CANVAS = { width: 210, height: 200 };
const pctx = { label: { widthMm: 100, heightMm: 100, dpmm: 8 } as LabelConfig, unit: "mm" } as const;

const mc = (mode: 2 | 3 | 4 | 5 | 6, content: string): LeafObject =>
Expand Down Expand Up @@ -67,16 +71,30 @@ describe("maxicode preflight producer", () => {
});

describe("maxicode footprint tracks dpmm (fixed physical size)", () => {
// Labelary ink: 200x193 dots @ 8dpmm, 300x289 @ 12dpmm. The pre-fix footprint
// was ~105x100 dots at BOTH densities (bwip's dpmm-independent pixel canvas).
it("measures the printed ink extent at 8 dpmm", () => {
it("measures the calibrated footprint at 8 dpmm", () => {
const dim = measureBarcodeFootprintDotsWith(engine, mc(4, "1234567890"), 8)!;
// Labelary-measured dots, pinned literally so a constants drift fails here.
expect([dim.w, dim.h]).toEqual([200, 193]);
// Visually calibrated dots (INK_DOTS_8DPMM), pinned so a drift fails here.
expect([dim.w, dim.h]).toEqual([202, 192]);
});

it("scales proportionally at 12 dpmm", () => {
const dim = measureBarcodeFootprintDotsWith(engine, mc(4, "1234567890"), 12)!;
expect([dim.w, dim.h]).toEqual([300, 289]);
expect([dim.w, dim.h]).toEqual([303, 288]);
});

// Guards the mmToDots quantization: an unquantised mm->px->dots path loses a
// ULP and flips a dot at some zoom levels whenever mm*dpmm lands near a tie.
it("stays zoom-stable at 12 dpmm and agrees with the palette default size", () => {
const def = resolveDefaultSizeDots(getEntry("maxicode")!.defaultSize, { dpmm: 12 } as LabelConfig);
expect([def.width, def.height]).toEqual([303, 288]);
for (const scale of [0.65, 0.75, 0.77, 1, 1.29, 1.5, 3]) {
const d = getDisplaySize(mc(4, "1234567890"), BWIP_CANVAS, scale, 12);
expect([pxToDots(d.w, scale, 12), pxToDots(d.h, scale, 12)]).toEqual([303, 288]);
}
});

it("crops bwip's dead bitmap margin so the drawn ink fills the footprint", () => {
const d = getDisplaySize(mc(4, "1234567890"), BWIP_CANVAS, 8, 8);
expect(d.bitmapCrop).toEqual({ x: 0, y: 1, width: 209, height: 198 });
});
});
26 changes: 19 additions & 7 deletions packages/core/src/registry/maxicode.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,38 @@
import type { ObjectTypeCore } from "../types/ObjectType";
import type { PreflightProducerResult } from "../types/preflight";
import { fieldPosZ, fdFieldFor } from "./zplHelpers";
import { clamp } from "./transformHelpers";
import { hasTemplateMarkers } from "../lib/fnTemplate";

// ISO/IEC 16023 fixed physical symbol; no magnification.
// 2=US SCM, 3=intl SCM, 4=standard, 5=full EEC, 6=reader programming.
// 2/3 require SCM payload; bwip surfaces errors. 6 produces a config symbol.
export const ALL_MODES = [2, 3, 4, 5, 6] as const;

// Printed ink extent (Labelary-measured: 200x193 dots @ 8dpmm, 300x289 @ 12);
// the symbol size tracks dpmm, not bwip's dpmm-independent pixel canvas.
export const MAXICODE_WIDTH_MM = 25.0;
export const MAXICODE_HEIGHT_MM = 24.1;
/** Canvas footprint at 8 dpmm, visually calibrated against the preview (the
* ZD230 raster measured 209x199 ink). The dots are the source of truth, the
* mm derived so the footprint tracks dpmm. */
const INK_DOTS_8DPMM = { w: 202, h: 192 } as const;
export const MAXICODE_WIDTH_MM = INK_DOTS_8DPMM.w / 8;
export const MAXICODE_HEIGHT_MM = INK_DOTS_8DPMM.h / 8;

/** Mode 4 = standard symbol, only mode without UPS-domain SCM requirement. */
/** Mode 4 = the only mode without a UPS-domain SCM requirement, so new objects
* spawn codable. Not the ^BD parse default, which is 2 (spec p106). */
const MAXICODE_DEFAULT_MODE = 4 as const;

// No rotation prop: ^BD has no orientation slot (spec p106).
export interface MaxicodeProps {
content: string;
mode: 2 | 3 | 4 | 5 | 6;
/** ^BD n/t structured append (spec p106, 1-8 each). Unexposed in the UI;
* carried so an imported multi-symbol set round-trips. */
symbolNumber?: number;
symbolTotal?: number;
}

/** Clamp a ^BD n/t slot into the spec's 1-8 range (p106). */
export function clampMaxicodeAppend(v: number): number {
return clamp(1, 8, v);
}

// bwip's mode 2/3 SCM parser splits the postcode/country/service fields on GS
Expand Down Expand Up @@ -67,10 +80,9 @@ export const maxicode: ObjectTypeCore<MaxicodeProps> = {

toZPL: (obj, ctx) => {
const p = obj.props;
// Structured-append slots fixed at (1,1) since unexposed.
return [
fieldPosZ(obj),
`^BD${p.mode},1,1`,
`^BD${p.mode},${p.symbolNumber ?? 1},${p.symbolTotal ?? 1}`,
fdFieldFor(p.content, ctx),
].join("");
},
Expand Down
4 changes: 3 additions & 1 deletion packages/mcp-server/src/footprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ let binding: { variables: readonly Variable[]; clock: ClockResolveCtx } | null =
const measure = (o: LabelObject, dpmm?: number) => {
if (isGroup(o)) return null;
const resolved = binding ? resolveForMeasure(o, binding.variables, binding.clock) : o;
// Width in dots is dpmm-invariant; 8 only backs a caller that passed none.
// The 8 only backs the 1D z-justify callers, whose dot width is dpmm-invariant.
// Fixed-physical types (maxicode) scale with dpmm, so any caller reaching them
// must pass a density.
return measureBarcodeFootprintDotsWith(engine, resolved as LeafObject, dpmm ?? 8);
};

Expand Down
58 changes: 58 additions & 0 deletions src/components/Canvas/BarcodeObject.fallbackSize.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeAll, afterEach } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { Stage, Layer } from "react-konva";
import type Konva from "konva";
import { BarcodeObject } from "./BarcodeObject";
import type { LeafObject } from "@zplab/core/registry";
import type { LabelObject } from "@zplab/core/types/Group";

beforeAll(() => {
const noop = () => undefined;
HTMLCanvasElement.prototype.getContext = (() =>
new Proxy({ getImageData: () => ({ data: new Uint8ClampedArray(4) }), measureText: () => ({ width: 0 }) }, {
get: (target, prop) => (prop in target ? target[prop as keyof typeof target] : noop),
})) as unknown as typeof HTMLCanvasElement.prototype.getContext;
});

afterEach(cleanup);

/** Mode 2 without a separator never encodes, so the render lands on the
* fallback card; that card's Rect is what the geometry surfaces snap to. */
const uncodableMaxicode = (): LeafObject =>
({
id: "m", type: "maxicode", x: 0, y: 0, rotation: 0, positionType: "FO",
props: { mode: 2, content: "1234567890" },
}) as LabelObject as LeafObject;

function cardRect(obj: LeafObject, dpmm: number) {
let stage: Konva.Stage | null = null;
render(
<Stage width={600} height={400} ref={(n) => { stage = n; }}>
<Layer>
<BarcodeObject
obj={obj}
scale={1}
dpmm={dpmm}
offsetX={0}
offsetY={0}
isSelected={false}
onSelect={() => undefined}
onChange={() => undefined}
snap={(d) => d}
preBindingContent={(obj.props as { content: string }).content}
/>
</Layer>
</Stage>,
);
const r = stage!.findOne("Rect") as Konva.Rect;
return { w: r.width(), h: r.height() };
}

describe("BarcodeObject fallback card size", () => {
it("draws a fixed-footprint type at its real footprint, not the 200x80 card", () => {
// 8 dpmm: the calibrated 202x192-dot footprint; px == dots/8 at scale 1
// via dotsToPx.
expect(cardRect(uncodableMaxicode(), 8)).toEqual({ w: 202 / 8, h: 192 / 8 });
});
});
13 changes: 10 additions & 3 deletions src/components/Canvas/BarcodeObject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, { useCallback, useEffect } from "react";
import { Image as KImage, Group, Rect, Shape, Text } from "react-konva";
import type Konva from "konva";
import { BARCODE_1D_TYPES, ObjectRegistry, objectResolvesCtrl } from "@zplab/core/registry";
import { dotsToPx, pxToDots } from "@zplab/core/lib/coordinates";
import { dotsToPx, mmToDots, pxToDots } from "@zplab/core/lib/coordinates";
import { barcodeFtAnchorOffset, qrPrintsAsGraphic } from "@zplab/core/lib/objectBounds";
import { useColorScheme, CANVAS_WARNING } from "../../hooks/useColorScheme";
import { useFontCacheVersion } from "../../hooks/useFontCacheVersion";
Expand Down Expand Up @@ -525,8 +525,15 @@ export function BarcodeObject({
obj.type === "maxicode" &&
maxicodeScmOwnedByPreflight(preBindingContent, obj.props as MaxicodeProps);
const fallbackError = scmOwned ? null : errorMsg;
const fbW = dotsToPx(200, scale, dpmm);
const fbH = dotsToPx(80, scale, dpmm);
// Spec-fixed-size types (maxicode) draw the card at their real footprint, so
// snap/align/selection never work against a phantom box.
const fixedSize = ObjectRegistry[obj.type]?.defaultSize;
const fbDots =
fixedSize && "widthMm" in fixedSize
? { w: mmToDots(fixedSize.widthMm, dpmm), h: mmToDots(fixedSize.heightMm, dpmm) }
: { w: 200, h: 80 };
const fbW = dotsToPx(fbDots.w, scale, dpmm);
const fbH = dotsToPx(fbDots.h, scale, dpmm);
return (
<Group
id={obj.id}
Expand Down
22 changes: 22 additions & 0 deletions src/lib/groupRotation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,28 @@ describe("rotateSelectionChanges", () => {
expect(Math.abs((ean.y as number) - 100)).toBeLessThanOrEqual(2);
});

// maxicode has no rotation prop, so it takes the no-turn fallback. That path
// used to write a top-left into obj.y, teleporting an ^FT-anchored symbol up
// by its own footprint height.
it("keeps an FT maxicode's baseline through a group turn", () => {
const mc = {
id: "mc", type: "maxicode", x: 100, y: 300, rotation: 0, positionType: "FT",
props: { mode: 4, content: "1234567890" },
} as unknown as LabelObject;
const measured = new Map([
[mc.id, { width: 209, height: 199, uprightBarWDots: 209, uprightBarHDots: 199 }],
]);
const companion = box(0, 0, 20, 20);
const before = objectBoundsDots(mc, ctx(measured));
const c = rotateSelectionChanges([mc, companion], [mc.id, companion.id], ctx(measured), 1).get(mc.id)!;
const after = objectBoundsDots({ ...mc, x: c.x, y: c.y } as LabelObject, ctx(measured));
// Footprint can't turn, so a quarter turn only moves the symbol's centre
// about the pivot; the bbox must keep its size and stay on the label.
expect([after.width, after.height]).toEqual([before.width, before.height]);
expect(after.y).toBe(100);
expect(c.y).toBe(299);
});

it("returns no changes for a zero (mod 4) turn", () => {
const b = box(10, 10, 40, 20);
expect(rotateSelectionChanges([b], [b.id], ctx(), 4).size).toBe(0);
Expand Down
Loading
Loading