This commit is contained in:
j3d1 2026-08-26 16:35:21 +02:00
parent 1356aa7749
commit 491ee05f15
13 changed files with 328 additions and 402 deletions

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;
}