stash
This commit is contained in:
parent
1356aa7749
commit
491ee05f15
13 changed files with 328 additions and 402 deletions
16
frontend/public/vendor/freetype.js
vendored
Normal file
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
BIN
frontend/public/vendor/freetype.wasm
vendored
Executable file
Binary file not shown.
BIN
frontend/src/assets/fonts/pixel/Chava.ttf
Normal file
BIN
frontend/src/assets/fonts/pixel/Chava.ttf
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/pixel/Pixelon.ttf
Normal file
BIN
frontend/src/assets/fonts/pixel/Pixelon.ttf
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/pixel/Terminus.ttf
Normal file
BIN
frontend/src/assets/fonts/pixel/Terminus.ttf
Normal file
Binary file not shown.
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,185 +0,0 @@
|
||||||
// FreeType-in-wasm glyph rendering for Print.vue's font-candidate comparison card - draws each
|
|
||||||
// candidate through FreeType's own monochrome rasterizer (FT_LOAD_TARGET_MONO), the same rendering
|
|
||||||
// mode ptouch-print.c uses (see weblabel.js's TEXT_COVERAGE comment), rather than the canvas
|
|
||||||
// fillText-then-threshold approximation drawCandidateCell uses for its own cell. Experimental-only,
|
|
||||||
// same as the rest of that card - not part of the real label pipeline. See
|
|
||||||
// prototypes/freetype-wasm/ for the source this wasm build comes from.
|
|
||||||
|
|
||||||
// Served unbundled from public/vendor/, same reasoning as libweblabel.js (see
|
|
||||||
// docs/implementation.md#libweblabel-served-unbundled) - its emscripten glue resolves
|
|
||||||
// freetype.wasm relative to its own import.meta.url, so both files must sit together, unhashed.
|
|
||||||
const FREETYPE_URL = "/vendor/freetype.js";
|
|
||||||
|
|
||||||
// A literal (or traceably-constant) specifier in `import()` - even dynamic, even @vite-ignore'd -
|
|
||||||
// gets resolved by Vite's dev server the moment it's requested, and it refuses to serve anything
|
|
||||||
// under public/ that way ("This file is in /public and will be copied as-is during build without
|
|
||||||
// going through the plugin transforms, and therefore should not be imported from source code. It
|
|
||||||
// can only be referenced via HTML tags."). A blob: URL re-export module sidesteps Vite's own
|
|
||||||
// import analysis (it never sees the blob's contents), but the app's CSP `script-src` allows
|
|
||||||
// `'unsafe-inline'`, not `blob:`, so a blob-URL module gets blocked at the browser level instead
|
|
||||||
// ("Failed to fetch dynamically imported module: blob:..."). An actual inline `<script
|
|
||||||
// type="module">` - textContent, no `src` - satisfies both: Vite never transforms DOM nodes
|
|
||||||
// injected at runtime, and `'unsafe-inline'` covers it. The inline script's own `import` is
|
|
||||||
// resolved by the browser as an ordinary top-level fetch of `url`, same as a real HTML tag would
|
|
||||||
// (which is exactly what Vite's error message suggests), handing the result back here via a
|
|
||||||
// one-off global callback since an injected script has no way to return a value directly.
|
|
||||||
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 freetypePromise = null;
|
|
||||||
|
|
||||||
// Memoized the same way label.js's preloadQrEncoder/anyd memoizes its wasm instance - callers
|
|
||||||
// (drawFreetypeFontTests, on every redraw) can call this freely.
|
|
||||||
export function loadFreeType() {
|
|
||||||
freetypePromise ??= (async () => {
|
|
||||||
const {default: FreeTypeModule} = await importPublicModule(FREETYPE_URL);
|
|
||||||
const Module = await FreeTypeModule();
|
|
||||||
const rc = Module.ccall("ft_init", "number", [], []);
|
|
||||||
if (rc !== 0) {
|
|
||||||
throw new Error(`FT_Init_FreeType failed (error ${rc})`);
|
|
||||||
}
|
|
||||||
return Module;
|
|
||||||
})();
|
|
||||||
return freetypePromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copies `bytes` into the wasm heap and hands them to FT_New_Memory_Face; returns the FT error
|
|
||||||
// code (0 = success) rather than throwing, since a candidate that fails to parse even for
|
|
||||||
// FreeType is itself worth showing.
|
|
||||||
export function loadFontFace(Module, bytes) {
|
|
||||||
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);
|
|
||||||
return rc;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ports index.html's drawGlyphBitmap: blits the glyph FreeType just rendered (via ft_render_glyph)
|
|
||||||
// onto `ctx` at (x, y), y being the baseline. 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(Module, 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 = ctx.createImageData(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);
|
|
||||||
ctx.drawImage(tmp, x + left, y - top);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Renders `text` at `fontPx` through the currently-loaded face (see loadFontFace), always with
|
|
||||||
// FT_LOAD_TARGET_MONO - true 1-bit output straight from FreeType's own rasterizer, not canvas
|
|
||||||
// fillText's grayscale AA thresholded after the fact. `text` is split on "\n" first and each line
|
|
||||||
// stacked at `lineHeight` px apart, the same way label-layouts.js splits the "text" field for the
|
|
||||||
// real pipeline (see e.g. its `content: c => c.text?.split("\n")` templates) and label.js's drawLabel
|
|
||||||
// stacks the resulting node.lines. Two passes over the same glyphs per line (measure via each
|
|
||||||
// glyph's advance, then actually draw) since ft_render_glyph doubles as the only way to learn a
|
|
||||||
// glyph's width - cheap at these sizes/string lengths, and simpler than caching bitmaps between
|
|
||||||
// passes. `fontPx` is rounded before crossing into wasm - FT_Set_Pixel_Sizes takes a C `int`, and
|
|
||||||
// the JS->wasm number coercion truncates toward zero rather than rounding, which would otherwise
|
|
||||||
// make a fractional prescale factor land on the wrong integer size half the time.
|
|
||||||
//
|
|
||||||
// `opts.lineHeight` (px) and `opts.tracking` (px, added to every glyph's advance, may be negative)
|
|
||||||
// let a caller (see Print.vue's optimal_fonts) retune spacing without touching the rendered glyph
|
|
||||||
// bitmaps at all - scaling a monochrome pixel font's bitmap to "fit" a tighter line height would
|
|
||||||
// blur/distort it, so a lineHeight smaller than the face's natural one instead just clips each
|
|
||||||
// line to its own shorter row (ascenders/descenders that don't fit are cropped, not shrunk), and
|
|
||||||
// tight/negative tracking simply lets neighboring glyphs overlap rather than resizing them.
|
|
||||||
// Returns the *effective* line height actually used (the override if given, else the face's own
|
|
||||||
// `ft_get_line_height` - same metric index.html's own demo shows), already for the prescaled
|
|
||||||
// fontPx a caller passed in, not the nominal column size.
|
|
||||||
export function drawMonoText(Module, canvas, fontPx, text, zoom, opts = {}) {
|
|
||||||
const {lineHeight: lineHeightOverride, tracking = 0} = opts;
|
|
||||||
Module.ccall("ft_set_pixel_size", "number", ["number"], [Math.round(fontPx)]);
|
|
||||||
const lineHeight = lineHeightOverride ?? Module.ccall("ft_get_line_height", "number", [], []);
|
|
||||||
const lines = text.split("\n").map(line => Array.from(line).map(ch => ch.codePointAt(0)));
|
|
||||||
|
|
||||||
const measureLine = codes => {
|
|
||||||
let width = 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;
|
|
||||||
}
|
|
||||||
return width;
|
|
||||||
};
|
|
||||||
const width = Math.max(0, ...lines.map(measureLine));
|
|
||||||
|
|
||||||
canvas.width = Math.max(1, width) + 4;
|
|
||||||
canvas.height = Math.max(1, lineHeight) * lines.length + 8;
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
ctx.fillStyle = "#fff";
|
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
||||||
|
|
||||||
let lineTop = 4;
|
|
||||||
for (const codes of lines) {
|
|
||||||
// Clips this line's glyphs to its own row - the "cropping" that keeps a tightened
|
|
||||||
// lineHeight from ever scaling the glyph raster (see the doc comment above).
|
|
||||||
ctx.save();
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.rect(0, lineTop, canvas.width, lineHeight);
|
|
||||||
ctx.clip();
|
|
||||||
const baseline = lineTop + Math.round(lineHeight * 0.8);
|
|
||||||
let x = 2;
|
|
||||||
for (const code of codes) {
|
|
||||||
if (Module.ccall("ft_render_glyph", "number", ["number", "number"], [code, 1]) !== 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
drawGlyphBitmap(Module, ctx, x, baseline);
|
|
||||||
x += Module.ccall("ft_get_advance_x", "number", [], []) + tracking;
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
lineTop += lineHeight;
|
|
||||||
}
|
|
||||||
canvas.style.width = `${canvas.width * zoom}px`;
|
|
||||||
canvas.style.height = `${canvas.height * zoom}px`;
|
|
||||||
return lineHeight;
|
|
||||||
}
|
|
||||||
|
|
@ -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) {
|
|
||||||
// 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);
|
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
209
frontend/src/pixel-font.js
Normal 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;
|
||||||
|
}
|
||||||
11
frontend/src/scss/_label-fonts.scss
Normal file
11
frontend/src/scss/_label-fonts.scss
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -50,40 +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: optimal font by size</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">
|
|
||||||
For every nominal size from 5px to 30px, the font and effective size
|
|
||||||
<code>optimal_fonts</code> actually picks for it - rendered live from the "Text"
|
|
||||||
field above through libfreetype-wasm's own monochrome rasterizer
|
|
||||||
(FT_LOAD_TARGET_MONO), a true 1-bit render, not canvas fillText's grayscale AA
|
|
||||||
thresholded after the fact. The line height under each canvas is
|
|
||||||
<code>ft_get_line_height</code> at that cell's actually-drawn size, the same
|
|
||||||
metric prototypes/freetype-wasm's own demo shows.
|
|
||||||
</p>
|
|
||||||
<div v-if="freetypeError" class="alert alert-warning small py-2">
|
|
||||||
FreeType failed to load: {{ freetypeError }} - canvases will stay blank.
|
|
||||||
</div>
|
|
||||||
<div class="d-flex flex-wrap gap-3">
|
|
||||||
<div v-for="size in previewSizes" :key="size" class="text-center">
|
|
||||||
<div class="small text-muted mb-1">{{ size }}px</div>
|
|
||||||
<canvas :ref="el => setPreviewCanvasRef(size, el)"
|
|
||||||
class="pixel-test-canvas"></canvas>
|
|
||||||
<div class="small text-muted mt-1" v-if="optimalFontFor(size)">
|
|
||||||
{{ optimalFontFor(size).family }} @{{ optimalFontFor(size).size }}px
|
|
||||||
</div>
|
|
||||||
<div class="small text-muted" v-if="freetypeLineHeights[size] !== undefined">
|
|
||||||
line height: {{ freetypeLineHeights[size] }}px
|
|
||||||
</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">
|
||||||
|
|
@ -280,22 +246,11 @@ import {
|
||||||
drawFallbackLabel,
|
drawFallbackLabel,
|
||||||
buildLabelContent,
|
buildLabelContent,
|
||||||
buildLabelFields,
|
buildLabelFields,
|
||||||
preloadQrEncoder
|
preloadQrEncoder,
|
||||||
|
preloadPixelFontRenderer
|
||||||
} from "@/label.js";
|
} 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";
|
||||||
import {loadFreeType, loadFontFace, drawMonoText} from "@/freetype-preview.js";
|
|
||||||
|
|
||||||
// URLs (not raw bytes - fetched lazily, see drawOptimalFontPreviews) for each font optimal_fonts
|
|
||||||
// can pick, fed to freetype-preview.js's loadFontFace. Silkscreen and Inter each need their own TTF
|
|
||||||
// copy (see pixel-candidates/LICENSE.md) since this build of FreeType can't parse WOFF2 (no brotli
|
|
||||||
// support - confirmed: FT error 2 on both production pixel/Silkscreen-Regular.woff2 and
|
|
||||||
// label/Inter-Regular.woff2).
|
|
||||||
import pixelonFontUrl from "@/assets/fonts/pixel-candidates/Pixelon.ttf?url";
|
|
||||||
import chavaFontUrl from "@/assets/fonts/pixel-candidates/Chava.ttf?url";
|
|
||||||
import terminusFontUrl from "@/assets/fonts/pixel-candidates/Terminus.ttf?url";
|
|
||||||
import silkscreenCandidateFontUrl from "@/assets/fonts/pixel-candidates/Silkscreen.ttf?url";
|
|
||||||
import interCandidateFontUrl from "@/assets/fonts/pixel-candidates/Inter.ttf?url";
|
|
||||||
|
|
||||||
// Print.vue-local calculated fields on top of label-layouts.js's DERIVED_VARS. See
|
// Print.vue-local calculated fields on top of label-layouts.js's DERIVED_VARS. See
|
||||||
// docs/implementation.md#calculated-short-link-fields.
|
// docs/implementation.md#calculated-short-link-fields.
|
||||||
|
|
@ -320,41 +275,6 @@ const RULER_TIERS = [
|
||||||
{aboveMm: 500, tickMm: 5, majorEveryMm: 25},
|
{aboveMm: 500, tickMm: 5, majorEveryMm: 25},
|
||||||
];
|
];
|
||||||
|
|
||||||
//after manually comparing all fonts at all sizes, it was decides that in some cases it's more readable to round down the font size to a more readable
|
|
||||||
// this is a lookup table for these optimal settings
|
|
||||||
// An entry may also set `lineHeight` (px) and/or `tracking` (px, may be negative) to retune a
|
|
||||||
// tier's spacing without touching its rendered glyphs at all - see drawMonoText's own doc comment
|
|
||||||
// in freetype-preview.js for why (scaling a pixel font's bitmap to fit blurs it, so a tighter
|
|
||||||
// lineHeight crops ascenders/descenders at the row edge instead, and tracking just overlaps
|
|
||||||
// neighboring glyphs). Omit either field to fall back to the face's natural metrics.
|
|
||||||
const optimal_fonts = (size) => {
|
|
||||||
if (size > 32)
|
|
||||||
return {family: "Inter", url: interCandidateFontUrl, size: size, lineHeight: size};
|
|
||||||
if (size === 32)
|
|
||||||
return {family: "Terminus", url: terminusFontUrl, size: 32, lineHeight: size}; // would even support bold mode
|
|
||||||
if (size >= 28)
|
|
||||||
return {family: "Terminus", url: terminusFontUrl, size: 28, lineHeight: size}; // would even support bold mode
|
|
||||||
if (size >= 24)
|
|
||||||
return {family: "Terminus", url: terminusFontUrl, size: 24, lineHeight: size}; // would even support bold mode
|
|
||||||
if (size >= 12)
|
|
||||||
return {family: "Terminus", url: terminusFontUrl, size: (size - (size % 2)), lineHeight: size}; // would even support bold mode >=14px
|
|
||||||
if (size >= 10)
|
|
||||||
return {family: "Pixelon", url: pixelonFontUrl, size: 10, lineHeight: size, tracking: -1};
|
|
||||||
if (size >= 8)
|
|
||||||
return {family: "Chava", url: chavaFontUrl, size: 8, lineHeight: size};
|
|
||||||
// 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
|
|
||||||
if (size >= 6)
|
|
||||||
return {family: "Silkscreen", url: silkscreenCandidateFontUrl, size: 8, lineHeight: size, tracking: -1}; //Silkscreen comes out to an effective height of 5px at size 8
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ABCDEFGHIJKLMNOPQRSTUVWXYZ
|
|
||||||
// abcdefghijklmnopqrstuvwxyz
|
|
||||||
// 0123456789-_@~ /:
|
|
||||||
// iiiiiiiiiiiiiiiiiiiiiiiiii
|
|
||||||
// mmmmmmmmmmmmmmmmmmmmmmmmmm
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "Print",
|
name: "Print",
|
||||||
components: {
|
components: {
|
||||||
|
|
@ -414,20 +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",
|
||||||
|
|
||||||
// Nominal sizes previewed by the "optimal font by size" card - 5px to 30px inclusive.
|
|
||||||
previewSizes: Array.from({length: 36}, (_, i) => i + 6),
|
|
||||||
previewZoom: 4,
|
|
||||||
// Keyed by family, populated lazily the first time drawOptimalFontPreviews loads a font -
|
|
||||||
// avoids refetching the same bytes on every varValues-triggered redraw.
|
|
||||||
freetypeFontBytes: {},
|
|
||||||
// Set if FreeType itself (not a specific font) fails to load - shown once above the grid
|
|
||||||
// instead of failing every cell silently.
|
|
||||||
freetypeError: null,
|
|
||||||
// Keyed by nominal size - each cell's effective line height in px (ft_get_line_height at
|
|
||||||
// the actually-drawn size), same metric prototypes/freetype-wasm's own index.html demo
|
|
||||||
// shows below its canvas.
|
|
||||||
freetypeLineHeights: {},
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|
@ -523,7 +429,6 @@ export default {
|
||||||
} else {
|
} else {
|
||||||
this.redrawFallback();
|
this.redrawFallback();
|
||||||
}
|
}
|
||||||
this.drawOptimalFontPreviews();
|
|
||||||
},
|
},
|
||||||
deep: true,
|
deep: true,
|
||||||
},
|
},
|
||||||
|
|
@ -790,66 +695,14 @@ export default {
|
||||||
await this.autoConnectPreferred();
|
await this.autoConnectPreferred();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
// Looks up the (family, url, effective size) optimal_fonts picks for a nominal size -
|
|
||||||
// exposed as a method (not a computed) since the template calls it per-size inside a v-for.
|
|
||||||
optimalFontFor(size) {
|
|
||||||
return optimal_fonts(size);
|
|
||||||
},
|
|
||||||
|
|
||||||
setPreviewCanvasRef(size, el) {
|
|
||||||
if (el) {
|
|
||||||
this.freetypeCanvases[size] = el;
|
|
||||||
} else {
|
|
||||||
delete this.freetypeCanvases[size];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Draws every previewSizes cell - see the card in the template. For each nominal size, looks
|
|
||||||
// up the font optimal_fonts actually picks and draws through libfreetype-wasm's own
|
|
||||||
// FT_LOAD_TARGET_MONO rasterizer - a true 1-bit render, not canvas fillText's grayscale AA
|
|
||||||
// thresholded after the fact. Font bytes are cached per family (see freetypeFontBytes) since
|
|
||||||
// several sizes can share the same font.
|
|
||||||
async drawOptimalFontPreviews() {
|
|
||||||
let ft;
|
|
||||||
try {
|
|
||||||
ft = await loadFreeType();
|
|
||||||
} catch (e) {
|
|
||||||
this.freetypeError = e.message;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.freetypeError = null;
|
|
||||||
for (const size of this.previewSizes) {
|
|
||||||
const cfg = optimal_fonts(size);
|
|
||||||
const canvas = this.freetypeCanvases[size];
|
|
||||||
if (!cfg || !canvas) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let bytes = this.freetypeFontBytes[cfg.family];
|
|
||||||
if (!bytes) {
|
|
||||||
const res = await fetch(cfg.url);
|
|
||||||
bytes = new Uint8Array(await res.arrayBuffer());
|
|
||||||
this.freetypeFontBytes[cfg.family] = bytes;
|
|
||||||
}
|
|
||||||
if (loadFontFace(ft, bytes) !== 0) {
|
|
||||||
continue; // fails to parse for FreeType too - leave that cell blank.
|
|
||||||
}
|
|
||||||
this.freetypeLineHeights[size] =
|
|
||||||
drawMonoText(ft, canvas, cfg.size, this.varValues.text, this.previewZoom,
|
|
||||||
{lineHeight: cfg.lineHeight, tracking: cfg.tracking});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
this.blob = null;
|
this.blob = null;
|
||||||
this.resizeObserver = null;
|
this.resizeObserver = null;
|
||||||
this.resizeTimer = null;
|
this.resizeTimer = null;
|
||||||
this.freetypeCanvases = {};
|
|
||||||
this.recentTemplateIds = this.loadRecentTemplateIds();
|
this.recentTemplateIds = this.loadRecentTemplateIds();
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
this.drawOptimalFontPreviews();
|
|
||||||
// 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;
|
||||||
|
|
@ -857,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;
|
||||||
}
|
}
|
||||||
|
|
@ -872,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();
|
||||||
|
|
@ -1002,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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue