PDF: öffnet an der gespeicherten Seite, Leserfarben, Seitenzahlen wie im Buch (sw CACHE v166)
Some checks failed
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 11m30s
Test / e2e (push) Failing after 1m30s

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 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-11-03 18:42:26 +01:00
parent 65a01569f9
commit 429b875faf
10 changed files with 410 additions and 134 deletions

View file

@ -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')

View file

@ -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

View file

@ -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; }

View file

@ -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;

View file

@ -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 = '<filter id="pdf-reader-colors" color-interpolation-filters="sRGB">' +
'<feColorMatrix type="matrix" values="0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 ' +
'0.2126 0.7152 0.0722 0 0 0 0 0 1 0"/>' +
'<feComponentTransfer><feFuncR type="table"/><feFuncG type="table"/><feFuncB type="table"/>' +
'</feComponentTransfer></filter>';
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};
}

View file

@ -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;

View file

@ -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() {
<label>Zoom <button class="btn btn-sm" id="rs-zoom-minus">−</button> <input type="range" id="rs-zoom" min="50" max="200" step="10" value="${readerSettings.pdfZoom}"> <button class="btn btn-sm" id="rs-zoom-plus">+</button> <span id="rs-zoom-val">${readerSettings.pdfZoom}%</span></label>
<button class="btn btn-sm ${readerSettings.pdfPaginated ? '' : 'active'}" data-rs-pdfmode="scroll">Scrollen</button>
<button class="btn btn-sm ${readerSettings.pdfPaginated ? 'active' : ''}" data-rs-pdfmode="pages">Seiten</button>
<button class="btn btn-sm ${readerSettings.pdfInverted ? 'active' : ''}" id="rs-invert">Invertieren</button>
<button class="btn btn-sm ${!readerSettings.pdfInverted && !readerSettings.pdfPageColors ? 'active' : ''}" data-rs-pdfcolors="original"
title="Die Seiten wie gedruckt">Originalfarben</button>
<button class="btn btn-sm ${!readerSettings.pdfInverted && readerSettings.pdfPageColors ? 'active' : ''}" data-rs-pdfcolors="reader"
title="Text und Hintergrund in den Farben des Lesers (Dunkel, Sepia), Bilder bleiben, wie sie sind">Leserfarben</button>
<button class="btn btn-sm ${readerSettings.pdfInverted ? 'active' : ''}" data-rs-pdfcolors="invert"
title="Die ganze Seite umgekehrt, auch Bilder (Farben bleiben ungefähr)">Invertieren</button>
<button class="btn btn-sm ${readerSettings.pdfSpread ? 'active' : ''}" id="rs-spread"${readerSettings.pdfPaginated ? ' style="display:none"' : ''}>Doppelseite</button>
`;
}
@ -328,12 +339,17 @@ 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);
// 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).
panel.querySelectorAll('[data-rs-pdfmode]').forEach(btn => {

View file

@ -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');
}
}
/**

View file

@ -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 {

View file

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