@gheop/tojiru 0.6.0 → 0.8.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/README.md +33 -2
- package/dist/cli.js +9 -4
- package/dist/cli.js.map +1 -1
- package/dist/convert.js +10 -3
- package/dist/convert.js.map +1 -1
- package/dist/extractors/outline.js +36 -0
- package/dist/extractors/outline.js.map +1 -0
- package/dist/extractors/pdf.js +31 -8
- package/dist/extractors/pdf.js.map +1 -1
- package/dist/extractors/types.js.map +1 -1
- package/dist/manifest.js +11 -2
- package/dist/manifest.js.map +1 -1
- package/dist/output/folder.js +6 -1
- package/dist/output/folder.js.map +1 -1
- package/dist/output/single-file.js +15 -22
- package/dist/output/single-file.js.map +1 -1
- package/dist/search.js +13 -0
- package/dist/search.js.map +1 -0
- package/package.json +1 -1
- package/reader/index.html +9 -3
- package/reader/reader.css +102 -17
- package/reader/reader.js +156 -8
package/README.md
CHANGED
|
@@ -15,6 +15,7 @@ tojiru book.pdf --out site/
|
|
|
15
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
16
|
- **Comics** (CBZ, CB7, CBR) and **DjVu** become image pages with thumbnails.
|
|
17
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
|
+
- **Full-text search** (Ctrl+F) for PDFs with a text layer, a **table of contents** built from the PDF outline, a **dark theme** that follows the system, a phone-friendly layout, and an optional **double-page / right-to-left (manga)** view.
|
|
18
19
|
- 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
|
|
|
20
21
|
## Supported formats
|
|
@@ -29,6 +30,8 @@ tojiru book.pdf --out site/
|
|
|
29
30
|
|
|
30
31
|
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
|
|
|
33
|
+
Two PDF extras are picked up automatically when present: `pdftotext` (poppler, usually installed with `pdftocairo`) builds the search index, and `mutool` (mupdf) reads the outline for the table of contents. Both are optional — without them you just lose that one feature.
|
|
34
|
+
|
|
32
35
|
## Install
|
|
33
36
|
|
|
34
37
|
Requires **Node ≥ 20**.
|
|
@@ -57,9 +60,9 @@ Optional system tools (install only what you need):
|
|
|
57
60
|
|
|
58
61
|
```bash
|
|
59
62
|
# Debian/Ubuntu
|
|
60
|
-
sudo apt install poppler-utils djvulibre-bin p7zip-full
|
|
63
|
+
sudo apt install poppler-utils djvulibre-bin p7zip-full mupdf-tools
|
|
61
64
|
# Fedora
|
|
62
|
-
sudo dnf install poppler-utils djvulibre p7zip
|
|
65
|
+
sudo dnf install poppler-utils djvulibre p7zip mupdf
|
|
63
66
|
```
|
|
64
67
|
|
|
65
68
|
## Usage
|
|
@@ -82,6 +85,21 @@ Generation shows per-page progress on stderr (e.g. `Converting 12/30`).
|
|
|
82
85
|
| `--single-file [file]` | Output a single portable HTML file instead of a folder |
|
|
83
86
|
| `--image-format <fmt>` | Raster page encoding: `keep` (as-is, default) or `webp` |
|
|
84
87
|
| `--quality <n>` | WebP quality 1-100 for lossy raster pages (default: 80) |
|
|
88
|
+
| `--spread` | Lay pages out two-up (double-page spread) |
|
|
89
|
+
| `--rtl` | Right-to-left reading order (manga); pairs with `--spread` |
|
|
90
|
+
|
|
91
|
+
### Reading
|
|
92
|
+
|
|
93
|
+
The generated reader works the same whatever the source format:
|
|
94
|
+
|
|
95
|
+
- **Search** — Ctrl+F (or `/`) opens a text search for PDFs that shipped a text layer. Matches list the page with a highlighted excerpt; click one to jump there.
|
|
96
|
+
- **Table of contents** — when a PDF has an outline, it shows at the top of the thumbnail column; click an entry to jump to its page.
|
|
97
|
+
- **Dark mode** — follows the system by default; the ◐ button toggles it and remembers your choice.
|
|
98
|
+
- **Double-page / manga** — `--spread` shows two pages per row; add `--rtl` for right-to-left order (page 1 on the right, left arrow advances).
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
tojiru manga.cbz --out manga/ --image-format webp --spread --rtl
|
|
102
|
+
```
|
|
85
103
|
|
|
86
104
|
### Single file
|
|
87
105
|
|
|
@@ -163,6 +181,19 @@ MIT — see [LICENSE](LICENSE).
|
|
|
163
181
|
|
|
164
182
|
## Changelog
|
|
165
183
|
|
|
184
|
+
### v0.8.0 — Search, dark mode, contents and manga layout (2026-06-26)
|
|
185
|
+
|
|
186
|
+
- **Full-text search** (Ctrl+F or `/`) for PDFs that carry a text layer. `pdftotext` builds an index at conversion time (a `search.json` file, or inlined in single-file mode); matches list the page with a highlighted excerpt and jump there on click. Scans and comics keep the browser's native find.
|
|
187
|
+
- **Dark theme** that follows the system by default, with a ◐ button that toggles and remembers the choice. The reader now has a phone layout (the thumbnail column collapses to an overlay, full-width pages, larger tap targets), and the toolbar controls and thumbnails are real buttons reachable by keyboard.
|
|
188
|
+
- **Table of contents** built from the PDF outline via `mutool`, shown at the top of the thumbnail column; click an entry to jump to its page. Falls back to nothing when `mutool` or the outline is absent.
|
|
189
|
+
- **Double-page spread** (`--spread`) and **right-to-left manga order** (`--rtl`): two pages per row, page 1 on the right, the left arrow advances. Pairs wrap to one page per row on narrow screens.
|
|
190
|
+
- Tests now run in CI on GitHub and GitLab, and `--serve` opens a browser on Windows too.
|
|
191
|
+
- Internals: the single-file output is derived from `index.html` instead of duplicating the markup, and `buildManifest` takes an options object.
|
|
192
|
+
|
|
193
|
+
### v0.7.0 — Lighter vector text (2026-06-26)
|
|
194
|
+
|
|
195
|
+
- SVG page coordinates are now rounded to 1 decimal (0.1 pt) instead of 2 — measured ~19% lighter (gzipped) on glyph-heavy text PDFs, verified visually lossless even when zoomed (0.1 pt is ~0.13 px on screen).
|
|
196
|
+
|
|
166
197
|
### v0.6.0 — WebP quality control (2026-06-26)
|
|
167
198
|
|
|
168
199
|
- `--quality <n>` (default 80) sets the lossy WebP quality for comic and auto-rasterized PDF pages. The default dropped from 82 to 80 — measured ~8% smaller with no visible loss.
|
package/dist/cli.js
CHANGED
|
@@ -8,9 +8,12 @@ import { VERSION } from './version.js';
|
|
|
8
8
|
import { convert } from './convert.js';
|
|
9
9
|
import { serve } from './serve.js';
|
|
10
10
|
function openBrowser(url) {
|
|
11
|
-
const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
11
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
12
|
+
// Windows: `cmd /c start "" <url>`. The empty "" is start's title argument, so a
|
|
13
|
+
// url with spaces isn't mistaken for the window title.
|
|
14
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
12
15
|
try {
|
|
13
|
-
spawn(cmd,
|
|
16
|
+
spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
|
|
14
17
|
}
|
|
15
18
|
catch { /* best-effort */ }
|
|
16
19
|
}
|
|
@@ -30,6 +33,8 @@ program
|
|
|
30
33
|
.option('--single-file [file]', 'output a single portable HTML file (double-click to read offline)')
|
|
31
34
|
.option('--image-format <fmt>', 'comic/raster page encoding: keep (as-is) or webp', 'keep')
|
|
32
35
|
.option('--quality <n>', 'WebP quality (1-100) for lossy raster pages', '80')
|
|
36
|
+
.option('--spread', 'lay pages out two-up (double-page spread)')
|
|
37
|
+
.option('--rtl', 'right-to-left reading order (manga); pairs with --spread')
|
|
33
38
|
.action(async (input, opts) => {
|
|
34
39
|
try {
|
|
35
40
|
if (!existsSync(input))
|
|
@@ -53,7 +58,7 @@ program
|
|
|
53
58
|
const htmlPath = typeof opts.singleFile === 'string'
|
|
54
59
|
? opts.singleFile
|
|
55
60
|
: basename(input).replace(/\.[^.]+$/, '') + '.html';
|
|
56
|
-
const r = await convert(input, { outDir: '', title: opts.title, onProgress, singleFile: htmlPath, imageFormat, quality });
|
|
61
|
+
const r = await convert(input, { outDir: '', title: opts.title, onProgress, singleFile: htmlPath, imageFormat, quality, spread: opts.spread, rtl: opts.rtl });
|
|
57
62
|
if (isTTY)
|
|
58
63
|
process.stderr.write('\x1b[2K\r');
|
|
59
64
|
console.log(`✓ ${r.pageCount} pages → ${htmlPath}`);
|
|
@@ -65,7 +70,7 @@ program
|
|
|
65
70
|
if (existsSync(outDir) && (await readdir(outDir)).length > 0 && !opts.force) {
|
|
66
71
|
throw new Error(`Folder ${outDir} is not empty. Use --force to overwrite.`);
|
|
67
72
|
}
|
|
68
|
-
const r = await convert(input, { outDir, title: opts.title, onProgress, imageFormat, quality });
|
|
73
|
+
const r = await convert(input, { outDir, title: opts.title, onProgress, imageFormat, quality, spread: opts.spread, rtl: opts.rtl });
|
|
69
74
|
// Clear progress line before success message
|
|
70
75
|
if (isTTY)
|
|
71
76
|
process.stderr.write('\x1b[2K\r');
|
package/dist/cli.js.map
CHANGED
|
@@ -1 +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,KAAK,EAAE,MAAM,oBAAoB,CAAA;AAC1C,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AACnC,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AACtC,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AACtC,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAA;AAElC,SAAS,WAAW,CAAC,GAAW;IAC9B,MAAM,GAAG,GAAG,OAAO,CAAC,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,UAAU,CAAA;
|
|
1
|
+
{"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"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"}
|
package/dist/convert.js
CHANGED
|
@@ -9,6 +9,7 @@ import { cbrExtractor } from './extractors/cbr.js';
|
|
|
9
9
|
import { djvuExtractor } from './extractors/djvu.js';
|
|
10
10
|
import { processPages } from './pages.js';
|
|
11
11
|
import { buildManifest } from './manifest.js';
|
|
12
|
+
import { buildSearchIndex } from './search.js';
|
|
12
13
|
import { writeFolder } from './output/folder.js';
|
|
13
14
|
import { writeSingleFile } from './output/single-file.js';
|
|
14
15
|
const EXTRACTORS = [pdfExtractor, cbzExtractor, cb7Extractor, cbrExtractor, djvuExtractor];
|
|
@@ -31,12 +32,18 @@ export async function convert(input, opts) {
|
|
|
31
32
|
if (doc.pages.length === 0)
|
|
32
33
|
throw new Error('No pages extracted.');
|
|
33
34
|
const pages = await processPages(doc, bundleDir, { imageFormat: opts.imageFormat, quality: opts.quality }, opts.onProgress);
|
|
34
|
-
const
|
|
35
|
+
const search = buildSearchIndex(doc);
|
|
36
|
+
const manifest = buildManifest(doc.title, doc.kind, pages, {
|
|
37
|
+
searchable: search.length > 0,
|
|
38
|
+
outline: doc.outline,
|
|
39
|
+
spread: opts.spread,
|
|
40
|
+
rtl: opts.rtl,
|
|
41
|
+
});
|
|
35
42
|
if (opts.singleFile) {
|
|
36
|
-
await writeSingleFile(manifest, bundleDir, opts.singleFile);
|
|
43
|
+
await writeSingleFile(manifest, bundleDir, opts.singleFile, search);
|
|
37
44
|
return { outDir: opts.singleFile, pageCount: doc.pages.length };
|
|
38
45
|
}
|
|
39
|
-
await writeFolder(manifest, bundleDir);
|
|
46
|
+
await writeFolder(manifest, bundleDir, search);
|
|
40
47
|
return { outDir: opts.outDir, pageCount: doc.pages.length };
|
|
41
48
|
}
|
|
42
49
|
finally {
|
package/dist/convert.js.map
CHANGED
|
@@ -1 +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;AAChD,OAAO,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAA;AAGzD,MAAM,UAAU,GAAgB,CAAC,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,aAAa,CAAC,CAAA;
|
|
1
|
+
{"version":3,"file":"convert.js","sourceRoot":"","sources":["../src/convert.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { hasBinary } from '../tools.js';
|
|
2
|
+
import { run } from '../run.js';
|
|
3
|
+
// Parses the output of `mutool show <pdf> outline`. Each line looks like:
|
|
4
|
+
// <marker><TAB×(depth+1)>"<title>"<TAB>#page=<n>
|
|
5
|
+
// where marker is '-' (the entry has children) or '|' (leaf/sibling), and the depth is
|
|
6
|
+
// the number of tabs before the title minus one. Entries whose destination carries no
|
|
7
|
+
// page number (external links, unresolved named destinations) are dropped, since the
|
|
8
|
+
// reader can only jump to a page.
|
|
9
|
+
export function parseOutline(stdout) {
|
|
10
|
+
const out = [];
|
|
11
|
+
for (const line of stdout.split('\n')) {
|
|
12
|
+
const m = line.match(/^[-|](\t+)"(.*)"\t#(.+)$/);
|
|
13
|
+
if (!m)
|
|
14
|
+
continue;
|
|
15
|
+
const page = m[3].match(/page=(\d+)/);
|
|
16
|
+
const title = m[2].trim();
|
|
17
|
+
if (!title || !page)
|
|
18
|
+
continue;
|
|
19
|
+
out.push({ title, page: Number(page[1]), depth: Math.max(0, m[1].length - 1) });
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
// Extracts a PDF outline via mutool. Returns an empty list when mutool is absent or the
|
|
24
|
+
// document has no outline — the table of contents is a bonus, never a hard requirement.
|
|
25
|
+
export async function extractOutline(file) {
|
|
26
|
+
if (!(await hasBinary('mutool')))
|
|
27
|
+
return [];
|
|
28
|
+
try {
|
|
29
|
+
const { stdout } = await run('mutool', ['show', file, 'outline']);
|
|
30
|
+
return parseOutline(stdout);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=outline.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"outline.js","sourceRoot":"","sources":["../../src/extractors/outline.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AACvC,OAAO,EAAE,GAAG,EAAE,MAAM,WAAW,CAAA;AAG/B,0EAA0E;AAC1E,mDAAmD;AACnD,uFAAuF;AACvF,sFAAsF;AACtF,qFAAqF;AACrF,kCAAkC;AAClC,MAAM,UAAU,YAAY,CAAC,MAAc;IACzC,MAAM,GAAG,GAAmB,EAAE,CAAA;IAC9B,KAAK,MAAM,IAAI,IAAI,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;QACtC,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,0BAA0B,CAAC,CAAA;QAChD,IAAI,CAAC,CAAC;YAAE,SAAQ;QAChB,MAAM,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,YAAY,CAAC,CAAA;QACrC,MAAM,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAA;QACzB,IAAI,CAAC,KAAK,IAAI,CAAC,IAAI;YAAE,SAAQ;QAC7B,GAAG,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,EAAE,CAAC,CAAA;IACjF,CAAC;IACD,OAAO,GAAG,CAAA;AACZ,CAAC;AAED,wFAAwF;AACxF,wFAAwF;AACxF,MAAM,CAAC,KAAK,UAAU,cAAc,CAAC,IAAY;IAC/C,IAAI,CAAC,CAAC,MAAM,SAAS,CAAC,QAAQ,CAAC,CAAC;QAAE,OAAO,EAAE,CAAA;IAC3C,IAAI,CAAC;QACH,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,GAAG,CAAC,QAAQ,EAAE,CAAC,MAAM,EAAE,IAAI,EAAE,SAAS,CAAC,CAAC,CAAA;QACjE,OAAO,YAAY,CAAC,MAAM,CAAC,CAAA;IAC7B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,CAAA;IACX,CAAC;AACH,CAAC"}
|
package/dist/extractors/pdf.js
CHANGED
|
@@ -2,13 +2,30 @@ import { readFile, unlink, writeFile } from 'node:fs/promises';
|
|
|
2
2
|
import { basename, extname, join } from 'node:path';
|
|
3
3
|
import { DEFAULT_QUALITY } from './types.js';
|
|
4
4
|
import { detectKind } from './detect.js';
|
|
5
|
-
import { findPdfConverter } from '../tools.js';
|
|
5
|
+
import { findPdfConverter, hasBinary } from '../tools.js';
|
|
6
6
|
import { run } from '../run.js';
|
|
7
7
|
import sharp from 'sharp';
|
|
8
8
|
import { imageDims } from './images.js';
|
|
9
|
+
import { extractOutline } from './outline.js';
|
|
9
10
|
function pad(n, width) {
|
|
10
11
|
return String(n).padStart(width, '0');
|
|
11
12
|
}
|
|
13
|
+
// Extracts plain text for every page in one pdftotext pass. pdftotext separates
|
|
14
|
+
// pages with a form feed (\f), so the split lines up one chunk per page. Returns an
|
|
15
|
+
// array indexed by page-1, or an empty array if pdftotext is missing or fails — text
|
|
16
|
+
// is a bonus (powers search), never a reason to fail the conversion.
|
|
17
|
+
async function extractText(file, count) {
|
|
18
|
+
if (!(await hasBinary('pdftotext')))
|
|
19
|
+
return [];
|
|
20
|
+
try {
|
|
21
|
+
const { stdout } = await run('pdftotext', ['-enc', 'UTF-8', file, '-']);
|
|
22
|
+
const chunks = stdout.split('\f');
|
|
23
|
+
return Array.from({ length: count }, (_, i) => (chunks[i] ?? '').replace(/\s+/g, ' ').trim());
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
12
29
|
async function pageCount(file) {
|
|
13
30
|
// pdfinfo is part of poppler; fall back to mutool if absent.
|
|
14
31
|
try {
|
|
@@ -36,11 +53,14 @@ function viewBox(svg) {
|
|
|
36
53
|
return { w: Math.round(Number(w[1])), h: Math.round(Number(h[1])) };
|
|
37
54
|
throw new Error('SVG has no usable dimensions');
|
|
38
55
|
}
|
|
39
|
-
// Rounds floats
|
|
40
|
-
// Integers and short floats (
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
56
|
+
// Rounds floats carrying more than `decimals` decimal places down to `decimals`.
|
|
57
|
+
// Integers and already-short floats are unchanged. At 1 decimal (0.1 pt) this is
|
|
58
|
+
// visually lossless for text — 0.1 pt is ~0.13 px on screen, well under a pixel even
|
|
59
|
+
// when the page is zoomed — and ~19% lighter (gzipped) than 2 decimals on glyph-heavy
|
|
60
|
+
// pages, where thousands of <use> positions dominate the bytes.
|
|
61
|
+
export function roundCoords(svg, decimals = 1) {
|
|
62
|
+
const re = new RegExp(`-?\\d+\\.\\d{${decimals + 1},}`, 'g');
|
|
63
|
+
return svg.replace(re, (m) => String(Number(parseFloat(m).toFixed(decimals))));
|
|
44
64
|
}
|
|
45
65
|
// A page is raster-dominated when pdftocairo wrapped a full-page bitmap in SVG:
|
|
46
66
|
// at least one <image> element and fewer than 50 <use> elements (vector glyphs).
|
|
@@ -62,6 +82,7 @@ export const pdfExtractor = {
|
|
|
62
82
|
}
|
|
63
83
|
const count = await pageCount(file);
|
|
64
84
|
const width = Math.max(4, String(count).length);
|
|
85
|
+
const text = await extractText(file, count);
|
|
65
86
|
const pages = [];
|
|
66
87
|
for (let i = 1; i <= count; i++) {
|
|
67
88
|
const stem = pad(i, width);
|
|
@@ -83,20 +104,22 @@ export const pdfExtractor = {
|
|
|
83
104
|
await sharp(pngPath).webp({ quality, effort: 6 }).toFile(webpPath);
|
|
84
105
|
await unlink(svgPath);
|
|
85
106
|
await unlink(pngPath);
|
|
86
|
-
pages.push({ type: 'raster', imagePath: webpPath, ...(await imageDims(webpPath)) });
|
|
107
|
+
pages.push({ type: 'raster', imagePath: webpPath, ...(await imageDims(webpPath)), text: text[i - 1] || undefined });
|
|
87
108
|
}
|
|
88
109
|
else {
|
|
89
110
|
// Vector page: round coordinates to shrink SVG, then store.
|
|
90
111
|
const rounded = roundCoords(svg);
|
|
91
112
|
await writeFile(svgPath, rounded, 'utf8');
|
|
92
|
-
pages.push({ type: 'vector', svgPath, ...viewBox(rounded) });
|
|
113
|
+
pages.push({ type: 'vector', svgPath, ...viewBox(rounded), text: text[i - 1] || undefined });
|
|
93
114
|
}
|
|
94
115
|
onProgress?.(i, count, 'Converting');
|
|
95
116
|
}
|
|
117
|
+
const outline = await extractOutline(file);
|
|
96
118
|
return {
|
|
97
119
|
title: basename(file, extname(file)),
|
|
98
120
|
kind: 'pdf',
|
|
99
121
|
pages,
|
|
122
|
+
outline: outline.length ? outline : undefined,
|
|
100
123
|
};
|
|
101
124
|
},
|
|
102
125
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pdf.js","sourceRoot":"","sources":["../../src/extractors/pdf.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AAC9D,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AAEnD,OAAO,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAC5C,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AACxC,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAA;
|
|
1
|
+
{"version":3,"file":"pdf.js","sourceRoot":"","sources":["../../src/extractors/pdf.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/extractors/types.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/extractors/types.ts"],"names":[],"mappings":"AAyBA,sFAAsF;AACtF,kFAAkF;AAClF,MAAM,CAAC,MAAM,eAAe,GAAG,EAAE,CAAA"}
|
package/dist/manifest.js
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
export function buildManifest(title, kind, pages) {
|
|
2
|
-
|
|
1
|
+
export function buildManifest(title, kind, pages, extras = {}) {
|
|
2
|
+
const m = { tojiru: 1, title, kind, pageCount: pages.length, pages };
|
|
3
|
+
if (extras.searchable)
|
|
4
|
+
m.searchable = true;
|
|
5
|
+
if (extras.outline && extras.outline.length)
|
|
6
|
+
m.outline = extras.outline;
|
|
7
|
+
if (extras.spread)
|
|
8
|
+
m.spread = true;
|
|
9
|
+
if (extras.rtl)
|
|
10
|
+
m.rtl = true;
|
|
11
|
+
return m;
|
|
3
12
|
}
|
|
4
13
|
//# sourceMappingURL=manifest.js.map
|
package/dist/manifest.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"manifest.js","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"manifest.js","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AA6BA,MAAM,UAAU,aAAa,CAAC,KAAa,EAAE,IAAU,EAAE,KAAsB,EAAE,SAAyB,EAAE;IAC1G,MAAM,CAAC,GAAa,EAAE,MAAM,EAAE,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,CAAC,MAAM,EAAE,KAAK,EAAE,CAAA;IAC9E,IAAI,MAAM,CAAC,UAAU;QAAE,CAAC,CAAC,UAAU,GAAG,IAAI,CAAA;IAC1C,IAAI,MAAM,CAAC,OAAO,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM;QAAE,CAAC,CAAC,OAAO,GAAG,MAAM,CAAC,OAAO,CAAA;IACvE,IAAI,MAAM,CAAC,MAAM;QAAE,CAAC,CAAC,MAAM,GAAG,IAAI,CAAA;IAClC,IAAI,MAAM,CAAC,GAAG;QAAE,CAAC,CAAC,GAAG,GAAG,IAAI,CAAA;IAC5B,OAAO,CAAC,CAAA;AACV,CAAC"}
|
package/dist/output/folder.js
CHANGED
|
@@ -6,9 +6,14 @@ import { join } from 'node:path';
|
|
|
6
6
|
export function readerDir() {
|
|
7
7
|
return fileURLToPath(new URL('../../reader/', import.meta.url));
|
|
8
8
|
}
|
|
9
|
-
export async function writeFolder(manifest, outDir) {
|
|
9
|
+
export async function writeFolder(manifest, outDir, search = []) {
|
|
10
10
|
await mkdir(outDir, { recursive: true });
|
|
11
11
|
await writeFile(join(outDir, 'manifest.json'), JSON.stringify(manifest));
|
|
12
|
+
// The search index is its own file so the reader fetches it lazily on first search,
|
|
13
|
+
// keeping the eagerly-loaded manifest small.
|
|
14
|
+
if (search.length > 0) {
|
|
15
|
+
await writeFile(join(outDir, 'search.json'), JSON.stringify(search));
|
|
16
|
+
}
|
|
12
17
|
const rd = readerDir();
|
|
13
18
|
for (const f of ['index.html', 'reader.js', 'reader.css']) {
|
|
14
19
|
await copyFile(join(rd, f), join(outDir, f));
|
|
@@ -1 +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;
|
|
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;AAIhC,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,EAAE,SAAwB,EAAE;IAC9F,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,oFAAoF;IACpF,6CAA6C;IAC7C,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACtB,MAAM,SAAS,CAAC,IAAI,CAAC,MAAM,EAAE,aAAa,CAAC,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,CAAA;IACtE,CAAC;IACD,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"}
|
|
@@ -9,7 +9,7 @@ function escapeScript(s) {
|
|
|
9
9
|
function escapeHtml(s) {
|
|
10
10
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
11
11
|
}
|
|
12
|
-
export async function writeSingleFile(manifest, bundleDir, outFile) {
|
|
12
|
+
export async function writeSingleFile(manifest, bundleDir, outFile, search = []) {
|
|
13
13
|
// Collect all page + thumb file paths
|
|
14
14
|
const allFiles = [];
|
|
15
15
|
for (const p of manifest.pages) {
|
|
@@ -33,37 +33,30 @@ export async function writeSingleFile(manifest, bundleDir, outFile) {
|
|
|
33
33
|
for (const [f, buf] of buffers) {
|
|
34
34
|
pagesMap[f] = buf.toString('base64');
|
|
35
35
|
}
|
|
36
|
-
// Read reader assets from package
|
|
36
|
+
// Read reader assets from package. The single-file HTML is derived from the same
|
|
37
|
+
// index.html the folder output uses, so the body chrome lives in exactly one place:
|
|
38
|
+
// inline the stylesheet and swap the external module script for the bundled data.
|
|
37
39
|
const rd = readerDir();
|
|
40
|
+
const indexHtml = await readFile(join(rd, 'index.html'), 'utf8');
|
|
38
41
|
const css = await readFile(join(rd, 'reader.css'), 'utf8');
|
|
39
42
|
const js = await readFile(join(rd, 'reader.js'), 'utf8');
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
'<meta name="viewport" content="width=device-width, initial-scale=1" />',
|
|
46
|
-
`<title>${escapeHtml(manifest.title)}</title>`,
|
|
47
|
-
'<style>',
|
|
48
|
-
css,
|
|
49
|
-
'</style>',
|
|
50
|
-
'</head>',
|
|
51
|
-
'<body>',
|
|
52
|
-
'<div id="reduce" title="Collapse / expand thumbnails">☰</div>',
|
|
53
|
-
'<nav id="menu" aria-label="Pages"></nav>',
|
|
54
|
-
'<div id="resize" title="Drag to resize"></div>',
|
|
55
|
-
'<main id="page"></main>',
|
|
43
|
+
const globals = [`window.__TOJIRU_PAGES = ${escapeScript(JSON.stringify(pagesMap))};`];
|
|
44
|
+
if (search.length > 0) {
|
|
45
|
+
globals.push(`window.__TOJIRU_SEARCH = ${escapeScript(JSON.stringify(search))};`);
|
|
46
|
+
}
|
|
47
|
+
const inlineScripts = [
|
|
56
48
|
`<script type="application/json" id="tojiru-manifest">${escapeScript(JSON.stringify(manifest))}</script>`,
|
|
57
49
|
'<script>',
|
|
58
|
-
|
|
50
|
+
globals.join('\n'),
|
|
59
51
|
'</script>',
|
|
60
52
|
'<script type="module">',
|
|
61
53
|
escapeScript(js),
|
|
62
54
|
'</script>',
|
|
63
|
-
'</body>',
|
|
64
|
-
'</html>',
|
|
65
|
-
'',
|
|
66
55
|
].join('\n');
|
|
56
|
+
const html = indexHtml
|
|
57
|
+
.replace('<title>tojiru</title>', `<title>${escapeHtml(manifest.title)}</title>`)
|
|
58
|
+
.replace('<link rel="stylesheet" href="reader.css" />', `<style>\n${css}\n</style>`)
|
|
59
|
+
.replace('<script type="module" src="reader.js"></script>', inlineScripts);
|
|
67
60
|
await writeFile(outFile, html);
|
|
68
61
|
}
|
|
69
62
|
//# sourceMappingURL=single-file.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"single-file.js","sourceRoot":"","sources":["../../src/output/single-file.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AACtD,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AAChC,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;
|
|
1
|
+
{"version":3,"file":"single-file.js","sourceRoot":"","sources":["../../src/output/single-file.ts"],"names":[],"mappings":"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"}
|
package/dist/search.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Builds the client search index from a document's per-page text. Pages with no text
|
|
2
|
+
// (scans, comics, blank pages) are dropped, so an all-image document yields an empty
|
|
3
|
+
// index and the reader keeps the browser's native find.
|
|
4
|
+
export function buildSearchIndex(doc) {
|
|
5
|
+
const entries = [];
|
|
6
|
+
doc.pages.forEach((p, i) => {
|
|
7
|
+
const t = p.text?.trim();
|
|
8
|
+
if (t)
|
|
9
|
+
entries.push({ n: i + 1, t });
|
|
10
|
+
});
|
|
11
|
+
return entries;
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=search.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"search.js","sourceRoot":"","sources":["../src/search.ts"],"names":[],"mappings":"AAKA,qFAAqF;AACrF,qFAAqF;AACrF,wDAAwD;AACxD,MAAM,UAAU,gBAAgB,CAAC,GAAa;IAC5C,MAAM,OAAO,GAAkB,EAAE,CAAA;IACjC,GAAG,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE;QACzB,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,IAAI,EAAE,CAAA;QACxB,IAAI,CAAC;YAAE,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,CAAC,CAAA;IACtC,CAAC,CAAC,CAAA;IACF,OAAO,OAAO,CAAA;AAChB,CAAC"}
|
package/package.json
CHANGED
package/reader/index.html
CHANGED
|
@@ -4,13 +4,19 @@
|
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>tojiru</title>
|
|
7
|
+
<script>try{var t=localStorage.getItem('tojiru:theme');if(t)document.documentElement.dataset.theme=t}catch(e){}</script>
|
|
7
8
|
<link rel="stylesheet" href="reader.css" />
|
|
8
9
|
</head>
|
|
9
10
|
<body>
|
|
10
|
-
<
|
|
11
|
+
<button id="reduce" type="button" title="Collapse / expand thumbnails" aria-label="Collapse or expand thumbnails">☰</button>
|
|
12
|
+
<button id="theme" type="button" title="Toggle dark mode" aria-label="Toggle dark mode">◐</button>
|
|
13
|
+
<div id="search" class="hidden" role="dialog" aria-label="Search the document">
|
|
14
|
+
<input id="search-input" type="search" placeholder="Search…" aria-label="Search text" autocomplete="off" spellcheck="false" />
|
|
15
|
+
<div id="search-results" aria-live="polite"></div>
|
|
16
|
+
</div>
|
|
11
17
|
<nav id="menu" aria-label="Pages"></nav>
|
|
12
|
-
<div id="resize" title="Drag to resize"></div>
|
|
13
|
-
<main id="page"></main>
|
|
18
|
+
<div id="resize" title="Drag to resize" aria-hidden="true"></div>
|
|
19
|
+
<main id="page" tabindex="0"></main>
|
|
14
20
|
<script type="module" src="reader.js"></script>
|
|
15
21
|
</body>
|
|
16
22
|
</html>
|
package/reader/reader.css
CHANGED
|
@@ -2,53 +2,125 @@
|
|
|
2
2
|
thumbnail column on the left, centered pages with a silver border and shadow,
|
|
3
3
|
pages/thumbnails dimmed in opacity, the current page at full opacity. */
|
|
4
4
|
|
|
5
|
+
:root {
|
|
6
|
+
--bg: #fff;
|
|
7
|
+
--muted: #999;
|
|
8
|
+
--chrome-bg: rgba(255, 255, 255, .85);
|
|
9
|
+
--chrome-fg: #555;
|
|
10
|
+
--chrome-border: #bbb;
|
|
11
|
+
--divider-bg: #ececec;
|
|
12
|
+
--divider-border: #999;
|
|
13
|
+
--page-border: silver;
|
|
14
|
+
--page-shadow: #ccc;
|
|
15
|
+
--focus: #4a90d9;
|
|
16
|
+
color-scheme: light;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/* Dark theme: explicit override (toggle button writes data-theme), and the same
|
|
20
|
+
values applied automatically when the system is dark and no light override is set. */
|
|
21
|
+
:root[data-theme="dark"] { --bg: #1c1c1e; --muted: #888; --chrome-bg: rgba(44, 44, 46, .9); --chrome-fg: #ccc; --chrome-border: #555; --divider-bg: #2c2c2e; --divider-border: #666; --page-border: #555; --page-shadow: #000; color-scheme: dark; }
|
|
22
|
+
@media (prefers-color-scheme: dark) {
|
|
23
|
+
:root:not([data-theme="light"]) { --bg: #1c1c1e; --muted: #888; --chrome-bg: rgba(44, 44, 46, .9); --chrome-fg: #ccc; --chrome-border: #555; --divider-bg: #2c2c2e; --divider-border: #666; --page-border: #555; --page-shadow: #000; color-scheme: dark; }
|
|
24
|
+
}
|
|
25
|
+
|
|
5
26
|
* { box-sizing: border-box; }
|
|
6
27
|
html, body { margin: 0; height: 100%; }
|
|
7
|
-
body { font-family: verdana, sans-serif; background:
|
|
28
|
+
body { font-family: verdana, sans-serif; background: var(--bg); }
|
|
8
29
|
#page, #menu { user-select: none; }
|
|
9
30
|
|
|
10
|
-
#reduce {
|
|
11
|
-
position: fixed; top: 5px;
|
|
12
|
-
width: 20px; height: 20px; line-height: 18px; text-align: center;
|
|
13
|
-
background:
|
|
14
|
-
cursor: pointer; font-size: 12px; color:
|
|
31
|
+
#reduce, #theme {
|
|
32
|
+
position: fixed; top: 5px; z-index: 4;
|
|
33
|
+
width: 20px; height: 20px; line-height: 18px; text-align: center; padding: 0;
|
|
34
|
+
background: var(--chrome-bg); border: 1px solid var(--chrome-border); border-radius: 3px;
|
|
35
|
+
cursor: pointer; font-size: 12px; color: var(--chrome-fg); font-family: inherit;
|
|
36
|
+
}
|
|
37
|
+
#reduce { left: 5px; }
|
|
38
|
+
#theme { left: 30px; }
|
|
39
|
+
#reduce:focus-visible, #theme:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
|
|
40
|
+
|
|
41
|
+
/* Search panel: Ctrl+F (or /) opens it over the top of the page area. */
|
|
42
|
+
#search {
|
|
43
|
+
position: fixed; top: 8px; left: 50%; transform: translateX(-50%); z-index: 6;
|
|
44
|
+
width: min(440px, calc(100vw - 24px));
|
|
45
|
+
background: var(--bg); border: 1px solid var(--chrome-border); border-radius: 6px;
|
|
46
|
+
box-shadow: 0 4px 18px rgba(0, 0, 0, .25); padding: 6px;
|
|
47
|
+
}
|
|
48
|
+
#search.hidden { display: none; }
|
|
49
|
+
#search-input {
|
|
50
|
+
width: 100%; box-sizing: border-box; padding: 7px 9px; font: inherit; font-size: 14px;
|
|
51
|
+
color: var(--chrome-fg); background: var(--bg);
|
|
52
|
+
border: 1px solid var(--chrome-border); border-radius: 4px;
|
|
15
53
|
}
|
|
54
|
+
#search-input:focus-visible { outline: 2px solid var(--focus); outline-offset: 0; }
|
|
55
|
+
#search-results { max-height: 50vh; overflow: auto; margin-top: 6px; }
|
|
56
|
+
#search-results:empty { display: none; }
|
|
57
|
+
#search-results .hit {
|
|
58
|
+
display: block; width: 100%; text-align: left; padding: 6px 8px; border: 0; border-radius: 4px;
|
|
59
|
+
background: none; color: var(--chrome-fg); font: inherit; font-size: 13px; cursor: pointer; line-height: 1.35;
|
|
60
|
+
}
|
|
61
|
+
#search-results .hit:hover, #search-results .hit:focus-visible { background: var(--divider-bg); outline: none; }
|
|
62
|
+
#search-results .hit .pno { color: var(--muted); font-size: 11px; margin-right: 6px; }
|
|
63
|
+
#search-results .hit mark { background: #ffe066; color: #000; border-radius: 2px; }
|
|
64
|
+
#search-results .empty { padding: 6px 8px; color: var(--muted); font-size: 13px; }
|
|
16
65
|
|
|
17
66
|
#menu {
|
|
18
67
|
position: fixed; top: 0; bottom: 0; left: 0; width: 150px;
|
|
19
|
-
overflow: auto; text-align: center; background:
|
|
68
|
+
overflow: auto; text-align: center; background: var(--bg); padding-top: 28px;
|
|
20
69
|
}
|
|
21
70
|
#menu.hidden { display: none; }
|
|
22
|
-
#
|
|
23
|
-
|
|
24
|
-
|
|
71
|
+
#toc { text-align: left; padding: 2px 0 8px; margin-bottom: 6px; border-bottom: 1px solid var(--chrome-border); }
|
|
72
|
+
#toc .toc-head { font-size: 10px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); padding: 2px 8px 5px; }
|
|
73
|
+
.toc-item {
|
|
74
|
+
display: block; width: 100%; text-align: left; border: 0; background: none; cursor: pointer;
|
|
75
|
+
font: inherit; font-size: 12px; color: var(--chrome-fg); padding: 4px 8px; line-height: 1.3;
|
|
76
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
77
|
+
}
|
|
78
|
+
.toc-item:hover, .toc-item:focus-visible { background: var(--divider-bg); outline: none; }
|
|
79
|
+
#menu .thumb {
|
|
80
|
+
display: block; width: 100%; padding: 0; margin: 0; border: 0;
|
|
81
|
+
background: none; cursor: pointer; text-align: center; font-family: inherit;
|
|
82
|
+
}
|
|
83
|
+
#menu .thumb img {
|
|
84
|
+
width: 100px; margin: 10px 0 2px; background: #fff;
|
|
85
|
+
opacity: .5; border: 1px solid var(--page-border); box-shadow: .2em .2em var(--page-shadow);
|
|
25
86
|
transition: opacity .15s ease, width .1s ease;
|
|
26
87
|
}
|
|
27
|
-
#menu
|
|
28
|
-
#menu
|
|
29
|
-
#menu .
|
|
88
|
+
#menu .thumb:hover img { width: 104px; }
|
|
89
|
+
#menu .thumb.select img { opacity: 1; }
|
|
90
|
+
#menu .thumb:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
|
|
91
|
+
#menu .num { display: block; font-size: 11px; color: var(--muted); margin-bottom: 8px; }
|
|
30
92
|
|
|
31
93
|
#resize {
|
|
32
94
|
position: fixed; top: 0; bottom: 0; left: 150px; width: 4px;
|
|
33
|
-
border-right: 1px solid
|
|
95
|
+
border-right: 1px solid var(--divider-border); background: var(--divider-bg); cursor: col-resize; z-index: 1;
|
|
34
96
|
}
|
|
35
97
|
#resize.hidden { display: none; }
|
|
36
98
|
|
|
37
99
|
#page {
|
|
38
100
|
position: fixed; top: 0; bottom: 0; left: 155px; right: 0;
|
|
39
|
-
overflow: auto; text-align: center; scroll-behavior: smooth; background:
|
|
101
|
+
overflow: auto; text-align: center; scroll-behavior: smooth; background: var(--bg);
|
|
40
102
|
}
|
|
41
103
|
#page.full { left: 0; }
|
|
42
104
|
|
|
43
105
|
.page {
|
|
44
106
|
display: block; margin: 12px auto; width: calc(100% - 36px);
|
|
45
107
|
}
|
|
108
|
+
|
|
109
|
+
/* Double-page spread: two pages per row (the open-book look). rtl reverses each pair so
|
|
110
|
+
page 1 sits on the right, the manga reading order. Pages wrap to one per row when the
|
|
111
|
+
viewport is too narrow for a fair pair. */
|
|
112
|
+
#page.spread { display: flex; flex-wrap: wrap; align-content: flex-start; justify-content: center; gap: 12px; padding: 12px 0; }
|
|
113
|
+
#page.spread .page { width: calc(50% - 18px); margin: 0; }
|
|
114
|
+
#page.spread.rtl { direction: rtl; }
|
|
115
|
+
@media (max-width: 700px) {
|
|
116
|
+
#page.spread .page { width: calc(100% - 24px); }
|
|
117
|
+
}
|
|
46
118
|
.page object, .page img {
|
|
47
119
|
display: block; width: 100%; height: 100%;
|
|
48
|
-
border: 1px solid
|
|
120
|
+
border: 1px solid var(--page-border); box-shadow: .2em .2em var(--page-shadow);
|
|
49
121
|
background: #fff; opacity: .7; transition: opacity .25s ease;
|
|
50
122
|
}
|
|
51
|
-
.page.select object, .page.select img { opacity: 1; border-color:
|
|
123
|
+
.page.select object, .page.select img { opacity: 1; border-color: var(--divider-border); }
|
|
52
124
|
|
|
53
125
|
/* Thin scrollbars, hidden until hovered. */
|
|
54
126
|
#menu, #page { scrollbar-width: thin; scrollbar-color: transparent transparent; }
|
|
@@ -56,3 +128,16 @@ body { font-family: verdana, sans-serif; background: #fff; }
|
|
|
56
128
|
#menu::-webkit-scrollbar, #page::-webkit-scrollbar { width: 7px; }
|
|
57
129
|
#menu::-webkit-scrollbar-thumb, #page::-webkit-scrollbar-thumb { background: transparent; border-radius: 4px; }
|
|
58
130
|
#menu:hover::-webkit-scrollbar-thumb, #page:hover::-webkit-scrollbar-thumb { background: #c4c4c4; }
|
|
131
|
+
|
|
132
|
+
/* Phones: the thumbnail column overlays the page instead of pushing it, the page
|
|
133
|
+
is always full width, and tap targets are a little larger. reader.js collapses
|
|
134
|
+
the column by default on small screens. */
|
|
135
|
+
@media (max-width: 640px) {
|
|
136
|
+
#reduce, #theme { width: 30px; height: 30px; line-height: 28px; font-size: 16px; }
|
|
137
|
+
#theme { left: 40px; }
|
|
138
|
+
#menu { width: 72vw; max-width: 280px; z-index: 3; box-shadow: 2px 0 14px rgba(0, 0, 0, .35); }
|
|
139
|
+
#menu .thumb img { width: 120px; }
|
|
140
|
+
#menu .thumb:hover img { width: 120px; }
|
|
141
|
+
#resize { display: none; }
|
|
142
|
+
#page { left: 0; }
|
|
143
|
+
}
|
package/reader/reader.js
CHANGED
|
@@ -24,6 +24,17 @@ async function getPageBytes(key) {
|
|
|
24
24
|
return new Uint8Array(await res.arrayBuffer())
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// Lazily resolves the search index: the inline global in single-file mode, otherwise
|
|
28
|
+
// search.json fetched once on first use. A missing/failed fetch yields an empty index.
|
|
29
|
+
let searchIndexPromise = null
|
|
30
|
+
function getSearchIndex() {
|
|
31
|
+
if (searchIndexPromise) return searchIndexPromise
|
|
32
|
+
searchIndexPromise = window.__TOJIRU_SEARCH
|
|
33
|
+
? Promise.resolve(window.__TOJIRU_SEARCH)
|
|
34
|
+
: fetch('search.json').then((r) => (r.ok ? r.json() : [])).catch(() => [])
|
|
35
|
+
return searchIndexPromise
|
|
36
|
+
}
|
|
37
|
+
|
|
27
38
|
// UTF-8-safe base64 of an SVG string (btoa is Latin1-only; accented text needs this).
|
|
28
39
|
function svgToBase64(text) {
|
|
29
40
|
const bytes = new TextEncoder().encode(text)
|
|
@@ -92,7 +103,35 @@ function init(manifest) {
|
|
|
92
103
|
const key = `tojiru:${manifest.title}`
|
|
93
104
|
let current = 0
|
|
94
105
|
|
|
106
|
+
// Table of contents, when the document carries an outline. It sits at the top of the
|
|
107
|
+
// thumbnail column; each entry jumps to its page (goTo is hoisted, defined below).
|
|
108
|
+
if (manifest.outline && manifest.outline.length) {
|
|
109
|
+
const toc = document.createElement('div')
|
|
110
|
+
toc.id = 'toc'
|
|
111
|
+
const head = document.createElement('div')
|
|
112
|
+
head.className = 'toc-head'
|
|
113
|
+
head.textContent = 'Contents'
|
|
114
|
+
toc.append(head)
|
|
115
|
+
for (const e of manifest.outline) {
|
|
116
|
+
const item = document.createElement('button')
|
|
117
|
+
item.type = 'button'
|
|
118
|
+
item.className = 'toc-item'
|
|
119
|
+
item.textContent = e.title
|
|
120
|
+
item.title = e.title
|
|
121
|
+
item.style.paddingLeft = `${8 + Math.min(e.depth, 5) * 12}px`
|
|
122
|
+
item.addEventListener('click', () => goTo(e.page))
|
|
123
|
+
toc.append(item)
|
|
124
|
+
}
|
|
125
|
+
menu.append(toc)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Each thumbnail is a real <button> so it can be reached and activated by
|
|
129
|
+
// keyboard, not just clicked. The .select class lives on the button.
|
|
95
130
|
const thumbs = manifest.pages.map((p) => {
|
|
131
|
+
const btn = document.createElement('button')
|
|
132
|
+
btn.type = 'button'
|
|
133
|
+
btn.className = 'thumb'
|
|
134
|
+
btn.setAttribute('aria-label', `Go to page ${p.n}`)
|
|
96
135
|
const t = document.createElement('img')
|
|
97
136
|
const inlinePages = window.__TOJIRU_PAGES
|
|
98
137
|
if (inlinePages && Object.prototype.hasOwnProperty.call(inlinePages, p.thumb)) {
|
|
@@ -101,15 +140,21 @@ function init(manifest) {
|
|
|
101
140
|
t.src = p.thumb
|
|
102
141
|
}
|
|
103
142
|
t.loading = 'lazy'
|
|
104
|
-
t.alt =
|
|
105
|
-
t.addEventListener('click', () => goTo(p.n))
|
|
143
|
+
t.alt = ''
|
|
106
144
|
const num = document.createElement('span')
|
|
107
145
|
num.className = 'num'
|
|
108
146
|
num.textContent = String(p.n)
|
|
109
|
-
|
|
110
|
-
|
|
147
|
+
btn.append(t, num)
|
|
148
|
+
btn.addEventListener('click', () => goTo(p.n))
|
|
149
|
+
menu.append(btn)
|
|
150
|
+
return btn
|
|
111
151
|
})
|
|
112
152
|
|
|
153
|
+
// Double-page spread / right-to-left layout, when the build asked for it. The classes
|
|
154
|
+
// drive the CSS; rtl also flips the horizontal navigation keys further down.
|
|
155
|
+
pageEl.classList.toggle('spread', !!manifest.spread)
|
|
156
|
+
pageEl.classList.toggle('rtl', !!manifest.rtl)
|
|
157
|
+
|
|
113
158
|
const io = new IntersectionObserver(onIntersect, { root: pageEl, rootMargin: '800px 0px' })
|
|
114
159
|
const containers = manifest.pages.map((p) => {
|
|
115
160
|
const c = document.createElement('div')
|
|
@@ -158,10 +203,94 @@ function init(manifest) {
|
|
|
158
203
|
}
|
|
159
204
|
}, { passive: true })
|
|
160
205
|
|
|
206
|
+
const isNarrow = () => matchMedia('(max-width: 640px)').matches
|
|
207
|
+
|
|
161
208
|
$('#reduce').addEventListener('click', () => {
|
|
162
209
|
const hidden = menu.classList.toggle('hidden')
|
|
163
|
-
|
|
164
|
-
|
|
210
|
+
// On phones the column overlays the page (CSS keeps #page full width), so the
|
|
211
|
+
// divider and the page offset only matter on wide screens.
|
|
212
|
+
if (!isNarrow()) {
|
|
213
|
+
resize.classList.toggle('hidden', hidden)
|
|
214
|
+
pageEl.classList.toggle('full', hidden)
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
// Dark-mode toggle. The current theme is data-theme on <html> (set early by the
|
|
219
|
+
// inline head script for saved overrides); with no override we follow the system.
|
|
220
|
+
$('#theme').addEventListener('click', () => {
|
|
221
|
+
const root = document.documentElement
|
|
222
|
+
const sysDark = matchMedia('(prefers-color-scheme: dark)').matches
|
|
223
|
+
const current = root.dataset.theme || (sysDark ? 'dark' : 'light')
|
|
224
|
+
const next = current === 'dark' ? 'light' : 'dark'
|
|
225
|
+
root.dataset.theme = next
|
|
226
|
+
try { localStorage.setItem('tojiru:theme', next) } catch {}
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
// --- Full-text search (only when the build shipped an index) ---
|
|
230
|
+
const searchBox = $('#search')
|
|
231
|
+
const searchInput = $('#search-input')
|
|
232
|
+
const searchResults = $('#search-results')
|
|
233
|
+
let searchTimer = 0
|
|
234
|
+
|
|
235
|
+
const openSearch = () => { searchBox.classList.remove('hidden'); searchInput.focus(); searchInput.select() }
|
|
236
|
+
// preventScroll: focusing the scroll container must not yank it back and cancel the
|
|
237
|
+
// goTo() jump that runs just before closing.
|
|
238
|
+
const closeSearch = () => { searchBox.classList.add('hidden'); pageEl.focus({ preventScroll: true }) }
|
|
239
|
+
|
|
240
|
+
// Builds a one-line excerpt around the first match in `text` for query `q` (lowercase).
|
|
241
|
+
function snippet(text, q) {
|
|
242
|
+
const idx = text.toLowerCase().indexOf(q)
|
|
243
|
+
if (idx < 0) return null
|
|
244
|
+
const start = Math.max(0, idx - 30)
|
|
245
|
+
const end = Math.min(text.length, idx + q.length + 60)
|
|
246
|
+
return {
|
|
247
|
+
pre: (start > 0 ? '… ' : '') + text.slice(start, idx),
|
|
248
|
+
match: text.slice(idx, idx + q.length),
|
|
249
|
+
post: text.slice(idx + q.length, end) + (end < text.length ? ' …' : ''),
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function runSearch() {
|
|
254
|
+
const q = searchInput.value.trim().toLowerCase()
|
|
255
|
+
searchResults.replaceChildren()
|
|
256
|
+
if (q.length < 2) return
|
|
257
|
+
const index = await getSearchIndex()
|
|
258
|
+
const hits = []
|
|
259
|
+
for (const e of index) {
|
|
260
|
+
const s = snippet(e.t, q)
|
|
261
|
+
if (s) hits.push({ n: e.n, s })
|
|
262
|
+
if (hits.length >= 60) break
|
|
263
|
+
}
|
|
264
|
+
if (hits.length === 0) {
|
|
265
|
+
const empty = document.createElement('div')
|
|
266
|
+
empty.className = 'empty'
|
|
267
|
+
empty.textContent = 'No matches'
|
|
268
|
+
searchResults.append(empty)
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
for (const h of hits) {
|
|
272
|
+
const btn = document.createElement('button')
|
|
273
|
+
btn.type = 'button'
|
|
274
|
+
btn.className = 'hit'
|
|
275
|
+
const pno = document.createElement('span')
|
|
276
|
+
pno.className = 'pno'
|
|
277
|
+
pno.textContent = `p.${h.n}`
|
|
278
|
+
const mark = document.createElement('mark')
|
|
279
|
+
mark.textContent = h.s.match
|
|
280
|
+
// textContent/createTextNode keep page text inert — no HTML injection from the PDF.
|
|
281
|
+
btn.append(pno, document.createTextNode(h.s.pre), mark, document.createTextNode(h.s.post))
|
|
282
|
+
btn.addEventListener('click', () => { goTo(h.n); closeSearch() })
|
|
283
|
+
searchResults.append(btn)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
searchInput.addEventListener('input', () => {
|
|
288
|
+
clearTimeout(searchTimer)
|
|
289
|
+
searchTimer = setTimeout(runSearch, 120)
|
|
290
|
+
})
|
|
291
|
+
searchInput.addEventListener('keydown', (ev) => {
|
|
292
|
+
if (ev.key === 'Escape') { closeSearch(); ev.preventDefault() }
|
|
293
|
+
else if (ev.key === 'Enter') { searchResults.querySelector('.hit')?.click(); ev.preventDefault() }
|
|
165
294
|
})
|
|
166
295
|
|
|
167
296
|
// Draggable divider between the thumbnail column and the page area.
|
|
@@ -183,12 +312,31 @@ function init(manifest) {
|
|
|
183
312
|
})
|
|
184
313
|
|
|
185
314
|
document.addEventListener('keydown', (ev) => {
|
|
186
|
-
|
|
187
|
-
|
|
315
|
+
// Open search on Ctrl/Cmd+F or "/", but only when a search index shipped — without
|
|
316
|
+
// one, leave the browser's native find untouched.
|
|
317
|
+
if (manifest.searchable && ((ev.key === 'f' && (ev.ctrlKey || ev.metaKey)) ||
|
|
318
|
+
(ev.key === '/' && !(ev.target instanceof HTMLInputElement)))) {
|
|
319
|
+
openSearch(); ev.preventDefault(); return
|
|
320
|
+
}
|
|
321
|
+
// Don't let page navigation steal keys while typing in the search box.
|
|
322
|
+
if (ev.target instanceof HTMLInputElement) return
|
|
323
|
+
// In RTL (manga), left advances and right goes back; vertical keys are unchanged.
|
|
324
|
+
const fwd = manifest.rtl ? 'ArrowLeft' : 'ArrowRight'
|
|
325
|
+
const back = manifest.rtl ? 'ArrowRight' : 'ArrowLeft'
|
|
326
|
+
if (['ArrowDown', ' ', 'PageDown', 'n', fwd].includes(ev.key)) { goTo(current + 1); ev.preventDefault() }
|
|
327
|
+
else if (['ArrowUp', 'PageUp', 'p', back].includes(ev.key)) { goTo(current - 1); ev.preventDefault() }
|
|
188
328
|
else if (ev.key === 'Home') goTo(1)
|
|
189
329
|
else if (ev.key === 'End') goTo(manifest.pages.length)
|
|
190
330
|
})
|
|
191
331
|
|
|
332
|
+
// On phones, start with the thumbnail column collapsed so the page gets the full
|
|
333
|
+
// width; the ☰ button reveals it as an overlay.
|
|
334
|
+
if (isNarrow()) {
|
|
335
|
+
menu.classList.add('hidden')
|
|
336
|
+
resize.classList.add('hidden')
|
|
337
|
+
pageEl.classList.add('full')
|
|
338
|
+
}
|
|
339
|
+
|
|
192
340
|
const fromHash = location.hash.match(/page=(\d+)/)
|
|
193
341
|
const saved = (() => { try { return Number(localStorage.getItem(key)) } catch { return 0 } })()
|
|
194
342
|
goTo(fromHash ? Number(fromHash[1]) : saved || 1)
|