@leopiccionia/epub-builder 0.0.1

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 ADDED
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2025, Leonardo Piccioni de Almeida
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # Epub Builder
2
+
3
+ A non-opinionated EPUB builder for reflowable content
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @leopiccionia/epub-builder
9
+ ```
10
+
11
+ ## Features
12
+
13
+ Generates EPUBs that are fully compatible with [EPUB 3.3](https://www.w3.org/TR/epub-33/) standard, with some additional compatibility with EPUB 2.
14
+
15
+ It provides a declarative API, for easy configuration, and writes for you:
16
+
17
+ - a machine-readable table of contents
18
+ - accessibility landmarks
19
+ - all the boring EPUB ceremony
20
+
21
+ ## Usage example
22
+
23
+ ```js
24
+ import { EpubBuilder } from '@leopiccionia/epub-builder'
25
+
26
+ const ebook = await EpubBuilder.init({
27
+ // Some book metadata
28
+ meta: {
29
+ title: 'An example book',
30
+ creators: [
31
+ { name: 'John Doe' },
32
+ ],
33
+ },
34
+ // The reading order
35
+ spine: [
36
+ 'cover.xhtml',
37
+ 'toc.xhtml',
38
+ 'chapter-1.xhtml',
39
+ ],
40
+ // The table of contents
41
+ toc: [
42
+ { href: 'toc.xhtml', text: 'Table of contents' },
43
+ { href: 'chapter-1.xhtml', text: 'Chapter 1', children: [
44
+ { href: 'chapter-1.xhtml#introduction', text: 'Introduction' },
45
+ { href: 'chapter-1.xhtml#conclusion', text: 'Conclusion' },
46
+ ] },
47
+ ],
48
+ // Some accessibility landmarks
49
+ landmarks: {
50
+ toc: 'toc.xhtml', // The table of contents
51
+ bodymatter: 'chapter-1.xhtml', // The start of content
52
+ },
53
+ })
54
+
55
+ // Add the ebook files
56
+ await ebook.addTextFile('toc.xhtml', '...')
57
+ await ebook.addTextFile('chapter-1.xhtml', '...')
58
+ await ebook.addTextFile('cover.xhtml', '...')
59
+ await ebook.copyFile('cover.png', 'PATH TO THE IMAGE')
60
+
61
+ // Returns the ZIP file
62
+ const blob = await ebook.seal()
63
+ ```
64
+
65
+ ## Related projects
66
+
67
+ - [Pubmark](https://github.com/leopiccionia/pubmark) - A Markdown to EPUB converter powered by this package
@@ -0,0 +1,309 @@
1
+ /**
2
+ * An EpubBuilder locale
3
+ */
4
+ interface Locale {
5
+ /**
6
+ * Translation of "Start of content"
7
+ */
8
+ bodymatter: string;
9
+ /**
10
+ * Translation of "Guide"
11
+ */
12
+ landmarks: string;
13
+ /**
14
+ * Translation of "List of images"
15
+ */
16
+ loi: string;
17
+ /**
18
+ * Translation of "Table of contents"
19
+ */
20
+ toc: string;
21
+ /**
22
+ * Translation of the name of another landmark
23
+ */
24
+ [landmark: string]: string | undefined;
25
+ }
26
+
27
+ /**
28
+ * An ebook set of metadata
29
+ */
30
+ interface EbookMeta {
31
+ /**
32
+ * The book title
33
+ */
34
+ title: string;
35
+ /**
36
+ * The book subtitle
37
+ */
38
+ subtitle: string;
39
+ /**
40
+ * The book description
41
+ */
42
+ description: string;
43
+ /**
44
+ * The reading direction (left-to-right, right-to-left, or auto)
45
+ */
46
+ direction: 'ltr' | 'rtl' | 'auto';
47
+ /**
48
+ * The language tag
49
+ * @see https://www.w3.org/International/articles/language-tags/
50
+ */
51
+ language: string;
52
+ /**
53
+ * The book publishing date, in `YYYY-MM-DD` format
54
+ */
55
+ date: string;
56
+ /**
57
+ * The book publisher
58
+ */
59
+ publisher: {
60
+ /**
61
+ * The publisher type
62
+ */
63
+ type?: 'Organization' | 'Person';
64
+ /**
65
+ * The publisher name
66
+ */
67
+ name: string;
68
+ };
69
+ /**
70
+ * A list of contributors to the book
71
+ */
72
+ creators: Array<{
73
+ /**
74
+ * The contributor's name
75
+ */
76
+ name: string;
77
+ /**
78
+ * The contributor's role MARC code
79
+ * @see https://www.loc.gov/marc/relators/relaterm.html
80
+ */
81
+ role: string;
82
+ /**
83
+ * The contributor's type
84
+ */
85
+ type?: 'Organization' | 'Person';
86
+ /**
87
+ * Normalized form of contributor's name
88
+ */
89
+ 'file as'?: string;
90
+ /**
91
+ * Contributor's name in alternative scripts or languages
92
+ */
93
+ alternate?: {
94
+ [language: string]: string;
95
+ };
96
+ }>;
97
+ /**
98
+ * The book subjects
99
+ */
100
+ subjects: Array<{
101
+ /**
102
+ * The human-readable subject description
103
+ */
104
+ label: string;
105
+ /**
106
+ * The authority that issued the subject term
107
+ */
108
+ authority: string;
109
+ /**
110
+ * The machine-readable subject term
111
+ */
112
+ term: number | string;
113
+ }>;
114
+ /**
115
+ * Unique identifiers
116
+ */
117
+ ids: {
118
+ /**
119
+ * An ISBN (International Standard Book Number) identifier
120
+ */
121
+ isbn?: string;
122
+ /**
123
+ * A DOI (Digital Object Identifier) identifier
124
+ */
125
+ doi?: string;
126
+ /**
127
+ * An UUID (Universally Unique Identifier) v4-compatible identifier
128
+ */
129
+ uuid?: string;
130
+ };
131
+ }
132
+ /**
133
+ * An EPUB landmarks definition
134
+ */
135
+ interface Landmarks {
136
+ /**
137
+ * The start of content
138
+ */
139
+ bodymatter: string;
140
+ /**
141
+ * The table of contents
142
+ */
143
+ toc: string;
144
+ /**
145
+ * The list of images
146
+ */
147
+ loi?: string;
148
+ /**
149
+ * Other landmarks (may not be supported by all readers)
150
+ */
151
+ [landmark: string]: string | undefined;
152
+ }
153
+ /**
154
+ * A link in the table of contents
155
+ */
156
+ type TocEntry = {
157
+ text: string;
158
+ href: string;
159
+ children?: TocEntry[];
160
+ };
161
+ /**
162
+ * An EPUB builder config
163
+ */
164
+ interface EpubBuilderConfig {
165
+ /**
166
+ * The ebook landmarks
167
+ */
168
+ landmarks: Landmarks;
169
+ /**
170
+ * The builder locale
171
+ */
172
+ locale: Locale;
173
+ /**
174
+ * The ebook metadata
175
+ */
176
+ meta: EbookMeta;
177
+ /**
178
+ * The ebook spine
179
+ */
180
+ spine: string[];
181
+ /**
182
+ * The table of contents
183
+ */
184
+ toc: TocEntry[];
185
+ }
186
+ interface EpubBuilderPartialConfig {
187
+ /**
188
+ * The ebook landmarks
189
+ */
190
+ landmarks: Landmarks;
191
+ /**
192
+ * The builder locale
193
+ */
194
+ locale?: Locale;
195
+ /**
196
+ * The ebook metadata
197
+ */
198
+ meta?: Partial<EbookMeta>;
199
+ /**
200
+ * The ebook spine
201
+ */
202
+ spine: string[];
203
+ /**
204
+ * The table of contents
205
+ */
206
+ toc: TocEntry[];
207
+ }
208
+ /**
209
+ * Fills the user-provided config with default values
210
+ * @param partialConfig The partial config
211
+ * @returns The completely-filled config
212
+ */
213
+ declare function populateConfig(partialConfig: EpubBuilderPartialConfig): EpubBuilderConfig;
214
+
215
+ /**
216
+ * A resource manifest property
217
+ * @see https://www.w3.org/TR/epub-33/#app-item-properties-vocab
218
+ */
219
+ type ResourceProperty = 'cover-image' | 'mathml' | 'nav' | 'remote-resources' | 'scripted' | 'svg' | 'switch';
220
+ /**
221
+ * A resource representing a file asset
222
+ */
223
+ interface Resource {
224
+ /**
225
+ * The resource's path
226
+ */
227
+ href: string;
228
+ /**
229
+ * The resource's MIME type
230
+ */
231
+ mime: string;
232
+ /**
233
+ * The resource's manifest properties
234
+ */
235
+ properties: ResourceProperty[];
236
+ }
237
+
238
+ /**
239
+ * A non-opinionated EPUB builder
240
+ */
241
+ declare class EpubBuilder {
242
+ #private;
243
+ /**
244
+ * The builder config
245
+ */
246
+ config: EpubBuilderConfig;
247
+ /**
248
+ * The list of registered EPUB resources
249
+ */
250
+ readonly resources: Resource[];
251
+ /**
252
+ * The EPUB ZIP container
253
+ */
254
+ private zip;
255
+ /**
256
+ * The private constructor
257
+ * @param config The builder config
258
+ */
259
+ private constructor();
260
+ /**
261
+ * Registers a binary-encoded file
262
+ * @param href The resource's path inside the EPUB container
263
+ * @param content The resource's binary-encoded content
264
+ * @param properties The resource's manifest properties
265
+ * @returns The registered resource
266
+ */
267
+ addBinaryFile(href: string, content: Blob, properties?: ResourceProperty[]): Promise<Resource>;
268
+ /**
269
+ * Registers a text-encoded file
270
+ * @param href The resource's path inside the EPUB container
271
+ * @param content The resource's text-encoded content
272
+ * @param properties The resource's manifest properties
273
+ * @returns The registered resource
274
+ */
275
+ addTextFile(href: string, content: string, properties?: ResourceProperty[]): Promise<Resource>;
276
+ /**
277
+ * Reads a file and registers it as a resource
278
+ * @param href The resource's path inside the EPUB container
279
+ * @param path The file's physical path
280
+ * @param properties The resource's manifest properties
281
+ * @returns The registered resource
282
+ */
283
+ copyFile(href: string, path: string, properties?: ResourceProperty[]): Promise<Resource>;
284
+ /**
285
+ * Returns an empty `EpubBuilder`
286
+ * @param partialConfig The builder config
287
+ */
288
+ static init(partialConfig: EpubBuilderPartialConfig): Promise<EpubBuilder>;
289
+ /**
290
+ * Closes the ZIP container, returning its content
291
+ * @returns The EPUB binary data
292
+ */
293
+ seal(): Promise<Blob>;
294
+ }
295
+
296
+ /**
297
+ * Detects the MIME type of a certain file
298
+ * @param path The file path
299
+ * @returns The MIME type of the file, or `undefined` if not recognized
300
+ */
301
+ declare function getMimeType(path: string): string | undefined;
302
+ /**
303
+ * Detects if the MIME type is an EPUB core media type
304
+ * @param mimeType The MIME type
305
+ * @returns Whether the media type is recognized and a core media type
306
+ */
307
+ declare function isCoreMediaType(mimeType: string | undefined): boolean;
308
+
309
+ export { type EbookMeta, EpubBuilder, type EpubBuilderConfig, type EpubBuilderPartialConfig, type Landmarks, type Locale, type Resource, type ResourceProperty, type TocEntry, getMimeType, isCoreMediaType, populateConfig };
@@ -0,0 +1,309 @@
1
+ /**
2
+ * An EpubBuilder locale
3
+ */
4
+ interface Locale {
5
+ /**
6
+ * Translation of "Start of content"
7
+ */
8
+ bodymatter: string;
9
+ /**
10
+ * Translation of "Guide"
11
+ */
12
+ landmarks: string;
13
+ /**
14
+ * Translation of "List of images"
15
+ */
16
+ loi: string;
17
+ /**
18
+ * Translation of "Table of contents"
19
+ */
20
+ toc: string;
21
+ /**
22
+ * Translation of the name of another landmark
23
+ */
24
+ [landmark: string]: string | undefined;
25
+ }
26
+
27
+ /**
28
+ * An ebook set of metadata
29
+ */
30
+ interface EbookMeta {
31
+ /**
32
+ * The book title
33
+ */
34
+ title: string;
35
+ /**
36
+ * The book subtitle
37
+ */
38
+ subtitle: string;
39
+ /**
40
+ * The book description
41
+ */
42
+ description: string;
43
+ /**
44
+ * The reading direction (left-to-right, right-to-left, or auto)
45
+ */
46
+ direction: 'ltr' | 'rtl' | 'auto';
47
+ /**
48
+ * The language tag
49
+ * @see https://www.w3.org/International/articles/language-tags/
50
+ */
51
+ language: string;
52
+ /**
53
+ * The book publishing date, in `YYYY-MM-DD` format
54
+ */
55
+ date: string;
56
+ /**
57
+ * The book publisher
58
+ */
59
+ publisher: {
60
+ /**
61
+ * The publisher type
62
+ */
63
+ type?: 'Organization' | 'Person';
64
+ /**
65
+ * The publisher name
66
+ */
67
+ name: string;
68
+ };
69
+ /**
70
+ * A list of contributors to the book
71
+ */
72
+ creators: Array<{
73
+ /**
74
+ * The contributor's name
75
+ */
76
+ name: string;
77
+ /**
78
+ * The contributor's role MARC code
79
+ * @see https://www.loc.gov/marc/relators/relaterm.html
80
+ */
81
+ role: string;
82
+ /**
83
+ * The contributor's type
84
+ */
85
+ type?: 'Organization' | 'Person';
86
+ /**
87
+ * Normalized form of contributor's name
88
+ */
89
+ 'file as'?: string;
90
+ /**
91
+ * Contributor's name in alternative scripts or languages
92
+ */
93
+ alternate?: {
94
+ [language: string]: string;
95
+ };
96
+ }>;
97
+ /**
98
+ * The book subjects
99
+ */
100
+ subjects: Array<{
101
+ /**
102
+ * The human-readable subject description
103
+ */
104
+ label: string;
105
+ /**
106
+ * The authority that issued the subject term
107
+ */
108
+ authority: string;
109
+ /**
110
+ * The machine-readable subject term
111
+ */
112
+ term: number | string;
113
+ }>;
114
+ /**
115
+ * Unique identifiers
116
+ */
117
+ ids: {
118
+ /**
119
+ * An ISBN (International Standard Book Number) identifier
120
+ */
121
+ isbn?: string;
122
+ /**
123
+ * A DOI (Digital Object Identifier) identifier
124
+ */
125
+ doi?: string;
126
+ /**
127
+ * An UUID (Universally Unique Identifier) v4-compatible identifier
128
+ */
129
+ uuid?: string;
130
+ };
131
+ }
132
+ /**
133
+ * An EPUB landmarks definition
134
+ */
135
+ interface Landmarks {
136
+ /**
137
+ * The start of content
138
+ */
139
+ bodymatter: string;
140
+ /**
141
+ * The table of contents
142
+ */
143
+ toc: string;
144
+ /**
145
+ * The list of images
146
+ */
147
+ loi?: string;
148
+ /**
149
+ * Other landmarks (may not be supported by all readers)
150
+ */
151
+ [landmark: string]: string | undefined;
152
+ }
153
+ /**
154
+ * A link in the table of contents
155
+ */
156
+ type TocEntry = {
157
+ text: string;
158
+ href: string;
159
+ children?: TocEntry[];
160
+ };
161
+ /**
162
+ * An EPUB builder config
163
+ */
164
+ interface EpubBuilderConfig {
165
+ /**
166
+ * The ebook landmarks
167
+ */
168
+ landmarks: Landmarks;
169
+ /**
170
+ * The builder locale
171
+ */
172
+ locale: Locale;
173
+ /**
174
+ * The ebook metadata
175
+ */
176
+ meta: EbookMeta;
177
+ /**
178
+ * The ebook spine
179
+ */
180
+ spine: string[];
181
+ /**
182
+ * The table of contents
183
+ */
184
+ toc: TocEntry[];
185
+ }
186
+ interface EpubBuilderPartialConfig {
187
+ /**
188
+ * The ebook landmarks
189
+ */
190
+ landmarks: Landmarks;
191
+ /**
192
+ * The builder locale
193
+ */
194
+ locale?: Locale;
195
+ /**
196
+ * The ebook metadata
197
+ */
198
+ meta?: Partial<EbookMeta>;
199
+ /**
200
+ * The ebook spine
201
+ */
202
+ spine: string[];
203
+ /**
204
+ * The table of contents
205
+ */
206
+ toc: TocEntry[];
207
+ }
208
+ /**
209
+ * Fills the user-provided config with default values
210
+ * @param partialConfig The partial config
211
+ * @returns The completely-filled config
212
+ */
213
+ declare function populateConfig(partialConfig: EpubBuilderPartialConfig): EpubBuilderConfig;
214
+
215
+ /**
216
+ * A resource manifest property
217
+ * @see https://www.w3.org/TR/epub-33/#app-item-properties-vocab
218
+ */
219
+ type ResourceProperty = 'cover-image' | 'mathml' | 'nav' | 'remote-resources' | 'scripted' | 'svg' | 'switch';
220
+ /**
221
+ * A resource representing a file asset
222
+ */
223
+ interface Resource {
224
+ /**
225
+ * The resource's path
226
+ */
227
+ href: string;
228
+ /**
229
+ * The resource's MIME type
230
+ */
231
+ mime: string;
232
+ /**
233
+ * The resource's manifest properties
234
+ */
235
+ properties: ResourceProperty[];
236
+ }
237
+
238
+ /**
239
+ * A non-opinionated EPUB builder
240
+ */
241
+ declare class EpubBuilder {
242
+ #private;
243
+ /**
244
+ * The builder config
245
+ */
246
+ config: EpubBuilderConfig;
247
+ /**
248
+ * The list of registered EPUB resources
249
+ */
250
+ readonly resources: Resource[];
251
+ /**
252
+ * The EPUB ZIP container
253
+ */
254
+ private zip;
255
+ /**
256
+ * The private constructor
257
+ * @param config The builder config
258
+ */
259
+ private constructor();
260
+ /**
261
+ * Registers a binary-encoded file
262
+ * @param href The resource's path inside the EPUB container
263
+ * @param content The resource's binary-encoded content
264
+ * @param properties The resource's manifest properties
265
+ * @returns The registered resource
266
+ */
267
+ addBinaryFile(href: string, content: Blob, properties?: ResourceProperty[]): Promise<Resource>;
268
+ /**
269
+ * Registers a text-encoded file
270
+ * @param href The resource's path inside the EPUB container
271
+ * @param content The resource's text-encoded content
272
+ * @param properties The resource's manifest properties
273
+ * @returns The registered resource
274
+ */
275
+ addTextFile(href: string, content: string, properties?: ResourceProperty[]): Promise<Resource>;
276
+ /**
277
+ * Reads a file and registers it as a resource
278
+ * @param href The resource's path inside the EPUB container
279
+ * @param path The file's physical path
280
+ * @param properties The resource's manifest properties
281
+ * @returns The registered resource
282
+ */
283
+ copyFile(href: string, path: string, properties?: ResourceProperty[]): Promise<Resource>;
284
+ /**
285
+ * Returns an empty `EpubBuilder`
286
+ * @param partialConfig The builder config
287
+ */
288
+ static init(partialConfig: EpubBuilderPartialConfig): Promise<EpubBuilder>;
289
+ /**
290
+ * Closes the ZIP container, returning its content
291
+ * @returns The EPUB binary data
292
+ */
293
+ seal(): Promise<Blob>;
294
+ }
295
+
296
+ /**
297
+ * Detects the MIME type of a certain file
298
+ * @param path The file path
299
+ * @returns The MIME type of the file, or `undefined` if not recognized
300
+ */
301
+ declare function getMimeType(path: string): string | undefined;
302
+ /**
303
+ * Detects if the MIME type is an EPUB core media type
304
+ * @param mimeType The MIME type
305
+ * @returns Whether the media type is recognized and a core media type
306
+ */
307
+ declare function isCoreMediaType(mimeType: string | undefined): boolean;
308
+
309
+ export { type EbookMeta, EpubBuilder, type EpubBuilderConfig, type EpubBuilderPartialConfig, type Landmarks, type Locale, type Resource, type ResourceProperty, type TocEntry, getMimeType, isCoreMediaType, populateConfig };
package/dist/index.mjs ADDED
@@ -0,0 +1,458 @@
1
+ import { u } from 'unist-builder';
2
+ import { x } from 'xastscript';
3
+ import { toXml } from 'xast-util-to-xml';
4
+ import { v4 } from '@lukeed/uuid/secure';
5
+ import { defu } from 'defu';
6
+ import mime from 'mime';
7
+ import { readFile } from 'node:fs/promises';
8
+ import { BlobWriter, ZipWriter, BlobReader, TextReader } from '@zip.js/zip.js';
9
+
10
+ function stringifyXml(tree) {
11
+ return toXml(tree, {
12
+ closeEmptyElements: true
13
+ });
14
+ }
15
+
16
+ function generateAppleDisplayOptionsXml(resources) {
17
+ const hasEmbeddedFonts = resources.some((resource) => {
18
+ return resource.mime.startsWith("font/");
19
+ });
20
+ const hasInteractiveContent = resources.some((resource) => {
21
+ return resource.mime === "application/javascript" || resource.properties.includes("scripted");
22
+ });
23
+ const tree = x(null, [
24
+ u("instruction", { name: "xml" }, 'version="1.0" encoding="utf-8"'),
25
+ x("display_options", [
26
+ x("platform", { name: "*" }, [
27
+ x("option", { name: "specified-fonts" }, String(hasEmbeddedFonts)),
28
+ x("option", { name: "interactive" }, String(hasInteractiveContent))
29
+ ])
30
+ ])
31
+ ]);
32
+ return stringifyXml(tree);
33
+ }
34
+
35
+ const bodymatter = "Start of content";
36
+ const landmarks = "Guide";
37
+ const loi = "List of images";
38
+ const toc = "Table of Contents";
39
+ const EN = {
40
+ bodymatter: bodymatter,
41
+ landmarks: landmarks,
42
+ loi: loi,
43
+ toc: toc
44
+ };
45
+
46
+ function populateConfig(partialConfig) {
47
+ return defu(partialConfig, {
48
+ locale: EN,
49
+ meta: {
50
+ title: "EPUB",
51
+ subtitle: "",
52
+ description: "",
53
+ direction: "ltr",
54
+ language: "en",
55
+ date: "",
56
+ publisher: {
57
+ type: "Organization",
58
+ name: ""
59
+ },
60
+ creators: [],
61
+ subjects: [],
62
+ ids: {
63
+ uuid: v4()
64
+ }
65
+ }
66
+ });
67
+ }
68
+
69
+ function generateContainerXml() {
70
+ const tree = x(null, [
71
+ u("instruction", { name: "xml" }, 'version="1.0" encoding="utf-8"'),
72
+ x("container", { version: "1.0", xmlns: "urn:oasis:names:tc:opendocument:xmlns:container" }, [
73
+ x("rootfiles", [
74
+ x("rootfile", { "full-path": "OEBPS/content.opf", "media-type": "application/oebps-package+xml" })
75
+ ])
76
+ ])
77
+ ]);
78
+ return stringifyXml(tree);
79
+ }
80
+
81
+ function generateGuide(landmarks, locale) {
82
+ return x("guide", [
83
+ x("reference", { href: landmarks.toc, title: locale.toc, type: "toc" })
84
+ ]);
85
+ }
86
+
87
+ function generateItemId(path) {
88
+ return path.replaceAll(/\W/g, "-");
89
+ }
90
+
91
+ function generateManifest(resources) {
92
+ return x("manifest", resources.map(({ href, mime, properties }) => x("item", {
93
+ href,
94
+ id: generateItemId(href),
95
+ "media-type": mime,
96
+ properties: properties.length > 0 ? properties.join(" ") : undefined
97
+ })));
98
+ }
99
+
100
+ const PUB_ID = "pub-id";
101
+ function generateCreators(meta) {
102
+ return meta.creators.flatMap((creator, index) => {
103
+ const id = `creators-${index + 1}`;
104
+ const dcTag = ["aut", "dub"].includes(creator.role) ? "dc:creator" : "dc:contributor";
105
+ const creatorMeta = [
106
+ x(dcTag, { id }, creator.name),
107
+ x("meta", { refines: `#${id}`, property: "role", scheme: "marc:relators" }, creator.role)
108
+ ];
109
+ if (creator["file as"]) {
110
+ creatorMeta.push(
111
+ x("meta", { refines: `#${id}`, property: "file-as" }, creator["file as"])
112
+ );
113
+ }
114
+ if (creator.alternate) {
115
+ for (const [lang, alias] of Object.entries(creator.alternate)) {
116
+ x("meta", { refines: `#${id}`, property: "alternate-script", "xml:lang": lang }, alias);
117
+ }
118
+ }
119
+ return creatorMeta;
120
+ });
121
+ }
122
+ function generateSubjects(meta) {
123
+ return meta.subjects.flatMap((subject, index) => {
124
+ const id = `subject-${index + 1}`;
125
+ return [
126
+ x("dc:subject", { id }, subject.label),
127
+ x("meta", { refines: `#${id}`, property: "authority" }, subject.authority),
128
+ x("meta", { refines: `#${id}`, property: "term" }, subject.term)
129
+ ];
130
+ });
131
+ }
132
+ function generateMetadata(meta, cover) {
133
+ const { id: pubId, onix } = getUniqueIdentifier(meta);
134
+ return x("metadata", { "xmlns:dc": "http://purl.org/dc/elements/1.1/", "xmlns:opf": "http://www.idpf.org/2007/opf" }, [
135
+ x("dc:identifier", { id: PUB_ID }, pubId),
136
+ x("dc:title", { id: "title" }, meta.title),
137
+ meta.subtitle ? x(null, [
138
+ x("dc:title", { id: "subtitle" }, meta.subtitle),
139
+ x("meta", { refines: "#subtitle", property: "title-type" }, "subtitle")
140
+ ]) : null,
141
+ meta.description ? x("dc:description", meta.description) : null,
142
+ meta.publisher.name ? x("dc:publisher", meta.publisher.name) : null,
143
+ meta.date ? x("dc:date", meta.date) : null,
144
+ x("dc:language", meta.language),
145
+ x("meta", { property: "dcterms:modified" }, getTimestamp()),
146
+ x("meta", { refines: `#${PUB_ID}`, property: "identifier-type", scheme: "onix:codelist5" }, onix),
147
+ cover && x("meta", { name: "cover", content: cover.href }),
148
+ ...generateCreators(meta),
149
+ ...generateSubjects(meta)
150
+ ]);
151
+ }
152
+ function getTimestamp() {
153
+ const isoString = (/* @__PURE__ */ new Date()).toISOString();
154
+ return isoString.slice(0, 19) + "Z";
155
+ }
156
+ function getUniqueIdentifier(meta) {
157
+ const { doi, isbn, uuid } = meta.ids;
158
+ if (isbn) {
159
+ const onix = isbn.length > 10 ? "15" : "02";
160
+ return { id: `urn:isbn:${isbn}`, onix };
161
+ } else if (doi) {
162
+ return { id: `urn:doi:${doi}`, onix: "06" };
163
+ } else {
164
+ return { id: `urn:uuid:${uuid}`, onix: "01" };
165
+ }
166
+ }
167
+
168
+ function generateSpine(meta, spine) {
169
+ return x(
170
+ "spine",
171
+ { "page-progression-direction": meta.direction, toc: "toc-ncx" },
172
+ spine.map((href) => x("itemref", {
173
+ idref: generateItemId(href),
174
+ linear: "yes"
175
+ }))
176
+ );
177
+ }
178
+
179
+ function generateContentOpf(config, resources) {
180
+ const { landmarks, locale, meta, spine } = config;
181
+ const cover = resources.find((resource) => resource.properties.includes("cover-image"));
182
+ const tree = x(null, [
183
+ u("instruction", { name: "xml" }, 'version="1.0" encoding="utf-8"'),
184
+ x("package", {
185
+ dir: meta.direction,
186
+ "unique-identifier": PUB_ID,
187
+ version: "3.0",
188
+ "xml:lang": meta.language,
189
+ xmlns: "http://www.idpf.org/2007/opf"
190
+ }, [
191
+ generateMetadata(meta, cover),
192
+ generateManifest(resources),
193
+ generateSpine(meta, spine),
194
+ generateGuide(landmarks, locale)
195
+ ])
196
+ ]);
197
+ return stringifyXml(tree);
198
+ }
199
+
200
+ function generateLandmarks(landmarks, locale) {
201
+ return x(
202
+ "ol",
203
+ Object.entries(landmarks).map(([landmark, href]) => x("li", [
204
+ x("a", { "epub:type": landmark, "href": href }, locale[landmark] ?? landmark)
205
+ ]))
206
+ );
207
+ }
208
+ function generateTocList(entries) {
209
+ return x(
210
+ "ol",
211
+ entries.map(({ children, href, text }) => x("li", [
212
+ x("a", { href }, text),
213
+ children && children.length > 0 ? generateTocList(children) : null
214
+ ]))
215
+ );
216
+ }
217
+ function generateNavXhtml(config) {
218
+ const { landmarks, locale, meta, toc } = config;
219
+ const tree = x("html", {
220
+ dir: meta.direction,
221
+ lang: meta.language,
222
+ "xml:lang": meta.language,
223
+ xmlns: "http://www.w3.org/1999/xhtml",
224
+ "xmlns:epub": "http://www.idpf.org/2007/ops"
225
+ }, [
226
+ x("head", [
227
+ x("meta", { charset: "UTF-8" }),
228
+ x("title", meta.title)
229
+ ]),
230
+ x("body", [
231
+ x("h1", meta.title),
232
+ x("nav", { "epub:type": "toc" }, [
233
+ x("h2", locale.toc),
234
+ generateTocList(toc)
235
+ ]),
236
+ x("nav", { "epub:type": "landmarks" }, [
237
+ x("h2", locale.landmarks),
238
+ generateLandmarks(landmarks, locale)
239
+ ])
240
+ ])
241
+ ]);
242
+ return stringifyXml(tree);
243
+ }
244
+
245
+ function generateList(entries, prefix = "ncx") {
246
+ return entries.map((entry, index) => {
247
+ const entryId = `${prefix}-${index}`;
248
+ return x("navPoint", { id: entryId }, [
249
+ x("navLabel", [
250
+ x("text", entry.text)
251
+ ]),
252
+ x("content", { src: entry.href }),
253
+ ...entry.children && entry.children.length > 0 ? generateList(entry.children, entryId) : []
254
+ ]);
255
+ });
256
+ }
257
+ function generateNcx(config) {
258
+ const { meta, toc } = config;
259
+ const tree = x("ncx", { version: "2005-1", "xml:lang": meta.language, xmlns: "http://www.daisy.org/z3986/2005/ncx/" }, [
260
+ x("head", [
261
+ x("meta", { content: getUniqueIdentifier(meta).id, name: "dtb:uid" })
262
+ ]),
263
+ x("docTitle", [
264
+ x("text", meta.title)
265
+ ]),
266
+ x("navMap", generateList(toc))
267
+ ]);
268
+ return stringifyXml(tree);
269
+ }
270
+
271
+ const ALLOWED_MEDIA_TYPES = [
272
+ // Images
273
+ "image/gif",
274
+ "image/jpeg",
275
+ "image/png",
276
+ "image/svg+xml",
277
+ "image/webp",
278
+ // Audio
279
+ "audio/mpeg",
280
+ "audio/mp4",
281
+ "audio/ogg",
282
+ // Style
283
+ "text/css",
284
+ // Fonts
285
+ "font/ttf",
286
+ "font/otf",
287
+ "font/woff",
288
+ "font/woff2",
289
+ // Other
290
+ "application/xhtml+xml",
291
+ "application/javascript",
292
+ "application/x-dtbncx+xml",
293
+ "application/smil+xml"
294
+ ];
295
+ function getMimeType(path) {
296
+ const mimeType = mime.getType(path) ?? undefined;
297
+ if (mimeType === "video/mp4") {
298
+ return "audio/mp4";
299
+ } else if (mimeType === "text/javascript") {
300
+ return "application/javascript";
301
+ }
302
+ return mimeType;
303
+ }
304
+ function isCoreMediaType(mimeType) {
305
+ if (!mimeType) {
306
+ return false;
307
+ }
308
+ return ALLOWED_MEDIA_TYPES.includes(mimeType);
309
+ }
310
+
311
+ function createResource(href, properties = []) {
312
+ return {
313
+ href,
314
+ mime: getMimeType(href),
315
+ properties
316
+ };
317
+ }
318
+
319
+ async function readBinaryFile(path) {
320
+ const buffer = await readFile(path);
321
+ return new Blob([buffer]);
322
+ }
323
+
324
+ class ZipContainer {
325
+ #zip;
326
+ /**
327
+ * The private constructor
328
+ */
329
+ constructor() {
330
+ const writer = new BlobWriter("application/epub+zip");
331
+ this.#zip = new ZipWriter(writer);
332
+ }
333
+ /**
334
+ * Adds a binary file to the container
335
+ * @param path The path to the file inside the container
336
+ * @param blob The binary data
337
+ * @param options Options passed to `zip.js`
338
+ */
339
+ async addBinaryFile(path, blob, options = {}) {
340
+ const reader = new BlobReader(blob);
341
+ await this.#zip.add(path, reader, options);
342
+ }
343
+ /**
344
+ * Adds a text file to the container
345
+ * @param path The path to the file inside the container
346
+ * @param text The textual data
347
+ * @param options Options passed to `zip.js`
348
+ */
349
+ async addTextFile(path, text, options = {}) {
350
+ const reader = new TextReader(text);
351
+ await this.#zip.add(path, reader, options);
352
+ }
353
+ /**
354
+ * Returns an empty `ZipContainer`
355
+ */
356
+ static async init() {
357
+ const container = new ZipContainer();
358
+ await container.addTextFile("mimetype", "application/epub+zip", { compressionMethod: 0, extendedTimestamp: false });
359
+ return container;
360
+ }
361
+ /**
362
+ * Closes the container, returning its content
363
+ * @returns The EPUB binary data
364
+ */
365
+ async seal() {
366
+ return this.#zip.close();
367
+ }
368
+ }
369
+
370
+ class EpubBuilder {
371
+ /**
372
+ * The builder config
373
+ */
374
+ config;
375
+ /**
376
+ * The list of registered EPUB resources
377
+ */
378
+ resources;
379
+ /**
380
+ * The EPUB ZIP container
381
+ */
382
+ zip;
383
+ /**
384
+ * The private constructor
385
+ * @param config The builder config
386
+ */
387
+ constructor(config) {
388
+ this.config = config;
389
+ this.resources = [];
390
+ }
391
+ /**
392
+ * Registers a binary-encoded file
393
+ * @param href The resource's path inside the EPUB container
394
+ * @param content The resource's binary-encoded content
395
+ * @param properties The resource's manifest properties
396
+ * @returns The registered resource
397
+ */
398
+ async addBinaryFile(href, content, properties = []) {
399
+ const resource = createResource(href, properties);
400
+ this.resources.push(resource);
401
+ await this.zip.addBinaryFile(`OEBPS/${href}`, content);
402
+ return resource;
403
+ }
404
+ /**
405
+ * Registers a text-encoded file
406
+ * @param href The resource's path inside the EPUB container
407
+ * @param content The resource's text-encoded content
408
+ * @param properties The resource's manifest properties
409
+ * @returns The registered resource
410
+ */
411
+ async addTextFile(href, content, properties = []) {
412
+ const resource = createResource(href, properties);
413
+ this.resources.push(resource);
414
+ await this.zip.addTextFile(`OEBPS/${href}`, content);
415
+ return resource;
416
+ }
417
+ /**
418
+ * Reads a file and registers it as a resource
419
+ * @param href The resource's path inside the EPUB container
420
+ * @param path The file's physical path
421
+ * @param properties The resource's manifest properties
422
+ * @returns The registered resource
423
+ */
424
+ async copyFile(href, path, properties = []) {
425
+ const blob = await readBinaryFile(path);
426
+ return this.addBinaryFile(href, blob, properties);
427
+ }
428
+ /**
429
+ * Generate metadata files for EPUB compliance
430
+ */
431
+ async #generateFiles() {
432
+ await this.addTextFile("nav.xhtml", generateNavXhtml(this.config), ["nav"]);
433
+ await this.addTextFile("toc.ncx", generateNcx(this.config));
434
+ await this.zip.addTextFile("OEBPS/content.opf", generateContentOpf(this.config, this.resources));
435
+ await this.zip.addTextFile("META-INF/com.apple.ibooks.display-options.xml", generateAppleDisplayOptionsXml(this.resources));
436
+ }
437
+ /**
438
+ * Returns an empty `EpubBuilder`
439
+ * @param partialConfig The builder config
440
+ */
441
+ static async init(partialConfig) {
442
+ const config = populateConfig(partialConfig);
443
+ const builder = new EpubBuilder(config);
444
+ builder.zip = await ZipContainer.init();
445
+ await builder.zip.addTextFile("META-INF/container.xml", generateContainerXml());
446
+ return builder;
447
+ }
448
+ /**
449
+ * Closes the ZIP container, returning its content
450
+ * @returns The EPUB binary data
451
+ */
452
+ async seal() {
453
+ await this.#generateFiles();
454
+ return this.zip.seal();
455
+ }
456
+ }
457
+
458
+ export { EpubBuilder, getMimeType, isCoreMediaType, populateConfig };
@@ -0,0 +1,6 @@
1
+ {
2
+ "bodymatter": "Start of content",
3
+ "landmarks": "Guide",
4
+ "loi": "List of images",
5
+ "toc": "Table of Contents"
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "bodymatter": "Início do conteúdo",
3
+ "landmarks": "Guia",
4
+ "loi": "Lista de imagens",
5
+ "toc": "Índice"
6
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@leopiccionia/epub-builder",
3
+ "version": "0.0.1",
4
+ "description": "A non-opinionated EPUB builder for reflowable content",
5
+ "type": "module",
6
+ "keywords": [
7
+ "ebook",
8
+ "epub"
9
+ ],
10
+ "homepage": "https://github.com/leopiccionia/epub-builder",
11
+ "author": {
12
+ "email": "leopiccionia@gmail.com",
13
+ "name": "Leonardo Piccioni de Almeida",
14
+ "url": "https://leopiccionia.github.io"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/leopiccionia/epub-builder.git"
19
+ },
20
+ "bugs": {
21
+ "url": "https://github.com/leopiccionia/epub-builder/issues",
22
+ "email": "leopiccionia@gmail.com"
23
+ },
24
+ "license": "BSD-3-Clause",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "import": "./dist/index.mjs"
29
+ },
30
+ "./locales/*": "./dist/locales/*"
31
+ },
32
+ "files": [
33
+ "dist"
34
+ ],
35
+ "scripts": {
36
+ "build": "unbuild",
37
+ "test": "vitest"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^22.13.0",
41
+ "@types/xast": "^2.0.4",
42
+ "unbuild": "^3.3.1",
43
+ "vitest": "^3.0.4"
44
+ },
45
+ "dependencies": {
46
+ "@lukeed/uuid": "^2.0.1",
47
+ "@zip.js/zip.js": "^2.7.57",
48
+ "defu": "^6.1.4",
49
+ "mime": "^4.0.6",
50
+ "unist-builder": "^4.0.0",
51
+ "xast-util-to-xml": "^4.0.0",
52
+ "xastscript": "^4.0.0"
53
+ }
54
+ }