@gheop/tojiru 0.7.0 → 0.9.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,23 @@ 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
+ | `--paged` | Default to the paged view (one page per screen); the reader can switch back |
91
+
92
+ ### Reading
93
+
94
+ The generated reader works the same whatever the source format:
95
+
96
+ - **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.
97
+ - **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, and the current section is highlighted as you read.
98
+ - **Dark mode** — follows the system by default; the ◐ button toggles it and remembers your choice.
99
+ - **Paged view** — the ≣ / ▭ button switches between continuous scroll and one page per screen (horizontal, arrow-key or swipe navigation). `--paged` makes the paged view the default; the choice is remembered.
100
+ - **Double-page / manga** — `--spread` shows two pages per row; add `--rtl` for right-to-left order (page 1 on the right, left arrow advances).
101
+
102
+ ```bash
103
+ tojiru manga.cbz --out manga/ --image-format webp --spread --rtl
104
+ ```
85
105
 
86
106
  ### Single file
87
107
 
@@ -163,6 +183,22 @@ MIT — see [LICENSE](LICENSE).
163
183
 
164
184
  ## Changelog
165
185
 
186
+ ### v0.9.0 — Paged view, contents highlight, leaner comics (2026-06-26)
187
+
188
+ - **Paged reading view** — the ≣ / ▭ button switches the reader between continuous scroll and one page per screen (horizontal, arrow-key or swipe navigation), and the choice is remembered. `--paged` makes it the build default; `--rtl` pages it right-to-left.
189
+ - **The table of contents follows along** — the outline entry covering the current page is highlighted as you read.
190
+ - **`--image-format webp` never inflates a comic** — each page keeps WebP or its original, whichever is smaller. An already-compressed scan that used to grow ~2× now stays at its source size, while colour comics still drop to ~×0.3 (measured points in [`examples/BENCH.md`](examples/BENCH.md)).
191
+ - Tests run in CI on Node 24 (the Node 20 deprecation warning is gone), and a flaky reader e2e was de-flaked.
192
+
193
+ ### v0.8.0 — Search, dark mode, contents and manga layout (2026-06-26)
194
+
195
+ - **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.
196
+ - **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.
197
+ - **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.
198
+ - **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.
199
+ - Tests now run in CI on GitHub and GitLab, and `--serve` opens a browser on Windows too.
200
+ - Internals: the single-file output is derived from `index.html` instead of duplicating the markup, and `buildManifest` takes an options object.
201
+
166
202
  ### v0.7.0 — Lighter vector text (2026-06-26)
167
203
 
168
204
  - 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).
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,9 @@ 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')
38
+ .option('--paged', 'default to the paged view (one page per screen); reader can switch back')
33
39
  .action(async (input, opts) => {
34
40
  try {
35
41
  if (!existsSync(input))
@@ -53,7 +59,7 @@ program
53
59
  const htmlPath = typeof opts.singleFile === 'string'
54
60
  ? opts.singleFile
55
61
  : basename(input).replace(/\.[^.]+$/, '') + '.html';
56
- const r = await convert(input, { outDir: '', title: opts.title, onProgress, singleFile: htmlPath, imageFormat, quality });
62
+ const r = await convert(input, { outDir: '', title: opts.title, onProgress, singleFile: htmlPath, imageFormat, quality, spread: opts.spread, rtl: opts.rtl, paged: opts.paged });
57
63
  if (isTTY)
58
64
  process.stderr.write('\x1b[2K\r');
59
65
  console.log(`✓ ${r.pageCount} pages → ${htmlPath}`);
@@ -65,7 +71,7 @@ program
65
71
  if (existsSync(outDir) && (await readdir(outDir)).length > 0 && !opts.force) {
66
72
  throw new Error(`Folder ${outDir} is not empty. Use --force to overwrite.`);
67
73
  }
68
- const r = await convert(input, { outDir, title: opts.title, onProgress, imageFormat, quality });
74
+ const r = await convert(input, { outDir, title: opts.title, onProgress, imageFormat, quality, spread: opts.spread, rtl: opts.rtl, paged: opts.paged });
69
75
  // Clear progress line before success message
70
76
  if (isTTY)
71
77
  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,19 @@ 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
+ paged: opts.paged,
42
+ });
35
43
  if (opts.singleFile) {
36
- await writeSingleFile(manifest, bundleDir, opts.singleFile);
44
+ await writeSingleFile(manifest, bundleDir, opts.singleFile, search);
37
45
  return { outDir: opts.singleFile, pageCount: doc.pages.length };
38
46
  }
39
- await writeFolder(manifest, bundleDir);
47
+ await writeFolder(manifest, bundleDir, search);
40
48
  return { outDir: opts.outDir, pageCount: doc.pages.length };
41
49
  }
