Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
cd728a2
docs(canvas): document ZPL-first sizing policy and add coverage guard
u8array May 9, 2026
4a42b0b
feat(canvas): include EAN/UPC and LOGMARS firmware-reserved text zone…
u8array May 9, 2026
6ed6f90
feat(canvas): use GS1 DataBar spec heights instead of bwip-natural ca…
u8array May 9, 2026
a765701
feat(canvas): split bitmap from bbox so EAN/UPC and LOGMARS render un…
u8array May 9, 2026
1fc3fa6
feat(canvas): rotation-aware text-zone for EAN/UPC bbox shift
u8array May 9, 2026
ba25a9b
docs(canvas): document GS1 sym 7 ZPL-validation gap and pin fallback …
u8array May 9, 2026
0d637f9
fix(canvas): extend bbox to full footprint in showText / showRotatedT…
u8array May 9, 2026
cd79547
refactor(canvas): replace text-zone if/else with type-keyed map
u8array May 9, 2026
d4fec6c
refactor(canvas): extract FT shift constant and consolidate dim varia…
u8array May 9, 2026
dc4604d
fix(canvas): position HRI digits at bar bottom, not bbox bottom
u8array May 9, 2026
8ffe765
feat(canvas): correct code93/code11/plessey widths to ZPL spec
u8array May 9, 2026
54077c1
feat(properties): subtle hint icon for visually-approximated barcodes
u8array May 9, 2026
0a862c4
fix(canvas): crop GS1 DataBar bitmap to skip bwip's paddingheight rows
u8array May 9, 2026
8edd4d7
feat(canvas): mark GS1 DataBar as visually approximate
u8array May 9, 2026
2af1b31
fix(canvas): match Zebra General Compaction width for AI 01 + 11-digi…
u8array May 9, 2026
0198722
build(ts): split test typecheck from app config
u8array May 9, 2026
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
206 changes: 136 additions & 70 deletions src/components/Canvas/BarcodeObject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
upceCheckDigit,
get1DBwipScale,
getEanUpcLayout,
type BarcodeDisplaySize,
type EanUpcType,
} from "./bwipHelpers";
import { objectRotation } from "../../registry/rotation";
Expand Down Expand Up @@ -69,41 +70,53 @@ export function BarcodeObject({
}
}

let displayW = 0;
let displayH = 0;
if (barcodeCanvas) {
const size = getDisplaySize(obj, barcodeCanvas, scale, dpmm);
displayW = size.w;
displayH = size.h;
}

// Apply ^FT baseline correction (same logic as KonvaObjectInner)
const displayX = obj.x;
let displayY = obj.y;
if (obj.positionType === "FT") {
// Single object holding the full ZPL footprint (w/h) and the bar
// sub-rectangle (barW/barH/barLeftPx/barTopPx). Defaults zero out
// when the bwip canvas hasn't rendered yet.
const dim: BarcodeDisplaySize = barcodeCanvas
? getDisplaySize(obj, barcodeCanvas, scale, dpmm)
: { w: 0, h: 0, barW: 0, barH: 0, barLeftPx: 0, barTopPx: 0 };

// Y delta in dots between the FT baseline (bar bottom) and the bbox
// top-left, plus the QR-specific firmware offset. Used forward in the
// render path and inverted in the drag-end handler.
// FT-positioned: subtract this from FT.y to get bbox-top-Y
// FO-positioned: zero except for QR's +10-dot artifact
// Computed once and reused so render and drag-end stay in lockstep.
const ftYShiftDots = (() => {
let d = 0;
if (barcodeCanvas) {
displayY -= pxToDots(displayH, scale, dpmm);
d += pxToDots(dim.barH, scale, dpmm);
} else if (BARCODE_1D_TYPES.has(obj.type)) {
displayY -= (obj.props as { height: number }).height;
d += (obj.props as { height: number }).height;
}
if (obj.type === "qrcode") {
// Zebra firmware artifact: ^FT for QR codes shifts the symbol up by exactly
// Zebra firmware artifact: ^FT for QR shifts the symbol up by exactly
// 3 modules (= 3 * magnification dots), independent of dpmm or content.
// Verified against Labelary API across magnifications 4–10 at 8 and 12 dpmm.
// Leading theory: the firmware reserves a dummy text-interpretation bounding
// box (as for 1D barcodes) even though QR codes have no human-readable text.
displayY -=
QR_FT_MODULE_OFFSET *
// Verified against Labelary across magnifications 4–10 at 8 and 12 dpmm.
// Leading theory: firmware reserves a dummy text-interpretation bbox
// even though QR codes have no human-readable text.
d += QR_FT_MODULE_OFFSET *
(obj.props as { magnification: number }).magnification;
}
} else if (obj.type === "qrcode") {
// Zebra firmware artifact: ^FO QR codes are rendered with a hardcoded +10 dot
// Y-offset, independent of magnification and dpmm. Verified against Labelary.
displayY += QR_FO_Y_OFFSET_DOTS;
}
return d;
})();

