stash
This commit is contained in:
parent
368268d288
commit
1356aa7749
2 changed files with 211 additions and 12 deletions
185
frontend/src/freetype-preview.js
Normal file
185
frontend/src/freetype-preview.js
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
// 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;
|
||||
}
|
||||
|
|
@ -322,26 +322,39 @@ const RULER_TIERS = [
|
|||
|
||||
//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};
|
||||
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}; // would even support bold mode >=14px
|
||||
return {family: "Terminus", url: terminusFontUrl, size: 28, lineHeight: size}; // would even support bold mode
|
||||
if (size >= 24)
|
||||
return {family: "Terminus", url: terminusFontUrl, size: 24}; // would even support bold mode >=14px
|
||||
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))}; // would even support bold mode >=14px
|
||||
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};
|
||||
return {family: "Pixelon", url: pixelonFontUrl, size: 10, lineHeight: size, tracking: -1};
|
||||
if (size >= 8)
|
||||
return {family: "Chava", url: chavaFontUrl, size: 8};
|
||||
if (size === 7)
|
||||
return {family: "Chava", url: chavaFontUrl, size: 7};
|
||||
if (size >= 5)
|
||||
return {family: "Silkscreen", url: silkscreenCandidateFontUrl, size: 8}; //Silkscreen comes out to an effective height of 5px at 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 {
|
||||
name: "Print",
|
||||
components: {
|
||||
|
|
@ -403,7 +416,7 @@ export default {
|
|||
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 + 5),
|
||||
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.
|
||||
|
|
@ -823,7 +836,8 @@ export default {
|
|||
continue; // fails to parse for FreeType too - leave that cell blank.
|
||||
}
|
||||
this.freetypeLineHeights[size] =
|
||||
drawMonoText(ft, canvas, cfg.size, this.varValues.text, this.previewZoom);
|
||||
drawMonoText(ft, canvas, cfg.size, this.varValues.text, this.previewZoom,
|
||||
{lineHeight: cfg.lineHeight, tracking: cfg.tracking});
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue