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

@ -0,0 +1,9 @@
# Pixel fonts used for small label text (see ../../../scss/_pixel-fonts.scss)
- **Tom Thumb** (`TomThumb.ttf`) - by Brian Swetland, TTF conversion by gheja
(https://github.com/gheja/tom-thumb-ttf). Licensed CC0 or CC-BY 3.0 (original:
https://robey.lag.net/2010/01/23/tiny-monospace-font.html).
- **PICO-8** (`PICO-8.ttf`) - reproduction by Jacob Pierce
(https://github.com/jacobpierce/pico-8-font). MIT License, Copyright (c) 2016 Jacob Pierce.
- **Silkscreen** (`Silkscreen-Regular.woff2`) - by Jason Kottke, served via Google Fonts
(https://fonts.google.com/specimen/Silkscreen). SIL Open Font License 1.1.

Binary file not shown.

Binary file not shown.

View file

@ -8,33 +8,33 @@ const GAP = {type: "empty", "min-width": "1mm", "min-height": "1mm"};
export const LABEL_TEMPLATES = [ export const LABEL_TEMPLATES = [
{ {
id: "qr", name: "QR code only", description: "Just the code - smallest label, prints fastest.", id: "qr", name: "QR code only", description: "Just the code - smallest label, prints fastest.",
required_vars: ["value"], required_vars: ["text"],
layout: [{type: "qrcode", content: c => c.value}] layout: [{type: "qrcode", content: c => c.text}]
}, },
{ {
id: "qr-text", name: "QR code + text", description: "The code with the encoded text printed next to it.", id: "qr-text", name: "QR code + text", description: "The code with the encoded text printed next to it.",
required_vars: ["value"], required_vars: ["text"],
layout: [{type: "qrcode", content: c => c.value}, GAP, {type: "text", content: c => c.value?.split("\n")}] layout: [{type: "qrcode", content: c => c.text}, GAP, {type: "text", content: c => c.text?.split("\n")}]
}, },
{ {
id: "qr-text-below", name: "QR code + text below", id: "qr-text-below", name: "QR code + text below",
description: "The code with the encoded text printed below it.", description: "The code with the encoded text printed below it.",
required_vars: ["value"], required_vars: ["text"],
layout: [[{type: "qrcode", content: c => c.value}, GAP, {type: "text", content: c => c.value?.split("\n")}]] layout: [[{type: "qrcode", content: c => c.text}, GAP, {type: "text", content: c => c.text?.split("\n")}]]
}, },
{ {
id: "id-qr-text-vertical", name: "ID + QR code + text below", id: "id-qr-text-vertical", name: "ID + QR code + text below",
description: "The code with the encoded text printed below it.", description: "The code with the encoded text printed below it.",
required_vars: ["itemId", "value", "userHandle"], required_vars: ["itemId", "text", "userHandle"],
layout: [[{type: "text", content: c => "Item: "+c.itemId}, GAP, { layout: [[{type: "text", content: c => "Item: "+c.itemId}, GAP, {
type: "qrcode", type: "qrcode",
content: c => c.value content: c => c.text
}, GAP, {type: "text", content: c => c.userHandle}]] }, GAP, {type: "text", content: c => c.userHandle}]]
}, },
{ {
id: "text", name: "Text only", description: "No code, just the text itself, as large as it fits.", id: "text", name: "Text only", description: "No code, just the text itself, as large as it fits.",
required_vars: ["value"], required_vars: ["text"],
layout: [{type: "text", content: c => c.value?.split("\n")}] layout: [{type: "text", content: c => c.text?.split("\n")}]
}, },
{ {
id: "item-handle", name: "Item handle", id: "item-handle", name: "Item handle",
@ -105,10 +105,19 @@ export const KNOWN_VARS = [...new Set(LABEL_TEMPLATES.flatMap(t => t.required_va
// A derived var is a format string calculated from other vars rather than typed directly - it // A derived var is a format string calculated from other vars rather than typed directly - it
// doesn't get its own input, just a read-only, live-recalculated display next to the ones that // doesn't get its own input, just a read-only, live-recalculated display next to the ones that
// do (see Print.vue and withDerivedVars below). `inputs` names every var (base or, in principle, // do (see Print.vue and withDerivedVars below). `inputs` names every var (base or, in principle,
// derived) `calc` reads - declared up front rather than inferred from calc's body so BASE_VARS // another derived one - see itemUrl/itemHandle below, which both read the derived userHandle)
// below can include a var like "webdomain" that only feeds a calculation and that no template // `calc` reads - declared up front rather than inferred from calc's body so BASE_VARS below can
// ever references directly. // include a var like "webdomain" that only feeds a calculation and that no template ever
// references directly. Declaration order matters here: withDerivedVars runs these in a single
// pass, so a derived var must be declared after every other derived var it depends on.
export const DERIVED_VARS = { export const DERIVED_VARS = {
// The full owner handle (see federation.md's Unique Handles section / ToolshedUser's
// separate username/domain columns) - kept as two base vars (user, domain) rather than one,
// since that's how the account itself is actually shaped, with this just the display/URL form.
userHandle: {
inputs: ["user", "domain"],
calc: (f) => `${f.user}@${f.domain}`,
},
// The self-contained Item URL (see docs/design-in-progress/items-labels.md) - what a printed // 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 // label actually encodes, since scanning it has to resolve the right frontend/backend/item
// with no other context, not just this browser's history. `webdomain` defaults to this // with no other context, not just this browser's history. `webdomain` defaults to this

View file

@ -1,4 +1,5 @@
import QRCode from "qrcode"; 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 */ 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 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 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) { function isSplit(node) {
return Array.isArray(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 /* 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 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 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) { function measureTextBlock(ctx, lines, referencePx) {
ctx.font = `${referencePx}px sans-serif`; ctx.font = `${referencePx}px sans-serif`;
const width = Math.max(...lines.map(line => ctx.measureText(line).width)); 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) { function drawTextLeaf(ctx, node, referencePx) {
const fontPx = referencePx * (node.box.height / node.naturalHeight); 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 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.textBaseline = "middle";
ctx.textAlign = "center"; ctx.textAlign = "center";
const centerX = node.box.x + node.box.width / 2; const centerX = node.box.x + node.box.width / 2;
@ -238,6 +262,7 @@ function drawTextLeaf(ctx, node, referencePx) {
ctx.fillText(line, centerX, y); ctx.fillText(line, centerX, y);
y += lineHeight; y += lineHeight;
} }
return fontPx;
} }
// Flip this to true (in a debugger or a local edit) to outline every leaf's box - including // 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(); 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)) { if (isSplit(node)) {
node.forEach(child => drawTree(ctx, child, referencePx)); node.forEach(child => drawTree(ctx, child, referencePx, textSizesPx));
return; return;
} }
if (node.type === "qrcode") { if (node.type === "qrcode") {
drawQrLeaf(ctx, node); drawQrLeaf(ctx, node);
} else if (node.type === "text") { } 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. // "empty" leaves carry no ink - their box just reserves the space.
if (DEBUG_LEAF_BORDERS) { 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."); + "try a shorter value, a different layout, or a bigger label.");
} }
layoutTree(tree, false, width, height, pxPerMm); layoutTree(tree, false, width, height, pxPerMm);
assertLeavesFit(tree);
return {tree, width}; return {tree, width};
} }
/* The tape-fed layout - draws a fully resolved content tree (see templateContent) at the tape's /* 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) { export function drawLabel(canvas, tape, content) {
const maxLength = tape.printLengthPx const maxLength = tape.printLengthPx
? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX ? 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); const originX = tape.leadPx + Math.floor((width - tape.leadPx - TRAILING_PADDING_PX - contentWidth) / 2);
positionTree(tree, false, originX, 0); 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 */ 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 /* 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 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 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) { export function drawFallbackLabel(canvas, content) {
const measureCtx = canvas.getContext("2d"); const measureCtx = canvas.getContext("2d");
const pxPerMm = FALLBACK_DPI / 25.4; const pxPerMm = FALLBACK_DPI / 25.4;
@ -349,7 +381,9 @@ export function drawFallbackLabel(canvas, content) {
ctx.fillStyle = "#000"; ctx.fillStyle = "#000";
positionTree(tree, false, 0, 0); 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 // 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 // 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 // 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. // 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) { 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 // (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. // available", so builders should only include a field once its inputs actually check out.
const LABEL_FIELD_BUILDERS = { 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}) => { "item-url": ({user, id}) => {
if (!user || !id) { if (!user || !id) {
return {}; 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),
};
}, },
}; };

View file

@ -0,0 +1,21 @@
// Specialized bitmap-style fonts label.js's drawTextLeaf switches to below an effective text
// size of 10px, where a general-purpose sans-serif gets blurry/illegible - each is designed for
// (and named after) roughly the pixel size it's used at. See
// ../assets/fonts/pixel/LICENSE.md for sources/licenses.
@font-face {
font-family: "Tom Thumb";
src: url("../assets/fonts/pixel/TomThumb.ttf") format("truetype");
font-display: block;
}
@font-face {
font-family: "PICO-8";
src: url("../assets/fonts/pixel/PICO-8.ttf") format("truetype");
font-display: block;
}
@font-face {
font-family: "Silkscreen";
src: url("../assets/fonts/pixel/Silkscreen-Regular.woff2") format("woff2");
font-display: block;
}

View file

@ -92,6 +92,7 @@ $body-color: $gray-700;
@import "forms"; @import "forms";
@import "tags"; @import "tags";
@import "dropdown"; @import "dropdown";
@import "pixel-fonts";
#root, body, html { #root, body, html {
height: 100%; height: 100%;

View file

@ -20,9 +20,9 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row g-3"> <div class="row g-3">
<div :class="v === 'value' ? 'col-12' : 'col-md-6'" v-for="v in baseVars" :key="v"> <div :class="v === 'text' ? 'col-12' : 'col-md-6'" v-for="v in baseVars" :key="v">
<label class="form-label">{{ varLabel(v) }}</label> <label class="form-label">{{ varLabel(v) }}</label>
<textarea v-if="v === 'value'" class="form-control" rows="3" <textarea v-if="v === 'text'" class="form-control" rows="3"
v-model="varValues[v]" v-model="varValues[v]"
placeholder="https://example.com/…"></textarea> placeholder="https://example.com/…"></textarea>
<input v-else type="text" class="form-control" v-model="varValues[v]"> <input v-else type="text" class="form-control" v-model="varValues[v]">
@ -88,7 +88,7 @@
<div class="card"> <div class="card">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">Label</h5> <h5 class="card-title mb-0">Label</h5>
<small v-if="tape" class="text-muted">{{ tape.mediaWidthMm }} mm tape</small> <small v-if="tape" class="text-muted">{{ tape.mediaWidthMm }} mm tape{{ textSizesSummary }}</small>
</div> </div>
<div class="card-body"> <div class="card-body">
<p v-if="!tape" class="text-muted"> <p v-if="!tape" class="text-muted">
@ -99,7 +99,7 @@
<div class="ruler-v"> <div class="ruler-v">
<div class="ruler-v-corner"></div> <div class="ruler-v-corner"></div>
<div class="ruler ruler-v-ticks" <div class="ruler ruler-v-ticks"
:style="{height: (tape.printAreaPx * zoom) + 'px'}"> :style="{height: (tape.mediaWidthMm * tapePxPerMm) + 'px'}">
<span v-for="t in verticalRulerTicks" :key="'tick-' + t.mm" <span v-for="t in verticalRulerTicks" :key="'tick-' + t.mm"
class="tick" :class="{'tick-major': t.major}" class="tick" :class="{'tick-major': t.major}"
:style="{top: t.pos + 'px'}"></span> :style="{top: t.pos + 'px'}"></span>
@ -108,7 +108,7 @@
:style="{top: t.pos + 'px'}">{{ t.mm }}</span> :style="{top: t.pos + 'px'}">{{ t.mm }}</span>
</div> </div>
</div> </div>
<div class="preview-scroll"> <div class="preview-track">
<div class="ruler ruler-h" <div class="ruler ruler-h"
:style="{width: (printedWidthPx * zoom) + 'px'}"> :style="{width: (printedWidthPx * zoom) + 'px'}">
<span v-for="t in horizontalRulerTicks" :key="'tick-' + t.mm" <span v-for="t in horizontalRulerTicks" :key="'tick-' + t.mm"
@ -118,11 +118,18 @@
:key="'label-' + t.mm" class="tick-label" :key="'label-' + t.mm" class="tick-label"
:style="{left: t.pos + 'px'}">{{ t.mm }}</span> :style="{left: t.pos + 'px'}">{{ t.mm }}</span>
</div> </div>
<!-- The tape's full physical width, printable area included - the
print head can't mark all the way to the tape's outer edges, so
the canvas (printAreaPx tall) is narrower than this and centered
within it; the rest is real, if unprintable, tape margin. -->
<div class="tape-full"
:style="{height: (tape.mediaWidthMm * tapePxPerMm) + 'px'}">
<div class="label-preview"> <div class="label-preview">
<canvas ref="labelCanvas"></canvas> <canvas ref="labelCanvas"></canvas>
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="row g-2 align-items-end"> <div class="row g-2 align-items-end">
<div class="col-auto"> <div class="col-auto">
@ -207,8 +214,17 @@ const BLOB_URL = "/vendor/libweblabel.js";
const MAX_ZOOM = 4; /* never magnify the preview more than this */ const MAX_ZOOM = 4; /* never magnify the preview more than this */
const MAX_PREVIEW_HEIGHT_PX = 300; /* never let the on-screen preview grow taller than this */ const MAX_PREVIEW_HEIGHT_PX = 300; /* never let the on-screen preview grow taller than this */
const RULER_TICK_MM = 1; /* finest tick spacing the mm ruler draws */ // How far apart plain and labeled/major ticks sit, both coarser the longer the ruler itself runs
const RULER_MAJOR_EVERY_MM = 5; /* every Nth tick is taller and labeled with its mm value */ // - tightly spaced ticks (and their labels) get too cramped to read/render once there are enough
// of them. Ordered smallest threshold first; rulerTicks below uses the last entry whose `aboveMm`
// the ruler's own length clears, so add a finer/coarser tier here rather than growing a pile of
// separate constants. Every tier's majorEveryMm is a multiple of its own tickMm, so major ticks
// always land on a tick that's actually drawn.
const RULER_TIERS = [
{aboveMm: 0, tickMm: 1, majorEveryMm: 5},
{aboveMm: 100, tickMm: 1, majorEveryMm: 10},
{aboveMm: 500, tickMm: 5, majorEveryMm: 25},
];
export default { export default {
name: "Print", name: "Print",
@ -244,17 +260,21 @@ export default {
// either one changes. // either one changes.
zoom: 1, zoom: 1,
printedWidthPx: 0, printedWidthPx: 0,
// Each "text" leaf's effective font size in the current render (see label.js's
// drawLabel) - shown alongside the tape width so a field rendering blank (too small
// even for the smallest pixel font) shows up as a suspiciously tiny number here rather
// than just silently not being there.
textSizesPx: [],
// One input per *base* template variable (see label-layouts.js's BASE_VARS) - the // One input per *base* template variable (see label-layouts.js's BASE_VARS) - the
// derived ones (itemUrl, itemHandle) are format strings calculated from these, not // derived ones (userHandle, itemUrl, itemHandle) are format strings calculated from
// typed directly, so they're only ever shown (see the `fields` computed below), never // these, not typed directly, so they're only ever shown (see the `fields` computed
// stored here. Prefilled from the ?kind=& query params where // below), never stored here. Prefilled from the ?kind=& query params where
// buildLabelContent/buildLabelFields have a value for them, editable from there so a // buildLabelContent/buildLabelFields have a value for them, editable from there so a
// template needing e.g. userHandle isn't stuck depending on a prefill that never // template needing e.g. domain isn't stuck depending on a prefill that never arrives.
// arrives.
varValues: { varValues: {
...Object.fromEntries(BASE_VARS.map(v => [v, ""])), ...Object.fromEntries(BASE_VARS.map(v => [v, ""])),
value: buildLabelContent(this.prefill), text: buildLabelContent(this.prefill),
// Defaults to wherever this page itself is being served from - editable since any // Defaults to wherever this page itself is being served from - editable since any
// frontend can resolve any handle (see label-layouts.js's DERIVED_VARS.itemUrl), // frontend can resolve any handle (see label-layouts.js's DERIVED_VARS.itemUrl),
// so a label doesn't have to point back at this particular one. // so a label doesn't have to point back at this particular one.
@ -327,17 +347,46 @@ export default {
tapePxPerMm() { tapePxPerMm() {
return this.tape ? (this.tape.dpi / 25.4) * this.zoom : 0; return this.tape ? (this.tape.dpi / 25.4) * this.zoom : 0;
}, },
// The physical length, in mm, each ruler axis actually needs to cover - see
// horizontalRulerTicks/verticalRulerTicks below for what each one measures and why.
horizontalTotalMm() {
return (this.tape && this.printedWidthPx) ? this.printedWidthPx / (this.tape.dpi / 25.4) : 0;
},
verticalTotalMm() {
return this.tape ? this.tape.mediaWidthMm : 0;
},
// The single RULER_TIERS entry both rulers draw from, keyed off whichever axis is
// physically longer - so a long label's ruler doesn't end up coarser (or finer) than the
// tape-width ruler right next to it just because the other axis happens to be shorter.
rulerTier() {
return RULER_TIERS.filter(t => Math.max(this.horizontalTotalMm, this.verticalTotalMm) >= t.aboveMm)
.at(-1);
},
// Ticks along the tape's length (the printed bitmap's actual width, lead/trailing feed // Ticks along the tape's length (the printed bitmap's actual width, lead/trailing feed
// margin included, since that's real physical tape too). // margin included, since that's real physical tape too).
horizontalRulerTicks() { horizontalRulerTicks() {
if (!this.tape || !this.printedWidthPx) { if (!this.tape || !this.printedWidthPx) {
return []; return [];
} }
return this.rulerTicks(this.printedWidthPx / (this.tape.dpi / 25.4)); return this.rulerTicks(this.horizontalTotalMm);
}, },
// Ticks across the tape's fixed physical width. // Ticks across the tape's full physical width, mediaWidthMm - not printAreaPx/dpi: a
// print head can't reach the tape's outer edges, so the printable area (see .tape-full in
// the template) is genuinely narrower than the tape itself, by an amount that isn't a
// fixed/predictable fraction of it. The ruler still has to show the *whole* tape - its
// container is sized from mediaWidthMm too (see the template's inline height) precisely so
// these ticks can't run past it, the way they did when both were sized from printAreaPx.
verticalRulerTicks() { verticalRulerTicks() {
return this.tape ? this.rulerTicks(this.tape.mediaWidthMm) : []; return this.tape ? this.rulerTicks(this.verticalTotalMm) : [];
},
// "(5px, 23px)" for the current render's text leaves (see data's textSizesPx), or "" once
// there's nothing to show - appended straight onto the tape-width <small>, so the blank
// string here just means that text is left with no trailing space.
textSizesSummary() {
if (!this.textSizesPx.length) {
return "";
}
return ` (${this.textSizesPx.map(px => Math.round(px) + "px").join(", ")})`;
}, },
}, },
watch: { watch: {
@ -377,12 +426,15 @@ export default {
return v.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, c => c.toUpperCase()); return v.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, c => c.toUpperCase());
}, },
// One tick per RULER_TICK_MM from 0 up to totalMm, each positioned in on-screen pixels // Ticks from 0 up to totalMm, each positioned in on-screen pixels via tapePxPerMm - shared
// via tapePxPerMm - shared by the horizontal/vertical ruler computeds above. // by the horizontal/vertical ruler computeds above. Both the plain tick spacing and the
// labeled/major one come from the shared rulerTier (see above), not from this totalMm, so
// both rulers always coarsen together once *either* axis is long enough to need it.
rulerTicks(totalMm) { rulerTicks(totalMm) {
const {tickMm, majorEveryMm} = this.rulerTier;
const ticks = []; const ticks = [];
for (let mm = 0; mm <= totalMm; mm += RULER_TICK_MM) { for (let mm = 0; mm <= totalMm; mm += tickMm) {
ticks.push({mm, pos: mm * this.tapePxPerMm, major: mm % RULER_MAJOR_EVERY_MM === 0}); ticks.push({mm, pos: mm * this.tapePxPerMm, major: mm % majorEveryMm === 0});
} }
return ticks; return ticks;
}, },
@ -463,13 +515,15 @@ export default {
return; return;
} }
this.resizeObserver.observe(canvas.parentElement); this.resizeObserver.observe(canvas.parentElement);
let textSizesPx;
try { try {
drawLabel(canvas, this.tape, content); ({textSizesPx} = drawLabel(canvas, this.tape, content));
} catch (e) { } catch (e) {
this.error = e.message; this.error = e.message;
return; return;
} }
this.error = null; this.error = null;
this.textSizesPx = textSizesPx;
const bitmap = canvasToBitmap(canvas); const bitmap = canvasToBitmap(canvas);
bitmapToCanvas(canvas, bitmap); bitmapToCanvas(canvas, bitmap);
this.labelBitmap = bitmap; this.labelBitmap = bitmap;
@ -593,7 +647,7 @@ export default {
.label-preview { .label-preview {
overflow-x: auto; overflow-x: auto;
padding: .75rem; padding: .75rem;
background: rgba(127, 127, 127, .08); //background: rgba(127, 127, 127, .08);
border-radius: .35rem; border-radius: .35rem;
text-align: center; text-align: center;
max-height: 300px; max-height: 300px;
@ -602,7 +656,7 @@ export default {
.label-preview canvas { .label-preview canvas {
display: inline-block; display: inline-block;
background: #fff; background: #fff;
box-shadow: 0 0 0 1px rgba(127, 127, 127, .5); //box-shadow: 0 0 0 1px rgba(127, 127, 127, .5);
} }
/* The tape-fed preview's mm ruler (see Print.vue's template/script) - a horizontal track above /* The tape-fed preview's mm ruler (see Print.vue's template/script) - a horizontal track above
@ -613,22 +667,38 @@ export default {
align-items: flex-start; align-items: flex-start;
} }
/* The single horizontal scroll region for a label wider than its card - the horizontal ruler and /* Holds the horizontal ruler and the canvas - deliberately never scrollable (no overflow-x:auto):
the canvas are both direct children of this, so they scroll in lockstep with no JS needed to fitZoom's zoom always satisfies `canvas.width * zoom <= available`, so the canvas can never
keep them in sync. */ actually be wider than this has room for, and a scrollbar here would let the ruler and canvas
.preview-scroll { drift apart (or just look broken) for no reason. min-width:0 only lets this flex item shrink
to the card's real available width - it doesn't enable scrolling. */
.preview-track {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
overflow-x: auto;
} }
/* Overrides the standalone rule above: nested here, .label-preview must neither clip nor center /* Overrides the standalone rule above: nested here, .label-preview must neither scroll nor center
its canvas - overflow-x:visible lets the canvas's true width "bubble up" to .preview-scroll's its canvas - overflow-x:visible (never auto) rules out a second, inner scrollbar, and
own scrollbar instead of scrolling twice, and text-align:left keeps the canvas flush with the text-align:left keeps the canvas flush with the ruler's zero tick instead of drifting to the
ruler's zero tick instead of drifting to the middle of whatever spare width this card has. */ middle of whatever spare width this card has. padding:0 so the canvas's own edges are exactly
.preview-scroll .label-preview { this box's edges too - the ruler's ticks (see .ruler-h/.ruler-v-ticks below) line up with those
same edges, so any padding here would leave the ticks and the actual canvas misaligned. */
.preview-track .label-preview {
overflow-x: visible; overflow-x: visible;
text-align: left; text-align: left;
padding: 0;
}
/* The tape's full physical width (see the template) - a print head can't mark all the way to a
tape's outer edges, so .label-preview/the canvas is narrower than this and centered within it
(the print area sits centered on the tape, with equal margin on both sides); a faint tint
distinguishes the margin as real (if blank, unprintable) tape rather than empty space. */
.tape-full {
display: flex;
flex-direction: column;
justify-content: center;
background: rgba(127, 127, 127, .08);
border-radius: .35rem;
} }
.ruler-v { .ruler-v {
@ -645,13 +715,11 @@ export default {
.ruler-v-ticks { .ruler-v-ticks {
position: relative; position: relative;
margin-top: .75rem; /* .label-preview's own padding, so tick 0 meets the canvas, not its box */
} }
.ruler-h { .ruler-h {
position: relative; position: relative;
height: 1.6rem; height: 1.6rem;
margin-left: .75rem; /* .label-preview's own padding, so tick 0 meets the canvas, not its box */
color: rgba(127, 127, 127, .9); color: rgba(127, 127, 127, .9);
} }