stash
This commit is contained in:
parent
1356aa7749
commit
491ee05f15
13 changed files with 328 additions and 402 deletions
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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue