feat(reader): let readers pick the font style for body text

Read variable-font axes and named instances so readers can select real weight and style variants instead of synthesized faces. Preserve the axis default, accept the full CSS weight range, and keep saved choices stable while font catalogs load.

Require the new settings fields for complete EPUB defaults, keep per-book patches partial, and widen the persisted font-weight constraints to match CSS.

Closes #533
This commit is contained in:
neon
2026-08-22 05:51:17 +00:00
parent 3a095efcbb
commit 7668f46c42
38 changed files with 34608 additions and 200 deletions
+59 -7
View File
@@ -9,13 +9,41 @@ export type FontScope = "user" | "server";
export const FONT_SCOPES: readonly FontScope[] = ["user", "server"];
/** One selectable style within a family: a weight paired with an upright/italic axis. */
export interface FontVariant {
weight: number;
style: FontStyle;
}
/**
* A style a variable font advertises through its `fvar` named instances, carrying the
* designer's own name for it ("SemiBold Italic") rather than one derived from the weight.
*/
export interface FontNamedInstance extends FontVariant {
/**
* The designer's own name for the instance. Null when the font did not name it, which
* leaves the reader to label it from the weight in the reader's own language.
*/
name: string | null;
}
export interface UserFont {
id: number;
familyName: string;
originalFileName: string;
format: FontFormat;
/** The face this file renders by default. For a variable font, its default instance. */
weight: number;
style: FontStyle;
/**
* Bounds of the file's `wght` variation axis, or null for a static face. A variable
* font declares these in `@font-face` so every weight in the range renders from the
* real axis instead of being synthesised from the default instance.
*/
weightMin: number | null;
weightMax: number | null;
/** Styles a variable file advertises by name. Null for a static face. */
instances: FontNamedInstance[] | null;
fileSize: number;
createdAt: string;
}
@@ -68,6 +96,13 @@ export function maxFontsForScope(scope: FontScope): number {
export const FONT_WEIGHTS = [100, 200, 300, 400, 500, 600, 700, 800, 900] as const;
export const CSS_FONT_WEIGHT_MIN = 1;
export const CSS_FONT_WEIGHT_MAX = 1000;
export function isCssFontWeight(value: unknown): value is number {
return typeof value === "number" && Number.isInteger(value) && value >= CSS_FONT_WEIGHT_MIN && value <= CSS_FONT_WEIGHT_MAX;
}
export const FONT_FAMILY_NAME_MAX_LENGTH = 200;
/**
@@ -95,10 +130,6 @@ export const FONT_CSS_FAMILY_PREFIXES: Record<FontScope, string> = {
server: "__serverfont_",
};
export function fontCssFamilyName(fontId: number): string {
return `${FONT_CSS_FAMILY_PREFIXES.user}${fontId}`;
}
/**
* Returns a stable, CSS-safe font-family name shared by all variants of a family.
* Using one name per family (differentiated by font-weight/font-style) lets the
@@ -118,9 +149,7 @@ export function fontScopeFromCssFamily(cssFamilyName: string | null | undefined)
if (!cssFamilyName) return null;
// Checked longest-prefix-first so the scopes stay distinguishable if a future
// prefix is ever added that extends another.
const byLength = [...FONT_SCOPES].sort(
(a, b) => FONT_CSS_FAMILY_PREFIXES[b].length - FONT_CSS_FAMILY_PREFIXES[a].length,
);
const byLength = [...FONT_SCOPES].sort((a, b) => FONT_CSS_FAMILY_PREFIXES[b].length - FONT_CSS_FAMILY_PREFIXES[a].length);
return byLength.find((scope) => cssFamilyName.startsWith(FONT_CSS_FAMILY_PREFIXES[scope])) ?? null;
}
@@ -128,3 +157,26 @@ export function fontScopeFromCssFamily(cssFamilyName: string | null | undefined)
export function isCustomFontCssFamily(cssFamilyName: string | null | undefined): boolean {
return fontScopeFromCssFamily(cssFamilyName) !== null;
}
/** True when the file carries a `wght` variation axis rather than a single static weight. */
export function isVariableFont(font: Pick<UserFont, "weightMin" | "weightMax">): boolean {
return font.weightMin !== null && font.weightMax !== null && font.weightMax > font.weightMin;
}
/**
* The styles a built-in font stack offers. Unlike an uploaded family there is no file
* list to read, so the reader offers the four the browser can always produce from a
* system stack.
*/
export const BUILT_IN_FONT_VARIANTS: readonly FontVariant[] = [
{ weight: 400, style: "normal" },
{ weight: 700, style: "normal" },
{ weight: 400, style: "italic" },
{ weight: 700, style: "italic" },
];
/** Orders variants the way a font picker reads: upright before italic, light before heavy. */
export function compareFontVariants(a: FontVariant, b: FontVariant): number {
if (a.style !== b.style) return a.style === "normal" ? -1 : 1;
return a.weight - b.weight;
}
+8
View File
@@ -1,3 +1,5 @@
import type { FontStyle } from "./font";
export type ReaderFormatGroup = "epub" | "pdf" | "cbx" | "audio";
export const EPUB_FONT_SIZE_MIN = 6;
@@ -56,6 +58,10 @@ export interface EpubReaderSettings {
themeName: string; // matches one of the reader's built-in theme names
isDark: boolean;
fontFamily: string | null; // null = use the book's embedded font
// Base style for body text. The book's own bold and italic runs still resolve relative
// to this, so a bold base leaves emphasis rendered at the same weight.
fontWeight: number; // CSS font-weight: integer from 1-1000
fontStyle: FontStyle;
fontSize: number; // EPUB_FONT_SIZE_MIN-EPUB_FONT_SIZE_MAX
lineHeight: number; // 0.8-3.0
maxColumnCount: number; // 1-10
@@ -114,6 +120,8 @@ export const EPUB_READER_DEFAULTS: EpubReaderSettings = {
themeName: "default",
isDark: false,
fontFamily: null,
fontWeight: 400,
fontStyle: "normal",
fontSize: 16,
lineHeight: 1.5,
maxColumnCount: 2,