42
50
  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":"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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"}
@@ -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 {
@@ -65,6 +82,7 @@ export const pdfExtractor = {
65
82
  }
66
83
  const count = await pageCount(file);
67
84
  const width = Math.max(4, String(count).length);
85
+ const text = await extractText(file, count);
68
86
  const pages = [];
69
87
  for (let i = 1; i <= count; i++) {
70
88
  const stem = pad(i, width);
@@ -86,20 +104,22 @@ export const pdfExtractor = {
86
104
  await sharp(pngPath).webp({ quality, effort: 6 }).toFile(webpPath);
87
105
  await unlink(svgPath);
88
106
  await unlink(pngPath);
89
- pages.push({ type: 'raster', imagePath: webpPath, ...(await imageDims(webpPath)) });
107
+ pages.push({ type: 'raster', imagePath: webpPath, ...(await imageDims(webpPath)), text: text[i - 1] || undefined });
90
108
  }
91
109
  else {
92
110
  // Vector page: round coordinates to shrink SVG, then store.
93
111
  const rounded = roundCoords(svg);
94
112
  await writeFile(svgPath, rounded, 'utf8');
95
- pages.push({ type: 'vector', svgPath, ...viewBox(rounded) });
113
+ pages.push({ type: 'vector', svgPath, ...viewBox(rounded), text: text[i - 1] || undefined });
96
114
  }
97
115
  onProgress?.(i, count, 'Converting');
98
116
  }
117
+ const outline = await extractOutline(file);
99
118
  return {
100
119
  title: basename(file, extname(file)),
101
120
  kind: 'pdf',
102
121
  pages,
122
+ outline: outline.length ? outline : undefined,
103
123
  };
104
124
  },
105
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,15 @@
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
+ if (extras.paged)
12
+ m.layout = 'paged';
13
+ return m;
3
14
  }
4
15
  //# 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":"AAiCA,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,IAAI,MAAM,CAAC,KAAK;QAAE,CAAC,CAAC,MAAM,GAAG,OAAO,CAAA;IACpC,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/pages.js CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, writeFile, copyFile, rm } from 'node:fs/promises';
1
+ import { mkdir, readFile, writeFile, copyFile, rm, stat } from 'node:fs/promises';
2
2
  import { gzipSync } from 'node:zlib';
3
3
  import { basename, join } from 'node:path';
4
4
  import sharp from 'sharp';
@@ -30,12 +30,21 @@ export async function processPages(doc, outDir, opts = {}, onProgress) {
30
30
  }
