438 lines
21 KiB
JavaScript
438 lines
21 KiB
JavaScript
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 */
|
|
|
|
/* 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 */
|
|
|
|
// 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);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* 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;
|
|
}
|
|
}
|
|
|
|
// 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 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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
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;
|
|
// 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;
|
|
}
|
|
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 (node.type === "qrcode") {
|
|
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 `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);
|
|
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. 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
|
|
: 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);
|
|
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 */
|
|
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. Returns {textSizesPx}, see drawLabel. */
|
|
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);
|
|
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
|
|
// 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/${encodeHandleForUrl(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 = {
|
|
// `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 {};
|
|
}
|
|
const at = user.indexOf("@");
|
|
return {
|
|
user: at === -1 ? user : user.slice(0, at),
|
|
domain: at === -1 ? "" : user.slice(at + 1),
|
|
itemId: String(id),
|
|
};
|
|
},
|
|
};
|
|
|
|
export function buildLabelFields(prefill) {
|
|
if (!prefill) {
|
|
return {};
|
|
}
|
|
const build = LABEL_FIELD_BUILDERS[prefill.kind];
|
|
return build ? build(prefill.components) : {};
|
|
}
|