639 lines
34 KiB
JavaScript
639 lines
34 KiB
JavaScript
import {loadAnyDCode} from "../vendor/anyd-qr.js";
|
|
import {encodeHandleForUrl} from "@/router"
|
|
|
|
// anyd-qr.js's own loadAnyDCode() memoizes the wasm instantiation itself, so calling it more
|
|
// than once (each of Print.vue and LabelLayoutPreview.vue does, on mount) is free - `anyd` just
|
|
// mirrors its resolved value so buildRenderTree below can use it synchronously. Until it
|
|
// resolves, a QR-family leaf (any QR_LEAF_TYPES entry) throws (see encodeQr) the same way an
|
|
// oversized value already does - callers already have to handle layoutContent throwing, so this
|
|
// reuses that path rather than adding a second failure mode.
|
|
let anyd = null;
|
|
|
|
export function preloadQrEncoder() {
|
|
return loadAnyDCode().then(instance => { anyd = instance; });
|
|
}
|
|
|
|
// Maps each of label-layouts.js's LABEL_TEMPLATES leaf types that draw a code to the anyd-qr.js
|
|
// symbology/error-correction level (and, for rMQR, size strategy) it renders as (see anyd's
|
|
// EncodeOptions - `ecc`/`size` - and its per-symbology EcLevel enums, `wasm.rs`'s
|
|
// qr_ec/micro_ec/rmqr_ec/rmqr_size) - which combination a given label uses is baked into its
|
|
// layout tree (see label-layouts.js's "qr"-prefixed templates), rather than a single choice
|
|
// applied to every code leaf alike, so there's no longer a global selector for any of them (see
|
|
// Print.vue). A plain symbology id (no suffix) always means anyd's own defaults - ecc "M", rMQR
|
|
// size "balanced" - every other value gets a "-<name>" suffix naming it:
|
|
// - ecc: the same letter anyd itself uses (qr_ec/micro_ec's L/M/Q/H), except micro-qr's
|
|
// "Detection" (`MicroEcLevel::Detection`, an M1-only error-*detection*-but-not-correction mode
|
|
// with no plain single-letter grade of its own). Coverage isn't uniform across symbologies
|
|
// (see qr_ec/micro_ec/rmqr_ec) - full QR takes all four grades, Micro QR swaps "L" (QR's
|
|
// actual lowest) for "Detection" (lower still, but M1-only) and has no "H" at all, and rMQR
|
|
// only ever supports "M" or "H".
|
|
// - size (rMQR only, see rmqr_size/SizeStrategy): "min"/"max" prefer the shortest (flattest,
|
|
// widest) or tallest (narrowest) symbol that fits the text, over the default "balanced"
|
|
// (smallest total module area) - which shape to prefer depends on which of the tape's two
|
|
// axes (across vs. along the feed) is more constrained.
|
|
// rMQR's matrix isn't square (see encodeQr's width/height below), unlike qr/micro-qr, which
|
|
// always are.
|
|
const QR_LEAF_TYPES = {
|
|
"qr-l": {codeType: "qr", ecc: "L"},
|
|
qr: {codeType: "qr", ecc: "M"},
|
|
"qr-q": {codeType: "qr", ecc: "Q"},
|
|
"qr-h": {codeType: "qr", ecc: "H"},
|
|
"mqr-d": {codeType: "micro-qr", ecc: "Detection"},
|
|
"mqr-l": {codeType: "micro-qr", ecc: "L"},
|
|
mqr: {codeType: "micro-qr", ecc: "M"},
|
|
"mqr-q": {codeType: "micro-qr", ecc: "Q"},
|
|
rmqr: {codeType: "rmqr", ecc: "M"},
|
|
"rmqr-min": {codeType: "rmqr", ecc: "M", size: "min"},
|
|
"rmqr-max": {codeType: "rmqr", ecc: "M", size: "max"},
|
|
"rmqr-h": {codeType: "rmqr", ecc: "H"},
|
|
"rmqr-h-min": {codeType: "rmqr", ecc: "H", size: "min"},
|
|
"rmqr-h-max": {codeType: "rmqr", ecc: "H", size: "max"},
|
|
};
|
|
|
|
function isQrLeaf(node) {
|
|
return node.type in QR_LEAF_TYPES;
|
|
}
|
|
|
|
function encodeQr(text, codeType, options) {
|
|
if (!anyd) {
|
|
throw new Error("The QR encoder is still loading — try again in a moment.");
|
|
}
|
|
// BitMatrix-alike view over anyd's row-major Uint8Array, matching the shape drawQrLeaf/
|
|
// snapQrToCrispSize below expect (they predate this and were written against the "qrcode"
|
|
// package's own modules.size/get()). anyd's matrix already excludes the quiet zone from
|
|
// width/height (see its ModuleMatrix type), same as the old library's BitMatrix. width/height
|
|
// are kept separate rather than a single `size` (the old library's own shape, always square)
|
|
// since rMQR symbols are rectangular.
|
|
const {width, height, modules} = anyd.encode(codeType, new TextEncoder().encode(text), options).matrix;
|
|
return {width, height, get: (row, col) => modules[row * width + col] !== 0};
|
|
}
|
|
|
|
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, content} where `type` is one of QR_LEAF_TYPES' keys draws a
|
|
QR/Micro QR/rMQR code at that id's symbology/error-correction level (see QR_LEAF_TYPES
|
|
above), {type: "text", content} draws a text block - either way `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 TEXT_REFERENCE_PX = 100; /* font size text leaves measure their natural aspect ratio at */
|
|
|
|
// Below 10px, a general-purpose sans-serif gets blurry/illegible, so drawTextLeaf switches to one
|
|
// of these bitmap-style fonts instead (see ../scss/_pixel-fonts.scss) - Tom Thumb for the smallest
|
|
// sizes, Silkscreen once there's enough room for its more conventional letterforms.
|
|
//
|
|
// Both were chosen only after rendering single letters in a real browser *at raw canvas pixel
|
|
// sizes* and inspecting the actual pixels - checking that fillText was merely *called* doesn't
|
|
// confirm anything legible got drawn, and neither does a DPI-adjusted size that was never the
|
|
// number actually handed to ctx.font. Silkscreen confirmed clean at 8px+. A third candidate,
|
|
// PICO-8, also rendered cleanly across the whole range, but has no lowercase glyphs at all - it
|
|
// silently draws lowercase input as uppercase - which rules it out for real label content (item
|
|
// handles, URLs) that isn't reliably all-caps. Tom Thumb's declared ascent/descent (0 / ~fontPx,
|
|
// backwards from a normal font) turned out not to be a centering quirk: its actual visible ink is
|
|
// only ~1/3.2 of its own nominal font-size (confirmed both by measuring actualBoundingBox at
|
|
// several sizes and by a live-browser check - "16px" reads as roughly 5px of real glyph height),
|
|
// hence the `scale` below - whatever logical size is requested, the font is actually drawn that
|
|
// many times larger so its real ink comes out at the intended size. Silkscreen's declared size
|
|
// already matches its ink, so it has no `scale` (equivalent to 1).
|
|
//
|
|
// belowPx and MIN_READABLE_TEXT_PX below are both compared against the *logical* (unscaled)
|
|
// fontPx, deliberately not adjusted for the tape's dpi: a browser's font rasterizer only ever
|
|
// sees a raw pixel count, with no notion of "physical size" at all, so that's what determines
|
|
// whether a glyph's fine detail survives - confirmed by the same real-Chromium testing, where a
|
|
// raw 4.35px render was a solid blob regardless of what a dpi-scaled version of that number would
|
|
// have implied.
|
|
const PIXEL_FONT_TIERS = [
|
|
{belowPx: 8, family: "Tom Thumb", scale: 3.2},
|
|
{belowPx: 10, family: "Silkscreen"},
|
|
];
|
|
|
|
function fontFamilyFor(fontPx) {
|
|
return PIXEL_FONT_TIERS.find(t => fontPx < t.belowPx) ?? {family: "sans-serif"};
|
|
}
|
|
|
|
// Tom Thumb (see PIXEL_FONT_TIERS above) held up down to 5px in the same real-Chromium pixel-level
|
|
// verification - a plain sans-serif this small would fail the old MIN_READABLE_TEXT_PX=8 floor
|
|
// that predates it. Below this, drawTextLeaf leaves that one field blank rather than drawing
|
|
// illegible ink - see there for why that's a quieter failure than rejecting the whole label over
|
|
// it.
|
|
const MIN_READABLE_TEXT_PX = 5;
|
|
|
|
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 (isQrLeaf(node) && node.crispWidth !== undefined) {
|
|
return {a: 0, b: wantWidth ? node.crispWidth : node.crispHeight};
|
|
}
|
|
if (isQrLeaf(node) || 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};
|
|
}
|
|
if (a === 0) {
|
|
// Every child is a fixed size (a === 0) in the combining direction - e.g. a row that's
|
|
// just one crisp QR leaf, with no scale-free (text) sibling to invert against. Inverting
|
|
// "width = b" for an a of 0 would divide by zero: a constant width genuinely doesn't
|
|
// determine a height, since nothing here actually scales with it. Ask each child directly
|
|
// for its own size in the wanted direction instead (every one of them must be similarly
|
|
// fixed, since only a fixed leaf ever contributes a === 0), and take the largest - the
|
|
// shared dimension has to fit whichever child needs the most room, with any child that
|
|
// ends up with room to spare centered within it (see drawQrLeaf).
|
|
const otherParts = node.map(child => relation(child, axis, wantWidth, pxPerMm));
|
|
return {a: 0, b: Math.max(...otherParts.map(p => p.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;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* 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 box its aspect ratio alone would suggest. Called once every QR-family leaf has a
|
|
provisional (scale-free) box from a first layoutTree pass, this pins each one's real
|
|
box.width/box.height as `crispWidth`/`crispHeight`, 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/differently-shaped code than what actually gets drawn. Kept as two independent
|
|
dimensions rather than one `crispSize` (as when every code here was a square QR) since an rMQR
|
|
symbol isn't square - see encodeQr. */
|
|
function snapQrToCrispSize(node) {
|
|
if (isSplit(node)) {
|
|
node.forEach(snapQrToCrispSize);
|
|
return;
|
|
}
|
|
if (isQrLeaf(node)) {
|
|
const {width: modulesW, height: modulesH} = node.qr;
|
|
const scale = Math.floor(Math.min(node.box.width / modulesW, node.box.height / modulesH));
|
|
if (!(scale >= 1)) {
|
|
throw new Error("This text needs a bigger code than the tape allows — "
|
|
+ "try a shorter value or a wider tape.");
|
|
}
|
|
node.crispWidth = modulesW * scale;
|
|
node.crispHeight = modulesH * scale;
|
|
}
|
|
}
|
|
|
|
// Always measures in sans-serif at the fixed reference size, even though drawTextLeaf may end up
|
|
// actually drawing in one of PIXEL_FONT_TIERS' fonts - the final effective size (and so which
|
|
// font applies) isn't known until layoutTree has already sized the box this aspect ratio feeds
|
|
// into. The pixel fonts are close enough in proportion for basic Latin/digits that the tiny
|
|
// resulting mismatch is invisible in practice at these sizes, and the MIN_READABLE_TEXT_PX check
|
|
// still catches anything that genuinely doesn't fit.
|
|
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-family leaf gets its
|
|
actual encoded modules (see encodeQr, keyed off the leaf's own type via QR_LEAF_TYPES) and an
|
|
aspect ratio taken from their real width/height - 1 (square) for qr/micro-qr, but not for rmqr,
|
|
whose symbols are rectangular - 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 (isQrLeaf(node)) {
|
|
const {codeType, ...options} = QR_LEAF_TYPES[node.type];
|
|
const qr = encodeQr(node.value, codeType, options);
|
|
return {type: node.type, aspect: qr.width / qr.height, qr};
|
|
}
|
|
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 {width: modulesW, height: modulesH} = node.qr;
|
|
const scale = Math.floor(Math.min(node.box.width / modulesW, node.box.height / modulesH));
|
|
const w = modulesW * scale;
|
|
const h = modulesH * scale;
|
|
const left = node.box.x + Math.floor((node.box.width - w) / 2);
|
|
const top = node.box.y + Math.floor((node.box.height - h) / 2);
|
|
for (let row = 0; row < modulesH; row++) {
|
|
for (let col = 0; col < modulesW; col++) {
|
|
if (node.qr.get(row, col)) {
|
|
ctx.fillRect(left + col * scale, top + row * scale, scale, scale);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Returns the effective (raw, un-normalized) font size drawn at - or that would have been, if
|
|
// it's too small to draw, see below - drawTree collects these into drawLabel/drawFallbackLabel's
|
|
// textSizesPx.
|
|
function drawTextLeaf(ctx, node, referencePx) {
|
|
const fontPx = referencePx * (node.box.height / node.naturalHeight);
|
|
// Even the smallest PIXEL_FONT_TIERS entry stops being legible below this - rather than
|
|
// reject the whole label over one field that's too small (the old behavior), just leave this
|
|
// leaf blank; its box was already accounted for, so nothing else in the layout shifts.
|
|
if (fontPx < MIN_READABLE_TEXT_PX) {
|
|
return fontPx;
|
|
}
|
|
const {family, scale = 1} = fontFamilyFor(fontPx);
|
|
const isPixelFont = family !== "sans-serif";
|
|
// A pixel font's glyphs are meant to land exactly on the pixel grid - node.box.x/y are
|
|
// ordinary layout math (sums/quotients of affine-solved sizes) and essentially never land on
|
|
// a whole pixel, so drawing at their exact fractional size/position would misalign a pixel
|
|
// font's 1px-wide strokes the same as it would any other font. Snapping size and position to
|
|
// the nearest whole pixel fixes that; sans-serif is left at its exact fractional fit, since
|
|
// ordinary anti-aliased text is expected to (and looks fine) regardless of position.
|
|
const snap = isPixelFont ? Math.round : (v) => v;
|
|
const drawFontPx = snap(fontPx);
|
|
// `scale` (Tom Thumb only, see PIXEL_FONT_TIERS above) corrects for a font whose declared
|
|
// size doesn't match its real visible ink - the size actually handed to ctx.font, not
|
|
// drawFontPx itself, which stays the logical size everything else here (box centering, line
|
|
// stacking) is measured against.
|
|
ctx.font = `${drawFontPx * scale}px "${family}"`;
|
|
// A @font-face family already in use elsewhere on the page loads in time for this, but canvas
|
|
// text silently falls back to the next font in the stack (there isn't one here, so the
|
|
// browser default) if drawn before its first-ever load finishes - unlike DOM text, a canvas
|
|
// fillText never waits or repaints on its own once the real font arrives. Kicking off the load
|
|
// here means only that very first draw at a given size risks the fallback; every redraw after
|
|
// it (Print.vue's live preview redraws on every keystroke) picks up the real font.
|
|
if (isPixelFont) {
|
|
document.fonts.load(ctx.font);
|
|
}
|
|
ctx.textAlign = "center";
|
|
const centerX = snap(node.box.x + node.box.width / 2);
|
|
const lineHeight = node.box.height / node.lines.length;
|
|
// Lines stack as a block, each centered under the last - keeps a multi-line field reading as
|
|
// one unit rather than drifting apart.
|
|
let sliceTop = node.box.y;
|
|
for (const line of node.lines) {
|
|
if (isPixelFont) {
|
|
// textBaseline:"middle" centers on the font's *declared* ascent/descent (its line
|
|
// height) - Tom Thumb's are backwards (see PIXEL_FONT_TIERS above) and would center on
|
|
// nonsense. Centering on actualBoundingBox{Ascent,Descent} instead - this specific
|
|
// string's real rendered ink (its character height) - costs nothing and stays correct
|
|
// regardless of whether a pixel font's declared metrics can be trusted.
|
|
ctx.textBaseline = "alphabetic";
|
|
const {actualBoundingBoxAscent: up, actualBoundingBoxDescent: down} = ctx.measureText(line);
|
|
ctx.fillText(line, centerX, snap(sliceTop + (lineHeight + up - down) / 2));
|
|
} else {
|
|
ctx.textBaseline = "middle";
|
|
ctx.fillText(line, centerX, sliceTop + lineHeight / 2);
|
|
}
|
|
sliceTop += lineHeight;
|
|
}
|
|
return fontPx;
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
|
|
// `textSizesPx` collects each "text" leaf's effective font size as drawTree walks the tree - see
|
|
// drawLabel/drawFallbackLabel, which hand it back to the caller (Print.vue shows it alongside the
|
|
// tape width) so a field rendering blank (see drawTextLeaf's MIN_READABLE_TEXT_PX check) shows up
|
|
// as a suspiciously small size here rather than just silently not being there.
|
|
function drawTree(ctx, node, referencePx, textSizesPx) {
|
|
if (isSplit(node)) {
|
|
node.forEach(child => drawTree(ctx, child, referencePx, textSizesPx));
|
|
return;
|
|
}
|
|
if (isQrLeaf(node)) {
|
|
drawQrLeaf(ctx, node);
|
|
} else if (node.type === "text") {
|
|
textSizesPx.push(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 `fixedSize` (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. `fixedSize` and the tree's content
|
|
fully determine its overall size along the other, growing axis (the one that runs along the
|
|
tape as it feeds); `maxLength`, when finite (a fixed-length/die-cut tape), rejects content that
|
|
doesn't fit rather than shrinking it.
|
|
|
|
`orientation` picks which axis `fixedSize` binds to: "along" (the default) fixes the tree's
|
|
height - the tape's cross-web width - and grows its width along the feed direction, same as a
|
|
plain read top-to-bottom design. "across" fixes the tree's width instead and grows its height,
|
|
so the design is built turned 90deg from how it'd read "along" - drawLabel/drawFallbackLabel
|
|
are what actually rotate the drawing back into the physical raster's fixed orientation; nothing
|
|
here needs to know about that rotation, since relation()/layoutTree() below already solve the
|
|
tree in either direction symmetrically.
|
|
|
|
Sizing runs twice: a first pass treats every QR-family leaf as the scale-free box its real
|
|
width/height ratio 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 size reflect what's actually drawn rather than the idealized box no code ever
|
|
quite fills. */
|
|
function layoutContent(ctx, content, fixedSize, maxLength, referencePx, pxPerMm, orientation) {
|
|
const tree = buildRenderTree(ctx, content, referencePx);
|
|
const alongTape = orientation !== "across";
|
|
|
|
const solve = () => {
|
|
const {a, b} = relation(tree, false, alongTape, pxPerMm);
|
|
return alongTape
|
|
? {width: a * fixedSize + b, height: fixedSize}
|
|
: {width: fixedSize, height: a * fixedSize + b};
|
|
};
|
|
|
|
let {width, height} = solve();
|
|
layoutTree(tree, false, width, height, pxPerMm);
|
|
snapQrToCrispSize(tree);
|
|
|
|
({width, height} = solve());
|
|
const length = alongTape ? width : height;
|
|
if (maxLength !== Infinity && length > 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);
|
|
return {tree, length};
|
|
}
|
|
|
|
/* The tape-fed layout - draws a fully resolved content tree (see templateContent) at the tape's
|
|
real pixel dimensions. `orientation` is "along" (the default) to lay the design out reading
|
|
along the tape's feed direction, or "across" to turn it 90deg so it reads across the tape
|
|
instead - either way the physical raster this returns is still exactly
|
|
printedLength x tape.printAreaPx (that's fixed by the tape/print head, not a choice this
|
|
makes); "across" just draws the (now width-fixed, see layoutContent) tree through a rotated
|
|
canvas transform so it lands correctly in that same raster, rather than transposing every box
|
|
the tree itself computed. See DEBUG_LEAF_BORDERS above to outline every leaf's box. Returns
|
|
{textSizesPx}: each "text" leaf's effective font size, in the tree's own left-to-right,
|
|
top-to-bottom order. */
|
|
export function drawLabel(canvas, tape, content, orientation = "along") {
|
|
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, length: contentLength} = layoutContent(
|
|
measureCtx, content, tape.printAreaPx, maxLength, TEXT_REFERENCE_PX, pxPerMm, orientation);
|
|
|
|
const printedLength = tape.printLengthPx || Math.ceil(contentLength + tape.leadPx + TRAILING_PADDING_PX);
|
|
canvas.width = printedLength;
|
|
canvas.height = tape.printAreaPx;
|
|
|
|
const ctx = canvas.getContext("2d", {willReadFrequently: true});
|
|
ctx.fillStyle = "#fff";
|
|
ctx.fillRect(0, 0, printedLength, canvas.height);
|
|
ctx.fillStyle = "#000";
|
|
|
|
const origin = tape.leadPx
|
|
+ Math.floor((printedLength - tape.leadPx - TRAILING_PADDING_PX - contentLength) / 2);
|
|
const textSizesPx = [];
|
|
if (orientation === "across") {
|
|
// The tree was solved width-fixed (see layoutContent) - its width already exactly fills
|
|
// tape.printAreaPx, so only its (growing) height needs the same along-the-feed centering
|
|
// originX got above; translate+rotate then carries that tree-local (x, y) box straight
|
|
// into the physical (printedLength x printAreaPx) raster, a quarter turn at a time.
|
|
positionTree(tree, false, 0, origin);
|
|
ctx.save();
|
|
ctx.translate(0, tape.printAreaPx);
|
|
ctx.rotate(-Math.PI / 2);
|
|
drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
|
|
ctx.restore();
|
|
} else {
|
|
positionTree(tree, false, origin, 0);
|
|
drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
|
|
}
|
|
return {textSizesPx};
|
|
}
|
|
|
|
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. `orientation`, see drawLabel. Returns {textSizesPx},
|
|
see drawLabel. */
|
|
export function drawFallbackLabel(canvas, content, orientation = "along") {
|
|
const measureCtx = canvas.getContext("2d");
|
|
const pxPerMm = FALLBACK_DPI / 25.4;
|
|
const {tree, length: contentLength} = layoutContent(
|
|
measureCtx, content, FALLBACK_LABEL_HEIGHT_PX, Infinity, TEXT_REFERENCE_PX, pxPerMm, orientation);
|
|
|
|
canvas.width = Math.ceil(contentLength);
|
|
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";
|
|
|
|
const textSizesPx = [];
|
|
if (orientation === "across") {
|
|
positionTree(tree, false, 0, 0);
|
|
ctx.save();
|
|
ctx.translate(0, FALLBACK_LABEL_HEIGHT_PX);
|
|
ctx.rotate(-Math.PI / 2);
|
|
drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
|
|
ctx.restore();
|
|
} else {
|
|
positionTree(tree, false, 0, 0);
|
|
drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
|
|
}
|
|
return {textSizesPx};
|
|
}
|
|
|
|
// 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. Nothing here
|
|
// needs anything beyond the prefill's own {userHandle, id} - the short link (see Print.vue's
|
|
// `shortUrl` computed) needs a store lookup no synchronous builder can do, so it's never baked
|
|
// into `text` this way; it's just another field/template a user can pick once the page is up.
|
|
"item": ({userHandle, id}) => `${window.location.origin}/i/${encodeHandleForUrl(userHandle)}/${id}`,
|
|
// Storage locations have no long-form URL route of their own (see router.js - only items get
|
|
// an /i/:handle/:id) - so there's nothing to bake synchronously here. Its base vars (below)
|
|
// still populate normally, so the short link (Print.vue's `shortUrl`) and any future
|
|
// location template are still available; `text` just starts blank until one is picked.
|
|
};
|
|
|
|
export function buildLabelContent(prefill) {
|
|
if (!prefill) {
|
|
return "";
|
|
}
|
|
const build = LABEL_CONTENT_BUILDERS[prefill.kind];
|
|
return build ? build(prefill.components) : "";
|
|
}
|
|
|
|
// A prefill's {userHandle, id} is the same raw identity for either resource kind below - this
|
|
// just splits the handle into label-layouts.js's separate `user`/`domain` base vars the same way
|
|
// store.js's own lookupServer does, and tags on whichever id field the resource's own templates
|
|
// key their required_vars by.
|
|
function splitUserHandle(userHandle) {
|
|
if (!userHandle) {
|
|
return null;
|
|
}
|
|
const at = userHandle.indexOf("@");
|
|
return {
|
|
user: at === -1 ? userHandle : userHandle.slice(0, at),
|
|
domain: at === -1 ? "" : userHandle.slice(at + 1),
|
|
};
|
|
}
|
|
|
|
// 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 (userHandle, itemUrl,
|
|
// itemHandle, …) aren't built here; they're calculated live from whatever the base vars currently
|
|
// are (see label-layouts.js's DERIVED_VARS and Print.vue's `shortUrl`), 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": ({userHandle, id}) => {
|
|
const split = splitUserHandle(userHandle);
|
|
if (!split || !id) {
|
|
return {};
|
|
}
|
|
return {...split, itemId: String(id)};
|
|
},
|
|
"storage-location": ({userHandle, id}) => {
|
|
const split = splitUserHandle(userHandle);
|
|
if (!split || !id) {
|
|
return {};
|
|
}
|
|
return {...split, locationId: String(id)};
|
|
},
|
|
};
|
|
|
|
export function buildLabelFields(prefill) {
|
|
if (!prefill) {
|
|
return {};
|
|
}
|
|
const build = LABEL_FIELD_BUILDERS[prefill.kind];
|
|
return build ? build(prefill.components) : {};
|
|
}
|