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.
- package/README.md +48 -9
- package/dist/components/chapter/Code.svelte +1 -1
- package/dist/components/editor/EmbedEditor.svelte +1 -1
- package/dist/components/page/DownloadEPUB.svelte +234 -0
- package/dist/components/page/DownloadEPUB.svelte.d.ts +3 -0
- package/dist/components/page/Rows.svelte +1 -1
- package/dist/components/page/Search.svelte +1 -1
- package/dist/components/page/TableOfContents.svelte +45 -30
- package/dist/epub/build.d.ts +39 -0
- package/dist/epub/build.js +143 -0
- package/dist/epub/build.test.d.ts +1 -0
- package/dist/epub/build.test.js +411 -0
- package/dist/epub/images.d.ts +79 -0
- package/dist/epub/images.js +260 -0
- package/dist/epub/images.test.d.ts +1 -0
- package/dist/epub/images.test.js +129 -0
- package/dist/epub/package.d.ts +75 -0
- package/dist/epub/package.js +392 -0
- package/dist/epub/stylesheet.test.d.ts +1 -0
- package/dist/epub/stylesheet.test.js +243 -0
- package/dist/epub/xhtml.d.ts +69 -0
- package/dist/epub/xhtml.js +367 -0
- package/dist/epub/xhtml.test.d.ts +1 -0
- package/dist/epub/xhtml.test.js +283 -0
- package/dist/epub/zip.d.ts +35 -0
- package/dist/epub/zip.js +202 -0
- package/dist/epub/zip.test.d.ts +1 -0
- package/dist/epub/zip.test.js +171 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/models/book/ChapterID.d.ts +1 -1
- package/dist/models/book/Edition.js +1 -0
- package/dist/models/chapter/TextNode.js +8 -5
- package/dist/models/chapter/TextNode.test.js +8 -0
- package/package.json +3 -1
|
@@ -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
|
+
});
|