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
|
//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
|
// 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) => {
|
const optimal_fonts = (size) => {
|
||||||
if (size > 32)
|
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)
|
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)
|
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)
|
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)
|
if (size >= 10)
|
||||||
return {family: "Pixelon", url: pixelonFontUrl, size: 10};
|
return {family: "Pixelon", url: pixelonFontUrl, size: 10, lineHeight: size, tracking: -1};
|
||||||
if (size >= 8)
|
if (size >= 8)
|
||||||
return {family: "Chava", url: chavaFontUrl, size: 8};
|
return {family: "Chava", url: chavaFontUrl, size: 8, lineHeight: size};
|
||||||
if (size === 7)
|
// TODO find or build better fonts for 6px, 7px and 9px
|
||||||
return {family: "Chava", url: chavaFontUrl, size: 7};
|
// The criteria would be to make better use of the height available to be more readable
|
||||||
if (size >= 5)
|
if (size >= 6)
|
||||||
return {family: "Silkscreen", url: silkscreenCandidateFontUrl, size: 8}; //Silkscreen comes out to an effective height of 5px at size 8
|
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;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ABCDEFGHIJKLMNOPQRSTUVWXYZ
|
||||||
|
// abcdefghijklmnopqrstuvwxyz
|
||||||
|
// 0123456789-_@~ /:
|
||||||
|
// iiiiiiiiiiiiiiiiiiiiiiiiii
|
||||||
|
// mmmmmmmmmmmmmmmmmmmmmmmmmm
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "Print",
|
name: "Print",
|
||||||
components: {
|
components: {
|
||||||
|
|
@ -403,7 +416,7 @@ export default {
|
||||||
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.
|
// 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,
|
previewZoom: 4,
|
||||||
// Keyed by family, populated lazily the first time drawOptimalFontPreviews loads a font -
|
// Keyed by family, populated lazily the first time drawOptimalFontPreviews loads a font -
|
||||||
// avoids refetching the same bytes on every varValues-triggered redraw.
|
// 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.
|
continue; // fails to parse for FreeType too - leave that cell blank.
|
||||||
}
|
}
|
||||||
this.freetypeLineHeights[size] =
|
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