bookish-press 0.8.2 → 0.9.2

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.
@@ -0,0 +1,392 @@
1
+ /**
2
+ * Assembles the documents that make up an EPUB 3 package.
3
+ *
4
+ * Nothing here touches the network or the DOM, so it can be unit tested and is
5
+ * safe to import during server rendering.
6
+ */
7
+ import Reference from '../models/book/Reference';
8
+ import FormatNode from '../models/chapter/FormatNode';
9
+ import Parser from '../models/chapter/Parser';
10
+ import { chapterPath, document_, endnoteSection, escapeAttribute, escapeText, serializeChapter, serializeFormat, } from './xhtml';
11
+ export const MIMETYPE = 'application/epub+zip';
12
+ /** Where the package's documents live inside the archive. */
13
+ export const CONTENT_DIRECTORY = 'OEBPS';
14
+ export const CONTAINER = `<?xml version="1.0" encoding="utf-8"?>
15
+ <container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
16
+ <rootfiles>
17
+ <rootfile full-path="${CONTENT_DIRECTORY}/content.opf" media-type="application/oebps-package+xml"/>
18
+ </rootfiles>
19
+ </container>`;
20
+ /**
21
+ * The packaged stylesheet.
22
+ *
23
+ * Two rules govern it. First, it sets no typeface and no absolute size for
24
+ * prose: e-reader users choose their own, and overriding that is the most
25
+ * common way an EPUB becomes unpleasant to read. (Code is the one exception --
26
+ * code that doesn't look like code is just wrong.) The book's own theme is not
27
+ * carried across either, since it imports fonts an offline reader can't fetch
28
+ * and encodes color e-ink can't show.
29
+ *
30
+ * Second, and learned the hard way on an XTeink X3: it assumes the reading
31
+ * system has *no* default stylesheet. Small readers often have barely any, so
32
+ * anything left unsaid is left unrendered -- headings came out all one size,
33
+ * ordered lists came out bulleted, and a definition list ran together into a
34
+ * paragraph. Every element the serializer can emit is given its display,
35
+ * spacing and emphasis here rather than being assumed. The contract test in
36
+ * stylesheet.test.ts derives that element list from the serializer itself, so
37
+ * a new node type that emits a new element fails until this file covers it.
38
+ */
39
+ export const STYLESHEET = `/* Reading systems with a minimal default stylesheet render unknown elements
40
+ inline, so every block element states that it is one. */
41
+ article, aside, details, div, dl, dd, dt, figcaption, figure, footer,
42
+ header, hgroup, main, nav, section, blockquote, p, h1, h2, h3, h4, h5, h6,
43
+ hr, ol, ul, pre { display: block; }
44
+ li { display: list-item; }
45
+ table { display: table; }
46
+ thead { display: table-header-group; }
47
+ tbody { display: table-row-group; }
48
+ tr { display: table-row; }
49
+ th, td { display: table-cell; }
50
+ caption { display: table-caption; }
51
+
52
+ body { margin: 0 5%; line-height: 1.5; widows: 2; orphans: 2; }
53
+
54
+ /* An explicit scale, in em so the reader's chosen size still governs. Without
55
+ it a reader with no defaults renders every level at body size. */
56
+ h1, h2, h3, h4, h5, h6 {
57
+ font-weight: bold;
58
+ line-height: 1.2;
59
+ margin: 1.2em 0 0.6em;
60
+ page-break-after: avoid;
61
+ break-after: avoid;
62
+ }
63
+ h1 { font-size: 1.6em; margin: 2em 0 1em; }
64
+ h2 { font-size: 1.35em; }
65
+ h3 { font-size: 1.2em; }
66
+ h4 { font-size: 1.1em; }
67
+ h5 { font-size: 1em; }
68
+ h6 { font-size: 0.9em; }
69
+
70
+ p { margin: 0; text-indent: 1.2em; }
71
+ /* The first paragraph of a section is not a continuation, so it isn't indented. */
72
+ p.first, h1 + p, h2 + p, h3 + p, h4 + p, h5 + p, h6 + p,
73
+ blockquote p:first-child, li > p:first-child { text-indent: 0; }
74
+
75
+ strong { font-weight: bold; }
76
+ em { font-style: italic; }
77
+ sup { vertical-align: super; font-size: 0.75em; line-height: 0; }
78
+ sub { vertical-align: sub; font-size: 0.75em; line-height: 0; }
79
+ a { text-decoration: underline; }
80
+
81
+ .chapter-number { display: block; margin-bottom: 0.5em; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.8em; }
82
+ .section-name { display: block; font-style: italic; }
83
+ .authors { display: block; font-style: italic; }
84
+
85
+ figure { margin: 1.5em 0; text-align: center; page-break-inside: avoid; break-inside: avoid; }
86
+ img { max-width: 100%; }
87
+ figcaption { font-size: 0.85em; text-align: left; margin-top: 0.5em; }
88
+ figcaption p { margin: 0; text-indent: 0; }
89
+ .credit { font-style: italic; }
90
+ .missing-image figcaption { text-align: center; font-style: italic; }
91
+
92
+ blockquote { margin: 1.5em 2em; font-style: italic; }
93
+ blockquote p { text-indent: 0; }
94
+ cite { font-style: normal; font-size: 0.85em; }
95
+
96
+ .callout { margin: 1.5em 1em; padding-left: 1em; border-left: 3px solid currentColor; }
97
+ .callout p { text-indent: 0; }
98
+
99
+ /* The one place a family is set: monospace is what distinguishes code from
100
+ prose, and without it the two are indistinguishable. */
101
+ pre, code, pre code, kbd, samp { font-family: monospace; }
102
+ pre { font-size: 0.8em; white-space: pre-wrap; overflow-wrap: break-word; margin: 1.5em 0; }
103
+ code { font-size: 0.9em; }
104
+
105
+ table { border-collapse: collapse; margin: 1.5em auto; font-size: 0.9em; }
106
+ th, td { border: 1px solid currentColor; padding: 0.3em 0.5em; text-align: left; }
107
+ th { font-weight: bold; }
108
+ caption { font-size: 0.85em; margin-bottom: 0.5em; }
109
+ caption p { margin: 0; text-indent: 0; }
110
+
111
+ /* Outside, so a wrapped item hangs with its text aligned rather than tucking
112
+ under the marker. Types are stated because a reader with no defaults numbers
113
+ nothing, and mirror how the web reader nests them. */
114
+ ul, ol { margin: 1em 0; padding-left: 1.5em; list-style-position: outside; }
115
+ ul { list-style-type: disc; }
116
+ ul ul { list-style-type: circle; }
117
+ ul ul ul { list-style-type: square; }
118
+ ol { list-style-type: decimal; }
119
+ ol ol { list-style-type: lower-alpha; }
120
+ ol ol ol { list-style-type: upper-alpha; }
121
+ li { margin-bottom: 0.25em; }
122
+
123
+ hr { border: 0; border-top: 1px solid currentColor; margin: 2em auto; width: 30%; }
124
+
125
+ .citation, .noteref { font-size: 0.75em; }
126
+ .notes { margin-top: 3em; font-size: 0.9em; page-break-before: always; break-before: page; }
127
+ .note { margin-bottom: 0.5em; }
128
+ .note p { text-indent: 0; margin-bottom: 0.5em; }
129
+
130
+ .reference { margin-bottom: 0.8em; }
131
+
132
+ /* The glossary is paragraphs rather than a definition list: see
133
+ glossaryDocument. Terms and meanings are distinguished by class. */
134
+ .definition { margin-bottom: 1em; }
135
+ .term { font-weight: bold; text-indent: 0; margin-top: 1em; }
136
+ .meaning { text-indent: 0; margin-left: 1.5em; }
137
+ .synonyms { font-size: 0.85em; font-style: italic; text-indent: 0; margin: 0.25em 0 0 1.5em; }
138
+ `;
139
+ /** Chapters that get their own document, in reading order. */
140
+ export function includedChapters(edition) {
141
+ // Forthcoming chapters have no text to include, matching the print view.
142
+ return edition.getChapters().filter((chapter) => !chapter.isForthcoming());
143
+ }
144
+ /** Turn a title into something usable as a filename. */
145
+ export function safeFilename(title) {
146
+ const cleaned = title
147
+ .replace(/[^\p{L}\p{N}]+/gu, '-')
148
+ .replace(/^-+|-+$/g, '')
149
+ .toLowerCase();
150
+ return cleaned.length > 0 ? cleaned.slice(0, 80) : 'book';
151
+ }
152
+ /** The plain-text form of a Bookdown string, for metadata. */
153
+ function text(edition, bookdown) {
154
+ return Parser.parseFormat(edition, bookdown).toText().trim();
155
+ }
156
+ /** The title page, which carries the metadata a reader wants up front. */
157
+ export function titleDocument(edition, context) {
158
+ const title = edition.getTitle();
159
+ const authors = edition.getAuthors();
160
+ const description = edition.getDescription();
161
+ const acknowledgements = edition.getAcknowledgements();
162
+ const license = edition.getLicense();
163
+ const body = `<section class="title-page" epub:type="titlepage">
164
+ <h1>${escapeText(title)}</h1>
165
+ ${authors.length > 0
166
+ ? `<p class="authors">${authors
167
+ .map((author) => serializeFormat(Parser.parseFormat(edition, author), context))
168
+ .join(', ')}</p>`
169
+ : ''}
170
+ <p class="edition">${escapeText(edition.getEditionLabel())} edition</p>
171
+ ${description.trim().length > 0
172
+ ? `<p class="description">${serializeFormat(Parser.parseFormat(edition, description), context)}</p>`
173
+ : ''}
174
+ ${acknowledgements.trim().length > 0
175
+ ? `<section class="acknowledgements"><h2>${escapeText(edition.getHeader('acknowledgements'))}</h2><p>${serializeFormat(Parser.parseFormat(edition, acknowledgements), context)}</p></section>`
176
+ : ''}
177
+ ${license.trim().length > 0
178
+ ? `<section class="license"><h2>${escapeText(edition.getHeader('license'))}</h2><p>${serializeFormat(Parser.parseFormat(edition, license), context)}</p></section>`
179
+ : ''}
180
+ </section>`;
181
+ return {
182
+ path: 'title.xhtml',
183
+ id: 'title',
184
+ content: document_(title, body),
185
+ navTitle: 'Title Page',
186
+ };
187
+ }
188
+ /** A page that displays the cover image, which some readers expect. */
189
+ export function coverDocument(imagePath) {
190
+ return {
191
+ path: 'cover.xhtml',
192
+ id: 'cover',
193
+ content: document_('Cover', `<section epub:type="cover" class="cover"><figure><img src="${escapeAttribute(imagePath)}" alt="Cover"/></figure></section>`),
194
+ };
195
+ }
196
+ /** One chapter, with its footnotes gathered into endnotes at the end. */
197
+ export function chapterDocument(edition, chapter, context) {
198
+ const ast = chapter.getAST(edition);
199
+ if (ast === undefined)
200
+ return undefined;
201
+ const { body, endnotes } = serializeChapter(ast, context);
202
+ const number = edition.getChapterNumber(chapter.getID());
203
+ const section = chapter.getSection();
204
+ const authors = chapter.getAuthors();
205
+ const header = `<header>
206
+ ${number !== undefined
207
+ ? `<span class="chapter-number">Chapter ${number}</span>`
208
+ : ''}
209
+ <h1>${escapeText(chapter.getTitle())}</h1>
210
+ ${section ? `<span class="section-name">${escapeText(section)}</span>` : ''}
211
+ ${authors.length > 0
212
+ ? `<p class="authors">${authors
213
+ .map((author) => serializeFormat(Parser.parseFormat(edition, author), context))
214
+ .join(', ')}</p>`
215
+ : ''}
216
+ </header>`;
217
+ return {
218
+ path: chapterPath(chapter.getID()),
219
+ id: `chapter-${chapter.getID()}`,
220
+ content: document_(chapter.getTitle(), `${header}\n${body}\n${endnoteSection(endnotes)}`),
221
+ navTitle: number !== undefined
222
+ ? `${number}. ${chapter.getTitle()}`
223
+ : chapter.getTitle(),
224
+ };
225
+ }
226
+ /** Render a reference the way the web reader's Reference.svelte does. */
227
+ function referenceContent(reference, context) {
228
+ if (reference instanceof FormatNode)
229
+ return serializeFormat(reference, context);
230
+ const title = escapeText(reference.title);
231
+ // The web reader omits the period when the title already ends in one.
232
+ const period = reference.title.trim().endsWith('?') ? '' : '.';
233
+ return `${escapeText(reference.authors)} (${escapeText(reference.year)}). ${reference.url
234
+ ? `<a href="${escapeAttribute(encodeURI(String(reference.url)))}">${title}</a>`
235
+ : title}${period} <em>${escapeText(reference.source)}</em>${reference.summary ? ` ${escapeText(reference.summary)}` : ''}`;
236
+ }
237
+ /** The references back matter, whose anchors the citations link to. */
238
+ export function referencesDocument(edition, context) {
239
+ const references = edition.getReferences();
240
+ const ids = Object.keys(references).sort();
241
+ if (ids.length === 0)
242
+ return undefined;
243
+ const header = edition.getHeader('references');
244
+ const body = `<section epub:type="bibliography">
245
+ <h1>${escapeText(header)}</h1>
246
+ ${ids
247
+ .map((id) => `<p class="reference" id="ref-${escapeAttribute(id)}">${referenceContent(references[id], context)}</p>`)
248
+ .join('\n')}
249
+ </section>`;
250
+ return {
251
+ path: 'references.xhtml',
252
+ id: 'references',
253
+ content: document_(header, body),
254
+ navTitle: header,
255
+ };
256
+ }
257
+ /** The glossary back matter, whose anchors the definitions link to. */
258
+ export function glossaryDocument(edition, context) {
259
+ const glossary = edition.getGlossary();
260
+ const ids = Object.keys(glossary).sort((a, b) => glossary[a].phrase.localeCompare(glossary[b].phrase));
261
+ if (ids.length === 0)
262
+ return undefined;
263
+ const header = edition.getHeader('glossary');
264
+ // Paragraphs rather than a <dl>. Definition lists are poorly supported by
265
+ // e-readers -- a term and its meaning run together into one paragraph on a
266
+ // reader that doesn't know <dt>/<dd> -- and <dt> takes only phrasing
267
+ // content, so there is no way to put a block inside it that would survive.
268
+ // The semantics are carried by the classes instead.
269
+ const body = `<section epub:type="glossary">
270
+ <h1>${escapeText(header)}</h1>
271
+ ${ids
272
+ .map((id) => {
273
+ const entry = glossary[id];
274
+ const synonyms = entry.synonyms ?? [];
275
+ // The web glossary renders no id, so its anchors don't resolve; here
276
+ // they must, because every definition in the text links to one.
277
+ return `<div class="definition">
278
+ <p class="term" id="gloss-${escapeAttribute(id)}"><strong>${escapeText(entry.phrase)}</strong></p>
279
+ <p class="meaning">${serializeFormat(Parser.parseFormat(edition, entry.definition), context)}</p>${synonyms.length > 0
280
+ ? `\n<p class="synonyms">${escapeText(synonyms.join(', '))}</p>`
281
+ : ''}
282
+ </div>`;
283
+ })
284
+ .join('\n')}
285
+ </section>`;
286
+ return {
287
+ path: 'glossary.xhtml',
288
+ id: 'glossary',
289
+ content: document_(header, body),
290
+ navTitle: header,
291
+ };
292
+ }
293
+ /** The EPUB 3 navigation document. */
294
+ export function navigationDocument(documents) {
295
+ const listed = documents.filter((d) => d.navTitle !== undefined);
296
+ const body = `<nav epub:type="toc" id="toc">
297
+ <h1>Contents</h1>
298
+ <ol>
299
+ ${listed
300
+ .map((d) => `<li><a href="${escapeAttribute(d.path)}">${escapeText(d.navTitle)}</a></li>`)
301
+ .join('\n')}
302
+ </ol>
303
+ </nav>`;
304
+ return {
305
+ path: 'nav.xhtml',
306
+ id: 'nav',
307
+ content: document_('Contents', body),
308
+ };
309
+ }
310
+ /**
311
+ * The EPUB 2 navigation document. Superseded by nav.xhtml, but older Kindle and
312
+ * Kobo firmware still looks for it, and it costs a few hundred bytes.
313
+ */
314
+ export function ncxDocument(identifier, title, documents) {
315
+ const listed = documents.filter((d) => d.navTitle !== undefined);
316
+ return `<?xml version="1.0" encoding="utf-8"?>
317
+ <ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
318
+ <head>
319
+ <meta name="dtb:uid" content="${escapeAttribute(identifier)}"/>
320
+ <meta name="dtb:depth" content="1"/>
321
+ <meta name="dtb:totalPageCount" content="0"/>
322
+ <meta name="dtb:maxPageNumber" content="0"/>
323
+ </head>
324
+ <docTitle><text>${escapeText(title)}</text></docTitle>
325
+ <navMap>
326
+ ${listed
327
+ .map((d, index) => `<navPoint id="nav-${index}" playOrder="${index + 1}">
328
+ <navLabel><text>${escapeText(d.navTitle)}</text></navLabel>
329
+ <content src="${escapeAttribute(d.path)}"/>
330
+ </navPoint>`)
331
+ .join('\n')}
332
+ </navMap>
333
+ </ncx>`;
334
+ }
335
+ /** The package document: metadata, manifest, and reading order. */
336
+ export function packageDocument(edition, identifier, documents, images, modified) {
337
+ const cover = images.find((image) => image.cover);
338
+ const authors = edition.getAuthors().map((a) => text(edition, a));
339
+ const published = edition.published;
340
+ const metadata = [
341
+ `<dc:identifier id="pub-id">${escapeText(identifier)}</dc:identifier>`,
342
+ `<dc:title>${escapeText(edition.getTitle())}</dc:title>`,
343
+ // The edition model has no language field, so this is a fixed guess.
344
+ `<dc:language>en</dc:language>`,
345
+ ...authors.map((author, index) => `<dc:creator id="creator-${index}">${escapeText(author)}</dc:creator>`),
346
+ published !== null
347
+ ? `<dc:date>${new Date(published).toISOString()}</dc:date>`
348
+ : '',
349
+ edition.getLicense().trim().length > 0
350
+ ? `<dc:rights>${escapeText(text(edition, edition.getLicense()))}</dc:rights>`
351
+ : '',
352
+ edition.getDescription().trim().length > 0
353
+ ? `<dc:description>${escapeText(text(edition, edition.getDescription()))}</dc:description>`
354
+ : '',
355
+ ...edition
356
+ .getTags()
357
+ .map((tag) => `<dc:subject>${escapeText(tag)}</dc:subject>`),
358
+ // Required by EPUB 3, and must not carry milliseconds.
359
+ `<meta property="dcterms:modified">${modified
360
+ .toISOString()
361
+ .replace(/\.\d{3}Z$/, 'Z')}</meta>`,
362
+ // The legacy way to declare a cover, which EPUB 2 readers look for.
363
+ cover ? `<meta name="cover" content="${cover.id}"/>` : '',
364
+ ]
365
+ .filter((line) => line.length > 0)
366
+ .join('\n');
367
+ const manifest = [
368
+ `<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>`,
369
+ `<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>`,
370
+ `<item id="style" href="style.css" media-type="text/css"/>`,
371
+ ...documents
372
+ .filter((d) => d.id !== 'nav')
373
+ .map((d) => `<item id="${escapeAttribute(d.id)}" href="${escapeAttribute(d.path)}" media-type="application/xhtml+xml"/>`),
374
+ ...images.map((image) => `<item id="${escapeAttribute(image.id)}" href="${escapeAttribute(image.path)}" media-type="${escapeAttribute(image.mediaType)}"${image.cover ? ' properties="cover-image"' : ''}/>`),
375
+ ].join('\n');
376
+ const spine = documents
377
+ .filter((d) => d.id !== 'nav')
378
+ .map((d) => `<itemref idref="${escapeAttribute(d.id)}"/>`)
379
+ .join('\n');
380
+ return `<?xml version="1.0" encoding="utf-8"?>
381
+ <package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="pub-id">
382
+ <metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
383
+ ${metadata}
384
+ </metadata>
385
+ <manifest>
386
+ ${manifest}
387
+ </manifest>
388
+ <spine toc="ncx">
389
+ ${spine}
390
+ </spine>
391
+ </package>`;
392
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,243 @@
1
+ import { expect, test } from 'vitest';
2
+ import Parser from '../models/chapter/Parser';
3
+ import Edition from '../models/book/Edition';
4
+ import { STYLESHEET, chapterDocument, coverDocument, glossaryDocument, navigationDocument, referencesDocument, titleDocument, } from './package';
5
+ import { document_, endnoteSection, serializeChapter, } from './xhtml';
6
+ /**
7
+ * The packaged stylesheet has to assume the reading system supplies nothing.
8
+ * Small e-readers often have almost no default stylesheet, so anything left
9
+ * unsaid is left unrendered: headings came out all one size, ordered lists came
10
+ * out bulleted, and a definition list ran together into one paragraph on an
11
+ * XTeink X3 until this was covered.
12
+ */
13
+ function makeEdition(options = {}) {
14
+ return Edition.fromJSON(undefined, {
15
+ title: 'Test',
16
+ authors: [],
17
+ number: 1,
18
+ summary: '',
19
+ published: null,
20
+ images: {},
21
+ description: '',
22
+ chapters: [
23
+ {
24
+ id: 'sample',
25
+ title: 'Sample',
26
+ authors: ['An Author'],
27
+ image: null,
28
+ numbered: true,
29
+ forthcoming: false,
30
+ section: 'A Section',
31
+ text: EVERYTHING,
32
+ uids: [],
33
+ },
34
+ ],
35
+ license: '',
36
+ acknowledgements: '',
37
+ tags: [],
38
+ sources: {},
39
+ references: options.references ?? {},
40
+ symbols: {},
41
+ glossary: options.glossary ?? {},
42
+ theme: null,
43
+ uids: [],
44
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
45
+ });
46
+ }
47
+ /** Bookdown exercising every construct the serializer can render. */
48
+ const EVERYTHING = `:label A paragraph with *bold*, _italic_, ^sup^, ^vsub^, \`code\`js and a [link|https://example.com].
49
+
50
+ # Header one
51
+
52
+ ## Header two
53
+
54
+ ### Header three
55
+
56
+ #### Header four
57
+
58
+ * A bullet
59
+
60
+ * Another bullet
61
+
62
+ A paragraph, because the parser merges adjacent lists into one.
63
+
64
+ 1. Numbered
65
+
66
+ 2. Also numbered
67
+
68
+ \`python
69
+ print(1)
70
+ \`A code caption
71
+
72
+ "
73
+ A quotation.
74
+ "Someone
75
+
76
+ =
77
+ A callout.
78
+ =
79
+
80
+ ,Head A|Head B
81
+ ,Cell one|Cell two
82
+ A table caption
83
+
84
+ |photo.jpg|Alt text|A caption|A credit|
85
+
86
+ -
87
+
88
+ A footnote{a note}, a citation<ref>, and a ~term~gloss.`;
89
+ /**
90
+ * Every element name the packager can put into an EPUB: the chapter serializer
91
+ * plus each of the standalone documents. The glossary's definition list only
92
+ * comes from the last of those, which is exactly where the device bug was.
93
+ */
94
+ function emittedElements() {
95
+ const edition = makeEdition({
96
+ references: { ref: 'A reference.' },
97
+ glossary: {
98
+ gloss: { phrase: 'term', definition: 'A meaning', synonyms: ['x'] },
99
+ },
100
+ });
101
+ const context = {
102
+ edition,
103
+ images: new Map([['photo.jpg', 'images/0.jpg']]),
104
+ chapters: new Set(['other']),
105
+ };
106
+ const { body, endnotes } = serializeChapter(Parser.parseChapter(edition, EVERYTHING), context);
107
+ const documents = [
108
+ // The whole document, so the head and body wrappers are covered too.
109
+ document_('Title', body + endnoteSection(endnotes)),
110
+ // The chapter document adds the header block around that body.
111
+ chapterDocument(edition, edition.getChapters()[0], context)?.content,
112
+ titleDocument(edition, context).content,
113
+ coverDocument('images/0.jpg').content,
114
+ referencesDocument(edition, context)?.content,
115
+ glossaryDocument(edition, context)?.content,
116
+ navigationDocument([titleDocument(edition, context)]).content,
117
+ ].filter((content) => content !== undefined);
118
+ const elements = new Set();
119
+ for (const content of documents)
120
+ for (const match of content.matchAll(/<([a-zA-Z][a-zA-Z0-9]*)[\s/>]/g))
121
+ elements.add(match[1].toLowerCase());
122
+ return elements;
123
+ }
124
+ /** Elements that are inline or structural, so they need no display rule. */
125
+ const NO_DISPLAY_RULE_NEEDED = new Set([
126
+ // Document structure, styled by the reading system's chrome.
127
+ 'html',
128
+ 'head',
129
+ 'body',
130
+ 'title',
131
+ 'meta',
132
+ 'link',
133
+ // Genuinely inline.
134
+ 'a',
135
+ 'span',
136
+ 'strong',
137
+ 'em',
138
+ 'sup',
139
+ 'sub',
140
+ 'code',
141
+ 'cite',
142
+ 'br',
143
+ 'img',
144
+ ]);
145
+ /** Whether the stylesheet gives an element an explicit display. */
146
+ function hasDisplayRule(element) {
147
+ // Match the element as a whole selector token in any rule that sets display.
148
+ for (const [, selectors, body] of STYLESHEET.matchAll(/([^{}]+)\{([^}]*)\}/g)) {
149
+ if (!/(^|[;\s])display\s*:/.test(body))
150
+ continue;
151
+ const tokens = selectors
152
+ .split(',')
153
+ .map((selector) => selector.trim().split(/\s+/).pop() ?? '');
154
+ if (tokens.includes(element))
155
+ return true;
156
+ }
157
+ return false;
158
+ }
159
+ test('every element the serializer emits has an explicit display', () => {
160
+ const missing = [...emittedElements()]
161
+ .filter((element) => !NO_DISPLAY_RULE_NEEDED.has(element))
162
+ .filter((element) => !hasDisplayRule(element))
163
+ .sort();
164
+ expect(missing, `these elements would render inline on a reader with no default stylesheet: ${missing.join(', ')}`).toEqual([]);
165
+ });
166
+ test('the fixture really does exercise the elements that broke on a device', () => {
167
+ // Guards the test above: if the fixture stopped emitting these, it would
168
+ // pass while covering nothing.
169
+ const emitted = emittedElements();
170
+ for (const element of [
171
+ 'table',
172
+ 'thead',
173
+ 'tbody',
174
+ 'tr',
175
+ 'th',
176
+ 'td',
177
+ 'caption',
178
+ 'figure',
179
+ 'figcaption',
180
+ 'section',
181
+ 'aside',
182
+ 'header',
183
+ 'ol',
184
+ 'ul',
185
+ 'li',
186
+ 'pre',
187
+ 'code',
188
+ 'h1',
189
+ 'h2',
190
+ ])
191
+ expect(emitted, `fixture no longer emits ${element}`).toContain(element);
192
+ });
193
+ test('headings are given distinct, decreasing sizes', () => {
194
+ const sizes = [1, 2, 3, 4, 5, 6].map((level) => {
195
+ const match = STYLESHEET.match(new RegExp(`(^|\\n)h${level}\\s*\\{[^}]*font-size:\\s*([\\d.]+)em`));
196
+ expect(match, `h${level} has no font-size`).not.toBeNull();
197
+ return Number(match?.[2]);
198
+ });
199
+ // Strictly decreasing, so levels are distinguishable.
200
+ for (let i = 1; i < sizes.length; i++)
201
+ expect(sizes[i], `h${i + 1} is not smaller than h${i}`).toBeLessThan(sizes[i - 1]);
202
+ // Relative units only, so the reader's chosen base size still governs.
203
+ expect(STYLESHEET).not.toMatch(/font-size:\s*\d+(px|pt)/);
204
+ });
205
+ test('code is monospace, and it is the only family the stylesheet sets', () => {
206
+ expect(STYLESHEET).toMatch(/pre[^{}]*\{[^}]*font-family:\s*monospace/);
207
+ // Prose keeps whatever the reader chose.
208
+ const families = [...STYLESHEET.matchAll(/font-family:\s*([^;]+);/g)].map((match) => match[1].trim());
209
+ expect(families).toEqual(families.map(() => 'monospace'));
210
+ });
211
+ test('ordered lists are numbered and unordered ones are not', () => {
212
+ expect(STYLESHEET).toMatch(/(^|\n)ol\s*\{[^}]*list-style-type:\s*decimal/);
213
+ expect(STYLESHEET).toMatch(/(^|\n)ul\s*\{[^}]*list-style-type:\s*disc/);
214
+ });
215
+ test('list markers sit outside, so wrapped items hang', () => {
216
+ expect(STYLESHEET).toMatch(/list-style-position:\s*outside/);
217
+ });
218
+ test('a glossary term and its meaning are separate paragraphs', () => {
219
+ // Definition lists flatten on readers that don't know <dt>/<dd>, and <dt>
220
+ // takes only phrasing content so nothing block-level can be nested inside
221
+ // it. The glossary uses classed paragraphs instead.
222
+ const glossary = glossaryDocument(makeEdition({
223
+ glossary: {
224
+ gloss: {
225
+ phrase: 'term',
226
+ definition: 'A meaning',
227
+ synonyms: ['x'],
228
+ },
229
+ },
230
+ }), {
231
+ edition: makeEdition(),
232
+ images: new Map(),
233
+ chapters: new Set(),
234
+ })?.content;
235
+ expect(glossary).not.toContain('<dl');
236
+ expect(glossary).toContain('<p class="term" id="gloss-gloss">');
237
+ expect(glossary).toContain('<p class="meaning">');
238
+ // Bold via an element, since font-weight is one of the few properties a
239
+ // minimal reader honours but the term must survive even without CSS.
240
+ expect(glossary).toContain('<strong>term</strong>');
241
+ expect(STYLESHEET).toMatch(/\.term\s*\{[^}]*font-weight:\s*bold/);
242
+ expect(STYLESHEET).toMatch(/\.definition\s*\{[^}]*margin/);
243
+ });