icon pack builder: measure glyphs when building an icon pack, use the metrics to create a more visually accurate vertical alignment for icons

This commit is contained in:
Adorian Doran 2026-09-11 18:45:38 +03:00
parent ae6eb1e61a
commit 02cf0a116a
14 changed files with 286 additions and 16 deletions

View File

@ -212,7 +212,9 @@ body.mobile .board-view-container .board-add-column {
.board-view-container .board-column h3 > .column-icon {
/* Sized down from the note header's button, whose scale suits a toolbar rather than a heading. */
--note-icon-size: 1.15em;
--note-icon-container-padding-size: 0.15em;
/* In pixels, as everywhere else the ring is set: it has to be whole for the glyph to sit centred
in the disc, and the glyph alone follows the heading's font. */
--note-icon-container-padding-size: 4px;
flex-shrink: 0;
margin-inline-end: 0.4em;

View File

@ -11,7 +11,9 @@ div.note-icon-widget {
}
.note-icon-widget button.note-icon {
--size: calc(var(--note-icon-size) + var(--note-icon-container-padding-size) * 2);
/* `1em` is this button's font size, which `--note-icon-size` already set: naming that variable
here again would scale a value given in `em` a second time. */
--size: calc(1em + var(--note-icon-container-padding-size) * 2);
width: var(--size);
height: var(--size);
@ -23,6 +25,17 @@ div.note-icon-widget {
font-size: var(--note-icon-size);
}
/* A glyph rasterises on whole pixels while the disc behind it does not, so a gap of half a pixel
between the two puts the icon high or low depending on where the widget lands on the page.
Rounding both leaves a gap of twice the ring, which is always whole. */
@supports (width: round(1px, 1px)) {
.note-icon-widget button.note-icon {
--size: calc(1em + round(var(--note-icon-container-padding-size), 1px) * 2);
font-size: round(var(--note-icon-size), 1px);
}
}
.note-icon-widget button.note-icon:disabled {
cursor: default;
opacity: .75;

View File

@ -7,8 +7,12 @@
"start": "tsx ."
},
"keywords": [],
"dependencies": {
"@triliumnext/commons": "workspace:*"
},
"devDependencies": {
"@mdi/font": "7.4.47",
"@phosphor-icons/web": "2.1.2"
"@phosphor-icons/web": "2.1.2",
"opentype.js": "2.0.0"
}
}

View File

@ -1,4 +1,4 @@
import type { IconPackManifest } from "@triliumnext/server/src/services/icon_packs";
import type { IconPackManifest } from "@triliumnext/core/src/services/icon_packs";
export interface IconPackData {
name: string;

View File

@ -2,6 +2,7 @@ import { readFileSync } from "fs";
import { join } from "path";
import { IconPackData } from "../provider";
import { readIconFontMetrics } from "../utils";
export default function buildIcons(pack: "basic" | "brands"): IconPackData {
const inputDir = join(__dirname, "../../boxicons-free/fonts");
@ -36,7 +37,8 @@ export default function buildIcons(pack: "basic" | "brands"): IconPackData {
content: readFileSync(join(inputDir, pack, `${fileName}.woff2`))
},
manifest: {
icons
icons,
metrics: readIconFontMetrics(join(inputDir, pack, `${fileName}.ttf`))
},
meta: {
version: "3.0.0",

View File

@ -2,7 +2,7 @@ import { readFileSync } from "fs";
import { join } from "path";
import type { IconPackData } from "../provider";
import { extractClassNamesFromCss, getModulePath } from "../utils";
import { extractClassNamesFromCss, getModulePath, readIconFontMetrics } from "../utils";
export default function buildIcons(): IconPackData {
const baseDir = getModulePath("@mdi/font");
@ -17,6 +17,7 @@ export default function buildIcons(): IconPackData {
icon: "mdi mdi-material-design",
manifest: {
icons: extractClassNamesFromCss(cssFileContent, "mdi"),
metrics: readIconFontMetrics(join(baseDir, "fonts", "materialdesignicons-webfont.ttf"))
},
fontFile: {
name: "materialdesignicons-webfont.woff2",

View File

@ -2,7 +2,7 @@ import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { IconPackData } from "../provider";
import { getModulePath } from "../utils";
import { getModulePath, readIconFontMetrics } from "../utils";
export default function buildIcons(packName: "regular" | "fill"): IconPackData {
const moduleDir = getModulePath("@phosphor-icons/web");
@ -30,6 +30,10 @@ export default function buildIcons(packName: "regular" | "fill"): IconPackData {
}
const fontFile = readdirSync(baseDir).find(f => f.endsWith(".woff2"));
if (!fontFile) {
throw new Error(`No WOFF2 font to build the pack from in ${baseDir}.`);
}
const prefix = packName === "regular" ? "ph" : `ph-${packName}`;
return {
@ -37,12 +41,13 @@ export default function buildIcons(packName: "regular" | "fill"): IconPackData {
prefix,
icon: `${prefix} ph-phosphor-logo`,
manifest: {
icons
icons,
metrics: readIconFontMetrics(join(baseDir, fontFile.replace(/\.woff2$/, ".ttf")))
},
fontFile: {
name: fontFile!,
name: fontFile,
mime: "font/woff2",
content: readFileSync(join(baseDir, fontFile!))
content: readFileSync(join(baseDir, fontFile))
},
meta: {
version: packageJson.version,

View File

@ -1,6 +1,10 @@
import { readFileSync } from "fs";
import { join } from "path";
import { IconPackManifest } from "../../server/src/services/icon_packs";
import { measureIconFont } from "@triliumnext/commons";
import opentype from "opentype.js";
import { IconPackManifest } from "@triliumnext/core/src/services/icon_packs";
export function extractClassNamesFromCss(css: string, prefix: string): IconPackManifest["icons"] {
const regex = /\.([a-zA-Z0-9-]+)::before\s*\{\s*content:\s*"\\([A-Fa-f0-9]+)"\s*\}/g;
@ -24,3 +28,36 @@ export function extractClassNamesFromCss(css: string, prefix: string): IconPackM
export function getModulePath(moduleName: string): string {
return join(__dirname, "../../../node_modules", moduleName);
}
/**
* The metrics of an icon font, read off its glyphs, for the pack's manifest to carry.
*
* Packs ship as WOFF2, which cannot be taken apart without a Brotli decoder, so this reads the
* TrueType build every provider has beside it. The two hold the same outlines.
*
* @param fontPath the `.ttf` to measure.
* @returns the metrics, or `undefined` where the font cannot be read.
*/
export function readIconFontMetrics(fontPath: string): IconPackManifest["metrics"] {
let font;
try {
const bytes = readFileSync(fontPath);
font = opentype.parse(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length));
} catch (e) {
console.warn(`Could not read font metrics from ${fontPath}: ${e}`);
return undefined;
}
const inkCentres: number[] = [];
for (const glyph of Object.values(font.glyphs.glyphs)) {
if (!glyph.unicode) continue;
const box = glyph.getBoundingBox();
// A blank glyph reports an inverted box, and says nothing about where the pack draws.
if (box.y2 > box.y1) {
inkCentres.push((box.y1 + box.y2) / 2);
}
}
return measureIconFont(font.unitsPerEm, inkCentres) ?? undefined;
}

View File

@ -8,6 +8,7 @@ export * from "./lib/test-utils.js";
export * from "./lib/attachment_roles.js";
export * from "./lib/custom_fonts.js";
export * from "./lib/font_mimes.js";
export * from "./lib/icon_font_metrics.js";
export * from "./lib/image_mimes.js";
export * from "./lib/mime_type.js";
export * from "./lib/office.js";

View File

@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { iconFontFaceOverrides, measureIconFont } from "./icon_font_metrics.js";
/** Ink centres for a pack drawn `offset` units above the baseline, with a little scatter. */
function pack(offset: number, count = 20) {
return Array.from({ length: count }, (_, index) => offset + (index % 5) - 2);
}
describe("measureIconFont", () => {
it("splits the em around the middle of the ink", () => {
// Boxicons 2: 1024 units to the em, drawn 448 of them above the baseline.
expect(measureIconFont(1024, pack(448))).toEqual({ ascent: 0.9375, descent: 0.0625 });
// Boxicons 3: the em sits on the baseline, so the box is all ascent.
expect(measureIconFont(300, pack(150))).toEqual({ ascent: 1, descent: 0 });
// Material Design Icons: 512 units to the em, 192 above the baseline.
expect(measureIconFont(512, pack(192))).toEqual({ ascent: 0.875, descent: 0.125 });
});
it("takes the middle of the pack, not the icons drawn off centre on purpose", () => {
const centres = [ ...pack(448), 0, 12, 1000, 1010 ];
expect(measureIconFont(1024, centres)).toEqual({ ascent: 0.9375, descent: 0.0625 });
});
it("declines a font it cannot read a box out of", () => {
expect(measureIconFont(0, pack(448))).toBeNull();
expect(measureIconFont(-1024, pack(448))).toBeNull();
expect(measureIconFont(1024, [])).toBeNull();
expect(measureIconFont(1024, pack(448, 7))).toBeNull();
// Ink so far from the em that the overrides would come out negative.
expect(measureIconFont(1024, pack(-900))).toBeNull();
expect(measureIconFont(1024, pack(2600))).toBeNull();
});
});
describe("iconFontFaceOverrides", () => {
it("writes the descriptors a font face carries", () => {
expect(iconFontFaceOverrides({ ascent: 0.9375, descent: 0.0625 })).toEqual([
"ascent-override: 93.75%;",
"descent-override: 6.25%;",
"line-gap-override: 0%;"
]);
});
it("declares nothing for a pack that gave no usable metrics", () => {
expect(iconFontFaceOverrides(undefined)).toEqual([]);
expect(iconFontFaceOverrides({ ascent: 0.9, descent: NaN })).toEqual([]);
expect(iconFontFaceOverrides({ ascent: -0.1, descent: 0.1 })).toEqual([]);
expect(iconFontFaceOverrides({ ascent: 4, descent: 0.1 })).toEqual([]);
});
it("drops a manifest value that is not a number, which a pack's author writes by hand", () => {
const evil = { ascent: "0%; } body { display: none } @font-face { x: 1", descent: 0.1 };
expect(iconFontFaceOverrides(evil as never)).toEqual([]);
});
});

View File

@ -0,0 +1,96 @@
/**
* Where an icon pack draws its glyphs, as fractions of the em.
*
* A browser takes the box it centres a glyph in from the font's platform metrics, which carry
* padding the pack never drew in: Boxicons 3 declares 300 units per em and a Windows ascent of 327,
* so each of its icons sits about 4.5% of the em below the middle of its container. Declaring the
* pack's own box through `ascent-override` and `descent-override` removes that, and pins the result
* across platforms that read different metrics out of the same file.
*/
export interface IconFontMetrics {
/** The `ascent-override` value. */
ascent: number;
/** The `descent-override` value. */
descent: number;
}
/** Below this many inked glyphs the middle of the set says too little to override anything by. */
const MIN_MEASURED_GLYPHS = 8;
/** The largest override that can be meant seriously, as a fraction of the em. */
const MAX_OVERRIDE = 2;
/**
* The metrics of a pack, worked out from where it draws.
*
* The middle of the ink is taken over the whole pack rather than per glyph: individual icons are
* drawn off centre on purpose, and the median says where the box they share sits without following
* any of them.
*
* @param unitsPerEm the font's own unit scale.
* @param inkCentres the vertical middle of each glyph's ink, in font units above the baseline.
* @returns the metrics, or `null` where the font says too little to correct.
*/
export function measureIconFont(unitsPerEm: number, inkCentres: number[]): IconFontMetrics | null {
if (!(unitsPerEm > 0) || inkCentres.length < MIN_MEASURED_GLYPHS) {
return null;
}
const middle = median(inkCentres) / unitsPerEm;
const ascent = 0.5 + middle;
const descent = 0.5 - middle;
if (!isOverride(ascent) || !isOverride(descent)) {
return null;
}
return { ascent: roundFraction(ascent), descent: roundFraction(descent) };
}
/**
* The `@font-face` descriptors a pack's metrics stand for, one per line, or nothing where it
* declares none.
*
* A pack's manifest is written by whoever made it, so a value that is not a usable number is
* dropped rather than passed on to the stylesheet.
*/
export function iconFontFaceOverrides(metrics: IconFontMetrics | undefined): string[] {
const ascent = overridePercentage(metrics?.ascent);
const descent = overridePercentage(metrics?.descent);
if (ascent === null || descent === null) {
return [];
}
return [
`ascent-override: ${ascent}%;`,
`descent-override: ${descent}%;`,
"line-gap-override: 0%;"
];
}
/** Whether a computed override is a number a `@font-face` can carry. */
function isOverride(value: number) {
return Number.isFinite(value) && value >= 0 && value <= MAX_OVERRIDE;
}
/** One override as a percentage, or `null` where the manifest's value cannot be used. */
function overridePercentage(value: number | undefined) {
if (typeof value !== "number" || !isOverride(value)) {
return null;
}
return Number((value * 100).toFixed(2));
}
/** Six decimals of the em, which is finer than any screen resolves. */
function roundFraction(value: number) {
return Number(value.toFixed(6));
}
function median(values: number[]) {
const sorted = [ ...values ].sort((first, second) => first - second);
const middle = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0
? (sorted[middle - 1] + sorted[middle]) / 2
: sorted[middle];
}

View File

@ -1,4 +1,8 @@
{
"metrics": {
"ascent": 0.9375,
"descent": 0.0625
},
"icons": {
"bx-child": {
"glyph": "",

View File

@ -164,6 +164,39 @@ describe("CSS generation", () => {
expect(css).toContain(`.bx.bx-ball::before { content: "\ue9c2"; }`);
expect(css).toContain(`.bx.bxs-party::before { content: "\uec92"; }`);
});
it("declares the box a pack was measured in, and nothing where it was not", () => {
const measured = cssForManifest({ ...manifest, metrics: { ascent: 0.875, descent: 0.125 } });
expect(measured).toContain("ascent-override: 87.5%;");
expect(measured).toContain("descent-override: 12.5%;");
expect(measured).toContain("line-gap-override: 0%;");
const unmeasured = cssForManifest(manifest);
expect(unmeasured).toContain("@font-face");
expect(unmeasured).not.toContain("ascent-override");
expect(unmeasured).not.toContain("descent-override");
});
it("drops metrics a pack's author wrote by hand and got wrong", () => {
const evil = "0%; } body { display: none } @font-face { a: 1";
const css = cssForManifest({ ...manifest, metrics: { ascent: evil, descent: 0.1 } as never });
expect(css).not.toContain("ascent-override");
expect(css).not.toContain("display: none");
});
function cssForManifest(iconPackManifest: IconPackManifest) {
const processed = processIconPack(buildNote({
type: "text",
title: "Boxicons v2",
content: JSON.stringify(iconPackManifest),
attachments: [ defaultAttachment ],
"#iconPack": "bx"
}));
expect(processed).toBeTruthy();
return (processed && generateCss(processed, "/api/attachments/x/download")) ?? "";
}
});
describe("Generating CSS for untrusted manifests", () => {

View File

@ -1,4 +1,4 @@
import { IconRegistry } from "@triliumnext/commons";
import { iconFontFaceOverrides, type IconFontMetrics, IconRegistry } from "@triliumnext/commons";
import type BAttachment from "../becca/entities/battachment";
import type BNote from "../becca/entities/bnote";
@ -38,6 +38,12 @@ export interface IconPackManifest {
glyph: string,
terms: string[];
}>;
/**
* Where the pack draws its glyphs, so that a browser centres them on the box they were drawn in
* rather than on the one the font's platform metrics describe. Packs built before this was
* measured carry none, and are left to the browser.
*/
metrics?: IconFontMetrics;
}
export interface ProcessedIconPack {
@ -174,12 +180,17 @@ export function generateCss({ manifest, fontMime, builtin, fontAttachmentId, pre
}
const fontFamily = builtin ? fontAttachmentId : `trilium-icon-pack-${prefix}`;
const fontFace = [
`font-family: '${fontFamily}';`,
"font-weight: normal;",
"font-style: normal;",
`src: url('${fontUrl}') format('${MIME_TO_CSS_FORMAT_MAPPINGS[fontMime]}');`,
...iconFontFaceOverrides(manifest.metrics)
].join("\n ");
return `\
@font-face {
font-family: '${fontFamily}';
font-weight: normal;
font-style: normal;
src: url('${fontUrl}') format('${MIME_TO_CSS_FORMAT_MAPPINGS[fontMime]}');
${fontFace}
}
.${prefix} {