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.
- package/LICENSE +21 -0
- package/README.md +277 -0
- package/dist/core/book.d.ts +75 -0
- package/dist/core/book.d.ts.map +1 -0
- package/dist/core/book.js +2 -0
- package/dist/core/book.js.map +1 -0
- package/dist/core/errors.d.ts +17 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +29 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/format.d.ts +13 -0
- package/dist/core/format.d.ts.map +1 -0
- package/dist/core/format.js +2 -0
- package/dist/core/format.js.map +1 -0
- package/dist/core/graphemes.d.ts +36 -0
- package/dist/core/graphemes.d.ts.map +1 -0
- package/dist/core/graphemes.js +62 -0
- package/dist/core/graphemes.js.map +1 -0
- package/dist/core/index.d.ts +11 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +6 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/lookup.d.ts +10 -0
- package/dist/core/lookup.d.ts.map +1 -0
- package/dist/core/lookup.js +16 -0
- package/dist/core/lookup.js.map +1 -0
- package/dist/core/open.d.ts +12 -0
- package/dist/core/open.d.ts.map +1 -0
- package/dist/core/open.js +23 -0
- package/dist/core/open.js.map +1 -0
- package/dist/core/position.d.ts +121 -0
- package/dist/core/position.d.ts.map +1 -0
- package/dist/core/position.js +327 -0
- package/dist/core/position.js.map +1 -0
- package/dist/core/reading-text.d.ts +77 -0
- package/dist/core/reading-text.d.ts.map +1 -0
- package/dist/core/reading-text.js +144 -0
- package/dist/core/reading-text.js.map +1 -0
- package/dist/core/source.d.ts +17 -0
- package/dist/core/source.d.ts.map +1 -0
- package/dist/core/source.js +39 -0
- package/dist/core/source.js.map +1 -0
- package/dist/core/storage.d.ts +7 -0
- package/dist/core/storage.d.ts.map +1 -0
- package/dist/core/storage.js +2 -0
- package/dist/core/storage.js.map +1 -0
- package/dist/core/text.d.ts +18 -0
- package/dist/core/text.d.ts.map +1 -0
- package/dist/core/text.js +32 -0
- package/dist/core/text.js.map +1 -0
- package/dist/formats/epub/href.d.ts +14 -0
- package/dist/formats/epub/href.d.ts.map +1 -0
- package/dist/formats/epub/href.js +52 -0
- package/dist/formats/epub/href.js.map +1 -0
- package/dist/formats/epub/index.d.ts +3 -0
- package/dist/formats/epub/index.d.ts.map +1 -0
- package/dist/formats/epub/index.js +223 -0
- package/dist/formats/epub/index.js.map +1 -0
- package/dist/formats/epub/opf.d.ts +27 -0
- package/dist/formats/epub/opf.d.ts.map +1 -0
- package/dist/formats/epub/opf.js +110 -0
- package/dist/formats/epub/opf.js.map +1 -0
- package/dist/formats/epub/toc.d.ts +6 -0
- package/dist/formats/epub/toc.d.ts.map +1 -0
- package/dist/formats/epub/toc.js +88 -0
- package/dist/formats/epub/toc.js.map +1 -0
- package/dist/formats/fb2/index.d.ts +13 -0
- package/dist/formats/fb2/index.d.ts.map +1 -0
- package/dist/formats/fb2/index.js +317 -0
- package/dist/formats/fb2/index.js.map +1 -0
- package/dist/formats/index.d.ts +4 -0
- package/dist/formats/index.d.ts.map +1 -0
- package/dist/formats/index.js +4 -0
- package/dist/formats/index.js.map +1 -0
- package/dist/formats/text/index.d.ts +11 -0
- package/dist/formats/text/index.d.ts.map +1 -0
- package/dist/formats/text/index.js +201 -0
- package/dist/formats/text/index.js.map +1 -0
- package/dist/formats/xml.d.ts +33 -0
- package/dist/formats/xml.d.ts.map +1 -0
- package/dist/formats/xml.js +321 -0
- package/dist/formats/xml.js.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/layout/chunk.d.ts +51 -0
- package/dist/layout/chunk.d.ts.map +1 -0
- package/dist/layout/chunk.js +131 -0
- package/dist/layout/chunk.js.map +1 -0
- package/dist/layout/index.d.ts +238 -0
- package/dist/layout/index.d.ts.map +1 -0
- package/dist/layout/index.js +476 -0
- package/dist/layout/index.js.map +1 -0
- package/dist/reader/index.d.ts +231 -0
- package/dist/reader/index.d.ts.map +1 -0
- package/dist/reader/index.js +958 -0
- package/dist/reader/index.js.map +1 -0
- package/dist/reader/input.d.ts +69 -0
- package/dist/reader/input.d.ts.map +1 -0
- package/dist/reader/input.js +75 -0
- package/dist/reader/input.js.map +1 -0
- package/dist/search/extract.d.ts +51 -0
- package/dist/search/extract.d.ts.map +1 -0
- package/dist/search/extract.js +401 -0
- package/dist/search/extract.js.map +1 -0
- package/dist/search/index.d.ts +11 -0
- package/dist/search/index.d.ts.map +1 -0
- package/dist/search/index.js +11 -0
- package/dist/search/index.js.map +1 -0
- package/dist/search/matcher.d.ts +56 -0
- package/dist/search/matcher.d.ts.map +1 -0
- package/dist/search/matcher.js +128 -0
- package/dist/search/matcher.js.map +1 -0
- package/dist/search/normalize.d.ts +48 -0
- package/dist/search/normalize.d.ts.map +1 -0
- package/dist/search/normalize.js +103 -0
- package/dist/search/normalize.js.map +1 -0
- package/dist/view/allowlist.d.ts +59 -0
- package/dist/view/allowlist.d.ts.map +1 -0
- package/dist/view/allowlist.js +198 -0
- package/dist/view/allowlist.js.map +1 -0
- package/dist/view/appearance.d.ts +137 -0
- package/dist/view/appearance.d.ts.map +1 -0
- package/dist/view/appearance.js +274 -0
- package/dist/view/appearance.js.map +1 -0
- package/dist/view/css.d.ts +24 -0
- package/dist/view/css.d.ts.map +1 -0
- package/dist/view/css.js +153 -0
- package/dist/view/css.js.map +1 -0
- package/dist/view/frame.d.ts +54 -0
- package/dist/view/frame.d.ts.map +1 -0
- package/dist/view/frame.js +980 -0
- package/dist/view/frame.js.map +1 -0
- package/dist/view/host.d.ts +160 -0
- package/dist/view/host.d.ts.map +1 -0
- package/dist/view/host.js +385 -0
- package/dist/view/host.js.map +1 -0
- package/dist/view/index.d.ts +12 -0
- package/dist/view/index.d.ts.map +1 -0
- package/dist/view/index.js +7 -0
- package/dist/view/index.js.map +1 -0
- package/dist/view/protocol.d.ts +220 -0
- package/dist/view/protocol.d.ts.map +1 -0
- package/dist/view/protocol.js +246 -0
- package/dist/view/protocol.js.map +1 -0
- package/dist/view/reference.d.ts +18 -0
- package/dist/view/reference.d.ts.map +1 -0
- package/dist/view/reference.js +25 -0
- package/dist/view/reference.js.map +1 -0
- package/dist/view/resources.d.ts +51 -0
- package/dist/view/resources.d.ts.map +1 -0
- package/dist/view/resources.js +242 -0
- package/dist/view/resources.js.map +1 -0
- package/dist/view/sanitize.d.ts +32 -0
- package/dist/view/sanitize.d.ts.map +1 -0
- package/dist/view/sanitize.js +226 -0
- package/dist/view/sanitize.js.map +1 -0
- package/dist/zip/central.d.ts +12 -0
- package/dist/zip/central.d.ts.map +1 -0
- package/dist/zip/central.js +72 -0
- package/dist/zip/central.js.map +1 -0
- package/dist/zip/crc32.d.ts +3 -0
- package/dist/zip/crc32.d.ts.map +1 -0
- package/dist/zip/crc32.js +28 -0
- package/dist/zip/crc32.js.map +1 -0
- package/dist/zip/eocd.d.ts +9 -0
- package/dist/zip/eocd.d.ts.map +1 -0
- package/dist/zip/eocd.js +84 -0
- package/dist/zip/eocd.js.map +1 -0
- package/dist/zip/errors.d.ts +19 -0
- package/dist/zip/errors.d.ts.map +1 -0
- package/dist/zip/errors.js +37 -0
- package/dist/zip/errors.js.map +1 -0
- package/dist/zip/index.d.ts +18 -0
- package/dist/zip/index.d.ts.map +1 -0
- package/dist/zip/index.js +78 -0
- package/dist/zip/index.js.map +1 -0
- package/dist/zip/inflate.d.ts +2 -0
- package/dist/zip/inflate.d.ts.map +1 -0
- package/dist/zip/inflate.js +43 -0
- package/dist/zip/inflate.js.map +1 -0
- package/dist/zip/source.d.ts +15 -0
- package/dist/zip/source.d.ts.map +1 -0
- package/dist/zip/source.js +34 -0
- package/dist/zip/source.js.map +1 -0
- package/package.json +75 -0
- package/src/core/book.ts +80 -0
- package/src/core/errors.ts +31 -0
- package/src/core/format.ts +14 -0
- package/src/core/graphemes.ts +73 -0
- package/src/core/index.ts +27 -0
- package/src/core/lookup.ts +16 -0
- package/src/core/open.ts +31 -0
- package/src/core/position.ts +485 -0
- package/src/core/reading-text.ts +200 -0
- package/src/core/source.ts +61 -0
- package/src/core/storage.ts +6 -0
- package/src/core/text.ts +35 -0
- package/src/formats/epub/href.ts +54 -0
- package/src/formats/epub/index.ts +235 -0
- package/src/formats/epub/opf.ts +143 -0
- package/src/formats/epub/toc.ts +120 -0
- package/src/formats/fb2/index.ts +348 -0
- package/src/formats/index.ts +3 -0
- package/src/formats/text/index.ts +208 -0
- package/src/formats/xml.ts +326 -0
- package/src/index.ts +26 -0
- package/src/layout/chunk.ts +169 -0
- package/src/layout/index.ts +573 -0
- package/src/reader/index.ts +1206 -0
- package/src/reader/input.ts +122 -0
- package/src/search/extract.ts +403 -0
- package/src/search/index.ts +10 -0
- package/src/search/matcher.ts +187 -0
- package/src/search/normalize.ts +122 -0
- package/src/view/allowlist.ts +273 -0
- package/src/view/appearance.ts +380 -0
- package/src/view/css.ts +172 -0
- package/src/view/frame.ts +1018 -0
- package/src/view/host.ts +527 -0
- package/src/view/index.ts +24 -0
- package/src/view/protocol.ts +412 -0
- package/src/view/reference.ts +30 -0
- package/src/view/resources.ts +265 -0
- package/src/view/sanitize.ts +266 -0
- package/src/zip/central.ts +85 -0
- package/src/zip/crc32.ts +30 -0
- package/src/zip/eocd.ts +97 -0
- package/src/zip/errors.ts +41 -0
- package/src/zip/index.ts +114 -0
- package/src/zip/inflate.ts +41 -0
- 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
|
+
}
|
package/src/view/css.ts
ADDED
|
@@ -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
|
+
}
|