This commit is contained in:
j3d1 2026-08-23 00:56:03 +02:00
parent 5c3b7fc252
commit bbe52e4a78
9 changed files with 817 additions and 287 deletions

View file

@ -4,21 +4,57 @@ 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 "qrcode" leaf 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.
// 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; });
}
// The three symbologies anyd-qr.js exposes (see its CodeType) - Print.vue's code-type selector
// offers exactly these. rMQR's matrix isn't square (see encodeQr's width/height below), unlike
// qr/micro-qr, which always are.
export const QR_CODE_TYPES = ["qr", "micro-qr", "rmqr"];
// 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 encodeQr(text, codeType) {
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.");
}
@ -28,7 +64,7 @@ function encodeQr(text, codeType) {
// 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), {ecc: "M"}).matrix;
const {width, height, modules} = anyd.encode(codeType, new TextEncoder().encode(text), options).matrix;
return {width, height, get: (row, col) => modules[row * width + col] !== 0};
}
@ -60,9 +96,11 @@ export function tapeFromStatus(status) {
(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",
- 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
@ -140,10 +178,10 @@ function parseMm(value, key) {
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.crispWidth !== undefined) {
if (isQrLeaf(node) && node.crispWidth !== undefined) {
return {a: 0, b: wantWidth ? node.crispWidth : node.crispHeight};
}
if (node.type === "qrcode" || node.type === "text") {
if (isQrLeaf(node) || node.type === "text") {
const aspect = node.aspect;
return wantWidth ? {a: aspect, b: 0} : {a: 1 / aspect, b: 0};
}
@ -206,7 +244,7 @@ function positionTree(node, ownAxis, x, y) {
/* 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 qrcode leaf has a
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
@ -220,7 +258,7 @@ function snapQrToCrispSize(node) {
node.forEach(snapQrToCrispSize);
return;
}
if (node.type === "qrcode") {
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)) {
@ -246,22 +284,22 @@ function measureTextBlock(ctx, lines, referencePx) {
}
/* 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 encoded
modules (see encodeQr) 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. `codeType`
is Print.vue's global qr/micro-qr/rmqr choice - see QR_CODE_TYPES - applied to every qrcode leaf
in the tree alike, the same way `orientation` applies to the whole tree in layoutContent. */
function buildRenderTree(ctx, node, referencePx, codeType) {
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, codeType));
return node.map(child => buildRenderTree(ctx, child, referencePx));
}
if (node.type === "qrcode") {
const qr = encodeQr(node.value, codeType);
return {type: "qrcode", aspect: qr.width / qr.height, qr};
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];
@ -372,7 +410,7 @@ function drawTree(ctx, node, referencePx, textSizesPx) {
node.forEach(child => drawTree(ctx, child, referencePx, textSizesPx));
return;
}
if (node.type === "qrcode") {
if (isQrLeaf(node)) {
drawQrLeaf(ctx, node);
} else if (node.type === "text") {
textSizesPx.push(drawTextLeaf(ctx, node, referencePx));
@ -398,14 +436,14 @@ function drawTree(ctx, node, referencePx, textSizesPx) {
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 qrcode leaf as the scale-free box its real
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. `codeType`, see buildRenderTree. */
function layoutContent(ctx, content, fixedSize, maxLength, referencePx, pxPerMm, orientation, codeType) {
const tree = buildRenderTree(ctx, content, referencePx, codeType);
quite fills. */
function layoutContent(ctx, content, fixedSize, maxLength, referencePx, pxPerMm, orientation) {
const tree = buildRenderTree(ctx, content, referencePx);
const alongTape = orientation !== "across";
const solve = () => {
@ -438,15 +476,15 @@ function layoutContent(ctx, content, fixedSize, maxLength, referencePx, pxPerMm,
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. `codeType` (default "qr"), see buildRenderTree/QR_CODE_TYPES. */
export function drawLabel(canvas, tape, content, orientation = "along", codeType = "qr") {
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, codeType);
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;
@ -484,13 +522,13 @@ const FALLBACK_DPI = 203; /* reference resolution for turning "empty" leaves' m
/* 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`/`codeType`, see drawLabel. Returns
{textSizesPx}, see drawLabel. */
export function drawFallbackLabel(canvas, content, orientation = "along", codeType = "qr") {
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, codeType);
measureCtx, content, FALLBACK_LABEL_HEIGHT_PX, Infinity, TEXT_REFERENCE_PX, pxPerMm, orientation);
canvas.width = Math.ceil(contentLength);
canvas.height = FALLBACK_LABEL_HEIGHT_PX;
@ -522,8 +560,15 @@ export function drawFallbackLabel(canvas, content, orientation = "along", codeTy
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/${encodeHandleForUrl(user)}/${id}`,
// 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) {
@ -534,27 +579,42 @@ export function buildLabelContent(prefill) {
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 (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.
// 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 = {
// `user` here is already a full "user@domain" handle (that's the form login usernames take -
// see Login.vue/store.js), so it's split into label-layouts.js's separate `user`/`domain`
// base vars the same way store.js's own lookupServer does, rather than stuffing the whole
// handle into one field the way userHandle (now derived from these two) used to be.
"item-url": ({user, id}) => {
if (!user || !id) {
"item": ({userHandle, id}) => {
const split = splitUserHandle(userHandle);
if (!split || !id) {
return {};
}
const at = user.indexOf("@");
return {
user: at === -1 ? user : user.slice(0, at),
domain: at === -1 ? "" : user.slice(at + 1),
itemId: String(id),
};
return {...split, itemId: String(id)};
},
"storage-location": ({userHandle, id}) => {
const split = splitUserHandle(userHandle);
if (!split || !id) {
return {};
}
return {...split, locationId: String(id)};
},
};