This commit is contained in:
j3d1 2026-08-21 21:37:12 +02:00
parent 7d9f67a77a
commit 3299c97392
13 changed files with 1407 additions and 327 deletions

View file

@ -46,6 +46,35 @@
</div>
</div>
<!--div class="card mb-3 border-warning">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">Experimental: new font candidates</h5>
<small class="text-muted">for judging legibility - not part of the real label pipeline</small>
</div>
<div class="card-body">
<p class="text-muted small mb-3">
Nine pixel/bitmap-style fonts found in frontend/public, rendered live from the "Text"
field above at a range of sizes so they can be judged the same way Tom Thumb/Silkscreen
were. <strong>CodersCrux</strong> and <strong>712Serif</strong> fail to load as web
fonts at all - Chromium's OTS sanitizer rejects their cmap table - so their cells below
are actually just the browser's fallback serif, not a rendering bug in this page.
<strong>Chava</strong> is uppercase-only, same dealbreaker as PICO-8.
<strong>6pxExpert</strong> only has pixel glyphs for digits; its letters are the same
fallback serif.
</p>
<div v-for="cfg in candidateFonts" :key="cfg.family" class="mb-3">
<div class="fw-bold small mb-1">{{ cfg.family }}</div>
<div class="d-flex flex-wrap gap-3">
<div v-for="size in candidateSizes" :key="cfg.family + '-' + size" class="text-center">
<div class="small text-muted mb-1">{{ size }}px</div>
<canvas :ref="el => setCandidateCanvasRef(cfg.family + '-' + size, el)"
class="pixel-test-canvas"></canvas>
</div>
</div>
</div>
</div>
</div-->
<div v-if="!usbSupported" class="row">
<div class="col-lg-7">
<div class="card">
@ -57,6 +86,37 @@
<canvas ref="fallbackCanvas"></canvas>
</div>
<div class="mb-3">
<label class="form-label">Orientation</label>
<div class="btn-group d-block" role="group">
<input type="radio" class="btn-check" id="orientation-along-fallback"
autocomplete="off" value="along" v-model="orientation">
<label class="btn btn-outline-secondary" for="orientation-along-fallback">
Along tape
</label>
<input type="radio" class="btn-check" id="orientation-across-fallback"
autocomplete="off" value="across" v-model="orientation">
<label class="btn btn-outline-secondary" for="orientation-across-fallback">
Across tape
</label>
</div>
</div>
<div class="mb-3">
<label class="form-label">Code type</label>
<div class="btn-group d-block" role="group">
<template v-for="opt in codeTypeOptions" :key="opt.id">
<input type="radio" class="btn-check"
:id="'code-type-' + opt.id + '-fallback'"
autocomplete="off" :value="opt.id" v-model="codeType">
<label class="btn btn-outline-secondary"
:for="'code-type-' + opt.id + '-fallback'">
{{ opt.label }}
</label>
</template>
</div>
</div>
<button class="btn btn-primary" :disabled="!fallbackReady" @click="downloadPng">
<b-icon-download class="me-1"></b-icon-download>
Download label as PNG
@ -132,6 +192,34 @@
</div>
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label">Orientation</label>
<div class="btn-group" role="group">
<input type="radio" class="btn-check" id="orientation-along"
autocomplete="off" value="along" v-model="orientation">
<label class="btn btn-outline-secondary" for="orientation-along">
Along tape
</label>
<input type="radio" class="btn-check" id="orientation-across"
autocomplete="off" value="across" v-model="orientation">
<label class="btn btn-outline-secondary" for="orientation-across">
Across tape
</label>
</div>
</div>
<div class="col-auto">
<label class="form-label">Code type</label>
<div class="btn-group" role="group">
<template v-for="opt in codeTypeOptions" :key="opt.id">
<input type="radio" class="btn-check"
:id="'code-type-' + opt.id" autocomplete="off"
:value="opt.id" v-model="codeType">
<label class="btn btn-outline-secondary" :for="'code-type-' + opt.id">
{{ opt.label }}
</label>
</template>
</div>
</div>
<div class="col-auto">
<label class="form-label">Copies</label>
<input type="number" class="form-control copies-input"
@ -187,7 +275,7 @@
<div class="row">
<div class="col-12">
<label-layout-preview :fields="fields" :value="selectedTemplate"
<label-layout-preview :fields="fields" :value="selectedTemplate" :code-type="codeType"
@input="selectedTemplate = $event"></label-layout-preview>
</div>
</div>
@ -203,7 +291,7 @@ import BaseLayout from "@/components/BaseLayout.vue";
import LabelLayoutPreview from "@/components/LabelLayoutPreview.vue";
import {MultiPrinterBlob, canvasToBitmap, bitmapToCanvas} from "../../vendor/weblabel.js";
import {tapeFromStatus, drawLabel, drawFallbackLabel, buildLabelContent, buildLabelFields} from "@/label.js";
import {tapeFromStatus, drawLabel, drawFallbackLabel, buildLabelContent, buildLabelFields, preloadQrEncoder, QR_CODE_TYPES} from "@/label.js";
import {LABEL_TEMPLATES, BASE_VARS, DERIVED_VARS, withDerivedVars, templateContent} from "@/label-layouts.js";
// Served verbatim from public/vendor/ rather than bundled: libweblabel.js's
@ -226,6 +314,15 @@ const RULER_TIERS = [
{aboveMm: 500, tickMm: 5, majorEveryMm: 25},
];
// Display labels for label.js's QR_CODE_TYPES - the code-type button group below reads this
// (via codeTypeOptions) rather than hard-coding its own list, so a new symbology added to
// anyd-qr.js/QR_CODE_TYPES only needs a label here, not a whole new radio group.
const CODE_TYPE_LABELS = {
"qr": "QR",
"micro-qr": "Micro QR",
"rmqr": "rMQR",
};
export default {
name: "Print",
components: {
@ -283,11 +380,40 @@ export default {
},
copies: 1,
selectedTemplate: LABEL_TEMPLATES[0].id,
// "along" draws a layout reading along the tape's feed direction (the usual case -
// constrained by the tape's cross-web width, growing as long as the content needs);
// "across" turns it 90deg, constrained by that same width but along the *other* axis
// instead, so it reads across the tape rather than along it. See label.js's
// drawLabel/drawFallbackLabel for how that turn is actually drawn.
orientation: "along",
// Which of label.js's QR_CODE_TYPES every "qrcode" leaf in the current template
// renders as - one global choice for the whole label, same as `orientation` above,
// since a template only ever has one scannable code in practice.
codeType: "qr",
fallbackReady: false,
// TODO: replace with the real commands for our printers.
brotherCliExample: "brother_ql --model QL-000 --printer usb://0000:0000 print --label 00 label.png",
niimbotCliExample: "niimprint --model b00 --conn usb print --density 3 --image label.png",
// Nine pixel/bitmap-style font candidates found in frontend/public (see
// ../assets/fonts/pixel-candidates/LICENSE.md) - none of these are used by label.js's
// real PIXEL_FONT_TIERS; this card exists purely so they can be judged the same way
// Tom Thumb/Silkscreen were, against real (live-typed) content instead of a fixed
// example.
candidateFonts: [
{family: "Pixelon"},
{family: "Pixelbasel"},
{family: "Chava"},
{family: "CodersCrux"},
{family: "712Serif"},
{family: "6pxNormal"},
{family: "6pxExpert"},
{family: "Jersey10"},
{family: "Jersey15"},
],
candidateSizes: [5, 6, 7, 8, 10, 12, 16, 20],
candidateZoom: 4,
};
},
computed: {
@ -297,6 +423,11 @@ export default {
baseVars() {
return BASE_VARS;
},
// {id, label} pairs for the code-type button group (see CODE_TYPE_LABELS above), in
// label.js's QR_CODE_TYPES order.
codeTypeOptions() {
return QR_CODE_TYPES.map(id => ({id, label: CODE_TYPE_LABELS[id]}));
},
derivedVars() {
return Object.keys(DERIVED_VARS);
},
@ -397,6 +528,7 @@ export default {
} else {
this.redrawFallback();
}
this.drawCandidateFontTests();
},
deep: true,
},
@ -407,6 +539,20 @@ export default {
this.redrawFallback();
}
},
orientation() {
if (this.usbSupported) {
this.redraw();
} else {
this.redrawFallback();
}
},
codeType() {
if (this.usbSupported) {
this.redraw();
} else {
this.redrawFallback();
}
},
// Covers connecting/disconnecting/switching printers - anything that changes the
// tape dimensions redraw() sizes the canvas from. flush: 'post' because the canvas
// itself only exists once `tape` is truthy (see the v-if/v-else in the template), so
@ -517,7 +663,7 @@ export default {
this.resizeObserver.observe(canvas.parentElement);
let textSizesPx;
try {
({textSizesPx} = drawLabel(canvas, this.tape, content));
({textSizesPx} = drawLabel(canvas, this.tape, content, this.orientation, this.codeType));
} catch (e) {
this.error = e.message;
return;
@ -543,7 +689,7 @@ export default {
}
this.resizeObserver.observe(canvas.parentElement);
try {
drawFallbackLabel(canvas, content);
drawFallbackLabel(canvas, content, this.orientation, this.codeType);
} catch (e) {
this.error = e.message;
return;
@ -575,7 +721,17 @@ export default {
if (!(available > 0)) {
return 1;
}
const zoom = Math.min(MAX_ZOOM, available / canvas.width, MAX_PREVIEW_HEIGHT_PX / canvas.height);
const rawZoom = Math.min(MAX_ZOOM, available / canvas.width, MAX_PREVIEW_HEIGHT_PX / canvas.height);
// When magnifying, round DOWN to a whole number: image-rendering:pixelated below
// only actually looks crisp when every source pixel maps to the *same* number of
// screen pixels - at a fractional zoom (the overwhelmingly common case, since rawZoom
// is just whatever ratio the tape/card happen to produce) some source pixels get
// rounded up to one extra screen pixel and others don't, unevenly warping fine,
// already-pixel-perfect detail like a crisp QR module or a tiny bitmap font glyph.
// Flooring (never rounding/ceiling) keeps the same "never bigger than available
// space" guarantee rawZoom already had. Shrinking (zoom < 1) has no equivalent "whole
// factor" to snap to - downsampling always blends source pixels - so it's left as-is.
const zoom = rawZoom >= 1 ? Math.max(1, Math.floor(rawZoom)) : rawZoom;
canvas.style.width = `${canvas.width * zoom}px`;
canvas.style.height = `${canvas.height * zoom}px`;
canvas.style.imageRendering = zoom >= 1 ? "pixelated" : "auto";
@ -607,17 +763,75 @@ export default {
onUsbChange() {
this.guard(() => this.refreshDevices());
},
// A plain :ref="key" inside v-for would still get Vue's refInFor array-collecting
// behavior (see LabelLayoutPreview.vue's setTemplateCanvasRef for the same pattern), so
// this keys the canvases by the caller's own composite key string explicitly instead.
setCandidateCanvasRef(key, el) {
if (el) {
this.candidateCanvases[key] = el;
} else {
delete this.candidateCanvases[key];
}
},
// Draws the live "Text" field's value into a single canvas at the exact size/family
// given - no layout math, no snapping, just ctx.font as requested, so what's judged here
// is the font itself rather than anything label.js's real pipeline does to it. Canvas size
// is measured from the text itself, and ink-centered vertically (see label.js's
// drawTextLeaf for the same idea) so a font with unreliable declared metrics still lands
// fully inside the canvas instead of clipped off the top/bottom.
drawCandidateCell(canvas, family, fontPx) {
if (!canvas) {
return;
}
const text = this.varValues.text;
const ctx = canvas.getContext("2d");
ctx.font = `${fontPx}px "${family}"`;
const {width, actualBoundingBoxAscent: up, actualBoundingBoxDescent: down} = ctx.measureText(text);
canvas.width = Math.ceil(width) + 4;
canvas.height = Math.ceil(up + down) + 8;
// Re-set font/alignment - changing canvas.width/height above resets the 2D context.
ctx.font = `${fontPx}px "${family}"`;
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#000";
ctx.fillText(text, 2, (canvas.height + up - down) / 2);
canvas.style.width = `${canvas.width * this.candidateZoom}px`;
canvas.style.height = `${canvas.height * this.candidateZoom}px`;
},
// Draws every (candidateFonts x candidateSizes) cell - see the card in the template.
async drawCandidateFontTests() {
await Promise.all(this.candidateFonts
.map(({family}) => document.fonts.load(`20px "${family}"`).catch(() => {})));
for (const {family} of this.candidateFonts) {
for (const size of this.candidateSizes) {
const canvas = this.candidateCanvases[`${family}-${size}`];
this.drawCandidateCell(canvas, family, size);
}
}
},
},
created() {
this.blob = null;
this.resizeObserver = null;
this.resizeTimer = null;
this.candidateCanvases = {};
},
async mounted() {
this.drawCandidateFontTests();
this.resizeObserver = new ResizeObserver(this.handleContainerResize);
// Kicked off here rather than awaited immediately, so it loads concurrently with
// MultiPrinterBlob below instead of serializing two independent wasm fetches - every
// redraw()/redrawFallback() call below still waits on it first, since a "qrcode" leaf
// throws (see label.js's encodeQr) until it resolves.
const qrReady = preloadQrEncoder();
if (!("usb" in navigator)) {
this.usbSupported = false;
await nextTick();
await Promise.all([nextTick(), qrReady]);
this.redrawFallback();
return;
}
@ -630,6 +844,7 @@ export default {
this.blobReady = true;
navigator.usb.addEventListener("connect", this.onUsbChange);
navigator.usb.addEventListener("disconnect", this.onUsbChange);
await qrReady;
await this.guard(() => this.refreshDevices());
},
beforeUnmount() {
@ -774,6 +989,13 @@ export default {
width: 5.5rem;
}
.pixel-test-canvas {
display: block;
background: #fff;
box-shadow: 0 0 0 1px rgba(127, 127, 127, .5);
image-rendering: pixelated;
}
.code-block {
background: rgba(127, 127, 127, .08);
border-radius: .35rem;