diff --git a/packages/core/src/lib/barcodeDims.ts b/packages/core/src/lib/barcodeDims.ts index 66452717..35fc6a3b 100644 --- a/packages/core/src/lib/barcodeDims.ts +++ b/packages/core/src/lib/barcodeDims.ts @@ -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, @@ -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, @@ -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 = { @@ -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": { diff --git a/packages/core/src/lib/bwipConstants.ts b/packages/core/src/lib/bwipConstants.ts index af95f88d..05100305 100644 --- a/packages/core/src/lib/bwipConstants.ts +++ b/packages/core/src/lib/bwipConstants.ts @@ -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([ "ean13", "ean8", diff --git a/packages/core/src/lib/zplParser/context.ts b/packages/core/src/lib/zplParser/context.ts index d5a4dbdb..281ca285 100644 --- a/packages/core/src/lib/zplParser/context.ts +++ b/packages/core/src/lib/zplParser/context.ts @@ -213,6 +213,8 @@ export interface FieldState { pdfColumns: number; aztecMag: number; maxicodeMode: MaxicodeProps["mode"]; + maxicodeNumber: number; + maxicodeTotal: number; mpdfRowHeight: number; cbRowHeight: number; cbColumns: number; @@ -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, diff --git a/packages/core/src/lib/zplParser/flushField.ts b/packages/core/src/lib/zplParser/flushField.ts index 4744af3c..bdee364c 100644 --- a/packages/core/src/lib/zplParser/flushField.ts +++ b/packages/core/src/lib/zplParser/flushField.ts @@ -502,6 +502,8 @@ export function createFlushField( { content, mode: s.field.maxicodeMode, + symbolNumber: s.field.maxicodeNumber, + symbolTotal: s.field.maxicodeTotal, } satisfies MaxicodeProps, posType, comment, diff --git a/packages/core/src/lib/zplParser/handlers/barcodes.ts b/packages/core/src/lib/zplParser/handlers/barcodes.ts index 6be25733..2e491344 100644 --- a/packages/core/src/lib/zplParser/handlers/barcodes.ts +++ b/packages/core/src/lib/zplParser/handlers/barcodes.ts @@ -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"; @@ -156,13 +156,15 @@ export function createBarcodeHandlers(s: ParserState): Record { 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 diff --git a/packages/core/src/registry/maxicode.test.ts b/packages/core/src/registry/maxicode.test.ts index 640644c7..1616115b 100644 --- a/packages/core/src/registry/maxicode.test.ts +++ b/packages/core/src/registry/maxicode.test.ts @@ -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 => @@ -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 }); }); }); diff --git a/packages/core/src/registry/maxicode.ts b/packages/core/src/registry/maxicode.ts index 3e747e74..45629726 100644 --- a/packages/core/src/registry/maxicode.ts +++ b/packages/core/src/registry/maxicode.ts @@ -1,6 +1,7 @@ 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. @@ -8,18 +9,30 @@ import { hasTemplateMarkers } from "../lib/fnTemplate"; // 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 @@ -67,10 +80,9 @@ export const maxicode: ObjectTypeCore = { 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(""); }, diff --git a/packages/mcp-server/src/footprint.ts b/packages/mcp-server/src/footprint.ts index 6d301241..e6071be1 100644 --- a/packages/mcp-server/src/footprint.ts +++ b/packages/mcp-server/src/footprint.ts @@ -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); }; diff --git a/src/components/Canvas/BarcodeObject.fallbackSize.test.tsx b/src/components/Canvas/BarcodeObject.fallbackSize.test.tsx new file mode 100644 index 00000000..ca0d3572 --- /dev/null +++ b/src/components/Canvas/BarcodeObject.fallbackSize.test.tsx @@ -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 = n; }}> + + undefined} + onChange={() => undefined} + snap={(d) => d} + preBindingContent={(obj.props as { content: string }).content} + /> + + , + ); + 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 }); + }); +}); diff --git a/src/components/Canvas/BarcodeObject.tsx b/src/components/Canvas/BarcodeObject.tsx index 912c5a66..a73800af 100644 --- a/src/components/Canvas/BarcodeObject.tsx +++ b/src/components/Canvas/BarcodeObject.tsx @@ -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"; @@ -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 ( { 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); diff --git a/src/lib/groupRotation.ts b/src/lib/groupRotation.ts index b5abf31d..8d5b087c 100644 --- a/src/lib/groupRotation.ts +++ b/src/lib/groupRotation.ts @@ -5,7 +5,7 @@ import type { LabelObject, LeafObject } from "@zplab/core/types/Group"; import { isGroup } from "@zplab/core/types/Group"; -import { objectBoundsDots, selectionUnionDots, type ObjectBoundsCtx } from "@zplab/core/lib/objectBounds"; +import { objectBoundsDots, selectionUnionDots, type BoundingBoxDots, type ObjectBoundsCtx } from "@zplab/core/lib/objectBounds"; import { ZPL_ROTATIONS, isZplRotation, type ZplRotation } from "@zplab/core/registry/rotation"; import { barSubRect } from "@zplab/core/lib/bwipConstants"; import { barcodeTextZoneDots, barcodeZoneAbove } from "@zplab/core/lib/barcodeHri"; @@ -55,6 +55,14 @@ function rotatedRect(leaf: LeafObject, pivot: Vec, steps: number, ctx: ObjectBou }; } +/** Model anchor that lands a probe bbox (measured at x=y=0) centred on `centre`. */ +function anchorFor(centre: Vec, probe: BoundingBoxDots): { x: number; y: number } { + return { + x: Math.round(centre.x - (probe.x + probe.width / 2)), + y: Math.round(centre.y - (probe.y + probe.height / 2)), + }; +} + function leafChanges( leaf: LeafObject, pivot: Vec, @@ -110,7 +118,9 @@ function leafChanges( const centre = rotateAbout({ x: b.x + b.width / 2, y: b.y + b.height / 2 }, pivot, steps); const props = leaf.props as { rotation?: string }; if (typeof props.rotation !== "string") { - return { x: Math.round(centre.x - b.width / 2), y: Math.round(centre.y - b.height / 2) }; + // Rotation-less type (maxicode): footprint can't turn, but the anchor may + // still be a ^FT baseline, so probe the bbox instead of writing a top-left. + return anchorFor(centre, objectBoundsDots({ ...leaf, x: 0, y: 0 } as LeafObject, ctx)); } const newRot = advanceRotation(props.rotation, steps) as ZplRotation; // Feed the probe the post-turn measured footprint + barcode bar sub-rect so @@ -120,11 +130,7 @@ function leafChanges( { ...leaf, x: 0, y: 0, props: { ...leaf.props, rotation: newRot } } as LeafObject, probeCtx, ); - return { - x: Math.round(centre.x - (probe.x + probe.width / 2)), - y: Math.round(centre.y - (probe.y + probe.height / 2)), - props: { rotation: newRot }, - }; + return { ...anchorFor(centre, probe), props: { rotation: newRot } }; } /** Clone the bounds ctx with this leaf's measured footprint set for `newRot`: diff --git a/src/lib/objectBounds.test.ts b/src/lib/objectBounds.test.ts index 4b801d81..27b00fa8 100644 --- a/src/lib/objectBounds.test.ts +++ b/src/lib/objectBounds.test.ts @@ -224,6 +224,17 @@ describe("objectBoundsDots", () => { expect(objectBoundsDots(bc, ctx(measured))).toEqual({ x: 20, y: 20, width: 150, height: 80 }); }); + // ZD230-measured (^IS/^HY preview, 8 dpmm): `^FT100,300^BD4,1,1` prints its + // ink bottom row exactly on y=300, so the full 199-dot footprint sits above + // the baseline, same as `^FO100,101`. + it("barcode (FT maxicode): the whole 199-dot footprint sits above the baseline", () => { + const mc = leaf("maxicode", 100, 300, { content: "1234567890", mode: 4 }, { positionType: "FT" }); + const measured = new Map([ + [mc.id, { width: 209, height: 199, uprightBarWDots: 209, uprightBarHDots: 199 }], + ]); + expect(objectBoundsDots(mc, ctx(measured))).toEqual({ x: 100, y: 101, width: 209, height: 199 }); + }); + it("barcode (FT 1D with HRI zone): top shifts by BAR height, not the full footprint", () => { // ean13: bar height 80, measured footprint 93 (13-dot HRI text zone below bars). const bc = leaf( diff --git a/src/registry/maxicode.test.ts b/src/registry/maxicode.test.ts index 0c735622..d923d20d 100644 --- a/src/registry/maxicode.test.ts +++ b/src/registry/maxicode.test.ts @@ -20,7 +20,7 @@ function makeObj(props: MaxicodeProps, overrides?: Partial): La describe("maxicode.toZPL", () => { const def = defined(ObjectRegistry["maxicode"]); - it("emits ^BD with mode and pinned (1,1) structured-append fields", () => { + it("emits ^BD with mode and defaults the structured-append slots to (1,1)", () => { const zpl = def.toZPL(makeObj({ content: "abc", mode: 4, @@ -29,6 +29,11 @@ describe("maxicode.toZPL", () => { expect(zpl).toContain("^BD4,1,1"); expect(zpl).toContain("^FDabc^FS"); }); + + it("emits the carried structured-append slots", () => { + const zpl = def.toZPL(makeObj({ content: "abc", mode: 4, symbolNumber: 2, symbolTotal: 3 })); + expect(zpl).toContain("^BD4,2,3"); + }); }); describe("validateMaxicodeBwip", () => { @@ -67,13 +72,54 @@ describe("maxicode parser roundtrip", () => { expect(defined(ObjectRegistry["maxicode"]).toZPL(obj)).toContain("^BD3,1,1"); }); - it("defaults mode to 4 when an out-of-range value is given", () => { - // mode 9 doesn't exist; parser clamps to the safe standalone default. + // Spec p106: m defaults to 2 and 2-6 is the whole value list, so an omitted + // and an out-of-range m both land on 2 (the firmware's reading). 4 is only the + // spawn default for new objects, never a parse fallback. + it("reads an omitted mode as the spec default 2", () => { + const src = "^XA^FO50,50^BD,1,1^FD12345\x1d840\x1d001\x1dX^FS^XZ"; + const { objects } = parseSingle(src); + const obj = objects[0]; + if (obj?.type !== "maxicode") throw new Error("expected maxicode"); + expect(obj.props.mode).toBe(2); + expect(defined(ObjectRegistry["maxicode"]).toZPL(obj)).toContain("^BD2,1,1"); + }); + + it("coerces an out-of-range mode to the spec default 2", () => { const src = "^XA^FO0,0^BD9,1,1^FDX^FS^XZ"; const { objects } = parseSingle(src); const obj = objects[0]; if (obj?.type !== "maxicode") throw new Error("expected maxicode"); - expect(obj.props.mode).toBe(4); + expect(obj.props.mode).toBe(2); + }); + + it("round-trips the structured-append slots of a 3-symbol set", () => { + const src = [ + "^XA", + "^FO0,0^BD4,1,3^FDA^FS", + "^FO0,220^BD4,2,3^FDB^FS", + "^FO0,440^BD4,3,3^FDC^FS", + "^XZ", + ].join(""); + const def = defined(ObjectRegistry["maxicode"]); + const symbols = parseSingle(src).objects.map((o) => { + if (o.type !== "maxicode") throw new Error("expected maxicode"); + return o; + }); + expect(symbols.map((o) => [o.props.symbolNumber, o.props.symbolTotal])).toEqual([ + [1, 3], [2, 3], [3, 3], + ]); + expect(symbols.map((o) => def.toZPL(o).match(/\^BD[\d,]+/)?.[0])).toEqual([ + "^BD4,1,3", + "^BD4,2,3", + "^BD4,3,3", + ]); + }); + + it("clamps out-of-range structured-append slots into the spec 1-8 range", () => { + const { objects } = parseSingle("^XA^FO0,0^BD4,0,99^FDX^FS^XZ"); + const obj = objects[0]; + if (obj?.type !== "maxicode") throw new Error("expected maxicode"); + expect([obj.props.symbolNumber, obj.props.symbolTotal]).toEqual([1, 8]); }); it("emit -> parse roundtrip preserves content and mode", () => { diff --git a/src/test/maxicodeBitmap.test.ts b/src/test/maxicodeBitmap.test.ts new file mode 100644 index 00000000..ef199818 --- /dev/null +++ b/src/test/maxicodeBitmap.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from "vitest"; +// Node build: the browser entry has no toBuffer, and this pins raw PNG pixels. +import bwipjs from "bwip-js/node"; +import { PNG } from "pngjs"; +import { BWIP_SCALE } from "@zplab/core/lib/barcodeDims"; +import { MAXICODE_INK_MARGIN_PX } from "@zplab/core/lib/bwipConstants"; + +// The maxicode crop is a fixed px rect measured at BWIP_SCALE, so a scale bump +// or a bwip upgrade that reshapes the bitmap must fail loudly here rather than +// silently squeezing the ink back off the printed raster. +async function maxicodePng(text: string): Promise { + const buf = await new Promise((resolve, reject) => { + bwipjs.toBuffer( + { bcid: "maxicode", text, scale: BWIP_SCALE, mode: 4 } as never, + (err: string | Error, png: Buffer) => (err ? reject(err) : resolve(png)), + ); + }); + return PNG.sync.read(buf); +} + +/** Ink bbox over opaque dark pixels; bwip's background is transparent. */ +function inkBox(png: PNG) { + let minX = png.width, minY = png.height, maxX = -1, maxY = -1; + for (let y = 0; y < png.height; y++) { + for (let x = 0; x < png.width; x++) { + const i = (y * png.width + x) * 4; + if ((png.data[i + 3] ?? 255) === 0) continue; + const lum = ((png.data[i] ?? 255) + (png.data[i + 1] ?? 255) + (png.data[i + 2] ?? 255)) / 3; + if (lum >= 128) continue; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + } + return { x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1 }; +} + +describe("bwip maxicode bitmap geometry at BWIP_SCALE", () => { + it.each(["1234567890", "ABC", "999999999999999999999999999999"])( + "renders a 210x200 canvas with the ink at (0,1) sized 209x198 for %s", + async (text) => { + const png = await maxicodePng(text); + expect([png.width, png.height]).toEqual([210, 200]); + expect(inkBox(png)).toEqual({ x: 0, y: 1, w: 209, h: 198 }); + }, + ); + + it("keeps MAXICODE_INK_MARGIN_PX in sync with the measured margins", async () => { + const png = await maxicodePng("1234567890"); + const ink = inkBox(png); + expect(MAXICODE_INK_MARGIN_PX).toEqual({ + left: ink.x, + top: ink.y, + right: png.width - ink.x - ink.w, + bottom: png.height - ink.y - ink.h, + }); + }); +});