wolfy-reader 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +277 -0
  3. package/dist/core/book.d.ts +75 -0
  4. package/dist/core/book.d.ts.map +1 -0
  5. package/dist/core/book.js +2 -0
  6. package/dist/core/book.js.map +1 -0
  7. package/dist/core/errors.d.ts +17 -0
  8. package/dist/core/errors.d.ts.map +1 -0
  9. package/dist/core/errors.js +29 -0
  10. package/dist/core/errors.js.map +1 -0
  11. package/dist/core/format.d.ts +13 -0
  12. package/dist/core/format.d.ts.map +1 -0
  13. package/dist/core/format.js +2 -0
  14. package/dist/core/format.js.map +1 -0
  15. package/dist/core/graphemes.d.ts +36 -0
  16. package/dist/core/graphemes.d.ts.map +1 -0
  17. package/dist/core/graphemes.js +62 -0
  18. package/dist/core/graphemes.js.map +1 -0
  19. package/dist/core/index.d.ts +11 -0
  20. package/dist/core/index.d.ts.map +1 -0
  21. package/dist/core/index.js +6 -0
  22. package/dist/core/index.js.map +1 -0
  23. package/dist/core/lookup.d.ts +10 -0
  24. package/dist/core/lookup.d.ts.map +1 -0
  25. package/dist/core/lookup.js +16 -0
  26. package/dist/core/lookup.js.map +1 -0
  27. package/dist/core/open.d.ts +12 -0
  28. package/dist/core/open.d.ts.map +1 -0
  29. package/dist/core/open.js +23 -0
  30. package/dist/core/open.js.map +1 -0
  31. package/dist/core/position.d.ts +121 -0
  32. package/dist/core/position.d.ts.map +1 -0
  33. package/dist/core/position.js +327 -0
  34. package/dist/core/position.js.map +1 -0
  35. package/dist/core/reading-text.d.ts +77 -0
  36. package/dist/core/reading-text.d.ts.map +1 -0
  37. package/dist/core/reading-text.js +144 -0
  38. package/dist/core/reading-text.js.map +1 -0
  39. package/dist/core/source.d.ts +17 -0
  40. package/dist/core/source.d.ts.map +1 -0
  41. package/dist/core/source.js +39 -0
  42. package/dist/core/source.js.map +1 -0
  43. package/dist/core/storage.d.ts +7 -0
  44. package/dist/core/storage.d.ts.map +1 -0
  45. package/dist/core/storage.js +2 -0
  46. package/dist/core/storage.js.map +1 -0
  47. package/dist/core/text.d.ts +18 -0
  48. package/dist/core/text.d.ts.map +1 -0
  49. package/dist/core/text.js +32 -0
  50. package/dist/core/text.js.map +1 -0
  51. package/dist/formats/epub/href.d.ts +14 -0
  52. package/dist/formats/epub/href.d.ts.map +1 -0
  53. package/dist/formats/epub/href.js +52 -0
  54. package/dist/formats/epub/href.js.map +1 -0
  55. package/dist/formats/epub/index.d.ts +3 -0
  56. package/dist/formats/epub/index.d.ts.map +1 -0
  57. package/dist/formats/epub/index.js +223 -0
  58. package/dist/formats/epub/index.js.map +1 -0
  59. package/dist/formats/epub/opf.d.ts +27 -0
  60. package/dist/formats/epub/opf.d.ts.map +1 -0
  61. package/dist/formats/epub/opf.js +110 -0
  62. package/dist/formats/epub/opf.js.map +1 -0
  63. package/dist/formats/epub/toc.d.ts +6 -0
  64. package/dist/formats/epub/toc.d.ts.map +1 -0
  65. package/dist/formats/epub/toc.js +88 -0
  66. package/dist/formats/epub/toc.js.map +1 -0
  67. package/dist/formats/fb2/index.d.ts +13 -0
  68. package/dist/formats/fb2/index.d.ts.map +1 -0
  69. package/dist/formats/fb2/index.js +317 -0
  70. package/dist/formats/fb2/index.js.map +1 -0
  71. package/dist/formats/index.d.ts +4 -0
  72. package/dist/formats/index.d.ts.map +1 -0
  73. package/dist/formats/index.js +4 -0
  74. package/dist/formats/index.js.map +1 -0
  75. package/dist/formats/text/index.d.ts +11 -0
  76. package/dist/formats/text/index.d.ts.map +1 -0
  77. package/dist/formats/text/index.js +201 -0
  78. package/dist/formats/text/index.js.map +1 -0
  79. package/dist/formats/xml.d.ts +33 -0
  80. package/dist/formats/xml.d.ts.map +1 -0
  81. package/dist/formats/xml.js +321 -0
  82. package/dist/formats/xml.js.map +1 -0
  83. package/dist/index.d.ts +27 -0
  84. package/dist/index.d.ts.map +1 -0
  85. package/dist/index.js +28 -0
  86. package/dist/index.js.map +1 -0
  87. package/dist/layout/chunk.d.ts +51 -0
  88. package/dist/layout/chunk.d.ts.map +1 -0
  89. package/dist/layout/chunk.js +131 -0
  90. package/dist/layout/chunk.js.map +1 -0
  91. package/dist/layout/index.d.ts +238 -0
  92. package/dist/layout/index.d.ts.map +1 -0
  93. package/dist/layout/index.js +476 -0
  94. package/dist/layout/index.js.map +1 -0
  95. package/dist/reader/index.d.ts +231 -0
  96. package/dist/reader/index.d.ts.map +1 -0
  97. package/dist/reader/index.js +958 -0
  98. package/dist/reader/index.js.map +1 -0
  99. package/dist/reader/input.d.ts +69 -0
  100. package/dist/reader/input.d.ts.map +1 -0
  101. package/dist/reader/input.js +75 -0
  102. package/dist/reader/input.js.map +1 -0
  103. package/dist/search/extract.d.ts +51 -0
  104. package/dist/search/extract.d.ts.map +1 -0
  105. package/dist/search/extract.js +401 -0
  106. package/dist/search/extract.js.map +1 -0
  107. package/dist/search/index.d.ts +11 -0
  108. package/dist/search/index.d.ts.map +1 -0
  109. package/dist/search/index.js +11 -0
  110. package/dist/search/index.js.map +1 -0
  111. package/dist/search/matcher.d.ts +56 -0
  112. package/dist/search/matcher.d.ts.map +1 -0
  113. package/dist/search/matcher.js +128 -0
  114. package/dist/search/matcher.js.map +1 -0
  115. package/dist/search/normalize.d.ts +48 -0
  116. package/dist/search/normalize.d.ts.map +1 -0
  117. package/dist/search/normalize.js +103 -0
  118. package/dist/search/normalize.js.map +1 -0
  119. package/dist/view/allowlist.d.ts +59 -0
  120. package/dist/view/allowlist.d.ts.map +1 -0
  121. package/dist/view/allowlist.js +198 -0
  122. package/dist/view/allowlist.js.map +1 -0
  123. package/dist/view/appearance.d.ts +137 -0
  124. package/dist/view/appearance.d.ts.map +1 -0
  125. package/dist/view/appearance.js +274 -0
  126. package/dist/view/appearance.js.map +1 -0
  127. package/dist/view/css.d.ts +24 -0
  128. package/dist/view/css.d.ts.map +1 -0
  129. package/dist/view/css.js +153 -0
  130. package/dist/view/css.js.map +1 -0
  131. package/dist/view/frame.d.ts +54 -0
  132. package/dist/view/frame.d.ts.map +1 -0
  133. package/dist/view/frame.js +980 -0
  134. package/dist/view/frame.js.map +1 -0
  135. package/dist/view/host.d.ts +160 -0
  136. package/dist/view/host.d.ts.map +1 -0
  137. package/dist/view/host.js +385 -0
  138. package/dist/view/host.js.map +1 -0
  139. package/dist/view/index.d.ts +12 -0
  140. package/dist/view/index.d.ts.map +1 -0
  141. package/dist/view/index.js +7 -0
  142. package/dist/view/index.js.map +1 -0
  143. package/dist/view/protocol.d.ts +220 -0
  144. package/dist/view/protocol.d.ts.map +1 -0
  145. package/dist/view/protocol.js +246 -0
  146. package/dist/view/protocol.js.map +1 -0
  147. package/dist/view/reference.d.ts +18 -0
  148. package/dist/view/reference.d.ts.map +1 -0
  149. package/dist/view/reference.js +25 -0
  150. package/dist/view/reference.js.map +1 -0
  151. package/dist/view/resources.d.ts +51 -0
  152. package/dist/view/resources.d.ts.map +1 -0
  153. package/dist/view/resources.js +242 -0
  154. package/dist/view/resources.js.map +1 -0
  155. package/dist/view/sanitize.d.ts +32 -0
  156. package/dist/view/sanitize.d.ts.map +1 -0
  157. package/dist/view/sanitize.js +226 -0
  158. package/dist/view/sanitize.js.map +1 -0
  159. package/dist/zip/central.d.ts +12 -0
  160. package/dist/zip/central.d.ts.map +1 -0
  161. package/dist/zip/central.js +72 -0
  162. package/dist/zip/central.js.map +1 -0
  163. package/dist/zip/crc32.d.ts +3 -0
  164. package/dist/zip/crc32.d.ts.map +1 -0
  165. package/dist/zip/crc32.js +28 -0
  166. package/dist/zip/crc32.js.map +1 -0
  167. package/dist/zip/eocd.d.ts +9 -0
  168. package/dist/zip/eocd.d.ts.map +1 -0
  169. package/dist/zip/eocd.js +84 -0
  170. package/dist/zip/eocd.js.map +1 -0
  171. package/dist/zip/errors.d.ts +19 -0
  172. package/dist/zip/errors.d.ts.map +1 -0
  173. package/dist/zip/errors.js +37 -0
  174. package/dist/zip/errors.js.map +1 -0
  175. package/dist/zip/index.d.ts +18 -0
  176. package/dist/zip/index.d.ts.map +1 -0
  177. package/dist/zip/index.js +78 -0
  178. package/dist/zip/index.js.map +1 -0
  179. package/dist/zip/inflate.d.ts +2 -0
  180. package/dist/zip/inflate.d.ts.map +1 -0
  181. package/dist/zip/inflate.js +43 -0
  182. package/dist/zip/inflate.js.map +1 -0
  183. package/dist/zip/source.d.ts +15 -0
  184. package/dist/zip/source.d.ts.map +1 -0
  185. package/dist/zip/source.js +34 -0
  186. package/dist/zip/source.js.map +1 -0
  187. package/package.json +75 -0
  188. package/src/core/book.ts +80 -0
  189. package/src/core/errors.ts +31 -0
  190. package/src/core/format.ts +14 -0
  191. package/src/core/graphemes.ts +73 -0
  192. package/src/core/index.ts +27 -0
  193. package/src/core/lookup.ts +16 -0
  194. package/src/core/open.ts +31 -0
  195. package/src/core/position.ts +485 -0
  196. package/src/core/reading-text.ts +200 -0
  197. package/src/core/source.ts +61 -0
  198. package/src/core/storage.ts +6 -0
  199. package/src/core/text.ts +35 -0
  200. package/src/formats/epub/href.ts +54 -0
  201. package/src/formats/epub/index.ts +235 -0
  202. package/src/formats/epub/opf.ts +143 -0
  203. package/src/formats/epub/toc.ts +120 -0
  204. package/src/formats/fb2/index.ts +348 -0
  205. package/src/formats/index.ts +3 -0
  206. package/src/formats/text/index.ts +208 -0
  207. package/src/formats/xml.ts +326 -0
  208. package/src/index.ts +26 -0
  209. package/src/layout/chunk.ts +169 -0
  210. package/src/layout/index.ts +573 -0
  211. package/src/reader/index.ts +1206 -0
  212. package/src/reader/input.ts +122 -0
  213. package/src/search/extract.ts +403 -0
  214. package/src/search/index.ts +10 -0
  215. package/src/search/matcher.ts +187 -0
  216. package/src/search/normalize.ts +122 -0
  217. package/src/view/allowlist.ts +273 -0
  218. package/src/view/appearance.ts +380 -0
  219. package/src/view/css.ts +172 -0
  220. package/src/view/frame.ts +1018 -0
  221. package/src/view/host.ts +527 -0
  222. package/src/view/index.ts +24 -0
  223. package/src/view/protocol.ts +412 -0
  224. package/src/view/reference.ts +30 -0
  225. package/src/view/resources.ts +265 -0
  226. package/src/view/sanitize.ts +266 -0
  227. package/src/zip/central.ts +85 -0
  228. package/src/zip/crc32.ts +30 -0
  229. package/src/zip/eocd.ts +97 -0
  230. package/src/zip/errors.ts +41 -0
  231. package/src/zip/index.ts +114 -0
  232. package/src/zip/inflate.ts +41 -0
  233. package/src/zip/source.ts +51 -0