// Zebra firmware artifact: ^FO QR codes render with a hardcoded +10-dot
// Y-offset, independent of magnification and dpmm. Verified via Labelary.
const foYShiftDots = obj.type === "qrcode" ? QR_FO_Y_OFFSET_DOTS : 0;

const x = offsetX + dotsToPx(displayX, scale, dpmm);
const y = offsetY + dotsToPx(displayY, scale, dpmm);
const displayX = obj.x;
const displayY = obj.positionType === "FT"
? obj.y - ftYShiftDots
: obj.y + foYShiftDots;

// Bars draw at FO; bbox top-left shifts by (-barLeftPx, -barTopPx)
// when the text zone extends LEFT/ABOVE the bars (rotated EAN/UPC,
// inverted EAN/UPC/LOGMARS). The Konva Group is positioned at bbox
// top-left and KImage offsets back to land bars at FO.
const x = offsetX + dotsToPx(displayX, scale, dpmm) - dim.barLeftPx;
const y = offsetY + dotsToPx(displayY, scale, dpmm) - dim.barTopPx;

const snapPos = (sx: number, sy: number) => ({
x:
Expand All @@ -119,30 +132,40 @@ export function BarcodeObject({
};

const handleDragEnd = (e: Konva.KonvaEventObject<DragEvent>) => {
let finalY = pxToDots(e.target.y() - offsetY, scale, dpmm);
if (obj.positionType === "FT") {
if (barcodeCanvas) {
finalY += pxToDots(displayH, scale, dpmm);
} else if (BARCODE_1D_TYPES.has(obj.type)) {
finalY += (obj.props as { height: number }).height;
}
if (obj.type === "qrcode") {
finalY +=
QR_FT_MODULE_OFFSET *
(obj.props as { magnification: number }).magnification;
}
} else if (obj.type === "qrcode") {
finalY -= QR_FO_Y_OFFSET_DOTS;
}
onChange({
x: pxToDots(e.target.x() - offsetX, scale, dpmm),
y: finalY,
});
// Inverse of the render-path math: Group origin is bbox top-left,
// FO/FT semantics anchor at the bars, so we add back the bbox shift
// (barLeftPx/barTopPx) before converting pixels to dots, then undo
// the FT/FO Y-shift to recover the saved obj.x/obj.y.
const finalX = pxToDots(
e.target.x() + dim.barLeftPx - offsetX,
scale,
dpmm,
);
const yDots = pxToDots(
e.target.y() + dim.barTopPx - offsetY,
scale,
dpmm,
);
const finalY = obj.positionType === "FT"
? yDots + ftYShiftDots
: yDots - foYShiftDots;
onChange({ x: finalX, y: finalY });
};

if (barcodeCanvas) {
const w = displayW;
const h = displayH;
const w = dim.w;
const h = dim.h;
// Bitmap is drawn at the bar sub-rectangle of the bbox so the bars
// render at their true height. The text-zone padding (which side
// depends on rotation) stays empty inside the bbox.
const bw = Math.max(dim.barW, 1);
const bh = Math.max(dim.barH, 1);
const btX = dim.barLeftPx;
const btY = dim.barTopPx;
// Konva crop prop is undefined when no cropping is needed; passing it
// selectively skips bwip's internal padding (e.g. GS1 DataBar's
// paddingheight rows) so bars fill the bbox at firmware-correct height.
const bitmapCrop = dim.bitmapCrop;
// Force-off when the symbology has no HRI in ZPL (e.g. GS1 Databar) — the
// canvas must match the print output even if a legacy saved object still
// carries printInterpretation: true.
Expand Down Expand Up @@ -182,7 +205,7 @@ export function BarcodeObject({

const { xLeft: xLeft13, xRight: xRight13, halfWidth: halfW13 } = layout;

const textY = Math.max(h, 1) + textGap;
const textY = Math.max(bh, 1) + textGap;
clipLeft = ldW;
textNodes = [
<Text
Expand Down Expand Up @@ -234,7 +257,7 @@ export function BarcodeObject({

const { xLeft: xLeft8, xRight: xRight8, halfWidth: halfW8 } = layout;

const textY = Math.max(h, 1) + textGap;
const textY = Math.max(bh, 1) + textGap;
textNodes = [
<Text
key="dl"
Expand Down Expand Up @@ -273,7 +296,7 @@ export function BarcodeObject({
const { xLeft: xLeftUpca, xRight: xRightUpca, halfWidth: halfUpca } =
layout;

const textY = Math.max(h, 1) + textGap;
const textY = Math.max(bh, 1) + textGap;
clipLeft = ldW;
textNodes = [
// number system digit — floated left of barcode image
Expand Down Expand Up @@ -329,7 +352,7 @@ export function BarcodeObject({

// UPC-E: 6 digits centered over the data area (modules 3–44 of 51)
const { xLeft: xMid, halfWidth: midW } = layout;
const textY = Math.max(h, 1) + textGap;
const textY = Math.max(bh, 1) + textGap;
clipLeft = ldW;
clipRight = ldW;
textNodes = [
Expand Down Expand Up @@ -395,11 +418,12 @@ export function BarcodeObject({
onDragEnd={handleDragEnd}
>
<KImage
x={0}
y={0}
x={btX}
y={btY}
image={barcodeCanvas}
width={Math.max(w, 1)}
height={Math.max(h, 1)}
crop={bitmapCrop}
width={bw}
height={bh}
imageSmoothingEnabled={false}
stroke={isSelected ? "#6366f1" : undefined}
strokeWidth={isSelected ? 2 : 0}
Expand Down Expand Up @@ -444,7 +468,7 @@ export function BarcodeObject({
const textLocalY = (sy: number) =>
isTextAbove
? -(textFontSize + aboveGap) / sy
: Math.max(h, 1) + textGap / sy;
: Math.max(bh, 1) + textGap / sy;
const txtY = textLocalY(1);

// Counter-scale the text so it stays at constant pixel size while the
Expand Down Expand Up @@ -488,12 +512,26 @@ export function BarcodeObject({
onTransform={handleTransform}
onTransformEnd={handleTransformEnd}
>
<KImage
{/* Invisible rect spanning the full ZPL footprint so the Group's
getClientRect picks up the text-zone reservation. The HRI Text
node has getSelfRect=0 (excluded from bbox to keep resize
anchored at the bars), so without this rect the bbox would
shrink to barH only. */}
<Rect
x={0}
y={0}
image={barcodeCanvas}
width={Math.max(w, 1)}
height={Math.max(h, 1)}
fill="transparent"
listening={false}
/>
<KImage
x={btX}
y={btY}
image={barcodeCanvas}
crop={bitmapCrop}
width={bw}
height={bh}
imageSmoothingEnabled={false}
stroke={isSelected ? "#6366f1" : undefined}
strokeWidth={isSelected ? 2 : 0}
Expand Down Expand Up @@ -621,7 +659,7 @@ export function BarcodeObject({
txtX = sideX; txtY = 0; txtWidth = h;
} else if (rotation === "I") {
txtX = w;
txtY = isTextAbove ? h + textGap + textFontSize : -textGap;
txtY = isTextAbove ? bh + textGap + textFontSize : -textGap;
txtWidth = w;
} else {
txtX = sideX; txtY = h; txtWidth = h;
Expand All @@ -645,8 +683,17 @@ export function BarcodeObject({
onDragMove={(e) => e.target.position(snapPos(e.target.x(), e.target.y()))}
onDragEnd={handleDragEnd}
>
<KImage x={0} y={0} image={barcodeCanvas}
{/* Full-bbox invisible rect — same role as in the upright/showText
branch: the rotated HRI Text overlay sits outside the bars and
its position varies per rotation, so the Group's auto-bbox
would not necessarily span the full ZPL footprint. */}
<Rect
x={0} y={0}
width={Math.max(w, 1)} height={Math.max(h, 1)}
fill="transparent" listening={false}
/>
<KImage x={btX} y={btY} image={barcodeCanvas} crop={bitmapCrop}
width={bw} height={bh}
imageSmoothingEnabled={false}
stroke={isSelected ? "#6366f1" : undefined}
strokeWidth={isSelected ? 2 : 0}
Expand All @@ -657,26 +704,45 @@ export function BarcodeObject({
);
}

// Default path. Wrapped in a Group so the bbox spans the full footprint
// (including any text zone reserved by firmware) while the bitmap
// renders only at the bar sub-rectangle. An invisible Rect at the full
// bbox dimensions keeps Group.getClientRect aligned with displayH even
// when btY > 0 or bh < h.
return (
<KImage
<Group
id={obj.id}
x={x}
y={y}
image={barcodeCanvas}
width={Math.max(w, 1)}
height={Math.max(h, 1)}
imageSmoothingEnabled={false}
stroke={isSelected ? "#6366f1" : undefined}
strokeWidth={isSelected ? 2 : 0}
strokeScaleEnabled={false}
draggable
onClick={(e) =>
onSelect(e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey)
}
onTap={() => onSelect(false)}
onDragMove={handleDragMove}
onDragEnd={handleDragEnd}
/>
>
<Rect
x={0}
y={0}
width={Math.max(w, 1)}
height={Math.max(h, 1)}
fill="transparent"
listening={false}
/>
<KImage
x={btX}
y={btY}
image={barcodeCanvas}
crop={bitmapCrop}
width={bw}
height={bh}
imageSmoothingEnabled={false}
stroke={isSelected ? "#6366f1" : undefined}
strokeWidth={isSelected ? 2 : 0}
strokeScaleEnabled={false}
/>
</Group>
);
}

Expand Down
60 changes: 60 additions & 0 deletions src/components/Canvas/bwipConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,69 @@ export const EAN_TEXT_ZONE_DOTS = 13;
// wider than the standard textGap used for text below other 1D barcodes.
export const LOGMARS_TEXT_ABOVE_GAP_DOTS = 10;

// Total LOGMARS text-zone reserved by firmware (regardless of printInterpretation):
// glyph height + LOGMARS_TEXT_ABOVE_GAP_DOTS. Empirically 20 dots — used as part
// of the ZPL-correct bbox so selection-handles match the printed footprint.
export const LOGMARS_TEXT_ZONE_DOTS = 20;

// bwip-js adds 3 quiet-zone rows to MicroPDF417 canvas output.
export const MICROPDF417_QUIET_ZONE_ROWS = 3;

/**
* bwip-vs-Zebra width-correction constants for symbologies whose bar
* pattern in bwip-js diverges from Zebra firmware.
*
* code93 / code11: bwip uses a narrower quiet zone than Zebra. The delta
* is content-independent — it's the per-side quiet-zone shortfall in
* modules. Adding it to the bwip canvas module count yields the
* ZPL-correct print width. The bitmap stretches by ~10-25% to fill;
* bars look slightly wider than the print but dimensions match.
*
* plessey: bwip uses a fundamentally different bar encoding from Zebra
* ^BP — both grow linearly with content but at different rates. The
* ratio (≈0.6) is empirically derived from the canonical "12345678"
* fixture. The bitmap squeezes to ~60% width; bars look noticeably
* compressed but the printed footprint matches.
*/
export const CODE93_QUIET_ZONE_DELTA_MODULES = 17;
export const CODE11_QUIET_ZONE_DELTA_MODULES = 19;
// Expressed as a fraction so the canonical fixture (492 → 294 dots)
// matches exactly without rounding drift; numerically ≈ 0.5976.
export const PLESSEY_BWIP_TO_ZEBRA_WIDTH_RATIO = 49 / 82;

/** Symbologies whose displayed bbox is dimensionally ZPL-correct but whose
* bitmap diverges visually from Zebra firmware — either through stretching
* to fit the corrected bbox (code93/code11/plessey) or through a different
* bar-pattern encoder (gs1databar: bwip-js and Zebra encode the same data
* with the same module count but at different transition positions). UI
* surfaces a hint when one is selected so users know the layout is correct
* even when the visual rendering is approximate. */
export const BWIP_VISUAL_APPROX_TYPES = new Set<string>([
"code93",
"code11",
"plessey",
"gs1databar",
]);

// Per-symbology spec module heights for GS1 DataBar. bwip-js renders most
// non-stacked variants at the same canvas height as the omni form (33 modules)
// regardless of the actual variant, which doesn't match Zebra firmware. Use
// these spec values to compute the ZPL-correct bbox height instead of trusting
// the bwip canvas dims. Sym 7 (Expanded Stacked) is segments-dependent and
// falls back to the bwip-natural height.
// 1 Omnidirectional, 2 Truncated, 3 Stacked, 4 Stacked Omnidirectional,
// 5 Limited, 6 Expanded — modules from GS1 General Specifications.
export const GS1_DATABAR_SPEC_HEIGHT_MODULES: Partial<
Record<1 | 2 | 3 | 4 | 5 | 6 | 7, number>
> = {
1: 33,
2: 13,
3: 14,
4: 72,
5: 10,
6: 34,
};

export const EAN_UPC_TYPES = new Set<string>([
"ean13",
"ean8",
Expand Down
Loading
Loading