@gheop/tojiru 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +117 -0
- package/dist/cli.js +34 -0
- package/dist/cli.js.map +1 -0
- package/dist/convert.js +35 -0
- package/dist/convert.js.map +1 -0
- package/dist/extractors/cb7.js +25 -0
- package/dist/extractors/cb7.js.map +1 -0
- package/dist/extractors/cbr.js +38 -0
- package/dist/extractors/cbr.js.map +1 -0
- package/dist/extractors/cbz.js +57 -0
- package/dist/extractors/cbz.js.map +1 -0
- package/dist/extractors/detect.js +36 -0
- package/dist/extractors/detect.js.map +1 -0
- package/dist/extractors/djvu.js +39 -0
- package/dist/extractors/djvu.js.map +1 -0
- package/dist/extractors/images.js +16 -0
- package/dist/extractors/images.js.map +1 -0
- package/dist/extractors/pdf.js +67 -0
- package/dist/extractors/pdf.js.map +1 -0
- package/dist/extractors/types.js +2 -0
- package/dist/extractors/types.js.map +1 -0
- package/dist/manifest.js +4 -0
- package/dist/manifest.js.map +1 -0
- package/dist/output/folder.js +17 -0
- package/dist/output/folder.js.map +1 -0
- package/dist/pages.js +33 -0
- package/dist/pages.js.map +1 -0
- package/dist/run.js +18 -0
- package/dist/run.js.map +1 -0
- package/dist/tools.js +19 -0
- package/dist/tools.js.map +1 -0
- package/dist/version.js +2 -0
- package/dist/version.js.map +1 -0
- package/package.json +40 -0
- package/reader/index.html +16 -0
- package/reader/reader.css +58 -0
- package/reader/reader.js +152 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gheop
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# tojiru
|
|
2
|
+
|
|
3
|
+
Turn a fixed-page document into a self-contained static web reader. Point it at a PDF, comic archive, or DjVu; get a folder you can drop on any static host or open locally. No server, no client-side PDF engine — pages are pre-rendered once and lazy-loaded.
|
|
4
|
+
|
|
5
|
+
`tojiru` (綴じる, "to bind a book") is the descendant of a PHP PDF reader written in 2009. The idea that kept it alive for 17 years: pre-render each page, ship it static, let the browser do nothing but display. This rewrite generalizes it to more formats and packages it as a CLI.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
tojiru book.pdf --out site/
|
|
11
|
+
# → site/ (index.html, reader.js, manifest.json, pages/, thumbs/)
|
|
12
|
+
# serve it, or open site/index.html
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
- **PDF** pages become crisp **vector SVG** (via poppler/mupdf). Text stays sharp at any zoom, and for born-digital PDFs the bundle is often *smaller* than the source.
|
|
16
|
+
- **Comics** (CBZ, CB7, CBR) and **DjVu** become image pages with thumbnails.
|
|
17
|
+
- The reader lazy-loads pages as you scroll (`IntersectionObserver`), has a thumbnail sidebar, keyboard navigation, reading-position memory (`localStorage`), and `#page=N` deep links.
|
|
18
|
+
- SVG pages are gzipped and **inflated in the browser** (`DecompressionStream`), so they render correctly on any host regardless of its `Content-Encoding` configuration.
|
|
19
|
+
|
|
20
|
+
## Supported formats
|
|
21
|
+
|
|
22
|
+
| Format | How | Extra dependency |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| PDF | one SVG per page (vector) | `pdftocairo` (poppler) or `mutool` (mupdf) |
|
|
25
|
+
| CBZ | zip of images | none (bundled) |
|
|
26
|
+
| CB7 | 7-Zip archive of images | `7z` (p7zip) |
|
|
27
|
+
| CBR | RAR archive of images | none (bundled, WASM) |
|
|
28
|
+
| DjVu | rendered page by page | `ddjvu`, `djvused` (djvulibre) |
|
|
29
|
+
|
|
30
|
+
Comics work out of the box. Only PDF and DjVu need a system tool, detected at runtime — if it's missing, `tojiru` tells you which package to install instead of crashing.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
Requires **Node ≥ 20**.
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npm install -g tojiru
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Or run it once without installing:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npx tojiru book.pdf --out reader/
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
From source:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
git clone <repo-url> tojiru
|
|
50
|
+
cd tojiru
|
|
51
|
+
npm install
|
|
52
|
+
npm run build
|
|
53
|
+
npm link # makes the `tojiru` command available
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Optional system tools (install only what you need):
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
# Debian/Ubuntu
|
|
60
|
+
sudo apt install poppler-utils djvulibre-bin p7zip-full
|
|
61
|
+
# Fedora
|
|
62
|
+
sudo dnf install poppler-utils djvulibre p7zip
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Usage
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
tojiru <input> [options]
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
| Option | Description |
|
|
72
|
+
|---|---|
|
|
73
|
+
| `-o, --out <dir>` | Output folder (default: the input name without extension) |
|
|
74
|
+
| `-t, --title <title>` | Document title shown in the reader |
|
|
75
|
+
| `-f, --force` | Overwrite a non-empty output folder |
|
|
76
|
+
|
|
77
|
+
Examples:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
tojiru novel.pdf --out reader/ --title "My Novel"
|
|
81
|
+
tojiru comic.cbz --out comic/
|
|
82
|
+
tojiru scan.djvu --out book/ --force
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Then serve the folder with any static server (`npx http-server reader/`) or open `index.html`.
|
|
86
|
+
|
|
87
|
+
## Where it shines (and where it doesn't)
|
|
88
|
+
|
|
89
|
+
`tojiru` is built for **vector text**. Born-digital text PDFs render as compact, razor-sharp SVG. Scanned PDFs and image-heavy pages are rasterized, so the bundle grows — for those, the format is the wrong fit. Measured numbers are in [`examples/BENCH.md`](examples/BENCH.md), with a reproducible sample corpus in [`examples/`](examples/).
|
|
90
|
+
|
|
91
|
+
## How it works
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
input → detect format (magic bytes) → extractor → pages → manifest.json → static bundle + reader
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Each format is a small plugin (`src/extractors/`) producing a normalized list of pages; everything downstream is format-agnostic. Adding a format means adding one extractor.
|
|
98
|
+
|
|
99
|
+
## Development
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
npm test # unit + integration (vitest)
|
|
103
|
+
npm run test:e2e # reader rendering (Playwright)
|
|
104
|
+
npm run dev -- <input> --out <dir> # run the CLI from source via tsx
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT — see [LICENSE](LICENSE).
|
|
110
|
+
|
|
111
|
+
## Changelog
|
|
112
|
+
|
|
113
|
+
### v0.1.0 — Initial release (2026-06-25)
|
|
114
|
+
|
|
115
|
+
- PDF → vector SVG pages; CBZ/CB7/CBR/DjVu → image pages
|
|
116
|
+
- Static folder bundle with a vanilla-JS lazy-loading reader (thumbnails, keyboard nav, resume, deep links)
|
|
117
|
+
- Format detection by content; graceful degradation when an optional system tool is missing
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readdir } from 'node:fs/promises';
|
|
3
|
+
import { basename } from 'node:path';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { Command } from 'commander';
|
|
6
|
+
import { VERSION } from './version.js';
|
|
7
|
+
import { convert } from './convert.js';
|
|
8
|
+
const program = new Command();
|
|
9
|
+
program
|
|
10
|
+
.name('tojiru')
|
|
11
|
+
.description('Turn a fixed-page document into a static web reader')
|
|
12
|
+
.version(VERSION)
|
|
13
|
+
.argument('<input>', 'source file (PDF, CBZ, CB7, CBR, DjVu)')
|
|
14
|
+
.option('-o, --out <dir>', 'output folder')
|
|
15
|
+
.option('-t, --title <title>', 'document title')
|
|
16
|
+
.option('-f, --force', 'overwrite a non-empty output folder')
|
|
17
|
+
.action(async (input, opts) => {
|
|
18
|
+
try {
|
|
19
|
+
if (!existsSync(input))
|
|
20
|
+
throw new Error(`File not found: ${input}`);
|
|
21
|
+
const outDir = opts.out ?? basename(input).replace(/\.[^.]+$/, '');
|
|
22
|
+
if (existsSync(outDir) && (await readdir(outDir)).length > 0 && !opts.force) {
|
|
23
|
+
throw new Error(`Folder ${outDir} is not empty. Use --force to overwrite.`);
|
|
24
|
+
}
|
|
25
|
+
const r = await convert(input, { outDir, title: opts.title });
|
|
26
|
+
console.log(`✓ ${r.pageCount} pages → ${r.outDir}/`);
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
console.error(`Error: ${e.message}`);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
program.parseAsync(process.argv);
|
|
34
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/cli.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAA;AAC1C,OAAO,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAA;AACpC,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAA;AACpC,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AACnC,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AACtC,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AAEtC,MAAM,OAAO,GAAG,IAAI,OAAO,EAAE,CAAA;AAC7B,OAAO;KACJ,IAAI,CAAC,QAAQ,CAAC;KACd,WAAW,CAAC,qDAAqD,CAAC;KAClE,OAAO,CAAC,OAAO,CAAC;KAChB,QAAQ,CAAC,SAAS,EAAE,wCAAwC,CAAC;KAC7D,MAAM,CAAC,iBAAiB,EAAE,eAAe,CAAC;KAC1C,MAAM,CAAC,qBAAqB,EAAE,gBAAgB,CAAC;KAC/C,MAAM,CAAC,aAAa,EAAE,qCAAqC,CAAC;KAC5D,MAAM,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE;IAC5B,IAAI,CAAC;QACH,IAAI,CAAC,UAAU,CAAC,KAAK,CAAC;YAAE,MAAM,IAAI,KAAK,CAAC,mBAAmB,KAAK,EAAE,CAAC,CAAA;QAEnE,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,IAAI,QAAQ,CAAC,KAAK,CAAC,CAAC,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC,CAAA;QAClE,IAAI,UAAU,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC;YAC5E,MAAM,IAAI,KAAK,CAAC,UAAU,MAAM,0CAA0C,CAAC,CAAA;QAC7E,CAAC;QAED,MAAM,CAAC,GAAG,MAAM,OAAO,CAAC,KAAK,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,CAAC,CAAA;QAC7D,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,SAAS,YAAY,CAAC,CAAC,MAAM,GAAG,CAAC,CAAA;IACtD,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,OAAO,CAAC,KAAK,CAAC,UAAW,CAAW,CAAC,OAAO,EAAE,CAAC,CAAA;QAC/C,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IACjB,CAAC;AACH,CAAC,CAAC,CAAA;AAEJ,OAAO,CAAC,UAAU,CAAC,OAAO,CAAC,IAAI,CAAC,CAAA"}
|
package/dist/convert.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { detectKind } from './extractors/detect.js';
|
|
5
|
+
import { pdfExtractor } from './extractors/pdf.js';
|
|
6
|
+
import { cbzExtractor } from './extractors/cbz.js';
|
|
7
|
+
import { cb7Extractor } from './extractors/cb7.js';
|
|
8
|
+
import { cbrExtractor } from './extractors/cbr.js';
|
|
9
|
+
import { djvuExtractor } from './extractors/djvu.js';
|
|
10
|
+
import { processPages } from './pages.js';
|
|
11
|
+
import { buildManifest } from './manifest.js';
|
|
12
|
+
import { writeFolder } from './output/folder.js';
|
|
13
|
+
const EXTRACTORS = [pdfExtractor, cbzExtractor, cb7Extractor, cbrExtractor, djvuExtractor];
|
|
14
|
+
export async function convert(input, opts) {
|
|
15
|
+
const kind = await detectKind(input);
|
|
16
|
+
const extractor = EXTRACTORS.find((e) => e.name === kind);
|
|
17
|
+
if (!extractor) {
|
|
18
|
+
throw new Error(kind ? `Format not yet supported: ${kind}` : 'Unrecognised format');
|
|
19
|
+
}
|
|
20
|
+
const work = await mkdtemp(join(tmpdir(), 'tojiru-'));
|
|
21
|
+
try {
|
|
22
|
+
const doc = await extractor.extract(input, work);
|
|
23
|
+
if (opts.title)
|
|
24
|
+
doc.title = opts.title;
|
|
25
|
+
if (doc.pages.length === 0)
|
|
26
|
+
throw new Error('No pages extracted.');
|
|
27
|
+
const pages = await processPages(doc, opts.outDir);
|
|
28
|
+
await writeFolder(buildManifest(doc.title, doc.kind, pages), opts.outDir);
|
|
29
|
+
return { outDir: opts.outDir, pageCount: doc.pages.length };
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
await rm(work, { recursive: true, force: true });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=convert.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"convert.js","sourceRoot":"","sources":["../src/convert.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,EAAE,EAAE,MAAM,kBAAkB,CAAA;AAC9C,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAA;AAChC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AAChC,OAAO,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAA;AACnD,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAA;AAClD,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAA;AAClD,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAA;AAClD,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAA;AAClD,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAA;AACpD,OAAO,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AACzC,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAA;AAC7C,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AAGhD,MAAM,UAAU,GAAgB,CAAC,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,aAAa,CAAC,CAAA;AAYvG,MAAM,CAAC,KAAK,UAAU,OAAO,CAAC,KAAa,EAAE,IAAoB;IAC/D,MAAM,IAAI,GAAG,MAAM,UAAU,CAAC,KAAK,CAAC,CAAA;IACpC,MAAM,SAAS,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,IAAI,CAAC,CAAA;IACzD,IAAI,CAAC,SAAS,EAAE,CAAC;QACf,MAAM,IAAI,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,6BAA6B,IAAI,EAAE,CAAC,CAAC,CAAC,qBAAqB,CAAC,CAAA;IACrF,CAAC;IAED,MAAM,IAAI,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,SAAS,CAAC,CAAC,CAAA;IACrD,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,SAAS,CAAC,OAAO,CAAC,KAAK,EAAE,IAAI,CAAC,CAAA;QAChD,IAAI,IAAI,CAAC,KAAK;YAAE,GAAG,CAAC,KAAK,GAAG,IAAI,CAAC,KAAK,CAAA;QACtC,IAAI,GAAG,CAAC,KAAK,CAAC,MAAM,KAAK,CAAC;YAAE,MAAM,IAAI,KAAK,CAAC,qBAAqB,CAAC,CAAA;QAClE,MAAM,KAAK,GAAG,MAAM,YAAY,CAAC,GAAG,EAAE,IAAI,CAAC,MAAM,CAAC,CAAA;QAClD,MAAM,WAAW,CAAC,aAAa,CAAC,GAAG,CAAC,KAAK,EAAE,GAAG,CAAC,IAAI,EAAE,KAAK,CAAC,EAAE,IAAI,CAAC,MAAM,CAAC,CAAA;QACzE,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,SAAS,EAAE,GAAG,CAAC,KAAK,CAAC,MAAM,EAAE,CAAA;IAC7D,CAAC;YAAS,CAAC;QACT,MAAM,EAAE,CAAC,IAAI,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAA;IAClD,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { mkdir, readdir } from 'node:fs/promises';
|
|
2
|
+
import { basename, extname, join } from 'node:path';
|
|
3
|
+
import { detectKind } from './detect.js';
|
|
4
|
+
import { hasBinary } from '../tools.js';
|
|
5
|
+
import { run } from '../run.js';
|
|
6
|
+
import { isImage, naturalCompare, imageDims } from './images.js';
|
|
7
|
+
export const cb7Extractor = {
|
|
8
|
+
name: 'cb7',
|
|
9
|
+
async canHandle(file) { return (await detectKind(file)) === 'cb7'; },
|
|
10
|
+
async extract(file, workdir) {
|
|
11
|
+
if (!(await hasBinary('7z')))
|
|
12
|
+
throw new Error('7z not found. Install p7zip (package p7zip / p7zip-full).');
|
|
13
|
+
await mkdir(workdir, { recursive: true });
|
|
14
|
+
// -y: yes to all; e: extract flat (no directory structure); -o: output folder
|
|
15
|
+
await run('7z', ['e', '-y', `-o${workdir}`, file]);
|
|
16
|
+
const files = (await readdir(workdir)).filter(isImage).sort(naturalCompare).map((n) => join(workdir, n));
|
|
17
|
+
if (files.length === 0)
|
|
18
|
+
throw new Error('No images found in the CB7');
|
|
19
|
+
const pages = [];
|
|
20
|
+
for (const f of files)
|
|
21
|
+
pages.push({ type: 'raster', imagePath: f, ...(await imageDims(f)) });
|
|
22
|
+
return { title: basename(file, extname(file)), kind: 'cb7', pages };
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
//# sourceMappingURL=cb7.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cb7.js","sourceRoot":"","sources":["../../src/extractors/cb7.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAA;AACjD,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AAEnD,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AACxC,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AACvC,OAAO,EAAE,GAAG,EAAE,MAAM,WAAW,CAAA;AAC/B,OAAO,EAAE,OAAO,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAEhE,MAAM,CAAC,MAAM,YAAY,GAAc;IACrC,IAAI,EAAE,KAAK;IACX,KAAK,CAAC,SAAS,CAAC,IAAI,IAAI,OAAO,CAAC,MAAM,UAAU,CAAC,IAAI,CAAC,CAAC,KAAK,KAAK,CAAA,CAAC,CAAC;IACnE,KAAK,CAAC,OAAO,CAAC,IAAI,EAAE,OAAO;QACzB,IAAI,CAAC,CAAC,MAAM,SAAS,CAAC,IAAI,CAAC,CAAC;YAAE,MAAM,IAAI,KAAK,CAAC,2DAA2D,CAAC,CAAA;QAC1G,MAAM,KAAK,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;QACzC,8EAA8E;QAC9E,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC,GAAG,EAAE,IAAI,EAAE,KAAK,OAAO,EAAE,EAAE,IAAI,CAAC,CAAC,CAAA;QAClD,MAAM,KAAK,GAAG,CAAC,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,CAAA;QACxG,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;YAAE,MAAM,IAAI,KAAK,CAAC,4BAA4B,CAAC,CAAA;QACrE,MAAM,KAAK,GAAiB,EAAE,CAAA;QAC9B,KAAK,MAAM,CAAC,IAAI,KAAK;YAAE,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,SAAS,EAAE,CAAC,EAAE,GAAG,CAAC,MAAM,SAAS,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAA;QAC5F,OAAO,EAAE,KAAK,EAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,CAAA;IACrE,CAAC;CACF,CAAA"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { mkdir, readdir } from 'node:fs/promises';
|
|
2
|
+
import { basename, extname, join } from 'node:path';
|
|
3
|
+
import { createExtractorFromFile } from 'node-unrar-js';
|
|
4
|
+
import { detectKind } from './detect.js';
|
|
5
|
+
import { isImage, naturalCompare, imageDims } from './images.js';
|
|
6
|
+
export const cbrExtractor = {
|
|
7
|
+
name: 'cbr',
|
|
8
|
+
async canHandle(file) { return (await detectKind(file)) === 'cbr'; },
|
|
9
|
+
async extract(file, workdir) {
|
|
10
|
+
await mkdir(workdir, { recursive: true });
|
|
11
|
+
// filenameTransform strips any directory component so that entries with
|
|
12
|
+
// `../` or absolute paths cannot escape workdir (zip-slip defence).
|
|
13
|
+
const extractor = await createExtractorFromFile({ filepath: file, targetPath: workdir, filenameTransform: (name) => basename(name) });
|
|
14
|
+
// Consume the full iterator to trigger writes to disk.
|
|
15
|
+
const { files } = extractor.extract();
|
|
16
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
17
|
+
for (const _ of files) { /* iterate to trigger writes to targetPath */ }
|
|
18
|
+
const found = [];
|
|
19
|
+
async function walk(d) {
|
|
20
|
+
for (const e of await readdir(d, { withFileTypes: true })) {
|
|
21
|
+
const p = join(d, e.name);
|
|
22
|
+
if (e.isDirectory())
|
|
23
|
+
await walk(p);
|
|
24
|
+
else if (isImage(e.name))
|
|
25
|
+
found.push(p);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
await walk(workdir);
|
|
29
|
+
found.sort((a, b) => naturalCompare(basename(a), basename(b)));
|
|
30
|
+
if (found.length === 0)
|
|
31
|
+
throw new Error('No images found in the CBR');
|
|
32
|
+
const pages = [];
|
|
33
|
+
for (const f of found)
|
|
34
|
+
pages.push({ type: 'raster', imagePath: f, ...(await imageDims(f)) });
|
|
35
|
+
return { title: basename(file, extname(file)), kind: 'cbr', pages };
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
//# sourceMappingURL=cbr.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cbr.js","sourceRoot":"","sources":["../../src/extractors/cbr.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAA;AACjD,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AACnD,OAAO,EAAE,uBAAuB,EAAE,MAAM,eAAe,CAAA;AAEvD,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AACxC,OAAO,EAAE,OAAO,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAEhE,MAAM,CAAC,MAAM,YAAY,GAAc;IACrC,IAAI,EAAE,KAAK;IACX,KAAK,CAAC,SAAS,CAAC,IAAI,IAAI,OAAO,CAAC,MAAM,UAAU,CAAC,IAAI,CAAC,CAAC,KAAK,KAAK,CAAA,CAAC,CAAC;IACnE,KAAK,CAAC,OAAO,CAAC,IAAI,EAAE,OAAO;QACzB,MAAM,KAAK,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;QACzC,wEAAwE;QACxE,oEAAoE;QACpE,MAAM,SAAS,GAAG,MAAM,uBAAuB,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,UAAU,EAAE,OAAO,EAAE,iBAAiB,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;QACrI,uDAAuD;QACvD,MAAM,EAAE,KAAK,EAAE,GAAG,SAAS,CAAC,OAAO,EAAE,CAAA;QACrC,6DAA6D;QAC7D,KAAK,MAAM,CAAC,IAAI,KAAK,EAAE,CAAC,CAAC,6CAA6C,CAAC,CAAC;QAExE,MAAM,KAAK,GAAa,EAAE,CAAA;QAC1B,KAAK,UAAU,IAAI,CAAC,CAAS;YAC3B,KAAK,MAAM,CAAC,IAAI,MAAM,OAAO,CAAC,CAAC,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC;gBAC1D,MAAM,CAAC,GAAG,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,CAAA;gBACzB,IAAI,CAAC,CAAC,WAAW,EAAE;oBAAE,MAAM,IAAI,CAAC,CAAC,CAAC,CAAA;qBAC7B,IAAI,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC;oBAAE,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;YACzC,CAAC;QACH,CAAC;QACD,MAAM,IAAI,CAAC,OAAO,CAAC,CAAA;QACnB,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,cAAc,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;QAC9D,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;YAAE,MAAM,IAAI,KAAK,CAAC,4BAA4B,CAAC,CAAA;QACrE,MAAM,KAAK,GAAiB,EAAE,CAAA;QAC9B,KAAK,MAAM,CAAC,IAAI,KAAK;YAAE,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,SAAS,EAAE,CAAC,EAAE,GAAG,CAAC,MAAM,SAAS,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAA;QAC5F,OAAO,EAAE,KAAK,EAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,CAAA;IACrE,CAAC;CACF,CAAA"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createWriteStream } from 'node:fs';
|
|
2
|
+
import { mkdir } from 'node:fs/promises';
|
|
3
|
+
import { basename, extname, join } from 'node:path';
|
|
4
|
+
import { pipeline } from 'node:stream/promises';
|
|
5
|
+
import yauzl from 'yauzl';
|
|
6
|
+
import { detectKind } from './detect.js';
|
|
7
|
+
import { isImage, naturalCompare, imageDims } from './images.js';
|
|
8
|
+
function openZip(file) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
yauzl.open(file, { lazyEntries: true, decodeStrings: false }, (err, zip) => (err ? reject(err) : resolve(zip)));
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
// Extracts all image entries to workdir (flattened names), returns the paths.
|
|
14
|
+
function extractAll(zip, workdir) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const out = [];
|
|
17
|
+
zip.on('entry', (entry) => {
|
|
18
|
+
const name = entry.fileName.toString('utf8');
|
|
19
|
+
if (name.endsWith('/') || !isImage(name)) {
|
|
20
|
+
zip.readEntry();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
zip.openReadStream(entry, async (err, rs) => {
|
|
24
|
+
if (err)
|
|
25
|
+
return reject(err);
|
|
26
|
+
const dest = join(workdir, basename(name));
|
|
27
|
+
try {
|
|
28
|
+
await pipeline(rs, createWriteStream(dest));
|
|
29
|
+
out.push(dest);
|
|
30
|
+
zip.readEntry();
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
reject(e);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
zip.on('end', () => resolve(out));
|
|
38
|
+
zip.on('error', reject);
|
|
39
|
+
zip.readEntry();
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
export const cbzExtractor = {
|
|
43
|
+
name: 'cbz',
|
|
44
|
+
async canHandle(file) { return (await detectKind(file)) === 'cbz'; },
|
|
45
|
+
async extract(file, workdir) {
|
|
46
|
+
await mkdir(workdir, { recursive: true });
|
|
47
|
+
const zip = await openZip(file);
|
|
48
|
+
const files = (await extractAll(zip, workdir)).sort((a, b) => naturalCompare(basename(a), basename(b)));
|
|
49
|
+
if (files.length === 0)
|
|
50
|
+
throw new Error('No images found in the CBZ');
|
|
51
|
+
const pages = [];
|
|
52
|
+
for (const f of files)
|
|
53
|
+
pages.push({ type: 'raster', imagePath: f, ...(await imageDims(f)) });
|
|
54
|
+
return { title: basename(file, extname(file)), kind: 'cbz', pages };
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
//# sourceMappingURL=cbz.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cbz.js","sourceRoot":"","sources":["../../src/extractors/cbz.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { open } from 'node:fs/promises';
|
|
2
|
+
async function readMagic(file, n = 16) {
|
|
3
|
+
const fh = await open(file, 'r');
|
|
4
|
+
try {
|
|
5
|
+
const buf = Buffer.alloc(n);
|
|
6
|
+
const { bytesRead } = await fh.read(buf, 0, n, 0);
|
|
7
|
+
return buf.subarray(0, bytesRead);
|
|
8
|
+
}
|
|
9
|
+
finally {
|
|
10
|
+
await fh.close();
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function startsWith(buf, sig) {
|
|
14
|
+
if (buf.length < sig.length)
|
|
15
|
+
return false;
|
|
16
|
+
return sig.every((b, i) => buf[i] === b);
|
|
17
|
+
}
|
|
18
|
+
// Detection is based on file content (magic bytes), not extension:
|
|
19
|
+
// a .cbz is often a disguised RAR or 7z.
|
|
20
|
+
export async function detectKind(file) {
|
|
21
|
+
const buf = await readMagic(file);
|
|
22
|
+
if (startsWith(buf, [0x25, 0x50, 0x44, 0x46]))
|
|
23
|
+
return 'pdf'; // %PDF
|
|
24
|
+
if (startsWith(buf, [0x52, 0x61, 0x72, 0x21]))
|
|
25
|
+
return 'cbr'; // Rar!
|
|
26
|
+
if (startsWith(buf, [0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c]))
|
|
27
|
+
return 'cb7'; // 7z
|
|
28
|
+
if (startsWith(buf, [0x41, 0x54, 0x26, 0x54]))
|
|
29
|
+
return 'djvu'; // AT&T
|
|
30
|
+
if (startsWith(buf, [0x50, 0x4b, 0x03, 0x04]) || startsWith(buf, [0x50, 0x4b, 0x05, 0x06])) {
|
|
31
|
+
// Zip: in v1 the only supported zip format is CBZ.
|
|
32
|
+
return 'cbz';
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=detect.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"detect.js","sourceRoot":"","sources":["../../src/extractors/detect.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAA;AAGvC,KAAK,UAAU,SAAS,CAAC,IAAY,EAAE,CAAC,GAAG,EAAE;IAC3C,MAAM,EAAE,GAAG,MAAM,IAAI,CAAC,IAAI,EAAE,GAAG,CAAC,CAAA;IAChC,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAA;QAC3B,MAAM,EAAE,SAAS,EAAE,GAAG,MAAM,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAA;QACjD,OAAO,GAAG,CAAC,QAAQ,CAAC,CAAC,EAAE,SAAS,CAAC,CAAA;IACnC,CAAC;YAAS,CAAC;QACT,MAAM,EAAE,CAAC,KAAK,EAAE,CAAA;IAClB,CAAC;AACH,CAAC;AAED,SAAS,UAAU,CAAC,GAAW,EAAE,GAAa;IAC5C,IAAI,GAAG,CAAC,MAAM,GAAG,GAAG,CAAC,MAAM;QAAE,OAAO,KAAK,CAAA;IACzC,OAAO,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAA;AAC1C,CAAC;AAED,mEAAmE;AACnE,yCAAyC;AACzC,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,IAAY;IAC3C,MAAM,GAAG,GAAG,MAAM,SAAS,CAAC,IAAI,CAAC,CAAA;IAEjC,IAAI,UAAU,CAAC,GAAG,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC;QAAE,OAAO,KAAK,CAAA,CAAQ,OAAO;IAC1E,IAAI,UAAU,CAAC,GAAG,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC;QAAE,OAAO,KAAK,CAAA,CAAQ,OAAO;IAC1E,IAAI,UAAU,CAAC,GAAG,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC;QAAE,OAAO,KAAK,CAAA,CAAC,KAAK;IAC7E,IAAI,UAAU,CAAC,GAAG,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC;QAAE,OAAO,MAAM,CAAA,CAAO,OAAO;IAC1E,IAAI,UAAU,CAAC,GAAG,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC,IAAI,UAAU,CAAC,GAAG,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC,EAAE,CAAC;QAC3F,mDAAmD;QACnD,OAAO,KAAK,CAAA;IACd,CAAC;IACD,OAAO,IAAI,CAAA;AACb,CAAC"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { mkdir } from 'node:fs/promises';
|
|
2
|
+
import { basename, extname, join } from 'node:path';
|
|
3
|
+
import sharp from 'sharp';
|
|
4
|
+
import { detectKind } from './detect.js';
|
|
5
|
+
import { hasBinary } from '../tools.js';
|
|
6
|
+
import { run } from '../run.js';
|
|
7
|
+
import { imageDims } from './images.js';
|
|
8
|
+
async function pageCount(file) {
|
|
9
|
+
const { stdout } = await run('djvused', ['-e', 'n', file]);
|
|
10
|
+
const n = parseInt(stdout.trim(), 10);
|
|
11
|
+
if (!Number.isFinite(n) || n < 1)
|
|
12
|
+
throw new Error('Could not read DjVu page count');
|
|
13
|
+
return n;
|
|
14
|
+
}
|
|
15
|
+
export const djvuExtractor = {
|
|
16
|
+
name: 'djvu',
|
|
17
|
+
async canHandle(file) { return (await detectKind(file)) === 'djvu'; },
|
|
18
|
+
async extract(file, workdir) {
|
|
19
|
+
if (!(await hasBinary('ddjvu')) || !(await hasBinary('djvused'))) {
|
|
20
|
+
throw new Error('djvulibre not found. Install djvulibre (ddjvu, djvused).');
|
|
21
|
+
}
|
|
22
|
+
await mkdir(workdir, { recursive: true });
|
|
23
|
+
const count = await pageCount(file);
|
|
24
|
+
const width = Math.max(4, String(count).length);
|
|
25
|
+
const pages = [];
|
|
26
|
+
for (let i = 1; i <= count; i++) {
|
|
27
|
+
const stem = String(i).padStart(width, '0');
|
|
28
|
+
const tiff = join(workdir, `${stem}.tiff`);
|
|
29
|
+
const webp = join(workdir, `${stem}.webp`);
|
|
30
|
+
await run('ddjvu', ['-format=tiff', `-page=${i}`, file, tiff]);
|
|
31
|
+
// DjVu scans are often bilevel/text: WebP lossless (≈50% of PNG) rather than lossy,
|
|
32
|
+
// which bloats and degrades text edges on bitonal content.
|
|
33
|
+
await sharp(tiff).webp({ lossless: true, effort: 6 }).toFile(webp);
|
|
34
|
+
pages.push({ type: 'raster', imagePath: webp, ...(await imageDims(webp)) });
|
|
35
|
+
}
|
|
36
|
+
return { title: basename(file, extname(file)), kind: 'djvu', pages };
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
//# sourceMappingURL=djvu.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"djvu.js","sourceRoot":"","sources":["../../src/extractors/djvu.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
const IMAGE_RE = /\.(jpe?g|png|gif|webp|bmp)$/i;
|
|
3
|
+
export function isImage(name) {
|
|
4
|
+
return IMAGE_RE.test(name);
|
|
5
|
+
}
|
|
6
|
+
// Natural sort: compares numeric segments by value, the rest lexicographically.
|
|
7
|
+
export function naturalCompare(a, b) {
|
|
8
|
+
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
|
|
9
|
+
}
|
|
10
|
+
export async function imageDims(path) {
|
|
11
|
+
const m = await sharp(path).metadata();
|
|
12
|
+
if (!m.width || !m.height)
|
|
13
|
+
throw new Error(`Cannot read dimensions: ${path}`);
|
|
14
|
+
return { w: m.width, h: m.height };
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=images.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"images.js","sourceRoot":"","sources":["../../src/extractors/images.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAEzB,MAAM,QAAQ,GAAG,8BAA8B,CAAA;AAE/C,MAAM,UAAU,OAAO,CAAC,IAAY;IAClC,OAAO,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;AAC5B,CAAC;AAED,gFAAgF;AAChF,MAAM,UAAU,cAAc,CAAC,CAAS,EAAE,CAAS;IACjD,OAAO,CAAC,CAAC,aAAa,CAAC,CAAC,EAAE,SAAS,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,MAAM,EAAE,CAAC,CAAA;AAC9E,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,SAAS,CAAC,IAAY;IAC1C,MAAM,CAAC,GAAG,MAAM,KAAK,CAAC,IAAI,CAAC,CAAC,QAAQ,EAAE,CAAA;IACtC,IAAI,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,CAAC,MAAM;QAAE,MAAM,IAAI,KAAK,CAAC,2BAA2B,IAAI,EAAE,CAAC,CAAA;IAC7E,OAAO,EAAE,CAAC,EAAE,CAAC,CAAC,KAAK,EAAE,CAAC,EAAE,CAAC,CAAC,MAAM,EAAE,CAAA;AACpC,CAAC"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { basename, extname, join } from 'node:path';
|
|
3
|
+
import { detectKind } from './detect.js';
|
|
4
|
+
import { findPdfConverter } from '../tools.js';
|
|
5
|
+
import { run } from '../run.js';
|
|
6
|
+
function pad(n, width) {
|
|
7
|
+
return String(n).padStart(width, '0');
|
|
8
|
+
}
|
|
9
|
+
async function pageCount(file) {
|
|
10
|
+
// pdfinfo is part of poppler; fall back to mutool if absent.
|
|
11
|
+
try {
|
|
12
|
+
const { stdout } = await run('pdfinfo', [file]);
|
|
13
|
+
const m = stdout.match(/^Pages:\s+(\d+)/m);
|
|
14
|
+
if (m)
|
|
15
|
+
return Number(m[1]);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
/* try mutool below */
|
|
19
|
+
}
|
|
20
|
+
const { stdout } = await run('mutool', ['info', file]);
|
|
21
|
+
const m = stdout.match(/Pages:\s+(\d+)/);
|
|
22
|
+
if (!m)
|
|
23
|
+
throw new Error('Could not determine page count for PDF');
|
|
24
|
+
return Number(m[1]);
|
|
25
|
+
}
|
|
26
|
+
function viewBox(svg) {
|
|
27
|
+
const vb = svg.match(/viewBox="[\d.]+ [\d.]+ ([\d.]+) ([\d.]+)"/);
|
|
28
|
+
if (vb)
|
|
29
|
+
return { w: Math.round(Number(vb[1])), h: Math.round(Number(vb[2])) };
|
|
30
|
+
const w = svg.match(/width="([\d.]+)/);
|
|
31
|
+
const h = svg.match(/height="([\d.]+)/);
|
|
32
|
+
if (w && h)
|
|
33
|
+
return { w: Math.round(Number(w[1])), h: Math.round(Number(h[1])) };
|
|
34
|
+
throw new Error('SVG has no usable dimensions');
|
|
35
|
+
}
|
|
36
|
+
export const pdfExtractor = {
|
|
37
|
+
name: 'pdf',
|
|
38
|
+
async canHandle(file) {
|
|
39
|
+
return (await detectKind(file)) === 'pdf';
|
|
40
|
+
},
|
|
41
|
+
async extract(file, workdir) {
|
|
42
|
+
const conv = await findPdfConverter();
|
|
43
|
+
if (!conv) {
|
|
44
|
+
throw new Error('No PDF converter found. Install poppler (pdftocairo) or mupdf (mutool).');
|
|
45
|
+
}
|
|
46
|
+
const count = await pageCount(file);
|
|
47
|
+
const width = Math.max(4, String(count).length);
|
|
48
|
+
const pages = [];
|
|
49
|
+
for (let i = 1; i <= count; i++) {
|
|
50
|
+
const svgPath = join(workdir, `${pad(i, width)}.svg`);
|
|
51
|
+
if (conv === 'pdftocairo') {
|
|
52
|
+
await run('pdftocairo', ['-svg', '-f', String(i), '-l', String(i), file, svgPath]);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
await run('mutool', ['draw', '-F', 'svg', '-o', svgPath, file, String(i)]);
|
|
56
|
+
}
|
|
57
|
+
const svg = await readFile(svgPath, 'utf8');
|
|
58
|
+
pages.push({ type: 'vector', svgPath, ...viewBox(svg) });
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
title: basename(file, extname(file)),
|
|
62
|
+
kind: 'pdf',
|
|
63
|
+
pages,
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
//# sourceMappingURL=pdf.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pdf.js","sourceRoot":"","sources":["../../src/extractors/pdf.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/extractors/types.ts"],"names":[],"mappings":""}
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"manifest.js","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AAWA,MAAM,UAAU,aAAa,CAAC,KAAa,EAAE,IAAU,EAAE,KAAsB;IAC7E,OAAO,EAAE,MAAM,EAAE,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,CAAC,MAAM,EAAE,KAAK,EAAE,CAAA;AACnE,CAAC"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { mkdir, writeFile, copyFile } from 'node:fs/promises';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
// reader/ is at the package root. Whether running from dist/output/ or src/output/,
|
|
5
|
+
// we go up two levels.
|
|
6
|
+
export function readerDir() {
|
|
7
|
+
return fileURLToPath(new URL('../../reader/', import.meta.url));
|
|
8
|
+
}
|
|
9
|
+
export async function writeFolder(manifest, outDir) {
|
|
10
|
+
await mkdir(outDir, { recursive: true });
|
|
11
|
+
await writeFile(join(outDir, 'manifest.json'), JSON.stringify(manifest));
|
|
12
|
+
const rd = readerDir();
|
|
13
|
+
for (const f of ['index.html', 'reader.js', 'reader.css']) {
|
|
14
|
+
await copyFile(join(rd, f), join(outDir, f));
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=folder.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"folder.js","sourceRoot":"","sources":["../../src/output/folder.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAA;AAC7D,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AACxC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AAGhC,oFAAoF;AACpF,uBAAuB;AACvB,MAAM,UAAU,SAAS;IACvB,OAAO,aAAa,CAAC,IAAI,GAAG,CAAC,eAAe,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAA;AACjE,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,WAAW,CAAC,QAAkB,EAAE,MAAc;IAClE,MAAM,KAAK,CAAC,MAAM,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;IACxC,MAAM,SAAS,CAAC,IAAI,CAAC,MAAM,EAAE,eAAe,CAAC,EAAE,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC,CAAA;IACxE,MAAM,EAAE,GAAG,SAAS,EAAE,CAAA;IACtB,KAAK,MAAM,CAAC,IAAI,CAAC,YAAY,EAAE,WAAW,EAAE,YAAY,CAAC,EAAE,CAAC;QAC1D,MAAM,QAAQ,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,CAAA;IAC9C,CAAC;AACH,CAAC"}
|
package/dist/pages.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile, copyFile } from 'node:fs/promises';
|
|
2
|
+
import { gzipSync } from 'node:zlib';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
import sharp from 'sharp';
|
|
5
|
+
export async function processPages(doc, outDir, opts = {}) {
|
|
6
|
+
const thumbWidth = opts.thumbWidth ?? 150;
|
|
7
|
+
const width = Math.max(4, String(doc.pages.length).length);
|
|
8
|
+
await mkdir(join(outDir, 'pages'), { recursive: true });
|
|
9
|
+
await mkdir(join(outDir, 'thumbs'), { recursive: true });
|
|
10
|
+
const out = [];
|
|
11
|
+
for (let i = 0; i < doc.pages.length; i++) {
|
|
12
|
+
const page = doc.pages[i];
|
|
13
|
+
const n = i + 1;
|
|
14
|
+
const stem = String(n).padStart(width, '0');
|
|
15
|
+
const thumb = `thumbs/${stem}.webp`;
|
|
16
|
+
if (page.type === 'vector') {
|
|
17
|
+
const svg = await readFile(page.svgPath);
|
|
18
|
+
const file = `pages/${stem}.svgz`;
|
|
19
|
+
await writeFile(join(outDir, file), gzipSync(svg, { level: 9 }));
|
|
20
|
+
await sharp(svg, { density: 96 }).resize({ width: thumbWidth }).webp().toFile(join(outDir, thumb));
|
|
21
|
+
out.push({ n, type: 'vector', w: page.w, h: page.h, file, thumb });
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
const ext = basename(page.imagePath).split('.').pop() ?? 'jpg';
|
|
25
|
+
const file = `pages/${stem}.${ext}`;
|
|
26
|
+
await copyFile(page.imagePath, join(outDir, file));
|
|
27
|
+
await sharp(page.imagePath).resize({ width: thumbWidth }).webp().toFile(join(outDir, thumb));
|
|
28
|
+
out.push({ n, type: 'raster', w: page.w, h: page.h, file, thumb });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=pages.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pages.js","sourceRoot":"","sources":["../src/pages.ts"],"names":[],"mappings":"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"}
|
package/dist/run.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
export function run(cmd, args) {
|
|
3
|
+
return new Promise((resolve, reject) => {
|
|
4
|
+
const child = spawn(cmd, args);
|
|
5
|
+
let stdout = '';
|
|
6
|
+
let stderr = '';
|
|
7
|
+
child.stdout.on('data', (d) => (stdout += d));
|
|
8
|
+
child.stderr.on('data', (d) => (stderr += d));
|
|
9
|
+
child.on('error', reject);
|
|
10
|
+
child.on('close', (code) => {
|
|
11
|
+
if (code === 0)
|
|
12
|
+
resolve({ stdout, code });
|
|
13
|
+
else
|
|
14
|
+
reject(new Error(`${cmd} failed (exit ${code}): ${stderr.trim()}`));
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=run.js.map
|
package/dist/run.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"run.js","sourceRoot":"","sources":["../src/run.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAA;AAE1C,MAAM,UAAU,GAAG,CAAC,GAAW,EAAE,IAAc;IAC7C,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,MAAM,KAAK,GAAG,KAAK,CAAC,GAAG,EAAE,IAAI,CAAC,CAAA;QAC9B,IAAI,MAAM,GAAG,EAAE,CAAA;QACf,IAAI,MAAM,GAAG,EAAE,CAAA;QACf,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,MAAM,IAAI,CAAC,CAAC,CAAC,CAAA;QAC7C,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,MAAM,IAAI,CAAC,CAAC,CAAC,CAAA;QAC7C,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,CAAC,CAAA;QACzB,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE;YACzB,IAAI,IAAI,KAAK,CAAC;gBAAE,OAAO,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAA;;gBACpC,MAAM,CAAC,IAAI,KAAK,CAAC,GAAG,GAAG,iBAAiB,IAAI,MAAM,MAAM,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,CAAA;QAC1E,CAAC,CAAC,CAAA;IACJ,CAAC,CAAC,CAAA;AACJ,CAAC"}
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
// Detects the presence of a binary via `command -v` (POSIX) with no dependencies.
|
|
3
|
+
// Internal: builds a shell command; call only with hardcoded trusted names ('pdftocairo', 'mutool').
|
|
4
|
+
export function hasBinary(cmd) {
|
|
5
|
+
return new Promise((resolve) => {
|
|
6
|
+
const probe = spawn('command', ['-v', cmd], { shell: true, stdio: 'ignore' });
|
|
7
|
+
probe.on('error', () => resolve(false));
|
|
8
|
+
probe.on('close', (code) => resolve(code === 0));
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
// poppler (pdftocairo) by default, mupdf (mutool) as fallback.
|
|
12
|
+
export async function findPdfConverter() {
|
|
13
|
+
if (await hasBinary('pdftocairo'))
|
|
14
|
+
return 'pdftocairo';
|
|
15
|
+
if (await hasBinary('mutool'))
|
|
16
|
+
return 'mutool';
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=tools.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tools.js","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAA;AAE1C,kFAAkF;AAClF,qGAAqG;AACrG,MAAM,UAAU,SAAS,CAAC,GAAW;IACnC,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE;QAC7B,MAAM,KAAK,GAAG,KAAK,CAAC,SAAS,EAAE,CAAC,IAAI,EAAE,GAAG,CAAC,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,QAAQ,EAAE,CAAC,CAAA;QAC7E,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAA;QACvC,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,KAAK,CAAC,CAAC,CAAC,CAAA;IAClD,CAAC,CAAC,CAAA;AACJ,CAAC;AAID,+DAA+D;AAC/D,MAAM,CAAC,KAAK,UAAU,gBAAgB;IACpC,IAAI,MAAM,SAAS,CAAC,YAAY,CAAC;QAAE,OAAO,YAAY,CAAA;IACtD,IAAI,MAAM,SAAS,CAAC,QAAQ,CAAC;QAAE,OAAO,QAAQ,CAAA;IAC9C,OAAO,IAAI,CAAA;AACb,CAAC"}
|
package/dist/version.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"version.js","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,OAAO,GAAG,OAAO,CAAA"}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gheop/tojiru",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Turn a fixed-page document (PDF, comic, DjVu) into a static web reader",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"tojiru": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"reader"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc",
|
|
19
|
+
"dev": "tsx src/cli.ts",
|
|
20
|
+
"test": "vitest run",
|
|
21
|
+
"test:e2e": "playwright test",
|
|
22
|
+
"prepublishOnly": "npm run build"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"commander": "^12.1.0",
|
|
26
|
+
"node-unrar-js": "^2.0.2",
|
|
27
|
+
"sharp": "^0.33.4",
|
|
28
|
+
"yauzl": "^3.4.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@playwright/test": "^1.45.0",
|
|
32
|
+
"@types/node": "^20.14.0",
|
|
33
|
+
"@types/yauzl": "^3.4.0",
|
|
34
|
+
"http-server": "^14.1.1",
|
|
35
|
+
"pdf-lib": "^1.17.1",
|
|
36
|
+
"tsx": "^4.16.0",
|
|
37
|
+
"typescript": "^5.5.0",
|
|
38
|
+
"vitest": "^2.0.0"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>tojiru</title>
|
|
7
|
+
<link rel="stylesheet" href="reader.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="reduce" title="Collapse / expand thumbnails">☰</div>
|
|
11
|
+
<nav id="menu" aria-label="Pages"></nav>
|
|
12
|
+
<div id="resize" title="Drag to resize"></div>
|
|
13
|
+
<main id="page"></main>
|
|
14
|
+
<script type="module" src="reader.js"></script>
|
|
15
|
+
</body>
|
|
16
|
+
</html>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/* Reproduces the look of the original reader (gheop, 2009):
|
|
2
|
+
thumbnail column on the left, centered pages with a silver border and shadow,
|
|
3
|
+
pages/thumbnails dimmed in opacity, the current page at full opacity. */
|
|
4
|
+
|
|
5
|
+
* { box-sizing: border-box; }
|
|
6
|
+
html, body { margin: 0; height: 100%; }
|
|
7
|
+
body { font-family: verdana, sans-serif; background: #fff; }
|
|
8
|
+
#page, #menu { user-select: none; }
|
|
9
|
+
|
|
10
|
+
#reduce {
|
|
11
|
+
position: fixed; top: 5px; left: 5px; z-index: 2;
|
|
12
|
+
width: 20px; height: 20px; line-height: 18px; text-align: center;
|
|
13
|
+
background: rgba(255, 255, 255, .85); border: 1px solid #bbb; border-radius: 3px;
|
|
14
|
+
cursor: pointer; font-size: 12px; color: #555;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
#menu {
|
|
18
|
+
position: fixed; top: 0; bottom: 0; left: 0; width: 150px;
|
|
19
|
+
overflow: auto; text-align: center; background: #fff; padding-top: 28px;
|
|
20
|
+
}
|
|
21
|
+
#menu.hidden { display: none; }
|
|
22
|
+
#menu img {
|
|
23
|
+
width: 100px; margin: 10px 0 2px; cursor: pointer; background: #fff;
|
|
24
|
+
opacity: .5; border: 1px solid silver; box-shadow: .2em .2em #ccc;
|
|
25
|
+
transition: opacity .15s ease, width .1s ease;
|
|
26
|
+
}
|
|
27
|
+
#menu img:hover { width: 104px; }
|
|
28
|
+
#menu img.select { opacity: 1; }
|
|
29
|
+
#menu .num { display: block; font-size: 11px; color: #999; margin-bottom: 8px; }
|
|
30
|
+
|
|
31
|
+
#resize {
|
|
32
|
+
position: fixed; top: 0; bottom: 0; left: 150px; width: 4px;
|
|
33
|
+
border-right: 1px solid #999; background: #ececec; cursor: col-resize; z-index: 1;
|
|
34
|
+
}
|
|
35
|
+
#resize.hidden { display: none; }
|
|
36
|
+
|
|
37
|
+
#page {
|
|
38
|
+
position: fixed; top: 0; bottom: 0; left: 155px; right: 0;
|
|
39
|
+
overflow: auto; text-align: center; scroll-behavior: smooth; background: #fff;
|
|
40
|
+
}
|
|
41
|
+
#page.full { left: 0; }
|
|
42
|
+
|
|
43
|
+
.page {
|
|
44
|
+
display: block; margin: 12px auto; width: calc(100% - 36px);
|
|
45
|
+
}
|
|
46
|
+
.page object, .page img {
|
|
47
|
+
display: block; width: 100%; height: 100%;
|
|
48
|
+
border: 1px solid silver; box-shadow: .2em .2em #ccc;
|
|
49
|
+
background: #fff; opacity: .7; transition: opacity .25s ease;
|
|
50
|
+
}
|
|
51
|
+
.page.select object, .page.select img { opacity: 1; border-color: #777; }
|
|
52
|
+
|
|
53
|
+
/* Thin scrollbars, hidden until hovered. */
|
|
54
|
+
#menu, #page { scrollbar-width: thin; scrollbar-color: transparent transparent; }
|
|
55
|
+
#menu:hover, #page:hover { scrollbar-color: #c4c4c4 transparent; }
|
|
56
|
+
#menu::-webkit-scrollbar, #page::-webkit-scrollbar { width: 7px; }
|
|
57
|
+
#menu::-webkit-scrollbar-thumb, #page::-webkit-scrollbar-thumb { background: transparent; border-radius: 4px; }
|
|
58
|
+
#menu:hover::-webkit-scrollbar-thumb, #page:hover::-webkit-scrollbar-thumb { background: #c4c4c4; }
|
package/reader/reader.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
const $ = (sel, el = document) => el.querySelector(sel)
|
|
2
|
+
|
|
3
|
+
async function loadManifest() {
|
|
4
|
+
const inline = document.getElementById('tojiru-manifest')
|
|
5
|
+
if (inline) return JSON.parse(inline.textContent)
|
|
6
|
+
const res = await fetch('manifest.json')
|
|
7
|
+
if (!res.ok) throw new Error('manifest.json not found')
|
|
8
|
+
return res.json()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function fetchSvg(file) {
|
|
12
|
+
const res = await fetch(file)
|
|
13
|
+
const buf = new Uint8Array(await res.arrayBuffer())
|
|
14
|
+
// If the host already applied Content-Encoding: gzip, the browser inflated it
|
|
15
|
+
// and these bytes are plain SVG (no gzip magic). Only inflate when the bytes
|
|
16
|
+
// are actually gzip — so the reader works on any host, no header dependency.
|
|
17
|
+
if (buf[0] === 0x1f && buf[1] === 0x8b) {
|
|
18
|
+
const stream = new Blob([buf]).stream().pipeThrough(new DecompressionStream('gzip'))
|
|
19
|
+
return new Response(stream).text()
|
|
20
|
+
}
|
|
21
|
+
return new TextDecoder().decode(buf)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function loadPage(p) {
|
|
25
|
+
if (p.type === 'vector') {
|
|
26
|
+
let text = await fetchSvg(p.file)
|
|
27
|
+
// pdftocairo restarts its glyph <symbol> ids at 0 on every page. Injecting all
|
|
28
|
+
// pages inline into one document makes a later <use href="#glyph…"> resolve to
|
|
29
|
+
// page 1's glyph (garbled text). Render each page as its own document via
|
|
30
|
+
// <object> so the ids stay isolated. Strip the fixed pt width/height so the
|
|
31
|
+
// SVG scales to its container through its viewBox.
|
|
32
|
+
text = text.replace(/(<svg[^>]*?)\swidth="[^"]*"/i, '$1').replace(/(<svg[^>]*?)\sheight="[^"]*"/i, '$1')
|
|
33
|
+
const obj = document.createElement('object')
|
|
34
|
+
obj.type = 'image/svg+xml'
|
|
35
|
+
obj.data = URL.createObjectURL(new Blob([text], { type: 'image/svg+xml' }))
|
|
36
|
+
obj.addEventListener('load', () => URL.revokeObjectURL(obj.data), { once: true })
|
|
37
|
+
return obj
|
|
38
|
+
}
|
|
39
|
+
const img = document.createElement('img')
|
|
40
|
+
img.src = p.file
|
|
41
|
+
img.alt = `page ${p.n}`
|
|
42
|
+
return img
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function init(manifest) {
|
|
46
|
+
document.title = manifest.title
|
|
47
|
+
const menu = $('#menu')
|
|
48
|
+
const pageEl = $('#page')
|
|
49
|
+
const resize = $('#resize')
|
|
50
|
+
const key = `tojiru:${manifest.title}`
|
|
51
|
+
let current = 0
|
|
52
|
+
|
|
53
|
+
const thumbs = manifest.pages.map((p) => {
|
|
54
|
+
const t = document.createElement('img')
|
|
55
|
+
t.src = p.thumb
|
|
56
|
+
t.loading = 'lazy'
|
|
57
|
+
t.alt = `page ${p.n}`
|
|
58
|
+
t.addEventListener('click', () => goTo(p.n))
|
|
59
|
+
const num = document.createElement('span')
|
|
60
|
+
num.className = 'num'
|
|
61
|
+
num.textContent = String(p.n)
|
|
62
|
+
menu.append(t, num)
|
|
63
|
+
return t
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const io = new IntersectionObserver(onIntersect, { root: pageEl, rootMargin: '800px 0px' })
|
|
67
|
+
const containers = manifest.pages.map((p) => {
|
|
68
|
+
const c = document.createElement('div')
|
|
69
|
+
c.className = 'page'
|
|
70
|
+
c.style.aspectRatio = `${p.w} / ${p.h}`
|
|
71
|
+
c.dataset.n = String(p.n)
|
|
72
|
+
io.observe(c)
|
|
73
|
+
pageEl.append(c)
|
|
74
|
+
return c
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
async function onIntersect(entries) {
|
|
78
|
+
for (const e of entries) {
|
|
79
|
+
if (!e.isIntersecting) continue
|
|
80
|
+
const c = e.target
|
|
81
|
+
if (!c.dataset.loaded) {
|
|
82
|
+
c.dataset.loaded = '1'
|
|
83
|
+
c.append(await loadPage(manifest.pages[Number(c.dataset.n) - 1]))
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function setCurrent(n) {
|
|
89
|
+
if (n === current) return
|
|
90
|
+
thumbs[current - 1]?.classList.remove('select')
|
|
91
|
+
containers[current - 1]?.classList.remove('select')
|
|
92
|
+
current = n
|
|
93
|
+
thumbs[current - 1]?.classList.add('select')
|
|
94
|
+
containers[current - 1]?.classList.add('select')
|
|
95
|
+
thumbs[current - 1]?.scrollIntoView({ block: 'nearest' })
|
|
96
|
+
history.replaceState(null, '', `#page=${n}`)
|
|
97
|
+
try { localStorage.setItem(key, String(n)) } catch {}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function goTo(n) {
|
|
101
|
+
n = Math.min(Math.max(1, n), manifest.pages.length)
|
|
102
|
+
containers[n - 1].scrollIntoView()
|
|
103
|
+
setCurrent(n)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
pageEl.addEventListener('scroll', () => {
|
|
107
|
+
const mid = pageEl.scrollTop + pageEl.clientHeight / 2
|
|
108
|
+
for (let i = 0; i < containers.length; i++) {
|
|
109
|
+
const c = containers[i]
|
|
110
|
+
if (c.offsetTop <= mid && c.offsetTop + c.offsetHeight > mid) { setCurrent(i + 1); break }
|
|
111
|
+
}
|
|
112
|
+
}, { passive: true })
|
|
113
|
+
|
|
114
|
+
$('#reduce').addEventListener('click', () => {
|
|
115
|
+
const hidden = menu.classList.toggle('hidden')
|
|
116
|
+
resize.classList.toggle('hidden', hidden)
|
|
117
|
+
pageEl.classList.toggle('full', hidden)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// Draggable divider between the thumbnail column and the page area.
|
|
121
|
+
resize.addEventListener('pointerdown', (ev) => {
|
|
122
|
+
ev.preventDefault()
|
|
123
|
+
resize.setPointerCapture(ev.pointerId)
|
|
124
|
+
const move = (e) => {
|
|
125
|
+
const w = Math.max(60, Math.min(e.clientX, 400))
|
|
126
|
+
menu.style.width = `${w}px`
|
|
127
|
+
resize.style.left = `${w}px`
|
|
128
|
+
pageEl.style.left = `${w + 5}px`
|
|
129
|
+
}
|
|
130
|
+
const up = () => {
|
|
131
|
+
resize.removeEventListener('pointermove', move)
|
|
132
|
+
resize.removeEventListener('pointerup', up)
|
|
133
|
+
}
|
|
134
|
+
resize.addEventListener('pointermove', move)
|
|
135
|
+
resize.addEventListener('pointerup', up)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
document.addEventListener('keydown', (ev) => {
|
|
139
|
+
if (['ArrowDown', 'ArrowRight', ' ', 'PageDown', 'n'].includes(ev.key)) { goTo(current + 1); ev.preventDefault() }
|
|
140
|
+
else if (['ArrowUp', 'ArrowLeft', 'PageUp', 'p'].includes(ev.key)) { goTo(current - 1); ev.preventDefault() }
|
|
141
|
+
else if (ev.key === 'Home') goTo(1)
|
|
142
|
+
else if (ev.key === 'End') goTo(manifest.pages.length)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
const fromHash = location.hash.match(/page=(\d+)/)
|
|
146
|
+
const saved = (() => { try { return Number(localStorage.getItem(key)) } catch { return 0 } })()
|
|
147
|
+
goTo(fromHash ? Number(fromHash[1]) : saved || 1)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
loadManifest().then(init).catch((e) => {
|
|
151
|
+
document.body.innerHTML = `<p style="padding:1rem">Load error: ${e.message}</p>`
|
|
152
|
+
})
|