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,171 @@
|
|
|
1
|
+
import { expect, test } from 'vitest';
|
|
2
|
+
import Zip, { crc32 } from './zip';
|
|
3
|
+
const decoder = new TextDecoder();
|
|
4
|
+
/**
|
|
5
|
+
* A minimal ZIP reader, so the writer's output is verified by parsing it back
|
|
6
|
+
* rather than by asserting on bytes we'd have to keep in sync by hand.
|
|
7
|
+
*/
|
|
8
|
+
function read(bytes) {
|
|
9
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
10
|
+
// Find the end of central directory record, which is last.
|
|
11
|
+
let end = bytes.length - 22;
|
|
12
|
+
while (end >= 0 && view.getUint32(end, true) !== 0x06054b50)
|
|
13
|
+
end--;
|
|
14
|
+
if (end < 0)
|
|
15
|
+
throw new Error('No end of central directory record');
|
|
16
|
+
const count = view.getUint16(end + 8, true);
|
|
17
|
+
let offset = view.getUint32(end + 16, true);
|
|
18
|
+
const entries = [];
|
|
19
|
+
for (let i = 0; i < count; i++) {
|
|
20
|
+
if (view.getUint32(offset, true) !== 0x02014b50)
|
|
21
|
+
throw new Error(`Bad central header at ${offset}`);
|
|
22
|
+
const nameLength = view.getUint16(offset + 28, true);
|
|
23
|
+
const extraLength = view.getUint16(offset + 30, true);
|
|
24
|
+
const commentLength = view.getUint16(offset + 32, true);
|
|
25
|
+
entries.push({
|
|
26
|
+
method: view.getUint16(offset + 10, true),
|
|
27
|
+
crc: view.getUint32(offset + 16, true),
|
|
28
|
+
size: view.getUint32(offset + 24, true),
|
|
29
|
+
offset: view.getUint32(offset + 42, true),
|
|
30
|
+
path: decoder.decode(bytes.subarray(offset + 46, offset + 46 + nameLength)),
|
|
31
|
+
});
|
|
32
|
+
offset += 46 + nameLength + extraLength + commentLength;
|
|
33
|
+
}
|
|
34
|
+
return { entries, view, bytes };
|
|
35
|
+
}
|
|
36
|
+
/** Read one entry's bytes, inflating if necessary. */
|
|
37
|
+
async function contentOf(archive, path) {
|
|
38
|
+
const entry = archive.entries.find((e) => e.path === path);
|
|
39
|
+
if (entry === undefined)
|
|
40
|
+
throw new Error(`No entry ${path}`);
|
|
41
|
+
const { view, bytes } = archive;
|
|
42
|
+
if (view.getUint32(entry.offset, true) !== 0x04034b50)
|
|
43
|
+
throw new Error(`Bad local header for ${path}`);
|
|
44
|
+
const nameLength = view.getUint16(entry.offset + 26, true);
|
|
45
|
+
const extraLength = view.getUint16(entry.offset + 28, true);
|
|
46
|
+
const compressedSize = view.getUint32(entry.offset + 18, true);
|
|
47
|
+
const start = entry.offset + 30 + nameLength + extraLength;
|
|
48
|
+
const data = bytes.subarray(start, start + compressedSize);
|
|
49
|
+
if (entry.method === 0)
|
|
50
|
+
return data;
|
|
51
|
+
return inflate(data);
|
|
52
|
+
}
|
|
53
|
+
/** Raw DEFLATE decompression, avoiding Blob.stream(), which jsdom lacks. */
|
|
54
|
+
async function inflate(data) {
|
|
55
|
+
const stream = new DecompressionStream('deflate-raw');
|
|
56
|
+
const writer = stream.writable.getWriter();
|
|
57
|
+
const written = writer.write(data).then(() => writer.close());
|
|
58
|
+
const reader = stream.readable.getReader();
|
|
59
|
+
const chunks = [];
|
|
60
|
+
let length = 0;
|
|
61
|
+
for (;;) {
|
|
62
|
+
const { done, value } = await reader.read();
|
|
63
|
+
if (done)
|
|
64
|
+
break;
|
|
65
|
+
chunks.push(value);
|
|
66
|
+
length += value.length;
|
|
67
|
+
}
|
|
68
|
+
await written;
|
|
69
|
+
const out = new Uint8Array(length);
|
|
70
|
+
let offset = 0;
|
|
71
|
+
for (const chunk of chunks) {
|
|
72
|
+
out.set(chunk, offset);
|
|
73
|
+
offset += chunk.length;
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
async function archiveOf(zip) {
|
|
78
|
+
return read(new Uint8Array(await (await zip.toBlob()).arrayBuffer()));
|
|
79
|
+
}
|
|
80
|
+
// The check value published in the zlib and PNG specifications.
|
|
81
|
+
test('crc32 matches the specification check value', () => {
|
|
82
|
+
expect(crc32(new TextEncoder().encode('123456789'))).toBe(0xcbf43926);
|
|
83
|
+
});
|
|
84
|
+
test('crc32 of nothing is zero', () => {
|
|
85
|
+
expect(crc32(new Uint8Array(0))).toBe(0);
|
|
86
|
+
});
|
|
87
|
+
test('round trips text and binary content', async () => {
|
|
88
|
+
const binary = new Uint8Array([0, 1, 2, 253, 254, 255]);
|
|
89
|
+
const zip = new Zip();
|
|
90
|
+
zip.add('hello.txt', 'Hello, world!');
|
|
91
|
+
zip.add('nested/deep/bytes.bin', binary);
|
|
92
|
+
const archive = await archiveOf(zip);
|
|
93
|
+
expect(archive.entries.map((e) => e.path)).toEqual([
|
|
94
|
+
'hello.txt',
|
|
95
|
+
'nested/deep/bytes.bin',
|
|
96
|
+
]);
|
|
97
|
+
expect(decoder.decode(await contentOf(archive, 'hello.txt'))).toBe('Hello, world!');
|
|
98
|
+
expect(await contentOf(archive, 'nested/deep/bytes.bin')).toEqual(binary);
|
|
99
|
+
});
|
|
100
|
+
test('round trips content that compresses well', async () => {
|
|
101
|
+
// Long enough that deflate definitely beats storing it.
|
|
102
|
+
const text = 'the quick brown fox '.repeat(500);
|
|
103
|
+
const zip = new Zip();
|
|
104
|
+
zip.add('repetitive.txt', text);
|
|
105
|
+
const archive = await archiveOf(zip);
|
|
106
|
+
expect(archive.entries[0].method).toBe(8);
|
|
107
|
+
expect(decoder.decode(await contentOf(archive, 'repetitive.txt'))).toBe(text);
|
|
108
|
+
});
|
|
109
|
+
test('stores rather than deflates when compression would not help', async () => {
|
|
110
|
+
const zip = new Zip();
|
|
111
|
+
// Incompressible: a short string has more deflate overhead than savings.
|
|
112
|
+
zip.add('tiny.txt', 'x');
|
|
113
|
+
const archive = await archiveOf(zip);
|
|
114
|
+
expect(archive.entries[0].method).toBe(0);
|
|
115
|
+
});
|
|
116
|
+
test('honors an explicit request to store', async () => {
|
|
117
|
+
const text = 'the quick brown fox '.repeat(500);
|
|
118
|
+
const zip = new Zip();
|
|
119
|
+
zip.add('repetitive.txt', text, true);
|
|
120
|
+
const archive = await archiveOf(zip);
|
|
121
|
+
expect(archive.entries[0].method).toBe(0);
|
|
122
|
+
expect(archive.entries[0].size).toBe(text.length);
|
|
123
|
+
});
|
|
124
|
+
test('records the uncompressed size and CRC of the original bytes', async () => {
|
|
125
|
+
const text = 'the quick brown fox '.repeat(500);
|
|
126
|
+
const zip = new Zip();
|
|
127
|
+
zip.add('repetitive.txt', text);
|
|
128
|
+
const archive = await archiveOf(zip);
|
|
129
|
+
expect(archive.entries[0].size).toBe(text.length);
|
|
130
|
+
expect(archive.entries[0].crc).toBe(crc32(new TextEncoder().encode(text)));
|
|
131
|
+
});
|
|
132
|
+
test('encodes non-ASCII paths and content as UTF-8', async () => {
|
|
133
|
+
const path = 'café/章.txt';
|
|
134
|
+
const content = 'Hello, 世界!';
|
|
135
|
+
const zip = new Zip();
|
|
136
|
+
zip.add(path, content);
|
|
137
|
+
const archive = await archiveOf(zip);
|
|
138
|
+
expect(archive.entries[0].path).toBe(path);
|
|
139
|
+
expect(decoder.decode(await contentOf(archive, path))).toBe(content);
|
|
140
|
+
});
|
|
141
|
+
// This is the constraint that makes a hand-rolled writer worth having: EPUB
|
|
142
|
+
// requires `mimetype` to be the first entry and to be stored, so that readers
|
|
143
|
+
// can identify the file by reading a fixed byte range at its head.
|
|
144
|
+
test('an EPUB-shaped archive puts a stored mimetype first', async () => {
|
|
145
|
+
const zip = new Zip();
|
|
146
|
+
zip.add('mimetype', 'application/epub+zip', true);
|
|
147
|
+
zip.add('META-INF/container.xml', '<container/>');
|
|
148
|
+
const blob = await zip.toBlob('application/epub+zip');
|
|
149
|
+
const bytes = new Uint8Array(await blob.arrayBuffer());
|
|
150
|
+
const archive = read(bytes);
|
|
151
|
+
expect(archive.entries[0].path).toBe('mimetype');
|
|
152
|
+
expect(archive.entries[0].method).toBe(0);
|
|
153
|
+
expect(archive.entries[0].offset).toBe(0);
|
|
154
|
+
// Signature, then the name at 30, then the stored content right after it.
|
|
155
|
+
expect(decoder.decode(bytes.subarray(0, 2))).toBe('PK');
|
|
156
|
+
expect(decoder.decode(bytes.subarray(30, 38))).toBe('mimetype');
|
|
157
|
+
expect(decoder.decode(bytes.subarray(38, 58))).toBe('application/epub+zip');
|
|
158
|
+
});
|
|
159
|
+
test('an empty archive is still a valid one', async () => {
|
|
160
|
+
const archive = await archiveOf(new Zip());
|
|
161
|
+
expect(archive.entries).toEqual([]);
|
|
162
|
+
});
|
|
163
|
+
test('output is reproducible for a fixed timestamp', async () => {
|
|
164
|
+
const build = async () => {
|
|
165
|
+
const zip = new Zip(new Date(2026, 0, 1, 12, 0, 0));
|
|
166
|
+
zip.add('mimetype', 'application/epub+zip', true);
|
|
167
|
+
zip.add('a.txt', 'content');
|
|
168
|
+
return new Uint8Array(await (await zip.toBlob()).arrayBuffer());
|
|
169
|
+
};
|
|
170
|
+
expect(await build()).toEqual(await build());
|
|
171
|
+
});
|
package/dist/index.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ export { default as Index } from "./components/page/Index.svelte";
|
|
|
8
8
|
export { default as References } from "./components/page/References.svelte";
|
|
9
9
|
export { default as Media } from "./components/page/Media.svelte";
|
|
10
10
|
export { default as Print } from "./components/page/Print.svelte";
|
|
11
|
+
export { default as DownloadEPUB } from "./components/page/DownloadEPUB.svelte";
|
|
11
12
|
export { default as Unknown } from "./components/page/Unknown.svelte";
|
|
12
13
|
export { default as Glossary } from "./components/page/Glossary.svelte";
|
|
13
14
|
export { default as Edition } from "./components/page/Edition.svelte";
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,7 @@ export { default as Index } from './components/page/Index.svelte';
|
|
|
9
9
|
export { default as References } from './components/page/References.svelte';
|
|
10
10
|
export { default as Media } from './components/page/Media.svelte';
|
|
11
11
|
export { default as Print } from './components/page/Print.svelte';
|
|
12
|
+
export { default as DownloadEPUB } from './components/page/DownloadEPUB.svelte';
|
|
12
13
|
export { default as Unknown } from './components/page/Unknown.svelte';
|
|
13
14
|
export { default as Glossary } from './components/page/Glossary.svelte';
|
|
14
15
|
export { default as Edition } from './components/page/Edition.svelte';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export type ChapterIDKey = 'references' | 'search' | 'media' | 'index' | 'glossary' | 'unknown' | 'cover';
|
|
2
|
-
export type TOCHeaderKey = 'chapters' | 'acknowledgements' | 'license' | 'print' | 'citation';
|
|
2
|
+
export type TOCHeaderKey = 'chapters' | 'acknowledgements' | 'license' | 'print' | 'epub' | 'citation';
|
|
3
3
|
declare const ChapterIDs: Record<string, ChapterIDKey>;
|
|
4
4
|
export default ChapterIDs;
|
|
@@ -49,12 +49,15 @@ export default class TextNode extends Node {
|
|
|
49
49
|
.replace(/(:)([a-z])/g, '\\:$2');
|
|
50
50
|
}
|
|
51
51
|
toHTML() {
|
|
52
|
+
// Ampersands are escaped first, or the entities introduced below get
|
|
53
|
+
// escaped in turn ('<' would become '&lt;'). The flags matter too:
|
|
54
|
+
// without them only the first occurrence of each character is escaped.
|
|
52
55
|
return this.#text
|
|
53
|
-
.replace(
|
|
54
|
-
.replace(
|
|
55
|
-
.replace(
|
|
56
|
-
.replace(
|
|
57
|
-
.replace(
|
|
56
|
+
.replace(/&/g, '&')
|
|
57
|
+
.replace(/</g, '<')
|
|
58
|
+
.replace(/>/g, '>')
|
|
59
|
+
.replace(/"/g, '"')
|
|
60
|
+
.replace(/'/g, ''');
|
|
58
61
|
}
|
|
59
62
|
getChildren() {
|
|
60
63
|
return [];
|
|
@@ -4,3 +4,11 @@ test('Insert character', () => {
|
|
|
4
4
|
const text = new TextNode('Hello');
|
|
5
5
|
expect(text.withCharacterAt('!', 5)?.toBookdown()).toBe('Hello!');
|
|
6
6
|
});
|
|
7
|
+
test('escapes every occurrence of a markup character, not just the first', () => {
|
|
8
|
+
expect(new TextNode('a < b < c').toHTML()).toBe('a < b < c');
|
|
9
|
+
});
|
|
10
|
+
test('escapes ampersands without escaping the entities it introduces', () => {
|
|
11
|
+
expect(new TextNode('<').toHTML()).toBe('<');
|
|
12
|
+
expect(new TextNode('a & b').toHTML()).toBe('a & b');
|
|
13
|
+
expect(new TextNode('<').toHTML()).toBe('&lt;');
|
|
14
|
+
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bookish-press",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.9.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "A platform for reading and writing readable, accessible, beautiful online books.",
|
|
7
7
|
"license": "MIT",
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
"dev": "vite dev",
|
|
16
16
|
"preview": "npm run build && vite preview",
|
|
17
17
|
"build": "npm run env && vite build",
|
|
18
|
+
"//demo": "echo 'Seeds a demo book that uses every feature into dedicated emulators and serves it at /demo. Read-only; no sign-in needed.'",
|
|
19
|
+
"demo": "bash scripts/demo.sh",
|
|
18
20
|
"//emu": "echo 'This copies the local .env file and starts the emulator'",
|
|
19
21
|
"emu": "firebase use dev && npm run env && firebase emulators:start",
|
|
20
22
|
"package": "svelte-package --watch",
|