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',