diff --git a/apps/client/src/widgets/collections/board/index.css b/apps/client/src/widgets/collections/board/index.css index d60c65f054..b524fbc0be 100644 --- a/apps/client/src/widgets/collections/board/index.css +++ b/apps/client/src/widgets/collections/board/index.css @@ -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; diff --git a/apps/client/src/widgets/note_icon.css b/apps/client/src/widgets/note_icon.css index 1b232cfb86..3629dd3342 100644 --- a/apps/client/src/widgets/note_icon.css +++ b/apps/client/src/widgets/note_icon.css @@ -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; diff --git a/apps/icon-pack-builder/package.json b/apps/icon-pack-builder/package.json index 0a6aaffff8..08dcdc2c66 100644 --- a/apps/icon-pack-builder/package.json +++ b/apps/icon-pack-builder/package.json @@ -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" } } diff --git a/apps/icon-pack-builder/src/provider.ts b/apps/icon-pack-builder/src/provider.ts index 533d592c53..1a73a11473 100644 --- a/apps/icon-pack-builder/src/provider.ts +++ b/apps/icon-pack-builder/src/provider.ts @@ -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; diff --git a/apps/icon-pack-builder/src/providers/boxicons3.ts b/apps/icon-pack-builder/src/providers/boxicons3.ts index d2f9187d0e..c74a0a4508 100644 --- a/apps/icon-pack-builder/src/providers/boxicons3.ts +++ b/apps/icon-pack-builder/src/providers/boxicons3.ts @@ -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", diff --git a/apps/icon-pack-builder/src/providers/mdi.ts b/apps/icon-pack-builder/src/providers/mdi.ts index 381818d903..7e7567076f 100644 --- a/apps/icon-pack-builder/src/providers/mdi.ts +++ b/apps/icon-pack-builder/src/providers/mdi.ts @@ -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", diff --git a/apps/icon-pack-builder/src/providers/phosphor.ts b/apps/icon-pack-builder/src/providers/phosphor.ts index 5c6a3e225a..923ce6c348 100644 --- a/apps/icon-pack-builder/src/providers/phosphor.ts +++ b/apps/icon-pack-builder/src/providers/phosphor.ts @@ -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, diff --git a/apps/icon-pack-builder/src/utils.ts b/apps/icon-pack-builder/src/utils.ts index b88dc0e6bd..bbd5ce9410 100644 --- a/apps/icon-pack-builder/src/utils.ts +++ b/apps/icon-pack-builder/src/utils.ts @@ -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; +} diff --git a/packages/commons/src/index.ts b/packages/commons/src/index.ts index cf140e0042..6f561454f3 100644 --- a/packages/commons/src/index.ts +++ b/packages/commons/src/index.ts @@ -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"; diff --git a/packages/commons/src/lib/icon_font_metrics.spec.ts b/packages/commons/src/lib/icon_font_metrics.spec.ts new file mode 100644 index 0000000000..49cb6e7bc0 --- /dev/null +++ b/packages/commons/src/lib/icon_font_metrics.spec.ts @@ -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([]); + }); +}); diff --git a/packages/commons/src/lib/icon_font_metrics.ts b/packages/commons/src/lib/icon_font_metrics.ts new file mode 100644 index 0000000000..41e2a2e558 --- /dev/null +++ b/packages/commons/src/lib/icon_font_metrics.ts @@ -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]; +} diff --git a/packages/trilium-core/src/services/icon_pack_boxicons-v2.json b/packages/trilium-core/src/services/icon_pack_boxicons-v2.json index 1ad1fb7926..b85712057e 100644 --- a/packages/trilium-core/src/services/icon_pack_boxicons-v2.json +++ b/packages/trilium-core/src/services/icon_pack_boxicons-v2.json @@ -1,4 +1,8 @@ { + "metrics": { + "ascent": 0.9375, + "descent": 0.0625 + }, "icons": { "bx-child": { "glyph": "", diff --git a/packages/trilium-core/src/services/icon_packs.spec.ts b/packages/trilium-core/src/services/icon_packs.spec.ts index f8c06820b2..a947a31f2f 100644 --- a/packages/trilium-core/src/services/icon_packs.spec.ts +++ b/packages/trilium-core/src/services/icon_packs.spec.ts @@ -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", () => { diff --git a/packages/trilium-core/src/services/icon_packs.ts b/packages/trilium-core/src/services/icon_packs.ts index 48487e11d3..421c451ef9 100644 --- a/packages/trilium-core/src/services/icon_packs.ts +++ b/packages/trilium-core/src/services/icon_packs.ts @@ -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} {