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