diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index 43b01a6a8..88b1a700e 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -2093,12 +2093,50 @@ const ps = Math.round(currentParagraphSpacing * layoutScale); // When useBookFonts is enabled (default), do not force the reader - // font family onto every element so the book's own fonts (e.g. - // embedded @font-face families) render where the book specifies them. - const bodyStarFontOverride = currentUseBookFonts - ? '' - : `:root:not(.vrtl):not(.vltr) body *:not(svg):not(svg *):not(math):not(math *):not(pre):not(pre *):not(code):not(code *):not(kbd):not(kbd *):not(samp):not(samp *) { + // font family onto html/body with !important: the book's own + // font-family (on html, body, or any element) must win where specified, + // so embedded @font-face families render. The reader font stays as a + // zero-specificity fallback via :where(html). Target only html (not + // body): body then INHERITS html's font-family, so when the book sets + // one on html it propagates to body text instead of body being pinned + // to the reader font by a direct rule. When disabled, force the reader + // font on html/body and every descendant. + // + // The monospace rule swaps sides with the same toggle: with book fonts + // honored it is zero-specificity (a book's own `pre { font-family: ... }` + // — its embedded code font — wins), and when overriding it needs BOTH + // !important AND higher specificity, because specificity still breaks + // ties between important author declarations: a plain `pre, code, kbd` + // at (0,0,1) loses to an authored `body pre { ... !important }` at + // (0,0,2). The guarded `:root ... body :is(...)` sits at (0,2,2). + // Inner code/kbd/samp INSIDE a pre get an explicit `inherit` instead: + // the UA stylesheet declares `code { font-family: monospace }` and a + // direct declaration (even a UA one) always beats inheritance, so + // exclusion alone would still cut `
` off from the book's
+ // font declared on pre. The zero-specificity inherit restores the
+ // chain while any author declaration on the element still wins it.
+ // Standalone inline code keeps the monospace fallback.
+ // Vertical writing (.vrtl/.vltr) keeps its own behavior: the forced
+ // overrides carry the horizontal guard like the pre-existing rules.
+ const readerFontOverride = currentUseBookFonts
+ ? `:where(html) {
+ font-family: var(--readany-font-family);
+}
+:where(pre, :not(pre) > code, :not(pre) > kbd, :not(pre) > samp) {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+}
+:where(pre :is(code, kbd, samp)) {
+ font-family: inherit;
+}`
+ : `:root:not(.vrtl):not(.vltr),
+:root:not(.vrtl):not(.vltr) body {
+ font-family: var(--readany-font-family) !important;
+}
+:root:not(.vrtl):not(.vltr) body *:not(svg):not(svg *):not(math):not(math *):not(pre):not(pre *):not(code):not(code *):not(kbd):not(kbd *):not(samp):not(samp *) {
font-family: var(--readany-font-family) !important;
+}
+:root:not(.vrtl):not(.vltr) body :is(pre, code, kbd, samp) {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important;
}`;
const baseStyles = `
@@ -2107,12 +2145,11 @@
}
:root:not(.vrtl):not(.vltr),
:root:not(.vrtl):not(.vltr) body {
- font-family: var(--readany-font-family) !important;
font-size: ${fSize}px !important;
-webkit-text-size-adjust: none;
text-size-adjust: none;
}
- ${bodyStarFontOverride}
+ ${readerFontOverride}
:root:not(.vrtl):not(.vltr) body *:not(svg):not(svg *):not(math):not(math *):not(pre):not(pre *):not(code):not(code *):not(kbd):not(kbd *):not(samp):not(samp *):not(rt):not(rp) {
font-size: ${fSize}px !important;
}
@@ -2145,9 +2182,6 @@
:root:not(.vrtl):not(.vltr) h6 {
line-height: ${lh} !important;
}
- pre, code, kbd, samp {
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important;
- }
:root:not(.vrtl):not(.vltr) p {
margin-top: ${ps}px !important;
margin-bottom: ${ps}px !important;
diff --git a/packages/app-expo/assets/reader/reader.template.html b/packages/app-expo/assets/reader/reader.template.html
index 87e38df3e..0c0d1795a 100644
--- a/packages/app-expo/assets/reader/reader.template.html
+++ b/packages/app-expo/assets/reader/reader.template.html
@@ -1918,12 +1918,50 @@
const ps = Math.round(currentParagraphSpacing * layoutScale);
// When useBookFonts is enabled (default), do not force the reader
- // font family onto every element so the book's own fonts (e.g.
- // embedded @font-face families) render where the book specifies them.
- const bodyStarFontOverride = currentUseBookFonts
- ? ''
- : `:root:not(.vrtl):not(.vltr) body *:not(svg):not(svg *):not(math):not(math *):not(pre):not(pre *):not(code):not(code *):not(kbd):not(kbd *):not(samp):not(samp *) {
+ // font family onto html/body with !important: the book's own
+ // font-family (on html, body, or any element) must win where specified,
+ // so embedded @font-face families render. The reader font stays as a
+ // zero-specificity fallback via :where(html). Target only html (not
+ // body): body then INHERITS html's font-family, so when the book sets
+ // one on html it propagates to body text instead of body being pinned
+ // to the reader font by a direct rule. When disabled, force the reader
+ // font on html/body and every descendant.
+ //
+ // The monospace rule swaps sides with the same toggle: with book fonts
+ // honored it is zero-specificity (a book's own `pre { font-family: ... }`
+ // — its embedded code font — wins), and when overriding it needs BOTH
+ // !important AND higher specificity, because specificity still breaks
+ // ties between important author declarations: a plain `pre, code, kbd`
+ // at (0,0,1) loses to an authored `body pre { ... !important }` at
+ // (0,0,2). The guarded `:root ... body :is(...)` sits at (0,2,2).
+ // Inner code/kbd/samp INSIDE a pre get an explicit `inherit` instead:
+ // the UA stylesheet declares `code { font-family: monospace }` and a
+ // direct declaration (even a UA one) always beats inheritance, so
+ // exclusion alone would still cut `` off from the book's
+ // font declared on pre. The zero-specificity inherit restores the
+ // chain while any author declaration on the element still wins it.
+ // Standalone inline code keeps the monospace fallback.
+ // Vertical writing (.vrtl/.vltr) keeps its own behavior: the forced
+ // overrides carry the horizontal guard like the pre-existing rules.
+ const readerFontOverride = currentUseBookFonts
+ ? `:where(html) {
+ font-family: var(--readany-font-family);
+}
+:where(pre, :not(pre) > code, :not(pre) > kbd, :not(pre) > samp) {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+}
+:where(pre :is(code, kbd, samp)) {
+ font-family: inherit;
+}`
+ : `:root:not(.vrtl):not(.vltr),
+:root:not(.vrtl):not(.vltr) body {
+ font-family: var(--readany-font-family) !important;
+}
+:root:not(.vrtl):not(.vltr) body *:not(svg):not(svg *):not(math):not(math *):not(pre):not(pre *):not(code):not(code *):not(kbd):not(kbd *):not(samp):not(samp *) {
font-family: var(--readany-font-family) !important;
+}
+:root:not(.vrtl):not(.vltr) body :is(pre, code, kbd, samp) {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important;
}`;
const baseStyles = `
@@ -1932,12 +1970,11 @@
}
:root:not(.vrtl):not(.vltr),
:root:not(.vrtl):not(.vltr) body {
- font-family: var(--readany-font-family) !important;
font-size: ${fSize}px !important;
-webkit-text-size-adjust: none;
text-size-adjust: none;
}
- ${bodyStarFontOverride}
+ ${readerFontOverride}
:root:not(.vrtl):not(.vltr) body *:not(svg):not(svg *):not(math):not(math *):not(pre):not(pre *):not(code):not(code *):not(kbd):not(kbd *):not(samp):not(samp *):not(rt):not(rp) {
font-size: ${fSize}px !important;
}
@@ -1970,9 +2007,6 @@
:root:not(.vrtl):not(.vltr) h6 {
line-height: ${lh} !important;
}
- pre, code, kbd, samp {
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important;
- }
:root:not(.vrtl):not(.vltr) p {
margin-top: ${ps}px !important;
margin-bottom: ${ps}px !important;
diff --git a/packages/app-expo/src/screens/reader/usebook-fonts-contract.test.ts b/packages/app-expo/src/screens/reader/usebook-fonts-contract.test.ts
new file mode 100644
index 000000000..9459ff4d3
--- /dev/null
+++ b/packages/app-expo/src/screens/reader/usebook-fonts-contract.test.ts
@@ -0,0 +1,77 @@
+import { readFileSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+
+const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../../../..");
+
+function readSource(relativePath: string): string {
+ return readFileSync(resolve(repositoryRoot, relativePath), "utf8");
+}
+
+// The useBookFonts cascade recipe exists in three places that must not drift:
+// the mobile template, its generated artifact, and the desktop generator.
+// The behavior hinges on subtle specificity/!important interactions
+// (zero-specificity :where() fallback vs. (0,0,3)/(0,2,2) forced override),
+// so the contract pins the rule SHAPES per toggle state.
+describe("useBookFonts font cascade contract", () => {
+ const template = readSource("packages/app-expo/assets/reader/reader.template.html");
+ const generated = readSource("packages/app-expo/assets/reader/reader.html");
+ const desktop = readSource("packages/app/src/components/reader/FoliateViewer.tsx");
+
+ it("honors book fonts by default: zero-specificity :where() fallbacks, no !important", () => {
+ for (const [name, source] of [
+ ["template", template],
+ ["generated", generated],
+ ["desktop", desktop],
+ ] as const) {
+ expect(source, name).toMatch(/:where\(html\)\s*\{\s*font-family: var\(--readany-font-family\);/);
+ // Inner code/kbd/samp inside a pre are excluded so they keep inheriting
+ // the book's font from pre instead of getting a direct declaration.
+ expect(source, name).toMatch(
+ /:where\(pre, :not\(pre\) > code, :not\(pre\) > kbd, :not\(pre\) > samp\)\s*\{\s*font-family: ui-monospace[^}]*;/,
+ );
+ // The UA stylesheet declares `code { font-family: monospace }` and any
+ // direct declaration beats inheritance — this zero-specificity inherit
+ // restores the `` chain to the book's font on pre.
+ expect(source, name).toMatch(
+ /:where\(pre :is\(code, kbd, samp\)\)\s*\{\s*font-family: inherit;/,
+ );
+ }
+ // The honoring branch must not carry !important anywhere.
+ expect(template).toMatch(/currentUseBookFonts\s*\?\s*`:where\(html\)[^`]*`/);
+ expect(template).not.toMatch(/:where\(html\)\s*\{[^}]*!important/);
+ expect(desktop).not.toMatch(/:where\(pre[^)]*\)\s*\{[^}]*!important/);
+ });
+
+ it("forces the reader font when the toggle is off, above authored !important rules", () => {
+ // Desktop: html body :is(...) at (0,0,3) — outranks an authored
+ // `body pre { ... !important }` at (0,0,2), which a bare `pre, code, kbd`
+ // at (0,0,1) would lose to despite its own !important.
+ expect(desktop).toMatch(
+ /html body :is\(pre, code, kbd, samp\)\s*\{\s*font-family: ui-monospace[^}]*!important/,
+ );
+ // Mobile: the same idea guarded to horizontal writing so the vertical
+ // branch keeps its pre-existing behavior (no descendant forcing).
+ expect(template).toMatch(
+ /:root:not\(\.vrtl\):not\(\.vltr\),\s*\n\s*:root:not\(\.vrtl\):not\(\.vltr\) body\s*\{\s*font-family: var\(--readany-font-family\) !important/,
+ );
+ expect(template).toMatch(
+ /:root:not\(\.vrtl\):not\(\.vltr\) body :is\(pre, code, kbd, samp\)\s*\{\s*font-family: ui-monospace[^}]*!important/,
+ );
+ // The forced body-star rule keeps the horizontal guard too.
+ expect(template).toMatch(
+ /:root:not\(\.vrtl\):not\(\.vltr\) body \*:not\(svg\)[^{]*\{\s*font-family: var\(--readany-font-family\) !important/,
+ );
+ // And the generated artifact carries the same shapes.
+ expect(generated).toMatch(/:root:not\(\.vrtl\):not\(\.vltr\) body :is\(pre, code, kbd, samp\)/);
+ expect(generated).toMatch(/:where\(pre, :not\(pre\) > code/);
+ });
+
+ it("emits exactly one unconditional monospace rule in the desktop output", () => {
+ // The legacy unconditional `pre, code, kbd, samp { ... !important }` must
+ // stay gone from the desktop generator — its reintroduction would silently
+ // override book code fonts again.
+ expect(desktop).not.toMatch(/(?` is the dominant code markup);
+ // standalone inline code still gets the fallback.
const readerFontOverride =
settings.useBookFonts === false
? `html, body {
@@ -3482,10 +3494,19 @@ function getRendererStyles(settings: ViewSettings, theme: AppTheme): string {
body *:not(svg):not(svg *):not(math):not(math *):not(pre):not(pre *):not(code):not(code *):not(kbd):not(kbd *):not(samp):not(samp *) {
font-family: var(--readany-font-family) !important;
}
+html body :is(pre, code, kbd, samp) {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important;
+}
`
: `:where(html) {
font-family: var(--readany-font-family);
}
+:where(pre, :not(pre) > code, :not(pre) > kbd, :not(pre) > samp) {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+}
+:where(pre :is(code, kbd, samp)) {
+ font-family: inherit;
+}
`;
return `${settings.customFontFaceCSS ? `/* Custom font faces */\n${settings.customFontFaceCSS}\n\n` : ""}/* Font styles */
@@ -3510,10 +3531,6 @@ body :not(#__readany_font_size_override):not(svg):not(svg *):not(math):not(math
font-size: ${settings.fontSize}px !important;
}
-pre, code, kbd, samp {
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important;
-}
-
/* Line height for text blocks */
p, div, blockquote, dd, li, span {
line-height: ${settings.lineHeight} !important;