mirror of
https://github.com/zadam/trilium.git
synced 2026-09-12 19:50:20 +05:00
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:
parent
ae6eb1e61a
commit
02cf0a116a
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
61
packages/commons/src/lib/icon_font_metrics.spec.ts
Normal file
61
packages/commons/src/lib/icon_font_metrics.spec.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
96
packages/commons/src/lib/icon_font_metrics.ts
Normal file
96
packages/commons/src/lib/icon_font_metrics.ts
Normal 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];
|
||||
}
|
||||
@ -1,4 +1,8 @@
|
||||
{
|
||||
"metrics": {
|
||||
"ascent": 0.9375,
|
||||
"descent": 0.0625
|
||||
},
|
||||
"icons": {
|
||||
"bx-child": {
|
||||
"glyph": "",
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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} {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user