31
31
  else {
32
32
  const ext = (basename(page.imagePath).split('.').pop() ?? 'jpg').toLowerCase();
33
- // --image-format webp re-encodes comic pages (often large lossless PNGs) to
34
- // lossy WebP. Sources already in WebP are copied re-encoding would only degrade.
33
+ // --image-format webp re-encodes comic pages (often large lossless PNGs) to lossy
34
+ // WebP. But an already-compressed source (a low-quality JPEG scan) can come out
35
+ // *bigger* as WebP, so we encode in memory and keep whichever is smaller — WebP
36
+ // never inflates a page. Sources already in WebP are copied (no re-encode).
35
37
  let file;
36
38
  if (imageFormat === 'webp' && ext !== 'webp') {
37
- file = `pages/${stem}.webp`;
38
- await sharp(page.imagePath).webp({ quality, effort: 6 }).toFile(join(outDir, file));
39
+ const webp = await sharp(page.imagePath).webp({ quality, effort: 6 }).toBuffer();
40
+ if (webp.length < (await stat(page.imagePath)).size) {
41
+ file = `pages/${stem}.webp`;
42
+ await writeFile(join(outDir, file), webp);
43
+ }
44
+ else {
45
+ file = `pages/${stem}.${ext}`;
46
+ await copyFile(page.imagePath, join(outDir, file));
47
+ }
39
48
  }
40
49
  else {
41
50
  file = `pages/${stem}.${ext}`;
package/dist/pages.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"pages.js","sourceRoot":"","sources":["../src/pages.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"pages.js","sourceRoot":"","sources":["../src/pages.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.7.0",
3
+ "version": "0.9.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,20 @@
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
+ <button id="layout" type="button" title="Reading layout: continuous / paged" aria-label="Toggle paged reading view">≣</button>
14
+ <div id="search" class="hidden" role="dialog" aria-label="Search the document">
15
+ <input id="search-input" type="search" placeholder="Search…" aria-label="Search text" autocomplete="off" spellcheck="false" />
16
+ <div id="search-results" aria-live="polite"></div>
17
+ </div>
11
18
  <nav id="menu" aria-label="Pages"></nav>
12
- <div id="resize" title="Drag to resize"></div>
13
- <main id="page"></main>
19
+ <div id="resize" title="Drag to resize" aria-hidden="true"></div>
20
+ <main id="page" tabindex="0"></main>
14
21
  <script type="module" src="reader.js"></script>
15
22
  </body>
16
23
  </html>
package/reader/reader.css CHANGED
@@ -2,53 +2,144 @@
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, #layout {
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;
15
36
  }
37
+ #reduce { left: 5px; }
38
+ #theme { left: 30px; }
39
+ #layout { left: 55px; }
40
+ #reduce:focus-visible, #theme:focus-visible, #layout:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
41
+
42
+ /* Search panel: Ctrl+F (or /) opens it over the top of the page area. */
43
+ #search {
44
+ position: fixed; top: 8px; left: 50%; transform: translateX(-50%); z-index: 6;
45
+ width: min(440px, calc(100vw - 24px));
46
+ background: var(--bg); border: 1px solid var(--chrome-border); border-radius: 6px;
47
+ box-shadow: 0 4px 18px rgba(0, 0, 0, .25); padding: 6px;
48
+ }
49
+ #search.hidden { display: none; }
50
+ #search-input {
51
+ width: 100%; box-sizing: border-box; padding: 7px 9px; font: inherit; font-size: 14px;
52
+ color: var(--chrome-fg); background: var(--bg);
53
+ border: 1px solid var(--chrome-border); border-radius: 4px;
54
+ }
55
+ #search-input:focus-visible { outline: 2px solid var(--focus); outline-offset: 0; }
56
+ #search-results { max-height: 50vh; overflow: auto; margin-top: 6px; }
57
+ #search-results:empty { display: none; }
58
+ #search-results .hit {
59
+ display: block; width: 100%; text-align: left; padding: 6px 8px; border: 0; border-radius: 4px;
60
+ background: none; color: var(--chrome-fg); font: inherit; font-size: 13px; cursor: pointer; line-height: 1.35;
61
+ }
62
+ #search-results .hit:hover, #search-results .hit:focus-visible { background: var(--divider-bg); outline: none; }
63
+ #search-results .hit .pno { color: var(--muted); font-size: 11px; margin-right: 6px; }
64
+ #search-results .hit mark { background: #ffe066; color: #000; border-radius: 2px; }
65
+ #search-results .empty { padding: 6px 8px; color: var(--muted); font-size: 13px; }
16
66
 
17
67
  #menu {
