Compare commits

...

4 commits

Author SHA1 Message Date
491ee05f15 stash 2026-08-26 16:35:21 +02:00
1356aa7749 stash 2026-08-26 14:20:02 +02:00
368268d288 stash 2026-08-26 13:17:30 +02:00
fb3c1e78e8 stash 2026-08-26 12:13:27 +02:00
12 changed files with 340 additions and 165 deletions

16
frontend/public/vendor/freetype.js vendored Normal file

File diff suppressed because one or more lines are too long

BIN
frontend/public/vendor/freetype.wasm vendored Executable file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -125,7 +125,7 @@
</style> </style>
<script> <script>
import {drawLabel, drawFallbackLabel, preloadQrEncoder} from "@/label.js"; import {drawLabel, drawFallbackLabel, preloadQrEncoder, preloadPixelFontRenderer} from "@/label.js";
import {LABEL_TEMPLATES, LABEL_TAGS, templateIsAvailable, templateContent} from "@/label-layouts.js"; import {LABEL_TEMPLATES, LABEL_TAGS, templateIsAvailable, templateContent} from "@/label-layouts.js";
export default { export default {
@ -319,9 +319,10 @@ export default {
this.templateCanvases = {}; this.templateCanvases = {};
}, },
async mounted() { async mounted() {
// See Print.vue's mounted() - every thumbnail here has a qr/mqr/rmqr leaf, so nothing's // See Print.vue's mounted() - every thumbnail here has a qr/mqr/rmqr leaf and text drawn
// worth drawing before the wasm resource resolves. // through label.js's bitmap-font tiers, so nothing's worth drawing before both wasm
await preloadQrEncoder(); // resources resolve.
await Promise.all([preloadQrEncoder(), preloadPixelFontRenderer()]);
this.redraw(); this.redraw();
} }
} }

View file

