This commit is contained in:
j3d1 2026-08-20 03:41:09 +02:00
parent ce1e5f1d62
commit c345372382
9 changed files with 233 additions and 82 deletions

View file

@ -1,4 +1,5 @@
import QRCode from "qrcode";
import {encodeHandleForUrl} from "@/handle-url";
const TRAILING_PADDING_PX = 3; /* blank columns after the cut, same idea as the leading margin */
@ -40,7 +41,26 @@ export function tapeFromStatus(status) {
*/
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 */
// Below 10px, a general-purpose sans-serif gets blurry/illegible, so drawTextLeaf switches to
// whichever of these bitmap-style fonts (see ../scss/_pixel-fonts.scss) is designed closest to -
// and no smaller than - the box's actual effective size. Ordered smallest first; fontFamilyFor
// below picks the first tier whose belowPx clears the requested size, sans-serif once none do.
const PIXEL_FONT_TIERS = [
{belowPx: 6, family: "Tom Thumb"}, /* designed for ~5px */
{belowPx: 7, family: "PICO-8"}, /* designed for ~6px */
{belowPx: 10, family: "Silkscreen"}, /* designed for ~7-8px */
];
function fontFamilyFor(fontPx) {
return PIXEL_FONT_TIERS.find(t => fontPx < t.belowPx)?.family ?? "sans-serif";
}
// A pixel font (see PIXEL_FONT_TIERS above) is what makes a size down here still legible - a
// plain sans-serif this small would fail the old MIN_READABLE_TEXT_PX=8 floor that predates them.
// 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);
@ -134,26 +154,6 @@ function positionTree(node, ownAxis, x, y) {
}
}
/* 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
@ -180,6 +180,12 @@ function snapQrToCrispSize(node) {
}
}
// 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));
@ -224,10 +230,28 @@ function drawQrLeaf(ctx, node) {
}
}
// Returns the effective 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 lineHeight = node.box.height / node.lines.length;
ctx.font = `${fontPx}px sans-serif`;
const family = fontFamilyFor(fontPx);
ctx.font = `${fontPx}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 (family !== "sans-serif") {
document.fonts.load(ctx.font);
}
ctx.textBaseline = "middle";
ctx.textAlign = "center";
const centerX = node.box.x + node.box.width / 2;
@ -238,6 +262,7 @@ function drawTextLeaf(ctx, node, referencePx) {
ctx.fillText(line, centerX, y);
y += lineHeight;
}
return fontPx;
}
// Flip this to true (in a debugger or a local edit) to outline every leaf's box - including
@ -256,15 +281,19 @@ function drawDebugBorder(ctx, node) {
ctx.restore();
}
function drawTree(ctx, node, referencePx) {
// `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));
node.forEach(child => drawTree(ctx, child, referencePx, textSizesPx));
return;
}
if (node.type === "qrcode") {
drawQrLeaf(ctx, node);
} else if (node.type === "text") {
drawTextLeaf(ctx, node, referencePx);
textSizesPx.push(drawTextLeaf(ctx, node, referencePx));
}
// "empty" leaves carry no ink - their box just reserves the space.
if (DEBUG_LEAF_BORDERS) {
@ -298,12 +327,13 @@ function layoutContent(ctx, content, height, maxLength, referencePx, pxPerMm) {
+ "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. */
real pixel dimensions. 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) {
const maxLength = tape.printLengthPx
? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX
@ -324,7 +354,9 @@ export function drawLabel(canvas, tape, content) {
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 textSizesPx = [];
drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
return {textSizesPx};
}
const FALLBACK_LABEL_HEIGHT_PX = 200; /* reference height the no-webusb preview/PNG scales from */
@ -333,7 +365,7 @@ 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. */
no real print head here to keep clear of. Returns {textSizesPx}, see drawLabel. */
export function drawFallbackLabel(canvas, content) {
const measureCtx = canvas.getContext("2d");
const pxPerMm = FALLBACK_DPI / 25.4;
@ -349,7 +381,9 @@ export function drawFallbackLabel(canvas, content) {
ctx.fillStyle = "#000";
positionTree(tree, false, 0, 0);
drawTree(ctx, tree, TEXT_REFERENCE_PX);
const textSizesPx = [];
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
@ -360,7 +394,7 @@ 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}`,
"item-url": ({user, id}) => `${window.location.origin}/i/${encodeHandleForUrl(user)}/${id}`,
};
export function buildLabelContent(prefill) {
@ -378,11 +412,20 @@ export function buildLabelContent(prefill) {
// (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) {
return {};
}
return {userHandle: user, itemId: String(id)};
const at = user.indexOf("@");
return {
user: at === -1 ? user : user.slice(0, at),
domain: at === -1 ? "" : user.slice(at + 1),
itemId: String(id),
};
},
};