18
68
  position: fixed; top: 0; bottom: 0; left: 0; width: 150px;
19
- overflow: auto; text-align: center; background: #fff; padding-top: 28px;
69
+ overflow: auto; text-align: center; background: var(--bg); padding-top: 28px;
20
70
  }
21
71
  #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;
72
+ #toc { text-align: left; padding: 2px 0 8px; margin-bottom: 6px; border-bottom: 1px solid var(--chrome-border); }
73
+ #toc .toc-head { font-size: 10px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); padding: 2px 8px 5px; }
74
+ .toc-item {
75
+ display: block; width: 100%; text-align: left; border: 0; background: none; cursor: pointer;
76
+ font: inherit; font-size: 12px; color: var(--chrome-fg); padding: 4px 8px; line-height: 1.3;
77
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
78
+ }
79
+ .toc-item:hover, .toc-item:focus-visible { background: var(--divider-bg); outline: none; }
80
+ .toc-item.active { color: var(--focus); font-weight: 600; }
81
+ #menu .thumb {
82
+ display: block; width: 100%; padding: 0; margin: 0; border: 0;
83
+ background: none; cursor: pointer; text-align: center; font-family: inherit;
84
+ }
85
+ #menu .thumb img {
86
+ width: 100px; margin: 10px 0 2px; background: #fff;
87
+ opacity: .5; border: 1px solid var(--page-border); box-shadow: .2em .2em var(--page-shadow);
25
88
  transition: opacity .15s ease, width .1s ease;
26
89
  }
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; }
90
+ #menu .thumb:hover img { width: 104px; }
91
+ #menu .thumb.select img { opacity: 1; }
92
+ #menu .thumb:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
93
+ #menu .num { display: block; font-size: 11px; color: var(--muted); margin-bottom: 8px; }
30
94
 
31
95
  #resize {
32
96
  position: fixed; top: 0; bottom: 0; left: 150px; width: 4px;
33
- border-right: 1px solid #999; background: #ececec; cursor: col-resize; z-index: 1;
97
+ border-right: 1px solid var(--divider-border); background: var(--divider-bg); cursor: col-resize; z-index: 1;
34
98
  }
35
99
  #resize.hidden { display: none; }
36
100
 
37
101
  #page {
38
102
  position: fixed; top: 0; bottom: 0; left: 155px; right: 0;
39
- overflow: auto; text-align: center; scroll-behavior: smooth; background: #fff;
103
+ overflow: auto; text-align: center; scroll-behavior: smooth; background: var(--bg);
40
104
  }
41
105
  #page.full { left: 0; }
42
106
 
43
107
  .page {
44
108
  display: block; margin: 12px auto; width: calc(100% - 36px);
45
109
  }
110
+
111
+ /* Double-page spread: two pages per row (the open-book look). rtl reverses each pair so
112
+ page 1 sits on the right, the manga reading order. Pages wrap to one per row when the
113
+ viewport is too narrow for a fair pair. */
114
+ #page.spread { display: flex; flex-wrap: wrap; align-content: flex-start; justify-content: center; gap: 12px; padding: 12px 0; }
115
+ #page.spread .page { width: calc(50% - 18px); margin: 0; }
116
+ #page.spread.rtl { direction: rtl; }
117
+ @media (max-width: 700px) {
118
+ #page.spread .page { width: calc(100% - 24px); }
119
+ }
120
+
121
+ /* Paged view: one page per screen with horizontal scroll-snap, a reading mode toggled
122
+ at runtime (the ≣/▭ button). rtl pages it right-to-left. Takes precedence over the
123
+ spread layout when both are set. */
124
+ #page.paged {
125
+ display: flex; flex-direction: row; flex-wrap: nowrap; gap: 0; padding: 0;
126
+ overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory;
127
+ }
128
+ #page.paged .page {
129
+ flex: 0 0 100%; width: 100%; height: 100%; margin: 0; padding: 12px;
130
+ scroll-snap-align: center;
131
+ }
132
+ #page.paged .page object, #page.paged .page img {
133
+ width: 100%; height: 100%; object-fit: contain;
134
+ border: 0; box-shadow: none; opacity: 1;
135
+ }
136
+ #page.paged.rtl { direction: rtl; }
46
137
  .page object, .page img {
47
138
  display: block; width: 100%; height: 100%;
48
- border: 1px solid silver; box-shadow: .2em .2em #ccc;
139
+ border: 1px solid var(--page-border); box-shadow: .2em .2em var(--page-shadow);
49
140
  background: #fff; opacity: .7; transition: opacity .25s ease;
50
141
  }