@ -1,5 +1,10 @@
import {loadAnyDCode} from "../vendor/anyd-qr.js"; import {loadAnyDCode} from "../vendor/anyd-qr.js";
import {encodeHandleForUrl} from "@/router" import {encodeHandleForUrl} from "@/router"
import {drawPixelText, preloadPixelFontRenderer} from "@/pixel-font.js";
// Re-exported so every caller can preload both the QR encoder and the bitmap-font rasterizer
// (see fontTierFor's `pixel` tiers below) the same way, alongside this file's own preloadQrEncoder.
export {preloadPixelFontRenderer};
// Mirrors loadAnyDCode()'s memoized wasm instance for synchronous use in buildRenderTree. See docs/implementation.md#qr-encoder-loading. // Mirrors loadAnyDCode()'s memoized wasm instance for synchronous use in buildRenderTree. See docs/implementation.md#qr-encoder-loading.
let anyd = null; let anyd = null;
@ -79,26 +84,64 @@ export function tapeFromStatus(status) {
// See docs/implementation.md#layout-tree-structure. // See docs/implementation.md#layout-tree-structure.
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 */
// Below 10px, a general-purpose sans-serif gets illegible, so drawTextLeaf switches to a bitmap // Below 33px, a general-purpose sans-serif gets illegible at real label sizes, so drawTextLeaf
// font (Tom Thumb/Silkscreen) instead; see the empirical rationale (font choice, `scale`, and why // switches to one of these bitmap-style fonts instead, rendered through pixel-font.js's
// sizes aren't dpi-adjusted) at docs/implementation.md#pixel-font-selection. The 10px+ tier names // drawPixelText (libfreetype-wasm's own monochrome rasterizer) rather than canvas fillText - see
// that file's own comment for why (fillText is always anti-aliased, however carefully its
// size/position are snapped, and canvasToBitmap's hard threshold turns that softness into
// stray/missing pixels). See the empirical rationale for the font/size choices themselves (and why
// sizes aren't dpi-adjusted) at docs/implementation.md#pixel-font-selection. The top branch names
// Inter explicitly (see ../scss/_label-fonts.scss) rather than falling back to the CSS generic // Inter explicitly (see ../scss/_label-fonts.scss) rather than falling back to the CSS generic
// "sans-serif" keyword, which resolves to a different real font per browser/OS and would make the // "sans-serif" keyword, which resolves to a different real font per browser/OS and would make the
// same label print differently depending on where it was rendered from. // same label print differently depending on where it was rendered from - Inter is the one tier
const FONT_TIERS = [ // still drawn with fillText, since anti-aliasing is expected/fine at this size (see drawTextLeaf).
{belowPx: 8, family: "Tom Thumb", scale: 3.2, pixel: true}, // Checked top-down (first branch whose threshold `fontPx` clears wins) - retune by eye in
{belowPx: 10, family: "Silkscreen", pixel: true}, // prototypes/freetype-wasm/ladder.html before editing this.
{belowPx: Infinity, family: "Inter"}, //
]; // `size` is the actual px handed to the rasterizer - a fixed/snapped value for the bitmap fonts
// (they read best at specific pixel sizes, not any arbitrary one - see each branch's comment) or
function fontFamilyFor(fontPx) { // `fontPx` itself for Inter, which scales cleanly. `fontPx` itself (the layout-derived logical
return FONT_TIERS.find(t => fontPx < t.belowPx); // size) still drives box centering/stacking regardless of what `size` resolves to - same idea as
// the old Tom Thumb `scale` correction this replaces, generalized to every tier. `tracking`
// (optional, px) retunes glyph spacing (drawPixelText adds it to every glyph's advance) without
// touching the rendered glyph size. `pixel: true` routes a tier through drawPixelText instead of
// fillText. ladder.html's own copy of this table additionally tunes a `lineHeight` per branch -
// meaningful there since it sizes that page's own canvas outright, but deliberately not ported
// here: inside drawTextLeaf the box is already sized correctly by the layout engine, so a tier's
// tighter lineHeight would only crop real glyph ink (descenders, accented capitals) for no benefit.
function fontTierFor(fontPx) {
if (fontPx >= 34)
return {family: "Inter", size: fontPx};
// Terminus reads best at its own hinted sizes rather than whatever fontPx asks for.
if (fontPx >= 32)
return {family: "Terminus", size: 32, pixel: true};
if (fontPx >= 28)
return {family: "Terminus", size: 28, pixel: true};
if (fontPx >= 24)
return {family: "Terminus", size: 24, pixel: true};
if (fontPx >= 12)
return {family: "Terminus", size: fontPx - (fontPx % 2), pixel: true};
if (fontPx >= 10)
return {family: "Pixelon", size: 10, tracking: -1, pixel: true};
// Uppercase-only - see ../assets/fonts/pixel/LICENSE.md - kept anyway; this tier's narrow.
if (fontPx >= 8)
return {family: "Chava", size: 8, pixel: true};
// TODO find or build better fonts for 6px, 7px and 9px - the criteria would be to make better
// use of the height available to be more readable.
// Effective ink comes out to ~5px tall at size 8 - see ../assets/fonts/pixel/LICENSE.md.
if (fontPx >= 6)
return {family: "Silkscreen", size: 8, tracking: -1, pixel: true};
// checkTextSizes (see MIN_TEXT_PX) already rejects anything below this floor before
// drawTextLeaf ever calls this, so this is a same-file consistency bug, not reachable from
// user input.
throw new Error(`No font tier covers ${fontPx}px - is MIN_TEXT_PX out of sync?`);
} }
// Below this font size (px) or physical height (mm) - Tom Thumb's real-Chromium-tested legibility // Below this font size (px) or physical height (mm) - fontTierFor's own floor (Silkscreen's
// floor - text is a hard failure, the same two-metric shape as MIN_RECOMMENDED_QR_PX_PER_MODULE/ // real-Chromium-tested legibility limit; see its `fontPx >= 6` branch above) - text is a hard
// MIN_RECOMMENDED_QR_MODULE_MM below (see checkTextSizes). No longer just silently blanked. // failure, the same two-metric shape as MIN_RECOMMENDED_QR_PX_PER_MODULE/MIN_RECOMMENDED_QR_MODULE_MM
const MIN_TEXT_PX = 5; // below (see checkTextSizes). No longer just silently blanked.
const MIN_TEXT_PX = 6;
const MIN_TEXT_MM = 0.5; const MIN_TEXT_MM = 0.5;
// Below this, still legible but a soft warning - flagged rather than blocking, same idea as // Below this, still legible but a soft warning - flagged rather than blocking, same idea as
// MIN_RECOMMENDED_QR_PX_PER_MODULE/MIN_RECOMMENDED_QR_MODULE_MM. // MIN_RECOMMENDED_QR_PX_PER_MODULE/MIN_RECOMMENDED_QR_MODULE_MM.
@ -295,7 +338,7 @@ function checkTextSizes(node, referencePx, pxPerMm, warnings, textInfo) {
} }
} }
// Always measures in Inter at the reference size, since the eventual font (see FONT_TIERS) isn't // Always measures in Inter at the reference size, since the eventual font (see fontTierFor) isn't
// known until layoutTree sizes the box this aspect ratio feeds into; the resulting mismatch (when // known until layoutTree sizes the box this aspect ratio feeds into; the resulting mismatch (when
// a pixel font tier ends up chosen instead) is invisible in practice, and checkTextSizes still // a pixel font tier ends up chosen instead) is invisible in practice, and checkTextSizes still
// catches real failures. // catches real failures.
@ -347,35 +390,28 @@ function drawQrLeaf(ctx, node) {
// there's no "too small to draw" case left to special-case here. // there's no "too small to draw" case left to special-case here.
function drawTextLeaf(ctx, node, referencePx) { function drawTextLeaf(ctx, node, referencePx) {
const fontPx = effectiveFontPx(node, referencePx); const fontPx = effectiveFontPx(node, referencePx);
const {family, scale = 1, pixel: isPixelFont = false} = fontFamilyFor(fontPx); const tier = fontTierFor(fontPx);
// Position (not size - fontPx is already a whole pixel) still needs snapping for pixel fonts if (tier.pixel) {
// to stay grid-aligned, since node.box.x/y are ordinary (fractional) layout math; Inter is // Own rasterizer, own centering/pixel-snapping, own multi-line stacking - see
// left exact since anti-aliasing handles fractional positions fine. // pixel-font.js's drawPixelText for why (and its own doc comment for what it does with
const snap = isPixelFont ? Math.round : (v) => v; // node.box/node.lines, the same inputs drawTextLeaf itself would otherwise use below).
// `scale` (Tom Thumb only, see FONT_TIERS above) corrects the size handed to ctx.font for its drawPixelText(ctx, tier, node.lines, node.box);
// real ink; fontPx itself stays the logical size used for centering/stacking math. return fontPx;
ctx.font = `${fontPx * scale}px "${family}"`; }
// Only Inter reaches here (fontTierFor's non-pixel tier) - canvas fillText's anti-aliasing is
// expected/fine at this size, unlike the bitmap tiers above (see pixel-font.js/fontTierFor).
ctx.font = `${tier.size}px "${tier.family}"`;
// Canvas text silently falls back if drawn before a not-yet-loaded font resolves, unlike DOM // Canvas text silently falls back if drawn before a not-yet-loaded font resolves, unlike DOM
// text. See docs/implementation.md#canvas-font-loading. Every tier now names a real, // text. See docs/implementation.md#canvas-font-loading.
// self-hosted webfont (see FONT_TIERS above), so this always applies, not just to pixel fonts.
document.fonts.load(ctx.font); document.fonts.load(ctx.font);
ctx.textAlign = "center"; ctx.textAlign = "center";
const centerX = snap(node.box.x + node.box.width / 2); ctx.textBaseline = "middle";
const centerX = node.box.x + node.box.width / 2;
const lineHeight = node.box.height / node.lines.length; const lineHeight = node.box.height / node.lines.length;
// Lines stack as a block, each centered under the last, so a multi-line field reads as one unit. // Lines stack as a block, each centered under the last, so a multi-line field reads as one unit.
let sliceTop = node.box.y; let sliceTop = node.box.y;
for (const line of node.lines) { for (const line of node.lines) {
if (isPixelFont) { ctx.fillText(line, centerX, sliceTop + lineHeight / 2);
// textBaseline:"middle" centers on declared ascent/descent, which is backwards for
// Tom Thumb; centering on actualBoundingBox{Ascent,Descent} instead measures this
// string's real rendered ink and stays correct regardless.
ctx.textBaseline = "alphabetic";
const {actualBoundingBoxAscent: up, actualBoundingBoxDescent: down} = ctx.measureText(line);
ctx.fillText(line, centerX, snap(sliceTop + (lineHeight + up - down) / 2));
} else {
ctx.textBaseline = "middle";
ctx.fillText(line, centerX, sliceTop + lineHeight / 2);
}
sliceTop += lineHeight; sliceTop += lineHeight;
} }
return fontPx; return fontPx;
@ -468,6 +504,10 @@ export function drawLabel(canvas, tape, content, orientation = "along") {
canvas.height = tape.printAreaPx; canvas.height = tape.printAreaPx;
const ctx = canvas.getContext("2d", {willReadFrequently: true}); const ctx = canvas.getContext("2d", {willReadFrequently: true});
// Defensive, not load-bearing: drawPixelText's own blits already land on integer pixels (see
// its own comment), but a 1:1 drawImage getting resampled by an engine quirk would reintroduce
// exactly the anti-aliasing canvasToBitmap's hard threshold turns into stray pixels.
ctx.imageSmoothingEnabled = false;
ctx.fillStyle = "#fff"; ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, printedLength, canvas.height); ctx.fillRect(0, 0, printedLength, canvas.height);
ctx.fillStyle = "#000"; ctx.fillStyle = "#000";
@ -507,6 +547,7 @@ export function drawFallbackLabel(canvas, content, orientation = "along") {
canvas.height = FALLBACK_LABEL_HEIGHT_PX; canvas.height = FALLBACK_LABEL_HEIGHT_PX;
const ctx = canvas.getContext("2d", {willReadFrequently: true}); const ctx = canvas.getContext("2d", {willReadFrequently: true});
ctx.imageSmoothingEnabled = false; /* see drawLabel's own comment */
ctx.fillStyle = "#fff"; ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#000"; ctx.fillStyle = "#000";

209
frontend/src/pixel-font.js Normal file
View file

@ -0,0 +1,209 @@
// Renders label.js's bitmap-style FONT_TIERS entries (Terminus/Pixelon/Chava/Silkscreen) through
// libfreetype-wasm's own monochrome rasterizer (FT_LOAD_TARGET_MONO) instead of canvas fillText.
// fillText is always anti-aliased, however carefully its size/position are snapped to the pixel
// grid - Canvas 2D has no monochrome/hinted-bitmap text mode - and that soft edge becomes
// stray/missing pixels once canvasToBitmap's hard threshold runs on it. Rendering through
// FreeType's own 1-bit rasterizer and blitting the result at integer pixel offsets removes both
// problems at the source instead of trying to compensate for them after the fact. See
// prototypes/freetype-wasm/README.md and docs/implementation.md#pixel-font-selection.
import terminusFontUrl from "@/assets/fonts/pixel/Terminus.ttf?url";
import pixelonFontUrl from "@/assets/fonts/pixel/Pixelon.ttf?url";
import chavaFontUrl from "@/assets/fonts/pixel/Chava.ttf?url";
import silkscreenFontUrl from "@/assets/fonts/pixel/Silkscreen.ttf?url";
// Every family fontTierFor (label.js) can pick with `pixel: true` - fetched once by
// preloadPixelFontRenderer, not lazily, so a first draw never races the font's own fetch.
const FONT_URLS = {
Terminus: terminusFontUrl,
Pixelon: pixelonFontUrl,
Chava: chavaFontUrl,
Silkscreen: silkscreenFontUrl,
};
// Served unbundled so its wasm sibling stays resolvable. See docs/implementation.md#libweblabel-served-unbundled.
const FREETYPE_URL = "/vendor/freetype.js";
// Vite refuses to import anything under public/ from source (see the long comment this is copied
// from, originally in freetype-preview.js) - an inline `<script type="module">` sidesteps both
// that and the CSP's blob:-URL restriction, since the browser resolves its `import` as an ordinary
// top-level fetch of `url`, same as a real HTML tag would.
let importCounter = 0;
async function importPublicModule(url) {
const absoluteUrl = new URL(url, document.baseURI).href;
const callbackName = `__importPublicModule_${importCounter++}`;
return await new Promise((resolve, reject) => {
window[callbackName] = (mod) => {
delete window[callbackName];
resolve(mod);
};
const script = document.createElement("script");
script.type = "module";
script.textContent = `import * as m from ${JSON.stringify(absoluteUrl)}; `
+ `window.${callbackName}(m);`;
script.onerror = () => {
delete window[callbackName];
reject(new Error(`failed to load ${absoluteUrl} as a module`));
};
document.head.appendChild(script);
script.remove();
});
}
let Module = null;
let modulePromise = null;
const fontBytes = {}; /* family -> Uint8Array, populated once by preload */
let loadedFamily = null; /* which family's face is currently active in the wasm module's single slot */
// Loads freetype.wasm and every FONT_URLS font's bytes up front - called once from each entry
// point (Print.vue/LabelLayoutPreview.vue's mounted(), alongside preloadQrEncoder) so drawPixelText
// never has to await anything mid-draw; label.js's drawTree is synchronous throughout. Memoized the
// same way label.js's own preloadQrEncoder memoizes anyd - callers can call this freely.
export function preloadPixelFontRenderer() {
modulePromise ??= (async () => {
const [{default: FreeTypeModule}] = await Promise.all([
importPublicModule(FREETYPE_URL),
...Object.entries(FONT_URLS).map(async ([family, url]) => {
const res = await fetch(url);
fontBytes[family] = new Uint8Array(await res.arrayBuffer());
}),
]);
const mod = await FreeTypeModule();
const rc = mod.ccall("ft_init", "number", [], []);
if (rc !== 0) {
throw new Error(`FT_Init_FreeType failed (error ${rc})`);
}
Module = mod;
})();
return modulePromise;
}
// Copies `family`'s bytes into the wasm heap and hands them to FT_New_Memory_Face, skipped if
// that family's face is already the one loaded - the module holds exactly one face at a time (see
// wrapper.c's ft_load_font), and a label typically draws several leaves in a row that often share
// a tier/family.
function ensureFace(family) {
if (loadedFamily === family) {
return;
}
const bytes = fontBytes[family];
const ptr = Module._malloc(bytes.length);
Module.HEAPU8.set(bytes, ptr);
const rc = Module.ccall("ft_load_font", "number", ["number", "number"], [ptr, bytes.length]);
Module._free(ptr);
if (rc !== 0) {
throw new Error(`FreeType failed to parse the ${family} font (error ${rc})`);
}
loadedFamily = family;
}
// Blits one glyph's rasterized bitmap onto `ctx` at (x, y), y being the baseline - same shape as
// prototypes/freetype-wasm's own drawGlyphBitmap. Handles both pixel modes FT_LOAD_TARGET_MONO can
// still yield - FT_PIXEL_MODE_MONO (1 bit/pixel, the expected case) and FT_PIXEL_MODE_GRAY (a
// glyph FreeType has no mono rasterizer path for, e.g. some embedded-bitmap formats).
function drawGlyphBitmap(ctx, x, y) {
const width = Module.ccall("ft_get_bitmap_width", "number", [], []);
const height = Module.ccall("ft_get_bitmap_height", "number", [], []);
if (width === 0 || height === 0) {
return;
}
const pitch = Module.ccall("ft_get_bitmap_pitch", "number", [], []);
const left = Module.ccall("ft_get_bitmap_left", "number", [], []);
const top = Module.ccall("ft_get_bitmap_top", "number", [], []);
const pixelMode = Module.ccall("ft_get_pixel_mode", "number", [], []);
const bufferPtr = Module.ccall("ft_get_bitmap_buffer", "number", [], []);
const src = Module.HEAPU8.subarray(bufferPtr, bufferPtr + pitch * height);
const imageData = new ImageData(width, height);
const dst = imageData.data;
for (let row = 0; row < height; row++) {
for (let col = 0; col < width; col++) {
let alpha;
if (pixelMode === 1) { // FT_PIXEL_MODE_MONO: 1 bit per pixel, MSB first
const byte = src[row * pitch + (col >> 3)];
alpha = (byte & (0x80 >> (col & 7))) ? 255 : 0;
} else { // FT_PIXEL_MODE_GRAY: 1 byte per pixel, 0-255 coverage
alpha = src[row * pitch + col];
}
const di = (row * width + col) * 4;
dst[di] = dst[di + 1] = dst[di + 2] = 0;
dst[di + 3] = alpha;
}
}
const tmp = document.createElement("canvas");
tmp.width = width;
tmp.height = height;
tmp.getContext("2d").putImageData(imageData, 0, 0);
// x/y are already integers (see drawPixelText) and this is an unscaled 1:1 blit, so this never
// resamples/blurs regardless - imageSmoothingEnabled is off on the destination anyway (see
// drawLabel/drawFallbackLabel) as a second, defensive line against that.
ctx.drawImage(tmp, x + left, y - top);
}
// Renders `codes` (an array of codepoints) at the current face/pixel-size without drawing
// anything, purely to learn the line's total advance width and real ink extents (max ascent/
// descent actually reached by these specific glyphs, not the font's generic metrics) - the same
// two-metric shape drawTextLeaf's old ctx.measureText(line).actualBoundingBox{Ascent,Descent} gave
// it, needed up front to center this line before any glyph is drawn.
function measureLine(codes, tracking) {
let width = 0;
let up = 0;
let down = 0;
for (const code of codes) {
if (Module.ccall("ft_render_glyph", "number", ["number", "number"], [code, 1]) !== 0) {
continue;
}
width += Module.ccall("ft_get_advance_x", "number", [], []) + tracking;
const height = Module.ccall("ft_get_bitmap_height", "number", [], []);
if (height === 0) {
continue;
}
const top = Module.ccall("ft_get_bitmap_top", "number", [], []);
up = Math.max(up, top);
down = Math.max(down, height - top);
}
return {width, up, down};
}
// Draws `codes` starting at the integer pen position (x, baseline), advancing by each glyph's real
// width plus `tracking` (px, may be negative - lets neighboring glyphs overlap rather than
// resizing them, same idea as ladder.html's own tracking).
function drawLine(ctx, codes, x, baseline, tracking) {
for (const code of codes) {
if (Module.ccall("ft_render_glyph", "number", ["number", "number"], [code, 1]) !== 0) {
continue;
}
drawGlyphBitmap(ctx, x, baseline);
x += Module.ccall("ft_get_advance_x", "number", [], []) + tracking;
}
}
// Draws `lines` (already split - see label.js's node.lines) through FreeType's own rasterizer,
// stacked one per `box.height / lines.length` row and each centered (both axes) within its row -
// same centering semantics drawTextLeaf's pixel-font branch used with ctx.fillText, just measured
// via FreeType's own bitmap metrics instead of canvas's actualBoundingBox. `box.x`/`width` may be
// ordinary fractional layout numbers; every actual draw coordinate here is rounded to a whole
// pixel first; a bitmap font blitted at a fractional offset would blur exactly like fillText does.
// Returns the face's line height in px at `size` (ft_get_line_height), for parity with the fontPx
// drawTextLeaf itself already returns/reports.
export function drawPixelText(ctx, {family, size, tracking = 0}, lines, box) {
if (!Module) {
throw new Error("The bitmap font renderer is still loading — try again in a moment.");
}
ensureFace(family);
Module.ccall("ft_set_pixel_size", "number", ["number"], [Math.round(size)]);
const lineHeight = Module.ccall("ft_get_line_height", "number", [], []);
const rowHeight = box.height / lines.length;
let sliceTop = box.y;
for (const line of lines) {
const codes = Array.from(line).map(ch => ch.codePointAt(0));
const {width, up, down} = measureLine(codes, tracking);
const x = Math.round(box.x + box.width / 2 - width / 2);
const baseline = Math.round(sliceTop + (rowHeight + up - down) / 2);
drawLine(ctx, codes, x, baseline, tracking);
sliceTop += rowHeight;
}
return lineHeight;
}

View file

@ -0,0 +1,11 @@
// Normal-size (>=33px) label text font, so drawTextLeaf never falls back to the CSS generic
// "sans-serif" keyword - see ../assets/fonts/label/LICENSE.md for source; label.js's fontTierFor
// names this explicitly. The one @font-face left in this pair of files: every smaller tier renders
// through ../pixel-font.js's own FreeType-wasm rasterizer instead, fetching its font bytes
// directly rather than going through the browser's font system - see
// docs/implementation.md#freetype-production-rendering.
@font-face {
font-family: "Inter";
src: url("../assets/fonts/label/Inter-Regular.woff2") format("woff2");
font-display: block;
}

View file

@ -1,14 +0,0 @@
// Bitmap-style fonts label.js's drawTextLeaf switches to below 10px, where sans-serif gets
// illegible (see ../assets/fonts/pixel/LICENSE.md for sources); label.js's PIXEL_FONT_TIERS uses
// only these two of three candidates - the third, PICO-8, has no lowercase glyphs.
@font-face {
font-family: "Tom Thumb";
src: url("../assets/fonts/pixel/TomThumb.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,8 +92,6 @@ $body-color: $gray-700;
@import "forms"; @import "forms";
@import "tags"; @import "tags";
@import "dropdown"; @import "dropdown";
@import "pixel-fonts";
@import "pixel-fonts-candidates";
@import "label-fonts"; @import "label-fonts";
#root, body, html { #root, body, html {

View file

@ -50,35 +50,6 @@
</div> </div>
</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">
Ten 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 v-if="!usbSupported" class="row">
<div class="col-lg-7"> <div class="col-lg-7">
<div class="card"> <div class="card">
@ -137,7 +108,9 @@
<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{{ textSizesSummary }}</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">
@ -249,8 +222,8 @@
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<label-layout-preview :fields="fields" :value="selectedTemplate" :tape="tape" <label-layout-preview :fields="fields" :value="selectedTemplate" :tape="tape"
:recent-template-ids="recentTemplateIds" :recent-template-ids="recentTemplateIds"
@input="selectedTemplate = $event"></label-layout-preview> @input="selectedTemplate = $event"></label-layout-preview>
</div> </div>
</div> </div>
</div> </div>
@ -267,7 +240,15 @@ import LabelLayoutPreview from "@/components/LabelLayoutPreview.vue";
import printerManager from "@/printerManager.js"; import printerManager from "@/printerManager.js";
import {MultiPrinterBlob, canvasToBitmap, bitmapToCanvas} from "../../vendor/weblabel.js"; import {MultiPrinterBlob, canvasToBitmap, bitmapToCanvas} from "../../vendor/weblabel.js";
import {tapeFromStatus, drawLabel, drawFallbackLabel, buildLabelContent, buildLabelFields, preloadQrEncoder} from "@/label.js"; import {
tapeFromStatus,
drawLabel,
drawFallbackLabel,
buildLabelContent,
buildLabelFields,
preloadQrEncoder,
preloadPixelFontRenderer
} from "@/label.js";
import {LABEL_TEMPLATES, BASE_VARS, DERIVED_VARS, withDerivedVars, templateContent} from "@/label-layouts.js"; import {LABEL_TEMPLATES, BASE_VARS, DERIVED_VARS, withDerivedVars, templateContent} from "@/label-layouts.js";
import {shortenedRoute} from "@/router"; import {shortenedRoute} from "@/router";
@ -353,22 +334,6 @@ export default {
// TODO: replace with the real commands for our printers. // TODO: replace with the real commands for our printers.
brotherCliExample: "brother_ql --model QL-000 --printer usb://0000:0000 print --label 00 label.png", 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", niimbotCliExample: "niimprint --model b00 --conn usb print --density 3 --image label.png",
// Ten pixel/bitmap font candidates for legibility testing only (see the disabled card above); not used by label.js's real FONT_TIERS.
candidateFonts: [
{family: "Pixelon"},
{family: "Pixelbasel"},
{family: "Chava"},
{family: "CodersCrux"},
{family: "712Serif"},
{family: "6pxNormal"},
{family: "6pxExpert"},
{family: "Jersey10"},
{family: "Jersey15"},
{family: "Terminus"},
],
candidateSizes: [5, 6, 7, 8, 10, 12, 16, 20],
candidateZoom: 4,
}; };
}, },
computed: { computed: {
@ -464,7 +429,6 @@ export default {
} else { } else {
this.redrawFallback(); this.redrawFallback();
} }
this.drawCandidateFontTests();
}, },
deep: true, deep: true,
}, },
@ -731,61 +695,14 @@ export default {
await this.autoConnectPreferred(); await this.autoConnectPreferred();
}); });
}, },
// Works around Vue's refInFor array-collecting behavior for :ref in v-for, same as LabelLayoutPreview.vue's setTemplateCanvasRef.
setCandidateCanvasRef(key, el) {
if (el) {
this.candidateCanvases[key] = el;
} else {
delete this.candidateCanvases[key];
}
},
// Renders the "Text" field at exact size/family with no layout snapping, so the font itself is judged; ink-centered vertically (like label.js's drawTextLeaf) so unreliable metrics don't clip. Thresholded through canvasToBitmap/bitmapToCanvas afterwards, same as redraw()'s real label preview, so a candidate is judged on the same 1-bit dots the printer would actually fire, not the anti-aliased canvas render.
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);
bitmapToCanvas(canvas, canvasToBitmap(canvas));
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() { created() {
this.blob = null; this.blob = null;
this.resizeObserver = null; this.resizeObserver = null;
this.resizeTimer = null; this.resizeTimer = null;
this.candidateCanvases = {};
this.recentTemplateIds = this.loadRecentTemplateIds(); this.recentTemplateIds = this.loadRecentTemplateIds();
}, },
async mounted() { async mounted() {
this.drawCandidateFontTests();
// Not awaited: resolving late just means shortUrl briefly shows "—" instead of blocking the unrelated printer/wasm setup below. // Not awaited: resolving late just means shortUrl briefly shows "—" instead of blocking the unrelated printer/wasm setup below.
this.fetchIdMap().catch(e => { this.fetchIdMap().catch(e => {
this.error = e.message; this.error = e.message;
@ -793,9 +710,12 @@ export default {
this.resizeObserver = new ResizeObserver(this.handleContainerResize); this.resizeObserver = new ResizeObserver(this.handleContainerResize);
// Loaded concurrently with MultiPrinterBlob below rather than serialized. See docs/implementation.md#concurrent-wasm-loading. // Loaded concurrently with MultiPrinterBlob below rather than serialized. See docs/implementation.md#concurrent-wasm-loading.
const qrReady = preloadQrEncoder(); const qrReady = preloadQrEncoder();
// Same reasoning - drawTextLeaf's bitmap-font tiers (see label.js's fontTierFor) need this
// loaded before the first real draw, same as qrReady below.
const fontsReady = Promise.all([qrReady, preloadPixelFontRenderer()]);
if (!("usb" in navigator)) { if (!("usb" in navigator)) {
this.usbSupported = false; this.usbSupported = false;
await Promise.all([nextTick(), qrReady]); await Promise.all([nextTick(), fontsReady]);
this.redrawFallback(); this.redrawFallback();
return; return;
} }
@ -808,7 +728,7 @@ export default {
this.blobReady = true; this.blobReady = true;
navigator.usb.addEventListener("connect", this.onUsbChange); navigator.usb.addEventListener("connect", this.onUsbChange);
navigator.usb.addEventListener("disconnect", this.onUsbChange); navigator.usb.addEventListener("disconnect", this.onUsbChange);
await qrReady; await fontsReady;
await this.guard(async () => { await this.guard(async () => {
await this.refreshDevices(); await this.refreshDevices();
await this.autoConnectPreferred(); await this.autoConnectPreferred();
@ -938,13 +858,6 @@ export default {
width: 5.5rem; 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 { .code-block {
background: rgba(127, 127, 127, .08); background: rgba(127, 127, 127, .08);
border-radius: .35rem; border-radius: .35rem;