@@ -0,0 +1,380 @@
1
+ /**
2
+ * The appearance system's theme half: the `--wr-*` CSS custom-property contract,
3
+ * the built-in themes, and the frame stylesheet that delivers them so publisher
4
+ * CSS can restyle its own content but can never reach the theme variables.
5
+ *
6
+ * This module is pure and DOM-free. It maps an {@link Appearance} to a variable
7
+ * set and emits the `<style>` the frame injects at document-assembly time — no
8
+ * `document`, no `window`, no protocol traffic. The frame-injection seam lives in
9
+ * `frame.ts`; the reader facade owns the live appearance state.
10
+ *
11
+ * The typography half (font family, size, line height, alignment, hyphenation,
12
+ * columns) adds more `--wr-*` names to the same namespace and rides the same
13
+ * cascade. The reflowing knobs (font, size, line height, columns) change chunk
14
+ * geometry, so the reader re-lays out and restores the reading position for them
15
+ * rather than repainting in place the way a colour change does.
16
+ */
17
+ import { CONTENT_ROOT_ID } from './frame.ts';
18
+
19
+ /** A built-in theme name, or `'custom'` for a caller-supplied variable set. */
20
+ export type ThemeName = 'light' | 'dark' | 'sepia' | 'custom';
21
+
22
+ /** Text alignment for body prose: publisher default, ragged left, or justified. */
23
+ export type TextAlign = 'start' | 'justify';
24
+
25
+ /**
26
+ * The live appearance state: the theme half (content colours) plus the typography
27
+ * half (font, size, line height, margin, alignment, hyphenation, columns). Both
28
+ * flow through the same `--wr-*` namespace and the same cascade, so publisher CSS
29
+ * can restyle its own content but can never reach an appearance variable.
30
+ * `customProperties` supplies bespoke `--wr-*` values that merge over the resolved
31
+ * theme (they win), so a `'custom'` theme is any override set and the built-in
32
+ * themes are equally overridable.
33
+ */
34
+ export interface Appearance {
35
+ /**
36
+ * Which theme drives content colours. Omitted (or `undefined`) means "do not
37
+ * force a theme" — the frame follows `prefers-color-scheme`. A named theme
38
+ * overrides the media query.
39
+ */
40
+ readonly theme?: ThemeName;
41
+ /**
42
+ * Caller-supplied `--wr-*` custom properties, merged over the resolved theme.
43
+ * Keys may be given with or without the leading `--`. Values are emitted
44
+ * verbatim into an inline `<style>` (CSP allows `style-src 'unsafe-inline'`),
45
+ * so a caller controls exactly what its own book renders.
46
+ */
47
+ readonly customProperties?: Readonly<Record<string, string>>;
48
+ /** Body font family (a CSS `font-family` value). Reflows. */
49
+ readonly fontFamily?: string;
50
+ /** Body font size in CSS px. Reflows. */
51
+ readonly fontSize?: number;
52
+ /** Body line height, a unitless multiplier. Reflows. */
53
+ readonly lineHeight?: number;
54
+ /**
55
+ * Page margin in CSS px — the whitespace framing the text on both page edges,
56
+ * which also serves as the gutter between columns (a page reads
57
+ * margin/col/margin/col/margin). It is not a `--wr-*` variable: page geometry
58
+ * belongs to the paginator, so it rides `PaginateOptions` (as `columnGap`) and
59
+ * reflows the layout. Reflows.
60
+ */
61
+ readonly margin?: number;
62
+ /** Text alignment: `'start'` (publisher default) or `'justify'`. May reflow. */
63
+ readonly textAlign?: TextAlign;
64
+ /** Shorthand for `textAlign: 'justify'` when `true`, `'start'` when `false`. */
65
+ readonly justify?: boolean;
66
+ /** Whether the content root hyphenates. May reflow line breaks. */
67
+ readonly hyphenate?: boolean;
68
+ /** Number of text columns per page: 1 or 2. Reflows. */
69
+ readonly columns?: 1 | 2;
70
+ }
71
+
72
+ /**
73
+ * The theme portion of the `--wr-*` contract. These names are the public,
74
+ * frozen-ish vocabulary a custom theme supplies; content colours flow through
75
+ * them so a theme change repaints without touching layout.
76
+ */
77
+ export interface ThemeVariables {
78
+ /** Reading surface background. */
79
+ readonly '--wr-background': string;
80
+ /** Body text colour. */
81
+ readonly '--wr-color': string;
82
+ /** Hyperlink colour. */
83
+ readonly '--wr-link-color': string;
84
+ /** Selection background. */
85
+ readonly '--wr-selection-background': string;
86
+ /** Selection text colour. */
87
+ readonly '--wr-selection-color': string;
88
+ }
89
+
90
+ /**
91
+ * The built-in themes. Each is a full {@link ThemeVariables} set so a forced
92
+ * theme is self-contained — it needs no media query to be complete.
93
+ */
94
+ export const THEMES: Readonly<Record<Exclude<ThemeName, 'custom'>, ThemeVariables>> = {
95
+ light: {
96
+ '--wr-background': '#ffffff',
97
+ '--wr-color': '#1a1a1a',
98
+ '--wr-link-color': '#1a5fb4',
99
+ '--wr-selection-background': '#b4d5fe',
100
+ '--wr-selection-color': '#1a1a1a',
101
+ },
102
+ dark: {
103
+ '--wr-background': '#121212',
104
+ '--wr-color': '#e6e6e6',
105
+ '--wr-link-color': '#8ab4f8',
106
+ '--wr-selection-background': '#2f5fb0',
107
+ '--wr-selection-color': '#ffffff',
108
+ },
109
+ sepia: {
110
+ '--wr-background': '#f4ecd8',
111
+ '--wr-color': '#5b4636',
112
+ '--wr-link-color': '#8a5a1a',
113
+ '--wr-selection-background': '#e0cfa8',
114
+ '--wr-selection-color': '#5b4636',
115
+ },
116
+ } as const;
117
+
118
+ /**
119
+ * The typography portion of the `--wr-*` contract. Each maps a live appearance
120
+ * knob to a custom property the content stylesheet consumes; publisher CSS never
121
+ * names these, so it cannot clobber them, exactly like the theme variables. All
122
+ * are optional: an unset knob leaves the publisher's own value in place.
123
+ */
124
+ export interface TypographyVariables {
125
+ /** Body font family. */
126
+ readonly '--wr-font-family'?: string;
127
+ /** Body font size (a CSS length, e.g. `18px`). */
128
+ readonly '--wr-font-size'?: string;
129
+ /** Body line height (unitless multiplier). */
130
+ readonly '--wr-line-height'?: string;
131
+ /** Body text alignment. */
132
+ readonly '--wr-text-align'?: string;
133
+ /** Content-root hyphenation (`auto` or `manual`). */
134
+ readonly '--wr-hyphens'?: string;
135
+ /** Number of text columns per page. */
136
+ readonly '--wr-column-count'?: string;
137
+ }
138
+
139
+ /** The default theme when the host does not force one and the OS is in light mode. */
140
+ const DEFAULT_LIGHT: Record<string, string> = { ...THEMES.light };
141
+ /** The default theme when the host does not force one and the OS is in dark mode. */
142
+ const DEFAULT_DARK: Record<string, string> = { ...THEMES.dark };
143
+
144
+ /** The cascade layer the theme reset lives in, so publisher rules outrank it. */
145
+ const THEME_LAYER = 'wolfy-reader-theme';
146
+
147
+ /** Normalize a custom-property key to its `--`-prefixed form. */
148
+ function normalizeVarName(name: string): string {
149
+ return name.startsWith('--') ? name : `--${name}`;
150
+ }
151
+
152
+ /**
153
+ * Merge two partial `Appearance`s. `update` wins field-by-field; `customProperties`
154
+ * shallow-merges (an update's keys override the base's, and only the base's other
155
+ * keys survive). Passing `theme: undefined` in `update` does not clear the base
156
+ * theme — omit the key to keep it, since `exactOptionalPropertyTypes` distinguishes
157
+ * absent from `undefined`.
158
+ */
159
+ export function mergeAppearance(base: Appearance, update: Appearance): Appearance {
160
+ const merged: {
161
+ theme?: ThemeName;
162
+ customProperties?: Record<string, string>;
163
+ fontFamily?: string;
164
+ fontSize?: number;
165
+ lineHeight?: number;
166
+ margin?: number;
167
+ textAlign?: TextAlign;
168
+ justify?: boolean;
169
+ hyphenate?: boolean;
170
+ columns?: 1 | 2;
171
+ } = {};
172
+ const theme = 'theme' in update ? update.theme : base.theme;
173
+ if (theme !== undefined) merged.theme = theme;
174
+ const custom = { ...(base.customProperties ?? {}), ...(update.customProperties ?? {}) };
175
+ if (Object.keys(custom).length > 0) merged.customProperties = custom;
176
+ const fontFamily = 'fontFamily' in update ? update.fontFamily : base.fontFamily;
177
+ if (fontFamily !== undefined) merged.fontFamily = fontFamily;
178
+ const fontSize = 'fontSize' in update ? update.fontSize : base.fontSize;
179
+ if (fontSize !== undefined) merged.fontSize = fontSize;
180
+ const lineHeight = 'lineHeight' in update ? update.lineHeight : base.lineHeight;
181
+ if (lineHeight !== undefined) merged.lineHeight = lineHeight;
182
+ const margin = 'margin' in update ? update.margin : base.margin;
183
+ if (margin !== undefined) merged.margin = margin;
184
+ const textAlign = 'textAlign' in update ? update.textAlign : base.textAlign;
185
+ if (textAlign !== undefined) merged.textAlign = textAlign;
186
+ const justify = 'justify' in update ? update.justify : base.justify;
187
+ if (justify !== undefined) merged.justify = justify;
188
+ const hyphenate = 'hyphenate' in update ? update.hyphenate : base.hyphenate;
189
+ if (hyphenate !== undefined) merged.hyphenate = hyphenate;
190
+ const columns = 'columns' in update ? update.columns : base.columns;
191
+ if (columns !== undefined) merged.columns = columns;
192
+ return merged;
193
+ }
194
+
195
+ /**
196
+ * The resolved `--wr-*` variables for a forced theme, or `null` when no theme is
197
+ * forced (the frame follows `prefers-color-scheme` instead). Custom properties
198
+ * merge over the theme's base and win; for `theme: 'custom'` the base is the light
199
+ * theme so an incomplete custom set still yields a complete, readable variable set.
200
+ */
201
+ export function resolveThemeProperties(appearance: Appearance): Record<string, string> | null {
202
+ const theme = appearance.theme;
203
+ const custom = appearance.customProperties;
204
+ if (theme === undefined && custom === undefined) return null;
205
+ const base: Record<string, string> =
206
+ theme === undefined || theme === 'custom'
207
+ ? { ...(theme === 'custom' ? DEFAULT_LIGHT : {}) }
208
+ : { ...THEMES[theme] };
209
+ // With no theme and only custom properties, seed from the light theme so a
210
+ // partial custom set is still complete; a forced-null default (below) covers
211
+ // the truly-unset case.
212
+ if (theme === undefined && custom !== undefined) Object.assign(base, DEFAULT_LIGHT);
213
+ if (custom !== undefined) {
214
+ for (const [name, value] of Object.entries(custom)) base[normalizeVarName(name)] = value;
215
+ }
216
+ return base;
217
+ }
218
+
219
+ /**
220
+ * The resolved typography `--wr-*` variables for an appearance, or `null` when no
221
+ * typography knob is set. `columns` maps to `--wr-column-count`; `justify` folds
222
+ * into `--wr-text-align` (an explicit `textAlign` wins over `justify`). Only the
223
+ * knobs the caller supplied are emitted — an unset knob leaves the publisher's own
224
+ * value in place rather than forcing a default.
225
+ */
226
+ export function resolveTypographyProperties(
227
+ appearance: Appearance,
228
+ ): TypographyVariables | null {
229
+ const vars: Record<string, string> = {};
230
+ if (appearance.fontFamily !== undefined) vars['--wr-font-family'] = appearance.fontFamily;
231
+ if (appearance.fontSize !== undefined) vars['--wr-font-size'] = `${appearance.fontSize}px`;
232
+ if (appearance.lineHeight !== undefined) vars['--wr-line-height'] = String(appearance.lineHeight);
233
+ const align = resolveTextAlign(appearance);
234
+ if (align !== undefined) vars['--wr-text-align'] = align;
235
+ if (appearance.hyphenate !== undefined) {
236
+ vars['--wr-hyphens'] = appearance.hyphenate ? 'auto' : 'manual';
237
+ }
238
+ if (appearance.columns !== undefined) vars['--wr-column-count'] = String(appearance.columns);
239
+ return Object.keys(vars).length === 0 ? null : (vars as TypographyVariables);
240
+ }
241
+
242
+ /** The effective text alignment: an explicit `textAlign` wins over `justify`. */
243
+ function resolveTextAlign(appearance: Appearance): string | undefined {
244
+ if (appearance.textAlign !== undefined) return appearance.textAlign;
245
+ if (appearance.justify !== undefined) return appearance.justify ? 'justify' : 'start';
246
+ return undefined;
247
+ }
248
+
249
+ /** Serialize a variable set into `--name: value;` declarations. */
250
+ function declarations(vars: Readonly<Record<string, string>>): string {
251
+ return Object.entries(vars)
252
+ .map(([name, value]) => `${name}:${value}`)
253
+ .join(';');
254
+ }
255
+
256
+ /** The `:root` variable block for a set of resolved theme variables. */
257
+ function forcedRoot(vars: Record<string, string>): string {
258
+ return `:root{${declarations(vars)}}`;
259
+ }
260
+
261
+ /**
262
+ * The `prefers-color-scheme`-driven default variables, used when the host does
263
+ * not force a theme. Light values sit at `:root`; a `@media (prefers-color-scheme:
264
+ * dark)` block swaps them, so the frame picks up the OS mode on its own opaque
265
+ * origin — the resolution stays inside the frame rather than being sniffed by the
266
+ * host. A forced theme's explicit `:root` block (emitted after this) overrides it.
267
+ */
268
+ function preferenceDefaults(): string {
269
+ return (
270
+ `:root{${declarations(DEFAULT_LIGHT)}}` +
271
+ `@media (prefers-color-scheme: dark){:root{${declarations(DEFAULT_DARK)}}}`
272
+ );
273
+ }
274
+
275
+ /**
276
+ * The reading surface — the outermost background and base text colour, anchored
277
+ * on the content root. This belongs to the reader, not the book, so it is set
278
+ * unlayered and `!important` at id specificity: an id selector plus `!important`
279
+ * outranks even a publisher `*{…!important}` or `body{…!important}`, so a hostile
280
+ * book cannot repaint the surface out from under the theme. It only pins the
281
+ * content root's *own* background/colour; descendants a book styles (`p{color}`)
282
+ * still win for themselves, because that is a direct match on the descendant.
283
+ */
284
+ function surfaceGuarantee(): string {
285
+ return (
286
+ `#${CONTENT_ROOT_ID}{` +
287
+ `background:var(--wr-background) !important;` +
288
+ `color:var(--wr-color) !important` +
289
+ `}`
290
+ );
291
+ }
292
+
293
+ /**
294
+ * The themeable defaults that consume the `--wr-*` variables: the html/body
295
+ * fallback surface, link colour and selection colours. These live in a cascade
296
+ * layer, so an unlayered publisher rule of any specificity outranks them *for the
297
+ * properties it sets* — a book restyling its own links or background wins — while
298
+ * the `--wr-*` variables themselves are unreachable by publisher CSS (they are
299
+ * custom-property names the book does not know). An unstyled book still themes
300
+ * fully, because nothing overrides the layer.
301
+ */
302
+ function themeableDefaults(): string {
303
+ return (
304
+ `@layer ${THEME_LAYER}{` +
305
+ `html{background:var(--wr-background);color:var(--wr-color)}` +
306
+ `body{background:var(--wr-background);color:var(--wr-color)}` +
307
+ `a{color:var(--wr-link-color)}` +
308
+ `::selection{background:var(--wr-selection-background);color:var(--wr-selection-color)}` +
309
+ `}`
310
+ );
311
+ }
312
+
313
+ /**
314
+ * The unlayered typography block: the reader's own font, size, line height,
315
+ * alignment and hyphenation, anchored on the content root and set `!important` at
316
+ * id specificity — the same cascade device as {@link surfaceGuarantee}. An id
317
+ * selector plus `!important` outranks even a hostile publisher `*{…!important}`,
318
+ * so a user's typography choice wins the reading surface; a descendant the book
319
+ * styles directly (`p{font-size:20px}`) still wins for itself, which is the
320
+ * intended split. Only the knobs actually set emit a declaration, and each reads
321
+ * its own `--wr-*` variable (unreachable by publisher CSS). `column-count` is not
322
+ * here — column geometry is per-chunk and applied frame-side by the paginator.
323
+ */
324
+ function typographyGuarantee(vars: TypographyVariables): string {
325
+ const rules: string[] = [];
326
+ if (vars['--wr-font-family'] !== undefined) rules.push('font-family:var(--wr-font-family) !important');
327
+ if (vars['--wr-font-size'] !== undefined) rules.push('font-size:var(--wr-font-size) !important');
328
+ if (vars['--wr-line-height'] !== undefined) rules.push('line-height:var(--wr-line-height) !important');
329
+ if (vars['--wr-text-align'] !== undefined) rules.push('text-align:var(--wr-text-align) !important');
330
+ if (vars['--wr-hyphens'] !== undefined) {
331
+ rules.push('-webkit-hyphens:var(--wr-hyphens) !important', 'hyphens:var(--wr-hyphens) !important');
332
+ }
333
+ if (rules.length === 0) return '';
334
+ return `:root{${declarations(vars as Record<string, string>)}}#${CONTENT_ROOT_ID}{${rules.join(';')}}`;
335
+ }
336
+
337
+ /**
338
+ * The full appearance stylesheet the frame injects at document assembly, before
339
+ * the publisher's `headHtml`. It carries the theme half (colours) and the
340
+ * typography half (font/size/line-height/alignment/hyphenation) through one
341
+ * `--wr-*` namespace. It declares the theme layer first (so its ordering is fixed
342
+ * regardless of any `@layer` a book declares later), emits the themeable defaults
343
+ * and the unlayered surface guarantee, then the `prefers-color-scheme` defaults,
344
+ * then — if a theme is forced — an unlayered `:root` block that overrides the
345
+ * media query, and finally the unlayered typography guarantee. `null` from
346
+ * {@link resolveThemeProperties} means "follow the OS"; a variable set means
347
+ * "force it".
348
+ */
349
+ export function themeStyleSheet(appearance: Appearance): string {
350
+ const forced = resolveThemeProperties(appearance);
351
+ const typography = resolveTypographyProperties(appearance);
352
+ return (
353
+ `@layer ${THEME_LAYER};` +
354
+ themeableDefaults() +
355
+ surfaceGuarantee() +
356
+ preferenceDefaults() +
357
+ (forced === null ? '' : forcedRoot(forced)) +
358
+ (typography === null ? '' : typographyGuarantee(typography))
359
+ );
360
+ }
361
+
362
+ /**
363
+ * The typography knobs that change chunk geometry and therefore require a re-layout
364
+ * (a `paginate`), not just a variable poke: font family, font size, line height,
365
+ * page margin, and column count. Alignment and hyphenation can shift line breaks
366
+ * too, so they are treated as reflowing to be safe. Whether an `update` touches any
367
+ * of these decides which path {@link Reader.setAppearance} takes.
368
+ */
369
+ export function isReflowingUpdate(update: Appearance): boolean {
370
+ return (
371
+ update.fontFamily !== undefined ||
372
+ update.fontSize !== undefined ||
373
+ update.lineHeight !== undefined ||
374
+ update.margin !== undefined ||
375
+ update.textAlign !== undefined ||
376
+ update.justify !== undefined ||
377
+ update.hyphenate !== undefined ||
378
+ update.columns !== undefined
379
+ );
380
+ }
@@ -0,0 +1,172 @@
1
+ export type CssReferenceKind = 'import' | 'url';
2
+
3
+ export interface CssReference {
4
+ readonly kind: CssReferenceKind;
5
+ /** The reference as written, with quotes and escapes removed. */
6
+ readonly value: string;
7
+ /** Index of the first character of the replaceable token. */
8
+ readonly start: number;
9
+ /** Index one past the last character of the replaceable token. */
10
+ readonly end: number;
11
+ }
12
+
13
+ interface Token {
14
+ readonly value: string;
15
+ readonly end: number;
16
+ }
17
+
18
+ const IDENT_CHAR = /[A-Za-z0-9_\-\\\u0080-\uFFFF]/;
19
+
20
+ function isIdentChar(character: string): boolean {
21
+ return character !== '' && IDENT_CHAR.test(character);
22
+ }
23
+
24
+ function matchesAt(css: string, index: number, literal: string): boolean {
25
+ return css.slice(index, index + literal.length).toLowerCase() === literal;
26
+ }
27
+
28
+ function skipComment(css: string, index: number): number {
29
+ const close = css.indexOf('*/', index + 2);
30
+ return close === -1 ? css.length : close + 2;
31
+ }
32
+
33
+ function skipSpace(css: string, index: number): number {
34
+ let i = index;
35
+ while (i < css.length) {
36
+ const character = css.charAt(i);
37
+ if (character === '/' && css.charAt(i + 1) === '*') {
38
+ i = skipComment(css, i);
39
+ continue;
40
+ }
41
+ if (character === ' ' || character === '\t' || character === '\n' || character === '\r' || character === '\f') {
42
+ i += 1;
43
+ continue;
44
+ }
45
+ return i;
46
+ }
47
+ return i;
48
+ }
49
+
50
+ function readString(css: string, index: number): Token | null {
51
+ const quote = css.charAt(index);
52
+ let i = index + 1;
53
+ let value = '';
54
+ while (i < css.length) {
55
+ const character = css.charAt(i);
56
+ if (character === '\\') {
57
+ if (css.charAt(i + 1) !== '\n') value += css.charAt(i + 1);
58
+ i += 2;
59
+ continue;
60
+ }
61
+ if (character === quote) return { value, end: i + 1 };
62
+ if (character === '\n') return null;
63
+ value += character;
64
+ i += 1;
65
+ }
66
+ return null;
67
+ }
68
+
69
+ function readUrl(css: string, index: number): Token | null {
70
+ let i = skipSpace(css, index + 4);
71
+ let value: string;
72
+ const quote = css.charAt(i);
73
+ if (quote === '"' || quote === "'") {
74
+ const string = readString(css, i);
75
+ if (string === null) return null;
76
+ value = string.value;
77
+ i = skipSpace(css, string.end);
78
+ } else {
79
+ let raw = '';
80
+ while (i < css.length && css.charAt(i) !== ')') {
81
+ if (css.charAt(i) === '\\') {
82
+ raw += css.charAt(i + 1);
83
+ i += 2;
84
+ continue;
85
+ }
86
+ raw += css.charAt(i);
87
+ i += 1;
88
+ }
89
+ value = raw.trim();
90
+ }
91
+ if (css.charAt(i) !== ')') return null;
92
+ return { value, end: i + 1 };
93
+ }
94
+
95
+ function readReference(css: string, index: number): Token | null {
96
+ const character = css.charAt(index);
97
+ if (character === '"' || character === "'") return readString(css, index);
98
+ if (matchesAt(css, index, 'url(')) return readUrl(css, index);
99
+ return null;
100
+ }
101
+
102
+ /**
103
+ * Finds every `url(...)` and `@import` target in a stylesheet.
104
+ *
105
+ * This is a token-level scan that understands strings and comments, not a CSS
106
+ * parser: those two constructs are the only ones a renderer has to rewrite, and
107
+ * a conforming parser is orders of magnitude more code for no extra coverage.
108
+ */
109
+ export function findCssReferences(css: string): CssReference[] {
110
+ const references: CssReference[] = [];
111
+ let i = 0;
112
+ while (i < css.length) {
113
+ const character = css.charAt(i);
114
+ if (character === '/' && css.charAt(i + 1) === '*') {
115
+ i = skipComment(css, i);
116
+ continue;
117
+ }
118
+ if (character === '"' || character === "'") {
119
+ const string = readString(css, i);
120
+ i = string === null ? i + 1 : string.end;
121
+ continue;
122
+ }
123
+ if (character === '@' && matchesAt(css, i, '@import') && !isIdentChar(css.charAt(i + 7))) {
124
+ const start = skipSpace(css, i + 7);
125
+ const token = readReference(css, start);
126
+ if (token === null) {
127
+ i += 7;
128
+ continue;
129
+ }
130
+ references.push({ kind: 'import', value: token.value, start, end: token.end });
131
+ i = token.end;
132
+ continue;
133
+ }
134
+ if (matchesAt(css, i, 'url(') && !isIdentChar(css.charAt(i - 1))) {
135
+ const token = readUrl(css, i);
136
+ if (token === null) {
137
+ i += 1;
138
+ continue;
139
+ }
140
+ references.push({ kind: 'url', value: token.value, start: i, end: token.end });
141
+ i = token.end;
142
+ continue;
143
+ }
144
+ i += 1;
145
+ }
146
+ return references;
147
+ }
148
+
149
+ function escapeCssUrl(url: string): string {
150
+ return url.replace(/[\\"]/g, '\\$&').replace(/[\n\r]/g, '');
151
+ }
152
+
153
+ /**
154
+ * Rewrites every reference the scan finds. A replacer returning undefined
155
+ * leaves the reference exactly as written.
156
+ */
157
+ export async function rewriteCssReferences(
158
+ css: string,
159
+ replace: (reference: CssReference) => Promise<string | undefined>,
160
+ ): Promise<string> {
161
+ const references = findCssReferences(css);
162
+ if (references.length === 0) return css;
163
+ let out = '';
164
+ let cursor = 0;
165
+ for (const reference of references) {
166
+ const replacement = await replace(reference);
167
+ if (replacement === undefined) continue;
168
+ out += `${css.slice(cursor, reference.start)}url("${escapeCssUrl(replacement)}")`;
169
+ cursor = reference.end;
170
+ }
171
+ return out + css.slice(cursor);
172
+ }