51
- .page.select object, .page.select img { opacity: 1; border-color: #777; }
142
+ .page.select object, .page.select img { opacity: 1; border-color: var(--divider-border); }
52
143
 
53
144
  /* Thin scrollbars, hidden until hovered. */
54
145
  #menu, #page { scrollbar-width: thin; scrollbar-color: transparent transparent; }
@@ -56,3 +147,17 @@ body { font-family: verdana, sans-serif; background: #fff; }
56
147
  #menu::-webkit-scrollbar, #page::-webkit-scrollbar { width: 7px; }
57
148
  #menu::-webkit-scrollbar-thumb, #page::-webkit-scrollbar-thumb { background: transparent; border-radius: 4px; }
58
149
  #menu:hover::-webkit-scrollbar-thumb, #page:hover::-webkit-scrollbar-thumb { background: #c4c4c4; }
150
+
151
+ /* Phones: the thumbnail column overlays the page instead of pushing it, the page
152
+ is always full width, and tap targets are a little larger. reader.js collapses
153
+ the column by default on small screens. */
154
+ @media (max-width: 640px) {
155
+ #reduce, #theme, #layout { width: 30px; height: 30px; line-height: 28px; font-size: 16px; }
156
+ #theme { left: 40px; }
157
+ #layout { left: 75px; }
158
+ #menu { width: 72vw; max-width: 280px; z-index: 3; box-shadow: 2px 0 14px rgba(0, 0, 0, .35); }
159
+ #menu .thumb img { width: 120px; }
160
+ #menu .thumb:hover img { width: 120px; }
161
+ #resize { display: none; }
162
+ #page { left: 0; }
163
+ }
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,50 @@ 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
+ // tocEntries keeps each button with its page so the current section can be highlighted.
109
+ const tocEntries = []
110
+ if (manifest.outline && manifest.outline.length) {
111
+ const toc = document.createElement('div')
112
+ toc.id = 'toc'
113
+ const head = document.createElement('div')
114
+ head.className = 'toc-head'
115
+ head.textContent = 'Contents'
116
+ toc.append(head)
117
+ for (const e of manifest.outline) {
118
+ const item = document.createElement('button')
119
+ item.type = 'button'
120
+ item.className = 'toc-item'
121
+ item.textContent = e.title
122
+ item.title = e.title
123
+ item.style.paddingLeft = `${8 + Math.min(e.depth, 5) * 12}px`
124
+ item.addEventListener('click', () => goTo(e.page))
125
+ toc.append(item)
126
+ tocEntries.push({ el: item, page: e.page })
127
+ }
128
+ menu.append(toc)
129
+ }
130
+
131
+ // Highlights the outline entry covering page n: the last one whose page is ≤ n.
132
+ let activeToc = -1
133
+ function updateTocActive(n) {
134
+ if (!tocEntries.length) return
135
+ let idx = -1
136
+ for (let i = 0; i < tocEntries.length; i++) if (tocEntries[i].page <= n) idx = i
137
+ if (idx === activeToc) return
138
+ tocEntries[activeToc]?.el.classList.remove('active')
139
+ activeToc = idx
140
+ tocEntries[idx]?.el.classList.add('active')
141
+ }
142
+
143
+ // Each thumbnail is a real <button> so it can be reached and activated by
144
+ // keyboard, not just clicked. The .select class lives on the button.
95
145
  const thumbs = manifest.pages.map((p) => {
146
+ const btn = document.createElement('button')
147
+ btn.type = 'button'
148
+ btn.className = 'thumb'
149
+ btn.setAttribute('aria-label', `Go to page ${p.n}`)
96
150
  const t = document.createElement('img')
97
151
  const inlinePages = window.__TOJIRU_PAGES
98
152
  if (inlinePages && Object.prototype.hasOwnProperty.call(inlinePages, p.thumb)) {
@@ -101,16 +155,32 @@ function init(manifest) {
101
155
  t.src = p.thumb
102
156
  }
103
157
  t.loading = 'lazy'
104
- t.alt = `page ${p.n}`
105
- t.addEventListener('click', () => goTo(p.n))
158
+ t.alt = ''
106
159
  const num = document.createElement('span')
107
160
  num.className = 'num'
108
161
  num.textContent = String(p.n)
109
- menu.append(t, num)
110
- return t
162
+ btn.append(t, num)
163
+ btn.addEventListener('click', () => goTo(p.n))
164
+ menu.append(btn)
165
+ return btn
111
166
  })
112
167
 
113
- const io = new IntersectionObserver(onIntersect, { root: pageEl, rootMargin: '800px 0px' })
168
+ // Double-page spread / right-to-left layout, when the build asked for it. The classes
169
+ // drive the CSS; rtl also flips the horizontal navigation keys further down.
170
+ pageEl.classList.toggle('spread', !!manifest.spread)
171
+ pageEl.classList.toggle('rtl', !!manifest.rtl)
172
+
173
+ // Reading layout: 'scroll' (vertical continuous) or 'paged' (one page per screen). A
174
+ // saved choice wins; otherwise the build's default (manifest.layout) applies.
175
+ let layout = (() => {
176
+ try { const saved = localStorage.getItem('tojiru:layout'); if (saved === 'paged' || saved === 'scroll') return saved } catch {}
177
+ return manifest.layout === 'paged' ? 'paged' : 'scroll'
178
+ })()
179
+ const paged = () => layout === 'paged'
180
+ pageEl.classList.toggle('paged', paged())
181
+
182
+ // 800px on every side so the next page preloads whichever way the reader scrolls.
183
+ const io = new IntersectionObserver(onIntersect, { root: pageEl, rootMargin: '800px' })
114
184
  const containers = manifest.pages.map((p) => {
115
185
  const c = document.createElement('div')
116
186
  c.className = 'page'
@@ -140,28 +210,138 @@ function init(manifest) {
140
210
  thumbs[current - 1]?.classList.add('select')
141
211
  containers[current - 1]?.classList.add('select')
142
212
  thumbs[current - 1]?.scrollIntoView({ block: 'nearest' })
213
+ updateTocActive(n)
143
214
  history.replaceState(null, '', `#page=${n}`)
144
215
  try { localStorage.setItem(key, String(n)) } catch {}
145
216
  }
146
217
 
147
218
  function goTo(n) {
148
219
  n = Math.min(Math.max(1, n), manifest.pages.length)
149
- containers[n - 1].scrollIntoView()
220
+ // Paged scrolls horizontally to centre the page; scroll mode aligns it to the top.
221
+ containers[n - 1].scrollIntoView(paged() ? { inline: 'center', block: 'nearest' } : undefined)
150
222
  setCurrent(n)
151
223
  }
152
224
 
153
225
  pageEl.addEventListener('scroll', () => {
154
- const mid = pageEl.scrollTop + pageEl.clientHeight / 2
155
- for (let i = 0; i < containers.length; i++) {
156
- const c = containers[i]
157
- if (c.offsetTop <= mid && c.offsetTop + c.offsetHeight > mid) { setCurrent(i + 1); break }
226
+ if (paged()) {
227
+ // Horizontal: the page whose box spans the viewport's centre x is current. Using
228
+ // getBoundingClientRect keeps this correct under rtl, where scrollLeft flips sign.
229
+ const r = pageEl.getBoundingClientRect()
230
+ const cx = r.left + r.width / 2
231
+ for (let i = 0; i < containers.length; i++) {
232
+ const cr = containers[i].getBoundingClientRect()
233
+ if (cr.left <= cx && cx < cr.right) { setCurrent(i + 1); break }
234
+ }
235
+ } else {
236
+ const mid = pageEl.scrollTop + pageEl.clientHeight / 2
237
+ for (let i = 0; i < containers.length; i++) {
238
+ const c = containers[i]
239
+ if (c.offsetTop <= mid && c.offsetTop + c.offsetHeight > mid) { setCurrent(i + 1); break }
240
+ }
158
241
  }
159
242
  }, { passive: true })
160
243
 
244
+ const isNarrow = () => matchMedia('(max-width: 640px)').matches
245
+
161
246
  $('#reduce').addEventListener('click', () => {
162
247
  const hidden = menu.classList.toggle('hidden')
163
- resize.classList.toggle('hidden', hidden)
164
- pageEl.classList.toggle('full', hidden)
248
+ // On phones the column overlays the page (CSS keeps #page full width), so the
249
+ // divider and the page offset only matter on wide screens.
250
+ if (!isNarrow()) {
251
+ resize.classList.toggle('hidden', hidden)
252
+ pageEl.classList.toggle('full', hidden)
253
+ }
254
+ })
255
+
256
+ // Dark-mode toggle. The current theme is data-theme on <html> (set early by the
257
+ // inline head script for saved overrides); with no override we follow the system.
258
+ $('#theme').addEventListener('click', () => {
259
+ const root = document.documentElement
260
+ const sysDark = matchMedia('(prefers-color-scheme: dark)').matches
261
+ const current = root.dataset.theme || (sysDark ? 'dark' : 'light')
262
+ const next = current === 'dark' ? 'light' : 'dark'
263
+ root.dataset.theme = next
264
+ try { localStorage.setItem('tojiru:theme', next) } catch {}
265
+ })
266
+
267
+ // Reading-layout toggle: continuous scroll (≣) ↔ paged (▭). The icon shows the
268
+ // current mode; switching re-centres the current page in the new axis.
269
+ const layoutBtn = $('#layout')
270
+ const syncLayoutBtn = () => { layoutBtn.textContent = paged() ? '▭' : '≣' }
271
+ syncLayoutBtn()
272
+ layoutBtn.addEventListener('click', () => {
273
+ layout = paged() ? 'scroll' : 'paged'
274
+ pageEl.classList.toggle('paged', paged())
275
+ try { localStorage.setItem('tojiru:layout', layout) } catch {}
276
+ syncLayoutBtn()
277
+ if (current) goTo(current)
278
+ })
279
+
280
+ // --- Full-text search (only when the build shipped an index) ---
281
+ const searchBox = $('#search')
282
+ const searchInput = $('#search-input')
283
+ const searchResults = $('#search-results')
284
+ let searchTimer = 0
285
+
286
+ const openSearch = () => { searchBox.classList.remove('hidden'); searchInput.focus(); searchInput.select() }
287
+ // preventScroll: focusing the scroll container must not yank it back and cancel the
288
+ // goTo() jump that runs just before closing.
289
+ const closeSearch = () => { searchBox.classList.add('hidden'); pageEl.focus({ preventScroll: true }) }
290
+
291
+ // Builds a one-line excerpt around the first match in `text` for query `q` (lowercase).
292
+ function snippet(text, q) {
293
+ const idx = text.toLowerCase().indexOf(q)
294
+ if (idx < 0) return null
295
+ const start = Math.max(0, idx - 30)
296
+ const end = Math.min(text.length, idx + q.length + 60)
297
+ return {
298
+ pre: (start > 0 ? '… ' : '') + text.slice(start, idx),
299
+ match: text.slice(idx, idx + q.length),
300
+ post: text.slice(idx + q.length, end) + (end < text.length ? ' …' : ''),
301
+ }
302
+ }
303
+
304
+ async function runSearch() {
305
+ const q = searchInput.value.trim().toLowerCase()
306
+ searchResults.replaceChildren()
307
+ if (q.length < 2) return
308
+ const index = await getSearchIndex()
309
+ const hits = []
310
+ for (const e of index) {
311
+ const s = snippet(e.t, q)
312
+ if (s) hits.push({ n: e.n, s })
313
+ if (hits.length >= 60) break
314
+ }
315
+ if (hits.length === 0) {
316
+ const empty = document.createElement('div')
317
+ empty.className = 'empty'
318
+ empty.textContent = 'No matches'
319
+ searchResults.append(empty)
320
+ return
321
+ }
322
+ for (const h of hits) {
323
+ const btn = document.createElement('button')
324
+ btn.type = 'button'
325
+ btn.className = 'hit'
326
+ const pno = document.createElement('span')
327
+ pno.className = 'pno'
328
+ pno.textContent = `p.${h.n}`
329
+ const mark = document.createElement('mark')
330
+ mark.textContent = h.s.match
331
+ // textContent/createTextNode keep page text inert — no HTML injection from the PDF.
332
+ btn.append(pno, document.createTextNode(h.s.pre), mark, document.createTextNode(h.s.post))
333
+ btn.addEventListener('click', () => { goTo(h.n); closeSearch() })
334
+ searchResults.append(btn)
335
+ }
336
+ }
337
+
338
+ searchInput.addEventListener('input', () => {
339
+ clearTimeout(searchTimer)
340
+ searchTimer = setTimeout(runSearch, 120)
341
+ })
342
+ searchInput.addEventListener('keydown', (ev) => {
343
+ if (ev.key === 'Escape') { closeSearch(); ev.preventDefault() }
344
+ else if (ev.key === 'Enter') { searchResults.querySelector('.hit')?.click(); ev.preventDefault() }
165
345
  })
166
346
 
167
347
  // Draggable divider between the thumbnail column and the page area.
@@ -183,12 +363,31 @@ function init(manifest) {
183
363
  })
184
364
 
185
365
  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() }
366
+ // Open search on Ctrl/Cmd+F or "/", but only when a search index shipped — without
367
+ // one, leave the browser's native find untouched.
368
+ if (manifest.searchable && ((ev.key === 'f' && (ev.ctrlKey || ev.metaKey)) ||
369
+ (ev.key === '/' && !(ev.target instanceof HTMLInputElement)))) {
370
+ openSearch(); ev.preventDefault(); return
371
+ }
372
+ // Don't let page navigation steal keys while typing in the search box.
373
+ if (ev.target instanceof HTMLInputElement) return
374
+ // In RTL (manga), left advances and right goes back; vertical keys are unchanged.
375
+ const fwd = manifest.rtl ? 'ArrowLeft' : 'ArrowRight'
376
+ const back = manifest.rtl ? 'ArrowRight' : 'ArrowLeft'
377
+ if (['ArrowDown', ' ', 'PageDown', 'n', fwd].includes(ev.key)) { goTo(current + 1); ev.preventDefault() }
378
+ else if (['ArrowUp', 'PageUp', 'p', back].includes(ev.key)) { goTo(current - 1); ev.preventDefault() }
188
379
  else if (ev.key === 'Home') goTo(1)
189
380
  else if (ev.key === 'End') goTo(manifest.pages.length)
190
381
  })
191
382
 
383
+ // On phones, start with the thumbnail column collapsed so the page gets the full
384
+ // width; the ☰ button reveals it as an overlay.
385
+ if (isNarrow()) {
386
+ menu.classList.add('hidden')
387
+ resize.classList.add('hidden')
388
+ pageEl.classList.add('full')
389
+ }
390
+
192
391
  const fromHash = location.hash.match(/page=(\d+)/)
193
392
  const saved = (() => { try { return Number(localStorage.getItem(key)) } catch { return 0 } })()
194
393
  goTo(fromHash ? Number(fromHash[1]) : saved || 1)