Compare commits

..

No commits in common. "491ee05f1506ec222c2f3968dd1d8d036cd67fe5" and "0c025db7998bf0ccec1b4ad2ccc6f2454a3b5c7b" have entirely different histories.

12 changed files with 165 additions and 340 deletions

File diff suppressed because one or more lines are too long

Binary file not shown.

View file

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

View file

@ -1,10 +1,5 @@
import {loadAnyDCode} from "../vendor/anyd-qr.js";
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.
let anyd = null;
@ -84,64 +79,26 @@ export function tapeFromStatus(status) {
// See docs/implementation.md#layout-tree-structure.
const TEXT_REFERENCE_PX = 100; /* font size text leaves measure their natural aspect ratio at */
// Below 33px, a general-purpose sans-serif gets illegible at real label sizes, so drawTextLeaf
// switches to one of these bitmap-style fonts instead, rendered through pixel-font.js's
// 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
// Below 10px, a general-purpose sans-serif gets illegible, so drawTextLeaf switches to a bitmap
// font (Tom Thumb/Silkscreen) instead; see the empirical rationale (font choice, `scale`, and why
// sizes aren't dpi-adjusted) at docs/implementation.md#pixel-font-selection. The 10px+ tier names
// 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
// same label print differently depending on where it was rendered from - Inter is the one tier
// still drawn with fillText, since anti-aliasing is expected/fine at this size (see drawTextLeaf).
// Checked top-down (first branch whose threshold `fontPx` clears wins) - retune by eye in
// prototypes/freetype-wasm/ladder.html before editing this.
//
// `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
// `fontPx` itself for Inter, which scales cleanly. `fontPx` itself (the layout-derived logical
// 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?`);
// same label print differently depending on where it was rendered from.
const FONT_TIERS = [
{belowPx: 8, family: "Tom Thumb", scale: 3.2, pixel: true},
{belowPx: 10, family: "Silkscreen", pixel: true},
{belowPx: Infinity, family: "Inter"},
];
function fontFamilyFor(fontPx) {
return FONT_TIERS.find(t => fontPx < t.belowPx);
}
// Below this font size (px) or physical height (mm) - fontTierFor's own floor (Silkscreen's
// real-Chromium-tested legibility limit; see its `fontPx >= 6` branch above) - text is a hard
// failure, the same two-metric shape as MIN_RECOMMENDED_QR_PX_PER_MODULE/MIN_RECOMMENDED_QR_MODULE_MM
// below (see checkTextSizes). No longer just silently blanked.
const MIN_TEXT_PX = 6;
// Below this font size (px) or physical height (mm) - Tom Thumb's real-Chromium-tested legibility
// floor - text is a hard failure, the same two-metric shape as MIN_RECOMMENDED_QR_PX_PER_MODULE/
// MIN_RECOMMENDED_QR_MODULE_MM below (see checkTextSizes). No longer just silently blanked.
const MIN_TEXT_PX = 5;
const MIN_TEXT_MM = 0.5;
// 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.
@ -338,7 +295,7 @@ function checkTextSizes(node, referencePx, pxPerMm, warnings, textInfo) {
}
}
// Always measures in Inter at the reference size, since the eventual font (see fontTierFor) isn't
// Always measures in Inter at the reference size, since the eventual font (see FONT_TIERS) isn't
// 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
// catches real failures.
@ -390,28 +347,35 @@ function drawQrLeaf(ctx, node) {
// there's no "too small to draw" case left to special-case here.
function drawTextLeaf(ctx, node, referencePx) {
const fontPx = effectiveFontPx(node, referencePx);
const tier = fontTierFor(fontPx);
if (tier.pixel) {
// Own rasterizer, own centering/pixel-snapping, own multi-line stacking - see
// pixel-font.js's drawPixelText for why (and its own doc comment for what it does with
// node.box/node.lines, the same inputs drawTextLeaf itself would otherwise use below).
drawPixelText(ctx, tier, node.lines, node.box);
return fontPx;
}
// 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}"`;
const {family, scale = 1, pixel: isPixelFont = false} = fontFamilyFor(fontPx);
// Position (not size - fontPx is already a whole pixel) still needs snapping for pixel fonts
// to stay grid-aligned, since node.box.x/y are ordinary (fractional) layout math; Inter is
// left exact since anti-aliasing handles fractional positions fine.
const snap = isPixelFont ? Math.round : (v) => v;
// `scale` (Tom Thumb only, see FONT_TIERS above) corrects the size handed to ctx.font for its
// real ink; fontPx itself stays the logical size used for centering/stacking math.
ctx.font = `${fontPx * scale}px "${family}"`;
// Canvas text silently falls back if drawn before a not-yet-loaded font resolves, unlike DOM
// text. See docs/implementation.md#canvas-font-loading.
// text. See docs/implementation.md#canvas-font-loading. Every tier now names a real,
// self-hosted webfont (see FONT_TIERS above), so this always applies, not just to pixel fonts.
document.fonts.load(ctx.font);
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const centerX = node.box.x + node.box.width / 2;
const centerX = snap(node.box.x + node.box.width / 2);
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.
let sliceTop = node.box.y;
for (const line of node.lines) {
ctx.fillText(line, centerX, sliceTop + lineHeight / 2);
if (isPixelFont) {
// 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;
}
return fontPx;
@ -504,10 +468,6 @@ export function drawLabel(canvas, tape, content, orientation = "along") {
canvas.height = tape.printAreaPx;
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.fillRect(0, 0, printedLength, canvas.height);
ctx.fillStyle = "#000";
@ -547,7 +507,6 @@ export function drawFallbackLabel(canvas, content, orientation = "along") {
canvas.height = FALLBACK_LABEL_HEIGHT_PX;
const ctx = canvas.getContext("2d", {willReadFrequently: true});
ctx.imageSmoothingEnabled = false; /* see drawLabel's own comment */
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#000";

View file

@ -1,209 +0,0 @@
// 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

@ -1,11 +0,0 @@
// 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

@ -0,0 +1,14 @@
// 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,6 +92,8 @@ $body-color: $gray-700;
@import "forms";
@import "tags";
@import "dropdown";
@import "pixel-fonts";
@import "pixel-fonts-candidates";
@import "label-fonts";
#root, body, html {

View file

@ -50,6 +50,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">
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 class="col-lg-7">
<div class="card">
@ -108,9 +137,7 @@
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<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 class="card-body">
<p v-if="!tape" class="text-muted">
@ -222,8 +249,8 @@
<div class="row">
<div class="col-12">
<label-layout-preview :fields="fields" :value="selectedTemplate" :tape="tape"
:recent-template-ids="recentTemplateIds"
@input="selectedTemplate = $event"></label-layout-preview>
:recent-template-ids="recentTemplateIds"
@input="selectedTemplate = $event"></label-layout-preview>
</div>
</div>
</div>
@ -240,15 +267,7 @@ import LabelLayoutPreview from "@/components/LabelLayoutPreview.vue";
import printerManager from "@/printerManager.js";
import {MultiPrinterBlob, canvasToBitmap, bitmapToCanvas} from "../../vendor/weblabel.js";
import {
tapeFromStatus,
drawLabel,
drawFallbackLabel,
buildLabelContent,
buildLabelFields,
preloadQrEncoder,
preloadPixelFontRenderer
} from "@/label.js";
import {tapeFromStatus, drawLabel, drawFallbackLabel, buildLabelContent, buildLabelFields, preloadQrEncoder} from "@/label.js";
import {LABEL_TEMPLATES, BASE_VARS, DERIVED_VARS, withDerivedVars, templateContent} from "@/label-layouts.js";
import {shortenedRoute} from "@/router";
@ -334,6 +353,22 @@ export default {
// 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",
// 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: {
@ -429,6 +464,7 @@ export default {
} else {
this.redrawFallback();
}
this.drawCandidateFontTests();
},
deep: true,
},
@ -695,14 +731,61 @@ export default {
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() {
this.blob = null;
this.resizeObserver = null;
this.resizeTimer = null;
this.candidateCanvases = {};
this.recentTemplateIds = this.loadRecentTemplateIds();
},
async mounted() {
this.drawCandidateFontTests();
// Not awaited: resolving late just means shortUrl briefly shows "—" instead of blocking the unrelated printer/wasm setup below.
this.fetchIdMap().catch(e => {
this.error = e.message;
@ -710,12 +793,9 @@ export default {
this.resizeObserver = new ResizeObserver(this.handleContainerResize);
// Loaded concurrently with MultiPrinterBlob below rather than serialized. See docs/implementation.md#concurrent-wasm-loading.
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)) {
this.usbSupported = false;
await Promise.all([nextTick(), fontsReady]);
await Promise.all([nextTick(), qrReady]);
this.redrawFallback();
return;
}
@ -728,7 +808,7 @@ export default {
this.blobReady = true;
navigator.usb.addEventListener("connect", this.onUsbChange);
navigator.usb.addEventListener("disconnect", this.onUsbChange);
await fontsReady;
await qrReady;
await this.guard(async () => {
await this.refreshDevices();
await this.autoConnectPreferred();
@ -858,6 +938,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;