@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 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, [url], { detached: true, stdio: 'ignore' }).unref();
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":"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"}
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 manifest = buildManifest(doc.title, doc.kind, pages);
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 {
@@ -1 +1 @@
1
- {"version":3,"file":"convert.js","sourceRoot":"","sources":["../src/convert.ts"],"names":[],"mappings":"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"}
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,gBAAgB,EAAE,MAAM,aAAa,CAAA;AAC9C,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;AAkBvG,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,4EAA4E;IAC5E,oFAAoF;IACpF,MAAM,SAAS,GAAG,IAAI,CAAC,UAAU;QAC/B,CAAC,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,gBAAgB,CAAC,CAAC;QACjD,CAAC,CAAC,IAAI,CAAC,MAAM,CAAA;IAEf,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,SAAS,CAAC,OAAO,CAAC,KAAK,EAAE,IAAI,EAAE,IAAI,CAAC,UAAU,EAAE,EAAE,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE,CAAC,CAAA;QAC5F,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,SAAS,EAAE,EAAE,WAAW,EAAE,IAAI,CAAC,WAAW,EAAE,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE,EAAE,IAAI,CAAC,UAAU,CAAC,CAAA;QAC3H,MAAM,MAAM,GAAG,gBAAgB,CAAC,GAAG,CAAC,CAAA;QACpC,MAAM,QAAQ,GAAG,aAAa,CAAC,GAAG,CAAC,KAAK,EAAE,GAAG,CAAC,IAAI,EAAE,KAAK,EAAE;YACzD,UAAU,EAAE,MAAM,CAAC,MAAM,GAAG,CAAC;YAC7B,OAAO,EAAE,GAAG,CAAC,OAAO;YACpB,MAAM,EAAE,IAAI,CAAC,MAAM;YACnB,GAAG,EAAE,IAAI,CAAC,GAAG;SACd,CAAC,CAAA;QACF,IAAI,IAAI,CAAC,UAAU,EAAE,CAAC;YACpB,MAAM,eAAe,CAAC,QAAQ,EAAE,SAAS,EAAE,IAAI,CAAC,UAAU,EAAE,MAAM,CAAC,CAAA;YACnE,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,UAAU,EAAE,SAAS,EAAE,GAAG,CAAC,KAAK,CAAC,MAAM,EAAE,CAAA;QACjE,CAAC;QACD,MAAM,WAAW,CAAC,QAAQ,EAAE,SAAS,EAAE,MAAM,CAAC,CAAA;QAC9C,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;QAChD,IAAI,IAAI,CAAC,UAAU,EAAE,CAAC;YACpB,MAAM,EAAE,CAAC,SAAS,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAA;QACvD,CAAC;IACH,CAAC;AACH,CAAC"}
@@ -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"}
@@ -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 with ≥3 decimal places to `decimals` places.
40
- // Integers and short floats (≤2 decimals) are unchanged.
41
- // Safe for glyph outlines and <use> positions at 2 decimals (0.01 pt precision).
42
- export function roundCoords(svg, decimals = 2) {
43
- return svg.replace(/-?\d+\.\d{3,}/g, (m) => String(Number(parseFloat(m).toFixed(decimals))));
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":"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"}
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":"AAkBA,sFAAsF;AACtF,kFAAkF;AAClF,MAAM,CAAC,MAAM,eAAe,GAAG,EAAE,CAAA"}
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
- return { tojiru: 1, title, kind, pageCount: pages.length, pages };
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
@@ -1 +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"}
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"}
@@ -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;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"}
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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 html = [
41
- '<!doctype html>',
42
- '<html lang="en">',
43
- '<head>',
44
- '<meta charset="utf-8" />',
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
- `window.__TOJIRU_PAGES = ${escapeScript(JSON.stringify(pagesMap))};`,
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":"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"}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gheop/tojiru",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Turn a fixed-page document (PDF, comic, DjVu) into a static web reader",
5
5
  "type": "module",
6
6
  "bin": {
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
- <div id="reduce" title="Collapse / expand thumbnails">☰</div>
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: #fff; }
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; 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;
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: #fff; padding-top: 28px;
68
+ overflow: auto; text-align: center; background: var(--bg); padding-top: 28px;
20
69
  }
21
70
  #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;
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 img:hover { width: 104px; }
28
- #menu img.select { opacity: 1; }
29
- #menu .num { display: block; font-size: 11px; color: #999; margin-bottom: 8px; }
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 #999; background: #ececec; cursor: col-resize; z-index: 1;
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: #fff;
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 silver; box-shadow: .2em .2em #ccc;
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: #777; }
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 = `page ${p.n}`
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
- menu.append(t, num)
110
- return t
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
- resize.classList.toggle('hidden', hidden)
164
- pageEl.classList.toggle('full', hidden)
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
- if (['ArrowDown', 'ArrowRight', ' ', 'PageDown', 'n'].includes(ev.key)) { goTo(current + 1); ev.preventDefault() }
187
- else if (['ArrowUp', 'ArrowLeft', 'PageUp', 'p'].includes(ev.key)) { goTo(current - 1); ev.preventDefault() }
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)