import QRCode from "qrcode"; const TRAILING_PADDING_PX = 3; /* blank columns after the cut, same idea as the leading margin */ /* Sizing a label needs numbers only the driver can supply. */ export function tapeFromStatus(status) { const printAreaPx = status?.tape?.printAreaPx; const dpi = status?.printer?.dpi; if (!(printAreaPx > 0) || !(dpi > 0)) { return null; } return { printAreaPx, dpi, mediaWidthMm: status.tape.mediaWidthMm, printLengthPx: status.tape.printLengthPx > 0 ? status.tape.printLengthPx : 0, /* Brother's documented margin for the mounted tape, in raster columns. */ leadPx: status.tape.marginsMm ? Math.round(status.tape.marginsMm * dpi / 25.4) : TRAILING_PADDING_PX, }; } /* A layout is a tree built from two shapes, alternating orientation by nesting depth: - An array is a "split" node: its children sit side by side (a *row*) at even depth (the root, depth 0, is always a row), or stacked (a *column*) at odd depth. To turn a row into a column, wrap it in an extra one-element array - that array is one depth deeper, so its lone child (the original row) is now read at odd depth. - An object is a leaf: {type: "qrcode", content} / {type: "text", content} draw a QR code or text block, where `content` is a function from the resolved field values to the string (or, for "text", an array of strings - one per line) to render. {type: "empty", "min-width": "2mm"} / {type: "empty", "min-height": "2mm"} is a spacer with no ink of its own - the *only* way padding/gaps enter a layout, since nothing here draws a border, margin or gap on its own. An "empty" leaf's dimension always names the axis its enclosing split flows along: "min-width" inside a row, "min-height" inside a column. See label-layouts.js's LABEL_TEMPLATES for concrete trees. */ const QR_ASPECT = 1; /* a QR code is always square */ const TEXT_REFERENCE_PX = 100; /* font size text leaves measure their natural aspect ratio at */ const MIN_READABLE_TEXT_PX = 8; /* below this, a layout is rejected rather than rendered unreadably small */ function isSplit(node) { return Array.isArray(node); } function parseMm(value, key) { const match = typeof value === "string" && /^([\d.]+)mm$/.exec(value); if (!match) { throw new Error(`An "empty" layout node needs a "${key}" like "2mm", got ${JSON.stringify(value)}.`); } return parseFloat(match[1]); } /* Every node's width/height relate to each other affinely - width = A*height + B for a node read in row context, height = A*width + B in column context - because a leaf is either scale-free (a text block, whose aspect ratio holds at any size: A = aspect or 1/aspect, B = 0) or a fixed physical size (an "empty" spacer, or a QR code once its crisp pixel size is known - see snapQrToCrispSize below: A = 0, B = the size in px). Splits combine their children's relations by addition (a row's total width is the sum of each child's width for the shared height, and symmetrically for a column), which stays affine, so the same two numbers describe a whole subtree no matter how deeply it nests. `ownAxis` is true if the split directly containing `node` is a row, false if a column - for a leaf, that's what "empty" measures itself against; for a split, its own axis (and thus how it combines its children) is always the opposite, per the alternating-depth rule. `wantWidth` is true to ask for {A, B} such that width = A*height + B, false for height = A*width + B; requesting the direction a split doesn't naturally combine in just inverts its own relation. */ function relation(node, ownAxis, wantWidth, pxPerMm) { if (!isSplit(node)) { if (node.type === "qrcode" && node.crispSize !== undefined) { return {a: 0, b: node.crispSize}; } if (node.type === "qrcode" || node.type === "text") { const aspect = node.aspect; return wantWidth ? {a: aspect, b: 0} : {a: 1 / aspect, b: 0}; } const key = ownAxis ? "min-width" : "min-height"; return {a: 0, b: parseMm(node[key], key) * pxPerMm}; } const axis = !ownAxis; // this split's own row(true)/column(false) axis const combinesAsWidth = axis; // a row sums widths for a shared height const parts = node.map(child => relation(child, axis, combinesAsWidth, pxPerMm)); const a = parts.reduce((sum, p) => sum + p.a, 0); const b = parts.reduce((sum, p) => sum + p.b, 0); if (combinesAsWidth === wantWidth) { return {a, b}; } return {a: 1 / a, b: -b / a}; // invert: solve the affine relation the other way } /* Top-down: given the fixed (width, height) box `node` must exactly fill, assigns that box to it and, recursively, an appropriately-shaped box to every descendant. `ownAxis` carries the same meaning as in relation() above. */ function layoutTree(node, ownAxis, width, height, pxPerMm) { node.box = {width, height}; if (!isSplit(node)) { return; } const axis = !ownAxis; for (const child of node) { if (axis) { const {a, b} = relation(child, axis, true, pxPerMm); layoutTree(child, axis, a * height + b, height, pxPerMm); } else { const {a, b} = relation(child, axis, false, pxPerMm); layoutTree(child, axis, width, a * width + b, pxPerMm); } } } /* Second top-down pass: turns each node's already-sized box into an absolute (x, y) position, placing a row's children left to right and a column's top to bottom. Kept separate from layoutTree since a node's size doesn't depend on its position, only on its box dimensions. */ function positionTree(node, ownAxis, x, y) { node.box.x = x; node.box.y = y; if (!isSplit(node)) { return; } const axis = !ownAxis; let cursor = axis ? x : y; for (const child of node) { if (axis) { positionTree(child, axis, cursor, y); cursor += child.box.width; } else { positionTree(child, axis, x, cursor); cursor += child.box.height; } } } /* Walks the tree once it's been sized (layoutTree) and given a hard minimum to check against - this is the text-too-small-to-read validation, kept as one pass over the already-final boxes rather than scattered through the drawing code, so nothing gets mutated on the canvas before every leaf is confirmed to fit. The equivalent QR-doesn't-fit check already happened earlier, in snapQrToCrispSize. */ function assertLeavesFit(node) { if (isSplit(node)) { node.forEach(child => assertLeavesFit(child)); return; } if (node.type === "text") { const fontPx = TEXT_REFERENCE_PX * (node.box.height / node.naturalHeight); if (fontPx < MIN_READABLE_TEXT_PX) { throw new Error("This text doesn't fit this layout even at the smallest readable size — " + "try a shorter value, a wider tape, or a different layout."); } } // "qrcode"/"empty" leaves have nothing left to check by this point. } /* A QR code needs an integer number of pixels per module to render crisply rather than blurring at a fractional scale, so its true size is whatever that rounds down to - almost never the scale-free square a bare aspect ratio of 1 would suggest. Called once every qrcode leaf has a provisional (scale-free) box from a first layoutTree pass, this pins each one's real box.width (in row context) or box.height (in column context - whichever axis its box doesn't already share with its siblings) as `crispSize`, so relation() above starts treating it as a fixed size, the same as an "empty" leaf, instead of one that scales with whatever height/width it's offered. A second relation()/layoutTree() pass (see layoutContent) then resizes everything else around that real footprint, so nothing downstream reserves - and leaves unfilled - room for a squarer QR code than what actually gets drawn. */ function snapQrToCrispSize(node) { if (isSplit(node)) { node.forEach(snapQrToCrispSize); return; } if (node.type === "qrcode") { const modules = node.qr.modules.size; const scale = Math.floor(Math.min(node.box.width, node.box.height) / modules); if (!(scale >= 1)) { throw new Error("This text needs a bigger QR code than the tape allows — " + "try a shorter value or a wider tape."); } node.crispSize = modules * scale; } } function measureTextBlock(ctx, lines, referencePx) { ctx.font = `${referencePx}px sans-serif`; const width = Math.max(...lines.map(line => ctx.measureText(line).width)); const height = referencePx * 1.15 * lines.length; return {width, height}; } /* Turns a resolved content tree (see templateContent below - leaf objects carry a `value` rather than a `content` function) into one ready for layout: a QR leaf gets its actual QRCode.create() object, a text leaf gets its measured natural aspect ratio, and an "empty" leaf passes through untouched. Multi-line text (`value` is an array) measures as one leaf, not one per line - splitting it into a column of independently-sized leaves would let each line grow to its own full width, ending up at a different font size than its neighbors, which is legible but not what "one text field" should look like. */ function buildRenderTree(ctx, node, referencePx) { if (isSplit(node)) { return node.map(child => buildRenderTree(ctx, child, referencePx)); } if (node.type === "qrcode") { return {type: "qrcode", aspect: QR_ASPECT, qr: QRCode.create(node.value)}; } if (node.type === "text") { const lines = Array.isArray(node.value) ? node.value : [node.value]; const {width, height} = measureTextBlock(ctx, lines, referencePx); return {type: "text", aspect: width / height, naturalHeight: height, lines}; } return {type: "empty", "min-width": node["min-width"], "min-height": node["min-height"]}; } function drawQrLeaf(ctx, node) { const modules = node.qr.modules.size; const scale = Math.floor(Math.min(node.box.width, node.box.height) / modules); const size = modules * scale; const left = node.box.x + Math.floor((node.box.width - size) / 2); const top = node.box.y + Math.floor((node.box.height - size) / 2); for (let row = 0; row < modules; row++) { for (let col = 0; col < modules; col++) { if (node.qr.modules.get(row, col)) { ctx.fillRect(left + col * scale, top + row * scale, scale, scale); } } } } function drawTextLeaf(ctx, node, referencePx) { const fontPx = referencePx * (node.box.height / node.naturalHeight); const lineHeight = node.box.height / node.lines.length; ctx.font = `${fontPx}px sans-serif`; ctx.textBaseline = "middle"; ctx.textAlign = "center"; const centerX = node.box.x + node.box.width / 2; // Lines stack as a block, each centered under the last - keeps a multi-line field reading as // one unit rather than drifting apart. let y = node.box.y + lineHeight / 2; for (const line of node.lines) { ctx.fillText(line, centerX, y); y += lineHeight; } } // Flip this to true (in a debugger or a local edit) to outline every leaf's box - including // "empty" ones, normally invisible - in a color that can't be mistaken for real label ink. Handy // for checking a layout's actual padding/alignment; never wanted on a real printed label, so it's // a manual toggle rather than something wired up to any UI. let DEBUG_LEAF_BORDERS = false; function drawDebugBorder(ctx, node) { ctx.save(); ctx.strokeStyle = "red"; ctx.lineWidth = 1; // Inset by half a pixel so the 1px stroke lands crisply on-pixel instead of straddling the // box edge and rendering as a blurry 2px line. ctx.strokeRect(node.box.x + 0.5, node.box.y + 0.5, node.box.width - 1, node.box.height - 1); ctx.restore(); } function drawTree(ctx, node, referencePx) { if (isSplit(node)) { node.forEach(child => drawTree(ctx, child, referencePx)); return; } if (node.type === "qrcode") { drawQrLeaf(ctx, node); } else if (node.type === "text") { drawTextLeaf(ctx, node, referencePx); } // "empty" leaves carry no ink - their box just reserves the space. if (DEBUG_LEAF_BORDERS) { drawDebugBorder(ctx, node); } } /* Builds, sizes and validates the tree for a fixed `height` (the tape's cross-web printAreaPx, or the fallback preview's reference height) - the one dimension every layout scales from, plus `pxPerMm` to turn "empty" leaves' physical sizes into pixels. `height` and the tree's content fully determine its overall width; `maxLength`, when finite (a fixed-length/die-cut tape), rejects content that doesn't fit rather than shrinking it. Sizing runs twice: a first pass treats every qrcode leaf as the scale-free square its aspect ratio of 1 suggests, purely to find out how much room each one would actually be offered; from that, snapQrToCrispSize pins each one's real (smaller, crisp-pixel) size. The second pass then resolves the whole tree again with that real size fixed in, so every sibling and the overall width reflect what's actually drawn rather than the idealized square no QR code ever quite fills. */ function layoutContent(ctx, content, height, maxLength, referencePx, pxPerMm) { const tree = buildRenderTree(ctx, content, referencePx); const measured = relation(tree, false, true, pxPerMm); layoutTree(tree, false, measured.a * height + measured.b, height, pxPerMm); snapQrToCrispSize(tree); const {a, b} = relation(tree, false, true, pxPerMm); const width = a * height + b; if (maxLength !== Infinity && width > maxLength) { throw new Error("This doesn't fit on this tape — " + "try a shorter value, a different layout, or a bigger label."); } layoutTree(tree, false, width, height, pxPerMm); assertLeavesFit(tree); return {tree, width}; } /* The tape-fed layout - draws a fully resolved content tree (see templateContent) at the tape's real pixel dimensions. See DEBUG_LEAF_BORDERS above to outline every leaf's box. */ export function drawLabel(canvas, tape, content) { const maxLength = tape.printLengthPx ? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX : Infinity; const measureCtx = canvas.getContext("2d"); const pxPerMm = tape.dpi / 25.4; const {tree, width: contentWidth} = layoutContent( measureCtx, content, tape.printAreaPx, maxLength, TEXT_REFERENCE_PX, pxPerMm); const width = tape.printLengthPx || Math.ceil(contentWidth + tape.leadPx + TRAILING_PADDING_PX); canvas.width = width; canvas.height = tape.printAreaPx; const ctx = canvas.getContext("2d", {willReadFrequently: true}); ctx.fillStyle = "#fff"; ctx.fillRect(0, 0, width, canvas.height); ctx.fillStyle = "#000"; const originX = tape.leadPx + Math.floor((width - tape.leadPx - TRAILING_PADDING_PX - contentWidth) / 2); positionTree(tree, false, originX, 0); drawTree(ctx, tree, TEXT_REFERENCE_PX); } const FALLBACK_LABEL_HEIGHT_PX = 200; /* reference height the no-webusb preview/PNG scales from */ const FALLBACK_DPI = 203; /* reference resolution for turning "empty" leaves' mm sizes into px */ /* The no-webusb preview/PNG - same layout tree and renderer as drawLabel, just scaled from a fixed reference height instead of a real tape's, and with no maxLength (there's no physical tape to run out of, so the canvas just grows to fit) and no printer feed margin, since there's no real print head here to keep clear of. */ export function drawFallbackLabel(canvas, content) { const measureCtx = canvas.getContext("2d"); const pxPerMm = FALLBACK_DPI / 25.4; const {tree, width: contentWidth} = layoutContent( measureCtx, content, FALLBACK_LABEL_HEIGHT_PX, Infinity, TEXT_REFERENCE_PX, pxPerMm); canvas.width = Math.ceil(contentWidth); canvas.height = FALLBACK_LABEL_HEIGHT_PX; const ctx = canvas.getContext("2d", {willReadFrequently: true}); ctx.fillStyle = "#fff"; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = "#000"; positionTree(tree, false, 0, 0); drawTree(ctx, tree, TEXT_REFERENCE_PX); } // Turns a {kind, components} prefill (see Print.vue's `prefill` prop) into the literal string a // print label should show/encode. Keeping this keyed by `kind` rather than having each caller // build its own string means the format for a given kind of label content only has to be gotten // right in one place. export const LABEL_CONTENT_BUILDERS = { // The self-contained Item URL (see docs/design-in-progress/items-labels.md) - what a // printed label actually encodes, since scanning it has to resolve the right // frontend/backend/item with no other context, not just this browser's history. "item-url": ({user, id}) => `${window.location.origin}/i/${user}/${id}`, }; export function buildLabelContent(prefill) { if (!prefill) { return ""; } const build = LABEL_CONTENT_BUILDERS[prefill.kind]; return build ? build(prefill.components) : ""; } // Seeds for the *base* label-layouts.js vars (see BASE_VARS there) - keyed by `kind` for the same // reason LABEL_CONTENT_BUILDERS is. Format-string vars derived from these (itemUrl, itemHandle) // aren't built here; they're calculated live from whatever the base vars currently are (see // label-layouts.js's DERIVED_VARS), prefill or hand-typed alike. A field missing from the result // (rather than present-but-empty) is what label-layouts.js's templateIsAvailable treats as "not // available", so builders should only include a field once its inputs actually check out. const LABEL_FIELD_BUILDERS = { "item-url": ({user, id}) => { if (!user || !id) { return {}; } return {userHandle: user, itemId: String(id)}; }, }; export function buildLabelFields(prefill) { if (!prefill) { return {}; } const build = LABEL_FIELD_BUILDERS[prefill.kind]; return build ? build(prefill.components) : {}; }