From 429b875faf74a4cced292b1185a94fde0a8897a7 Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Tue, 3 Nov 2026 18:42:26 +0100 Subject: [PATCH] =?UTF-8?q?PDF:=20=C3=B6ffnet=20an=20der=20gespeicherten?= =?UTF-8?q?=20Seite,=20Leserfarben,=20Seitenzahlen=20wie=20im=20Buch=20(sw?= =?UTF-8?q?=20CACHE=20v166)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Lesestand wird gleich beim Öffnen erfragt (parallel zur Datei), ein PDF zeichnet zuerst die Seite, an der es weitergeht, statt Seite 1; Titel, Inhaltsverzeichnis und Seitenzahlen kommen nebenher und halten das Öffnen nicht mehr auf. Eine Zeichnung, die schon läuft, wird abgewartet statt neu begonnen. „Leserfarben“ (neben Originalfarben und Invertieren): die Seite in den Farben des Lesers (Dunkel, Sepia) per SVG-Filter, Bilder darüber wie gedruckt (ihre Lage aus den Zeichenbefehlen); ein Bild, das die halbe Seite füllt, ist ein Scan und wird mit umgefärbt. pdf.js' eigene pageColors färben die Bilder mit um. Ein anderes Design ändert nur die Farben des Filters. Seitenzahlen des Buchs (PageLabels: „iv“, „A-3“) im Kopf mit der Seite der Datei daneben, eingeben lässt sich beides; Lesezeichen und der Zurück-Knopf nennen sie. Das Test-PDF hat dafür Seitenzahlen und ein farbiges Bild bekommen. Co-Authored-By: Claude Opus 5.5 --- e2e/fixtures/make_test_pdf.py | 19 ++- e2e/fixtures/test.pdf | 177 +++++++++++---------- static/css/app.css | 4 + static/js/app/50-reader-state.js | 2 +- static/js/app/58-pdf.js | 233 +++++++++++++++++++++++++--- static/js/app/62-reader.js | 65 ++++++-- static/js/app/64-reader-settings.js | 30 +++- static/js/app/66-pdf-paginated.js | 8 +- static/js/app/68-bookmarks.js | 4 +- static/js/sw.js | 2 +- 10 files changed, 410 insertions(+), 134 deletions(-) diff --git a/e2e/fixtures/make_test_pdf.py b/e2e/fixtures/make_test_pdf.py index b306acb..556c314 100644 --- a/e2e/fixtures/make_test_pdf.py +++ b/e2e/fixtures/make_test_pdf.py @@ -5,8 +5,10 @@ 12 pages: A4 portrait text pages, one A5 page and one A4 landscape page (mixed sizes), page 7 nearly empty (only a page number: the smart crop must not zoom it to infinity), an outline (table of contents) with three entries, internal -links from page 1 to pages 5 and 9 and one external link. Plain PDF 1.4, -Helvetica, no dependencies. +links from page 1 to pages 5 and 9 and one external link, a coloured picture on +page 2 (it must stay as it is in the reader's colours) and page labels like a +book's: i, ii for the first two pages, then 1, 2, 3 … Plain PDF 1.4, Helvetica, +no dependencies. """ import sys @@ -42,6 +44,8 @@ def page_content(no, size): for ln in lines: ops.append(f'({esc(ln)}) Tj T*') ops.append('ET') + if no == 2: + ops += ['q', f'200 0 0 120 {w - 260} 60 cm', '/Im1 Do', 'Q'] if no == 1: ops += ['BT', '/F1 12 Tf', f'50 {y - 30 - 14 * len(lines)} Td', '(Link zu Seite 5) Tj', '0 -20 Td', '(Link zu Seite 9) Tj', '0 -20 Td', '(Externer Link: example.org) Tj', 'ET'] @@ -62,6 +66,11 @@ def build(path): catalog = add(None) pages_obj = add(None) font = add('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>') + # A picture of four colours (2 x 2 pixels, drawn large). + pixels = b'FF0000 00A000 0000FF FFD700>' + image = add(b'<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB ' + b'/BitsPerComponent 8 /Filter /ASCIIHexDecode /Length %d >>\nstream\n' % len(pixels) + + pixels + b'\nendstream') page_ids = [] for i, size in enumerate(sizes): content = page_content(i + 1, size).encode('cp1252') @@ -80,8 +89,9 @@ def build(path): f'/A << /S /URI /URI (https://example.org/) >> >>')] for n, (pid, cid, (w, h)) in enumerate(page_ids): extra = f' /Annots [{" ".join(f"{a} 0 R" for a in annots)}]' if n == 0 else '' + xobj = f' /XObject << /Im1 {image} 0 R >>' if n == 1 else '' objs[pid - 1] = (f'<< /Type /Page /Parent {pages_obj} 0 R /MediaBox [0 0 {w} {h}] ' - f'/Resources << /Font << /F1 {font} 0 R >> >> /Contents {cid} 0 R{extra} >>') + f'/Resources << /Font << /F1 {font} 0 R >>{xobj} >> /Contents {cid} 0 R{extra} >>') objs[pages_obj - 1] = f'<< /Type /Pages /Kids [{" ".join(f"{p} 0 R" for p, _, _ in page_ids)}] /Count {len(page_ids)} >>' # outline: three entries outline = add(None) @@ -96,7 +106,8 @@ def build(path): objs[oid - 1] = (f'<< /Title ({esc(title)}) /Parent {outline} 0 R{nav} ' f'/Dest [{page_ids[page - 1][0]} 0 R /XYZ 0 {sizes[page - 1][1]} null] >>') objs[outline - 1] = f'<< /Type /Outlines /First {ids[0]} 0 R /Last {ids[-1]} 0 R /Count {len(ids)} >>' - objs[catalog - 1] = f'<< /Type /Catalog /Pages {pages_obj} 0 R /Outlines {outline} 0 R /PageMode /UseOutlines >>' + objs[catalog - 1] = (f'<< /Type /Catalog /Pages {pages_obj} 0 R /Outlines {outline} 0 R /PageMode /UseOutlines ' + f'/PageLabels << /Nums [0 << /S /r >> 2 << /S /D >>] >> >>') info = add('<< /Title (Testdokument PDF) /Author (diora Test) >>') out = bytearray(b'%PDF-1.4\n%\xe2\xe3\xcf\xd3\n') diff --git a/e2e/fixtures/test.pdf b/e2e/fixtures/test.pdf index e09c0a2..49b0fef 100644 --- a/e2e/fixtures/test.pdf +++ b/e2e/fixtures/test.pdf @@ -1,15 +1,21 @@ %PDF-1.4 %âãÏÓ 1 0 obj -<< /Type /Catalog /Pages 2 0 R /Outlines 31 0 R /PageMode /UseOutlines >> +<< /Type /Catalog /Pages 2 0 R /Outlines 32 0 R /PageMode /UseOutlines /PageLabels << /Nums [0 << /S /r >> 2 << /S /D >>] >> >> endobj 2 0 obj -<< /Type /Pages /Kids [5 0 R 7 0 R 9 0 R 11 0 R 13 0 R 15 0 R 17 0 R 19 0 R 21 0 R 23 0 R 25 0 R 27 0 R] /Count 12 >> +<< /Type /Pages /Kids [6 0 R 8 0 R 10 0 R 12 0 R 14 0 R 16 0 R 18 0 R 20 0 R 22 0 R 24 0 R 26 0 R 28 0 R] /Count 12 >> endobj 3 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj 4 0 obj +<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 28 >> +stream +FF0000 00A000 0000FF FFD700> +endstream +endobj +5 0 obj << /Length 2463 >> stream BT @@ -66,11 +72,11 @@ BT ET endstream endobj -5 0 obj -<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 4 0 R /Annots [28 0 R 29 0 R 30 0 R] >> -endobj 6 0 obj -<< /Length 2345 >> +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 5 0 R /Annots [29 0 R 30 0 R 31 0 R] >> +endobj +7 0 obj +<< /Length 2379 >> stream BT /F1 22 Tf @@ -110,6 +116,10 @@ BT (finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T* (eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T* ET +q +200 0 0 120 335 60 cm +/Im1 Do +Q BT /F1 10 Tf 288 30 Td @@ -117,10 +127,10 @@ BT ET endstream endobj -7 0 obj -<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 6 0 R >> -endobj 8 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> /XObject << /Im1 4 0 R >> >> /Contents 7 0 R >> +endobj +9 0 obj << /Length 2345 >> stream BT @@ -168,10 +178,10 @@ BT ET endstream endobj -9 0 obj -<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 8 0 R >> -endobj 10 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 9 0 R >> +endobj +11 0 obj << /Length 1196 >> stream BT @@ -209,10 +219,10 @@ BT ET endstream endobj -11 0 obj -<< /Type /Page /Parent 2 0 R /MediaBox [0 0 420 595] /Resources << /Font << /F1 3 0 R >> >> /Contents 10 0 R >> -endobj 12 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 420 595] /Resources << /Font << /F1 3 0 R >> >> /Contents 11 0 R >> +endobj +13 0 obj << /Length 2345 >> stream BT @@ -260,10 +270,10 @@ BT ET endstream endobj -13 0 obj -<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 12 0 R >> -endobj 14 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 13 0 R >> +endobj +15 0 obj << /Length 2345 >> stream BT @@ -311,10 +321,10 @@ BT ET endstream endobj -15 0 obj -<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 14 0 R >> -endobj 16 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 15 0 R >> +endobj +17 0 obj << /Length 32 >> stream BT @@ -324,10 +334,10 @@ BT ET endstream endobj -17 0 obj -<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 16 0 R >> -endobj 18 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 17 0 R >> +endobj +19 0 obj << /Length 2345 >> stream BT @@ -375,10 +385,10 @@ BT ET endstream endobj -19 0 obj -<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 18 0 R >> -endobj 20 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 19 0 R >> +endobj +21 0 obj << /Length 1547 >> stream BT @@ -416,10 +426,10 @@ BT ET endstream endobj -21 0 obj -<< /Type /Page /Parent 2 0 R /MediaBox [0 0 842 595] /Resources << /Font << /F1 3 0 R >> >> /Contents 20 0 R >> -endobj 22 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 842 595] /Resources << /Font << /F1 3 0 R >> >> /Contents 21 0 R >> +endobj +23 0 obj << /Length 2348 >> stream BT @@ -467,10 +477,10 @@ BT ET endstream endobj -23 0 obj -<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 22 0 R >> -endobj 24 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 23 0 R >> +endobj +25 0 obj << /Length 2348 >> stream BT @@ -518,10 +528,10 @@ BT ET endstream endobj -25 0 obj -<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 24 0 R >> -endobj 26 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 25 0 R >> +endobj +27 0 obj << /Length 2348 >> stream BT @@ -569,73 +579,74 @@ BT ET endstream endobj -27 0 obj -<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 26 0 R >> -endobj 28 0 obj -<< /Type /Annot /Subtype /Link /Rect [48 306 220 324] /Border [0 0 0] /Dest [13 0 R /XYZ 0 842 null] >> +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 27 0 R >> endobj 29 0 obj -<< /Type /Annot /Subtype /Link /Rect [48 286 220 304] /Border [0 0 0] /Dest [21 0 R /XYZ 0 595 null] >> +<< /Type /Annot /Subtype /Link /Rect [48 306 220 324] /Border [0 0 0] /Dest [14 0 R /XYZ 0 842 null] >> endobj 30 0 obj -<< /Type /Annot /Subtype /Link /Rect [48 266 300 284] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >> +<< /Type /Annot /Subtype /Link /Rect [48 286 220 304] /Border [0 0 0] /Dest [22 0 R /XYZ 0 595 null] >> endobj 31 0 obj -<< /Type /Outlines /First 32 0 R /Last 34 0 R /Count 3 >> +<< /Type /Annot /Subtype /Link /Rect [48 266 300 284] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >> endobj 32 0 obj -<< /Title (Anfang) /Parent 31 0 R /Next 33 0 R /Dest [5 0 R /XYZ 0 842 null] >> +<< /Type /Outlines /First 33 0 R /Last 35 0 R /Count 3 >> endobj 33 0 obj -<< /Title (Querformat) /Parent 31 0 R /Prev 32 0 R /Next 34 0 R /Dest [21 0 R /XYZ 0 595 null] >> +<< /Title (Anfang) /Parent 32 0 R /Next 34 0 R /Dest [6 0 R /XYZ 0 842 null] >> endobj 34 0 obj -<< /Title (Fast leer) /Parent 31 0 R /Prev 33 0 R /Dest [17 0 R /XYZ 0 842 null] >> +<< /Title (Querformat) /Parent 32 0 R /Prev 33 0 R /Next 35 0 R /Dest [22 0 R /XYZ 0 595 null] >> endobj 35 0 obj +<< /Title (Fast leer) /Parent 32 0 R /Prev 34 0 R /Dest [18 0 R /XYZ 0 842 null] >> +endobj +36 0 obj << /Title (Testdokument PDF) /Author (diora Test) >> endobj xref -0 36 +0 37 0000000000 65535 f 0000000015 00000 n -0000000104 00000 n -0000000237 00000 n -0000000334 00000 n -0000002849 00000 n -0000003006 00000 n -0000005403 00000 n -0000005529 00000 n -0000007926 00000 n -0000008052 00000 n -0000009301 00000 n -0000009429 00000 n -0000011827 00000 n -0000011955 00000 n -0000014353 00000 n -0000014481 00000 n -0000014564 00000 n -0000014692 00000 n -0000017090 00000 n -0000017218 00000 n -0000018818 00000 n -0000018946 00000 n -0000021347 00000 n -0000021475 00000 n -0000023876 00000 n -0000024004 00000 n -0000026405 00000 n -0000026533 00000 n -0000026653 00000 n -0000026773 00000 n -0000026907 00000 n -0000026981 00000 n -0000027077 00000 n -0000027191 00000 n -0000027291 00000 n +0000000158 00000 n +0000000292 00000 n +0000000389 00000 n +0000000584 00000 n +0000003099 00000 n +0000003256 00000 n +0000005687 00000 n +0000005839 00000 n +0000008236 00000 n +0000008363 00000 n +0000009612 00000 n +0000009740 00000 n +0000012138 00000 n +0000012266 00000 n +0000014664 00000 n +0000014792 00000 n +0000014875 00000 n +0000015003 00000 n +0000017401 00000 n +0000017529 00000 n +0000019129 00000 n +0000019257 00000 n +0000021658 00000 n +0000021786 00000 n +0000024187 00000 n +0000024315 00000 n +0000026716 00000 n +0000026844 00000 n +0000026964 00000 n +0000027084 00000 n +0000027218 00000 n +0000027292 00000 n +0000027388 00000 n +0000027502 00000 n +0000027602 00000 n trailer -<< /Size 36 /Root 1 0 R /Info 35 0 R >> +<< /Size 37 /Root 1 0 R /Info 36 0 R >> startxref -27360 +27671 %%EOF diff --git a/static/css/app.css b/static/css/app.css index b882aa5..7ca6dde 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -1820,6 +1820,10 @@ body.dnd-mode .timer-display { /* PDF invert */ /* Dark pages: inverted, and the hues turned back, so photos and coloured figures aren't negatives. */ #reader-overlay.pdf-inverted .pdf-page { filter:invert(1) hue-rotate(180deg); } +/* Leserfarben (58-pdf.js _pdfColorFilter): the page recoloured, its pictures over it as printed */ +.pdf-page.pdf-recolored { filter:url(#pdf-reader-colors); } +.pdf-image-layer { position:absolute; top:0; left:0; width:100%; height:100%; pointer-events:none; } +.pdf-image-layer canvas { position:absolute; display:block; } /* ---- Immersive reader mode ---- */ .navbar { transition: transform 0.35s ease; } diff --git a/static/js/app/50-reader-state.js b/static/js/app/50-reader-state.js index 2ba6b3c..fd6fa63 100644 --- a/static/js/app/50-reader-state.js +++ b/static/js/app/50-reader-state.js @@ -3,7 +3,7 @@ // Reader settings let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark', fontFamily: 'serif', noBold: false, fontWeight: null, - pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false }; + pdfZoom: 100, pdfInverted: false, pdfPageColors: false, pdfPaginated: false, pdfSpread: false }; let readerSettingsPanelOpen = false; let currentPdfDoc = null; let currentPdfBuffer = null; diff --git a/static/js/app/58-pdf.js b/static/js/app/58-pdf.js index cdfee60..8fdd59e 100644 --- a/static/js/app/58-pdf.js +++ b/static/js/app/58-pdf.js @@ -96,12 +96,184 @@ function _pdfPageString(tc) { return tc._diora; } +// Title, table of contents and page labels: asked for once per document, alongside the +// pages (opening doesn't wait for them; a layout anew reuses them). +let _pdfExtras = null; + +function _pdfDocExtras(pdf) { + if (_pdfExtras && _pdfExtras.pdf === pdf) return _pdfExtras; + const meta = pdf.getMetadata().then(m => ({ + title: (m && m.info && m.info.Title || '').trim(), + author: (m && m.info && m.info.Author || '').trim(), + }), () => ({title: '', author: ''})); + const toc = pdf.getOutline().then(o => (o && o.length ? _parsePdfOutline(pdf, o) : []), () => []); + const labels = pdf.getPageLabels().then(l => { + // Labels that only say 1, 2, 3 … are no labels. + if (!l || !l.length || l.every((s, i) => !s || s === String(i + 1))) return null; + return l.map((s, i) => s || String(i + 1)); + }, () => null); + _pdfExtras = {pdf, meta, toc, labels}; + return _pdfExtras; +} + +// The page labels of the open document (front matter "iv", appendix "A-3" …), null without. +let _pdfLabels = null; + +/** Page [n] (from 1) as the book numbers it. */ +function pdfPageLabel(n) { + return (_pdfLabels && _pdfLabels[n - 1]) || String(n); +} + +/** The page meant by [value] typed in: a label of the book first, else a page number. 0: none. */ +function _pdfPageFromInput(value) { + const v = String(value || '').trim(); + if (!v) return 0; + if (_pdfLabels) { + let i = _pdfLabels.indexOf(v); + if (i < 0) i = _pdfLabels.findIndex(l => l.toLowerCase() === v.toLowerCase()); + if (i >= 0) return i + 1; + } + return parseInt(v, 10) || 0; +} + +/** + * The pages in the reader's colours (Leserfarben): {background, foreground} as #rrggbb, the + * reader's (Dunkel, Sepia …). null: as printed (also when the reader is black on white). + */ +function _pdfPageColors() { + if (!readerSettings.pdfPageColors || readerSettings.pdfInverted) return null; + const contentEl = $('reader-content'); + if (!contentEl) return null; + let bg = null; + for (let el = contentEl; el && !bg; el = el.parentElement) bg = _pdfCssHex(getComputedStyle(el).backgroundColor); + const fg = _pdfCssHex(getComputedStyle(contentEl).color); + // On white the page is as printed anyway (Hell). + if (!bg || !fg || bg === '#ffffff') return null; + return {background: bg, foreground: fg}; +} + +/** "rgb(…)" / "rgba(…)" as #rrggbb; null for transparent. */ +function _pdfCssHex(css) { + const m = /rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)(?:[,\s/]+([\d.]+))?/.exec(css || ''); + if (!m || (m[4] !== undefined && parseFloat(m[4]) === 0)) return null; + return '#' + [m[1], m[2], m[3]].map(x => (+x).toString(16).padStart(2, '0')).join(''); +} + +// Whether the pages are drawn for Leserfarben (with their pictures laid over them, below): +// switching that draws them anew; another theme only changes the filter's colours. +function _pdfColorsKey() { + return _pdfPageColors() ? 'reader' : ''; +} + +/** + * Leserfarben: the page is drawn as printed and recoloured by an SVG filter on its canvas — + * its brightness from the reader's text colour (black) to its background (white). (pdf.js's + * own pageColors recolours the pictures too, and lets nothing be left out.) Kept up to date + * with the colours; true when it applies. + */ +function _pdfColorFilter() { + const c = _pdfPageColors(); + if (!c) return false; + const ns = 'http://www.w3.org/2000/svg'; + let svg = document.getElementById('pdf-reader-colors-svg'); + if (!svg) { + svg = document.createElementNS(ns, 'svg'); + svg.id = 'pdf-reader-colors-svg'; + svg.setAttribute('width', '0'); + svg.setAttribute('height', '0'); + svg.setAttribute('aria-hidden', 'true'); + svg.style.position = 'absolute'; + svg.innerHTML = '' + + '' + + '' + + ''; + document.body.appendChild(svg); + } + const ch = (hex, i) => (parseInt(hex.substr(1 + i * 2, 2), 16) / 255).toFixed(3); + ['R', 'G', 'B'].forEach((name, i) => { + svg.querySelector('feFunc' + name).setAttribute('tableValues', `${ch(c.foreground, i)} ${ch(c.background, i)}`); + }); + return true; +} + +/** + * Where page [page]'s pictures are, in CSS pixels of [viewport]: from its drawing operations + * (the unit square of each image under the transform at that point). Image masks are left + * out (they're drawn in a colour, like text). + */ +async function _pdfImageRects(page, viewport) { + const OPS = pdfjsLib.OPS; + let list; + try { + list = await page.getOperatorList({annotationMode: pdfjsLib.AnnotationMode.ENABLE_FORMS}); + } catch (e) { + return []; + } + const mul = (m, n) => [m[0] * n[0] + m[2] * n[1], m[1] * n[0] + m[3] * n[1], + m[0] * n[2] + m[2] * n[3], m[1] * n[2] + m[3] * n[3], + m[0] * n[4] + m[2] * n[5] + m[4], m[1] * n[4] + m[3] * n[5] + m[5]]; + let ctm = viewport.transform.slice(); + const stack = []; + const rects = []; + for (let i = 0; i < list.fnArray.length; i++) { + const fn = list.fnArray[i], args = list.argsArray[i]; + if (fn === OPS.save) stack.push(ctm); + else if (fn === OPS.restore) ctm = stack.pop() || ctm; + else if (fn === OPS.transform) ctm = mul(ctm, args); + else if (fn === OPS.paintFormXObjectBegin) { + stack.push(ctm); + if (Array.isArray(args[0]) && args[0].length === 6) ctm = mul(ctm, args[0]); + } else if (fn === OPS.paintFormXObjectEnd) ctm = stack.pop() || ctm; + else if (fn === OPS.paintImageXObject || fn === OPS.paintInlineImageXObject) { + const xs = [ctm[4], ctm[0] + ctm[4], ctm[2] + ctm[4], ctm[0] + ctm[2] + ctm[4]]; + const ys = [ctm[5], ctm[1] + ctm[5], ctm[3] + ctm[5], ctm[1] + ctm[3] + ctm[5]]; + const x = Math.max(0, Math.min.apply(null, xs)), y = Math.max(0, Math.min.apply(null, ys)); + const w = Math.min(viewport.width, Math.max.apply(null, xs)) - x; + const h = Math.min(viewport.height, Math.max.apply(null, ys)) - y; + if (w > 2 && h > 2) rects.push({x, y, w, h}); + } + } + return rects; +} + +// A picture covering this much of the page is the page itself (a scan): recoloured with it. +const PDF_SCAN_SHARE = 0.5; + +/** Leserfarben: page [e]'s pictures laid over it as printed (copied from its canvas). */ +async function _pdfImageLayer(e, page, viewport, ratio, token) { + const rects = await _pdfImageRects(page, viewport); + if (e.token !== token) return; + const area = viewport.width * viewport.height; + const shown = rects.filter(r => r.w * r.h < area * PDF_SCAN_SHARE); + if (!shown.length) return; + const layer = document.createElement('div'); + layer.className = 'pdf-image-layer'; + for (const r of shown) { + const c = document.createElement('canvas'); + const sx = Math.floor(r.x * ratio), sy = Math.floor(r.y * ratio); + const sw = Math.min(e.canvas.width - sx, Math.ceil(r.w * ratio)), sh = Math.min(e.canvas.height - sy, Math.ceil(r.h * ratio)); + if (sw <= 0 || sh <= 0) continue; + c.width = sw; + c.height = sh; + c.getContext('2d').drawImage(e.canvas, sx, sy, sw, sh, 0, 0, sw, sh); + c.style.left = sx / ratio + 'px'; + c.style.top = sy / ratio + 'px'; + c.style.width = sw / ratio + 'px'; + c.style.height = sh / ratio + 'px'; + layer.appendChild(c); + } + e.inner.insertBefore(layer, e.canvas.nextSibling); +} + // Internal links followed, the pages they were followed from (the back button). let _pdfBackStack = []; /** The document is gone (another one, or the reader closed): what belonged to it goes too. */ function _pdfForgetDoc() { _pdfTexts = null; + _pdfExtras = null; + _pdfLabels = null; _pdfBackStack = []; _pdfShowBack(); if (typeof _pdfSearchStop === 'function') _pdfSearchStop(); @@ -143,7 +315,7 @@ function _pdfShowBack() { }); overlay.appendChild(btn); } - btn.textContent = `← Zurück zu S. ${from}`; + btn.textContent = `← Zurück zu S. ${pdfPageLabel(from)}`; } /** A page's text layer and links taken off (another scale, or the page let go). */ @@ -153,7 +325,7 @@ function _pdfDropLayers(e) { e.textDivs = null; e.textInfo = null; e.marked = null; - e.inner.querySelectorAll('.pdf-text-layer, .pdf-link-layer').forEach(el => el.remove()); + e.inner.querySelectorAll('.pdf-text-layer, .pdf-link-layer, .pdf-image-layer').forEach(el => el.remove()); } /** @@ -238,7 +410,23 @@ function _pdfStopLazy() { * of the same canvas still under way (pdf.js refuses two at once) and waits for it to let go. * Returns false when a newer drawing (or letting go) came first. */ -async function _pdfDraw(e, page, cssScale) { +function _pdfDraw(e, page, cssScale) { + // The same drawing asked for again while it's under way (the page shown, then laid out + // again as the bars go): that one, not a new one cancelling it. + if (e.drawing && e.drawing.scale === cssScale) return e.drawing.promise; + const drawing = {scale: cssScale, promise: null}; + drawing.promise = _pdfDrawNow(e, page, cssScale).then(ok => { + if (e.drawing === drawing) e.drawing = null; + return ok; + }, err => { + if (e.drawing === drawing) e.drawing = null; + throw err; + }); + e.drawing = drawing; + return drawing.promise; +} + +async function _pdfDrawNow(e, page, cssScale) { const token = ++e.token; _pdfDropLayers(e); const prev = e.task; @@ -271,6 +459,9 @@ async function _pdfDraw(e, page, cssScale) { if (e.task === task) e.task = null; if (e.token !== token) return false; e.drawn = cssScale; + const recolor = _pdfColorFilter(); + e.canvas.classList.toggle('pdf-recolored', recolor); + if (recolor) _pdfImageLayer(e, page, viewport, ratio, token).catch(err => console.warn('PDF page pictures', e.num, err)); _pdfLayers(e, page, viewport, token).catch(err => console.warn('PDF page layers', e.num, err)); return true; } @@ -278,6 +469,7 @@ async function _pdfDraw(e, page, cssScale) { /** Let a page's pixels go (its place stays) and pdf.js forget what it parsed of it. */ function _pdfLetGo(e, pdf) { e.token++; + e.drawing = null; if (e.task) { try { e.task.cancel(); } catch (err) {} } _pdfDropLayers(e); e.drawn = 0; @@ -318,20 +510,15 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) { } if (_pdfRenderGen !== myGen) return null; currentPdfDoc = pdf; + // [pivotPage] may be given as what of the document it is (the place saved, a fraction). + if (typeof pivotPage === 'function') pivotPage = pivotPage(pdf.numPages); - let pdfTitle = '', pdfAuthor = ''; - try { - const meta = await pdf.getMetadata(); - pdfTitle = meta.info?.Title?.trim() || ''; - pdfAuthor = meta.info?.Author?.trim() || ''; - } catch (e) {} - - let toc = []; - try { - const outline = await pdf.getOutline(); - if (outline && outline.length) toc = await _parsePdfOutline(pdf, outline); - } catch (e) {} - if (_pdfRenderGen !== myGen) return null; + const extras = _pdfDocExtras(pdf); + extras.labels.then(labels => { + if (pdf !== currentPdfDoc) return; + _pdfLabels = labels; + if (labels) _pdfSetCurrentPage(pdfCurrentPage); + }); contentEl.innerHTML = ''; @@ -389,7 +576,8 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) { const pivot = Math.max(1, Math.min(pdf.numPages, pivotPage || 1)); const view = {pdf, contentEl, entries, gen: myGen, stopped: false, observer: null, resize: null, - resizeTimer: 0, queue: new Set(), working: false, scaleFor, showToken: 0}; + resizeTimer: 0, queue: new Set(), working: false, scaleFor, showToken: 0, + colorsKey: _pdfColorsKey()}; _pdfView = _pdfLazy = view; const gone = () => view.stopped || _pdfRenderGen !== myGen; @@ -439,7 +627,7 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) { pdfCurrentPage = pivot; await _pdfShowPage(pivot); if (gone()) return null; - return {title: pdfTitle, author: pdfAuthor, toc, numPages: pdf.numPages}; + return _pdfResult(pdf, extras); } if (pivot > 1) entries[pivot - 1].wrapper.scrollIntoView({block: 'start'}); @@ -470,5 +658,12 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) { pe.near = true; if (!pe.drawn) await draw(pe); if (gone()) return null; - return {title: pdfTitle, author: pdfAuthor, toc, numPages: pdf.numPages}; + return _pdfResult(pdf, extras); +} + +// What openBook needs of the document: the title (asked for while the page was drawn) and +// the table of contents to come (a long one resolves its pages after the book is shown). +async function _pdfResult(pdf, extras) { + const meta = await extras.meta; + return {title: meta.title, author: meta.author, toc: [], tocLater: extras.toc, numPages: pdf.numPages}; } diff --git a/static/js/app/62-reader.js b/static/js/app/62-reader.js index 03fbf98..d7da52d 100644 --- a/static/js/app/62-reader.js +++ b/static/js/app/62-reader.js @@ -147,6 +147,36 @@ async function openBook(bookId) { } } +async /** + * Book [bookId]'s entry with its place: the server's (offline: the one kept here), or an own + * newer one the server doesn't have yet. null when there's none. + */ +async function _bookProgress(bookId) { + let bookData = null; + try { + const progressRes = await fetch('/books/', {cache: 'no-store'}); + _noteServerNowHeader(progressRes); + const allBooks = await progressRes.json(); + bookData = allBooks.find(b => b.id === bookId) || null; + } catch { + // offline — use cached meta + return _bookProgressCached(bookId); + } + // The newer of the server's place and an own one it doesn't have yet. + return bookData ? _withPendingProgress(bookData) : null; +} + +/** The same from what's kept on this device only (no waiting for the network). */ +async function _bookProgressCached(bookId) { + try { + const cached = await _loadCachedBookMeta(); + const bookData = cached.find(b => b.id === bookId) || null; + return bookData ? _withPendingProgress(bookData) : null; + } catch { + return null; + } +} + async function _openBookOnce(bookId) { // Nothing is saved for this book until its place is restored. _readPos = {bookId, ready: false, key: null, readAt: 0, moved: false, hold: false}; @@ -168,6 +198,9 @@ async function _openBookOnce(bookId) { loadingEl.querySelector('button').addEventListener('click', () => cancelBookOpening()); const showLoading = setTimeout(() => { if (!tok.cancelled) overlay.appendChild(loadingEl); }, 400); const left = () => tok.cancelled; + // The place this book was read at: asked for at once, alongside the book's file (a PDF + // opens at that page instead of drawing page 1 first). + const progressP = _bookProgress(bookId).catch(() => null); try { loadReaderSettings(bookId); @@ -215,12 +248,23 @@ async function _openBookOnce(bookId) { // A document still open (another book) goes; renderPdf loads this one. if (currentPdfDoc) { try { currentPdfDoc.destroy(); } catch (e) {} } currentPdfDoc = null; - const result = await renderPdf(plain, contentEl); + // The colours of the reader first: the pages may be drawn in them (Leserfarben). + applyReaderSettings(true); + // The page it opens at: the place saved, if it's known by now (else the one kept here). + const saved = await Promise.race([progressP, new Promise(r => setTimeout(() => r(undefined), 400))]) + || await _bookProgressCached(bookId); + const startFraction = saved ? (saved.scroll_fraction || 0) : 0; + const result = await renderPdf(plain, contentEl, undefined, + n => (startFraction > 0 && n > 1 ? Math.max(1, Math.min(n, Math.round(startFraction * (n - 1)) + 1)) : 1)); if (left() || !result) return; title = result.title || title; author = result.author || author; toc = result.toc; numPages = result.numPages; + // The table of contents follows when its pages are resolved. + result.tocLater.then(entries => { + if (currentBookId === bookId && currentPdfDoc && entries.length) currentBookToc = entries; + }); // pdf.js holds the document: the decrypted bytes needn't stay as well. currentPdfBuffer = null; } else { @@ -314,8 +358,9 @@ async function _openBookOnce(bookId) { _readerListen(progressInput, 'change', function () { _suppressScrollJumpDetect(); if (isPdf) { - const page = Math.min(numPages, Math.max(1, parseInt(this.value, 10) || 1)); - this.value = page; + // The book's page number ("iv", "12") or the file's. + const page = Math.min(numPages, Math.max(1, _pdfPageFromInput(this.value) || pdfCurrentPage || 1)); + this.value = pdfPageLabel(page); pdfGoToPage(page); } else { const pct = Math.min(100, Math.max(0, _parseLocalePercent(this.value))); @@ -332,19 +377,7 @@ async function _openBookOnce(bookId) { let openedProgress = null; try { let fraction = 0, anchor = ''; - let bookData = null; - try { - const progressRes = await fetch('/books/', {cache: 'no-store'}); - _noteServerNowHeader(progressRes); - const allBooks = await progressRes.json(); - bookData = allBooks.find(b => b.id === bookId) || null; - } catch { - // offline — use cached meta - const cached = await _loadCachedBookMeta(); - bookData = cached.find(b => b.id === bookId) || null; - } - // The newer of the server's place and an own one it doesn't have yet. - if (bookData) bookData = _withPendingProgress(bookData); + const bookData = await progressP; fraction = bookData ? (bookData.scroll_fraction || 0) : 0; anchor = bookData ? (bookData.position_anchor || '') : ''; openedProgress = bookData; diff --git a/static/js/app/64-reader-settings.js b/static/js/app/64-reader-settings.js index 328ffdb..8e7288e 100644 --- a/static/js/app/64-reader-settings.js +++ b/static/js/app/64-reader-settings.js @@ -22,7 +22,7 @@ function loadReaderSettings(bookId) { _readerSettingsKey = bookId; // Reset to defaults, then apply per-book overrides Object.assign(readerSettings, { theme: 'app', fontFamily: 'serif', noBold: false, - pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false }, + pdfZoom: 100, pdfInverted: false, pdfPageColors: false, pdfPaginated: false, pdfSpread: false }, _readerTypeDefaults()); try { const saved = JSON.parse(localStorage.getItem(`diora_reader_settings_${bookId}`) || '{}'); @@ -94,6 +94,12 @@ function applyReaderSettings(isPdf) { // PDF invert if (isPdf && readerSettings.pdfInverted) overlay.classList.add('pdf-inverted'); else overlay.classList.remove('pdf-inverted'); + // Leserfarben: another theme changes the filter's colours; switching it on or off draws + // the pages anew (with or without their pictures laid over them). + if (isPdf && currentPdfDoc) { + _pdfColorFilter(); + if (_pdfView && _pdfView.colorsKey !== _pdfColorsKey()) reRenderPdf(); + } // Annotations (marker mode + margin panel) are EPUB-only for now — PDFs have no text layer yet const marginBtn = $('reader-margin-btn'); @@ -175,7 +181,12 @@ function toggleSettingsPanel() { - + + + `; } @@ -328,11 +339,16 @@ function toggleSettingsPanel() { panel.querySelector('#rs-zoom-plus').addEventListener('click', () => applyPdfZoom(Math.min(200, readerSettings.pdfZoom + 10))); - panel.querySelector('#rs-invert').addEventListener('click', function () { - readerSettings.pdfInverted = !readerSettings.pdfInverted; - this.classList.toggle('active', readerSettings.pdfInverted); - applyReaderSettings(true); - saveReaderSettings(); + // The pages' colours: as printed, in the reader's colours, or inverted. + panel.querySelectorAll('[data-rs-pdfcolors]').forEach(btn => { + btn.addEventListener('click', () => { + const which = btn.dataset.rsPdfcolors; + readerSettings.pdfInverted = which === 'invert'; + readerSettings.pdfPageColors = which === 'reader'; + panel.querySelectorAll('[data-rs-pdfcolors]').forEach(b => b.classList.toggle('active', b === btn)); + applyReaderSettings(true); + saveReaderSettings(); + }); }); // Scrolling or page by page (phones and e-readers start with pages). diff --git a/static/js/app/66-pdf-paginated.js b/static/js/app/66-pdf-paginated.js index ea818be..5b6a8a6 100644 --- a/static/js/app/66-pdf-paginated.js +++ b/static/js/app/66-pdf-paginated.js @@ -129,7 +129,13 @@ function _pdfTouchEnd(e) { function _pdfSetCurrentPage(n) { pdfCurrentPage = n; const input = $('reader-progress-input'); - if (input && currentPdfDoc && document.activeElement !== input) input.value = n; + if (input && currentPdfDoc && document.activeElement !== input) input.value = pdfPageLabel(n); + // With the book's own page numbers: which page of the file it is beside them. + const suffix = $('reader-progress-suffix'); + if (suffix && currentPdfDoc && pdfTotalPages) { + suffix.textContent = _pdfLabels ? `(${n} / ${pdfTotalPages})` : `/ ${pdfTotalPages}`; + if (input) input.setAttribute('inputmode', _pdfLabels ? 'text' : 'decimal'); + } } /** diff --git a/static/js/app/68-bookmarks.js b/static/js/app/68-bookmarks.js index 8fdd94b..2670340 100644 --- a/static/js/app/68-bookmarks.js +++ b/static/js/app/68-bookmarks.js @@ -59,8 +59,8 @@ function addBookmark() { let label, anchor, scrollFraction; if (currentPdfDoc) { - const page = pdfCurrentPage || parseInt($('reader-progress-input')?.value, 10) || 1; - label = `Seite ${page}`; + const page = pdfCurrentPage || 1; + label = `Seite ${pdfPageLabel(page)}`; anchor = `pdf-page-${page}`; scrollFraction = (page - 1) / Math.max(1, pdfTotalPages - 1); } else { diff --git a/static/js/sw.js b/static/js/sw.js index 0349357..c7a8dbd 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v165'; +const CACHE = 'diora-v166'; const SHELL = [ '/static/css/app.css', '/static/js/app/01-core.js',