diff --git a/e2e/fixtures/make_test_pdf.py b/e2e/fixtures/make_test_pdf.py new file mode 100644 index 0000000..b306acb --- /dev/null +++ b/e2e/fixtures/make_test_pdf.py @@ -0,0 +1,118 @@ +"""A small PDF with the cases the PDF reader has to get right (e2e and by hand). + + python e2e/fixtures/make_test_pdf.py e2e/fixtures/test.pdf + +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. +""" +import sys + +A4 = (595, 842) +A5 = (420, 595) +A4L = (842, 595) + +WORDS = ('Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. ' + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. ').split() + + +def esc(t): + return t.replace('\\', '\\\\').replace('(', '\\(').replace(')', '\\)') + + +def text_lines(n, words_per_line=11): + out, i = [], 0 + for _ in range(n): + out.append(' '.join(WORDS[(i + k) % len(WORDS)] for k in range(words_per_line))) + i += words_per_line + return out + + +def page_content(no, size): + w, h = size + ops = ['BT', '/F1 22 Tf', f'50 {h - 70} Td', f'(Kapitel {no} - Seite {no}) Tj', 'ET'] + if no == 7: + # Nearly empty: just the number at the bottom. + return '\n'.join(['BT', '/F1 10 Tf', f'{w / 2 - 10:.0f} 30 Td', f'({no}) Tj', 'ET']) + y = h - 110 + lines = text_lines(28 if h > 700 else 18, 11 if w > 500 else 8) + ops += ['BT', '/F1 11 Tf', f'50 {y} Td', '14 TL'] + for ln in lines: + ops.append(f'({esc(ln)}) Tj T*') + ops.append('ET') + 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'] + ops += ['BT', '/F1 10 Tf', f'{w / 2 - 10:.0f} 30 Td', f'({no}) Tj', 'ET'] + return '\n'.join(ops) + + +def build(path): + sizes = [A4] * 12 + sizes[3] = A5 # page 4 + sizes[8] = A4L # page 9 + objs = [] # index = object number - 1 + + def add(body): + objs.append(body) + return len(objs) + + catalog = add(None) + pages_obj = add(None) + font = add('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>') + page_ids = [] + for i, size in enumerate(sizes): + content = page_content(i + 1, size).encode('cp1252') + cid = add(b'<< /Length %d >>\nstream\n' % len(content) + content + b'\nendstream') + pid = add(None) + page_ids.append((pid, cid, size)) + # links on page 1 (placed where the text is: bottom part of the text block) + p1_h = sizes[0][1] + base_y = p1_h - 110 - 30 - 14 * 28 + + def link_to(page_no, y): + return add(f'<< /Type /Annot /Subtype /Link /Rect [48 {y - 4} 220 {y + 14}] /Border [0 0 0] ' + f'/Dest [{page_ids[page_no - 1][0]} 0 R /XYZ 0 {sizes[page_no - 1][1]} null] >>') + annots = [link_to(5, base_y), link_to(9, base_y - 20), + add(f'<< /Type /Annot /Subtype /Link /Rect [48 {base_y - 44} 300 {base_y - 26}] /Border [0 0 0] ' + 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 '' + 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} >>') + 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) + entries = [('Anfang', 1), ('Querformat', 9), ('Fast leer', 7)] + ids = [add(None) for _ in entries] + for k, ((title, page), oid) in enumerate(zip(entries, ids)): + nav = '' + if k > 0: + nav += f' /Prev {ids[k - 1]} 0 R' + if k < len(ids) - 1: + nav += f' /Next {ids[k + 1]} 0 R' + 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 >>' + info = add('<< /Title (Testdokument PDF) /Author (diora Test) >>') + + out = bytearray(b'%PDF-1.4\n%\xe2\xe3\xcf\xd3\n') + offsets = [] + for i, body in enumerate(objs): + offsets.append(len(out)) + data = body if isinstance(body, bytes) else body.encode('cp1252') + out += b'%d 0 obj\n' % (i + 1) + data + b'\nendobj\n' + xref = len(out) + out += b'xref\n0 %d\n0000000000 65535 f \n' % (len(objs) + 1) + for off in offsets: + out += b'%010d 00000 n \n' % off + out += (b'trailer\n<< /Size %d /Root %d 0 R /Info %d 0 R >>\nstartxref\n%d\n%%%%EOF\n' + % (len(objs) + 1, catalog, info, xref)) + open(path, 'wb').write(out) + + +if __name__ == '__main__': + build(sys.argv[1] if len(sys.argv) > 1 else 'test.pdf') diff --git a/e2e/fixtures/test.pdf b/e2e/fixtures/test.pdf new file mode 100644 index 0000000..e09c0a2 --- /dev/null +++ b/e2e/fixtures/test.pdf @@ -0,0 +1,641 @@ +%PDF-1.4 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /Outlines 31 0 R /PageMode /UseOutlines >> +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 >> +endobj +3 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> +endobj +4 0 obj +<< /Length 2463 >> +stream +BT +/F1 22 Tf +50 772 Td +(Kapitel 1 - Seite 1) Tj +ET +BT +/F1 11 Tf +50 732 Td +14 TL +(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T* +(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T* +(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T* +(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* +(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T* +(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T* +(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T* +(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T* +(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T* +(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T* +(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T* +(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T* +(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T* +(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T* +(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T* +(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T* +(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T* +(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T* +(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T* +(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T* +(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T* +(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T* +(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T* +(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T* +(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T* +(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 +BT +/F1 12 Tf +50 310 Td +(Link zu Seite 5) Tj +0 -20 Td +(Link zu Seite 9) Tj +0 -20 Td +(Externer Link: example.org) Tj +ET +BT +/F1 10 Tf +288 30 Td +(1) Tj +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 >> +stream +BT +/F1 22 Tf +50 772 Td +(Kapitel 2 - Seite 2) Tj +ET +BT +/F1 11 Tf +50 732 Td +14 TL +(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T* +(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T* +(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T* +(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* +(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T* +(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T* +(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T* +(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T* +(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T* +(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T* +(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T* +(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T* +(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T* +(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T* +(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T* +(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T* +(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T* +(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T* +(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T* +(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T* +(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T* +(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T* +(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T* +(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T* +(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T* +(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 +BT +/F1 10 Tf +288 30 Td +(2) Tj +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 +<< /Length 2345 >> +stream +BT +/F1 22 Tf +50 772 Td +(Kapitel 3 - Seite 3) Tj +ET +BT +/F1 11 Tf +50 732 Td +14 TL +(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T* +(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T* +(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T* +(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* +(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T* +(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T* +(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T* +(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T* +(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T* +(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T* +(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T* +(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T* +(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T* +(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T* +(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T* +(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T* +(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T* +(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T* +(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T* +(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T* +(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T* +(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T* +(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T* +(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T* +(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T* +(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 +BT +/F1 10 Tf +288 30 Td +(3) Tj +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 +<< /Length 1196 >> +stream +BT +/F1 22 Tf +50 525 Td +(Kapitel 4 - Seite 4) Tj +ET +BT +/F1 11 Tf +50 485 Td +14 TL +(Die Seite trägt Text, damit Auswahl, Suche und) Tj T* +(Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T* +(consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T* +(Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T* +(etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T* +(adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T* +(trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T* +(finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T* +(elit, sed do eiusmod tempor. Die Seite trägt) Tj T* +(Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T* +(Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T* +(sed do eiusmod tempor. Die Seite trägt Text,) Tj T* +(damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T* +(ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T* +(do eiusmod tempor. Die Seite trägt Text, damit) Tj T* +(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T* +(dolor sit amet, consectetur adipiscing elit, sed do) Tj T* +(eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T* +ET +BT +/F1 10 Tf +200 30 Td +(4) Tj +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 +<< /Length 2345 >> +stream +BT +/F1 22 Tf +50 772 Td +(Kapitel 5 - Seite 5) Tj +ET +BT +/F1 11 Tf +50 732 Td +14 TL +(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T* +(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T* +(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T* +(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* +(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T* +(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T* +(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T* +(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T* +(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T* +(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T* +(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T* +(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T* +(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T* +(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T* +(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T* +(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T* +(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T* +(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T* +(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T* +(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T* +(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T* +(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T* +(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T* +(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T* +(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T* +(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 +BT +/F1 10 Tf +288 30 Td +(5) Tj +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 +<< /Length 2345 >> +stream +BT +/F1 22 Tf +50 772 Td +(Kapitel 6 - Seite 6) Tj +ET +BT +/F1 11 Tf +50 732 Td +14 TL +(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T* +(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T* +(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T* +(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* +(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T* +(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T* +(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T* +(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T* +(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T* +(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T* +(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T* +(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T* +(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T* +(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T* +(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T* +(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T* +(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T* +(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T* +(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T* +(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T* +(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T* +(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T* +(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T* +(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T* +(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T* +(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 +BT +/F1 10 Tf +288 30 Td +(6) Tj +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 +<< /Length 32 >> +stream +BT +/F1 10 Tf +288 30 Td +(7) Tj +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 +<< /Length 2345 >> +stream +BT +/F1 22 Tf +50 772 Td +(Kapitel 8 - Seite 8) Tj +ET +BT +/F1 11 Tf +50 732 Td +14 TL +(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T* +(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T* +(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T* +(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* +(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T* +(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T* +(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T* +(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T* +(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T* +(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T* +(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T* +(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T* +(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T* +(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T* +(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T* +(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T* +(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T* +(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T* +(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T* +(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T* +(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T* +(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T* +(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T* +(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T* +(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T* +(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 +BT +/F1 10 Tf +288 30 Td +(8) Tj +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 +<< /Length 1547 >> +stream +BT +/F1 22 Tf +50 525 Td +(Kapitel 9 - Seite 9) Tj +ET +BT +/F1 11 Tf +50 485 Td +14 TL +(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T* +(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T* +(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T* +(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* +(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T* +(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T* +(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T* +(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T* +(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T* +(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T* +(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T* +(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T* +(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T* +(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T* +(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T* +(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T* +(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T* +ET +BT +/F1 10 Tf +411 30 Td +(9) Tj +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 +<< /Length 2348 >> +stream +BT +/F1 22 Tf +50 772 Td +(Kapitel 10 - Seite 10) Tj +ET +BT +/F1 11 Tf +50 732 Td +14 TL +(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T* +(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T* +(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T* +(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* +(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T* +(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T* +(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T* +(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T* +(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T* +(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T* +(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T* +(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T* +(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T* +(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T* +(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T* +(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T* +(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T* +(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T* +(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T* +(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T* +(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T* +(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T* +(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T* +(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T* +(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T* +(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 +BT +/F1 10 Tf +288 30 Td +(10) Tj +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 +<< /Length 2348 >> +stream +BT +/F1 22 Tf +50 772 Td +(Kapitel 11 - Seite 11) Tj +ET +BT +/F1 11 Tf +50 732 Td +14 TL +(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T* +(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T* +(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T* +(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* +(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T* +(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T* +(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T* +(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T* +(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T* +(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T* +(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T* +(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T* +(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T* +(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T* +(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T* +(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T* +(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T* +(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T* +(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T* +(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T* +(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T* +(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T* +(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T* +(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T* +(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T* +(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 +BT +/F1 10 Tf +288 30 Td +(11) Tj +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 +<< /Length 2348 >> +stream +BT +/F1 22 Tf +50 772 Td +(Kapitel 12 - Seite 12) Tj +ET +BT +/F1 11 Tf +50 732 Td +14 TL +(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T* +(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T* +(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T* +(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* +(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T* +(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T* +(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T* +(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T* +(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T* +(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T* +(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T* +(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T* +(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T* +(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T* +(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T* +(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T* +(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T* +(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T* +(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T* +(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T* +(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T* +(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T* +(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T* +(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T* +(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T* +(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 +BT +/F1 10 Tf +288 30 Td +(12) Tj +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] >> +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] >> +endobj +30 0 obj +<< /Type /Annot /Subtype /Link /Rect [48 266 300 284] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >> +endobj +31 0 obj +<< /Type /Outlines /First 32 0 R /Last 34 0 R /Count 3 >> +endobj +32 0 obj +<< /Title (Anfang) /Parent 31 0 R /Next 33 0 R /Dest [5 0 R /XYZ 0 842 null] >> +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] >> +endobj +34 0 obj +<< /Title (Fast leer) /Parent 31 0 R /Prev 33 0 R /Dest [17 0 R /XYZ 0 842 null] >> +endobj +35 0 obj +<< /Title (Testdokument PDF) /Author (diora Test) >> +endobj +xref +0 36 +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 +trailer +<< /Size 36 /Root 1 0 R /Info 35 0 R >> +startxref +27360 +%%EOF diff --git a/static/css/app.css b/static/css/app.css index 9c6f893..40233a5 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -1812,7 +1812,8 @@ body.dnd-mode .timer-display { .pdf-text-layer span.reader-search-match.active { background:rgba(230,57,70,.6); } /* PDF invert */ -#reader-overlay.pdf-inverted .pdf-page { filter:invert(1); } +/* 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); } /* ---- Immersive reader mode ---- */ .navbar { transition: transform 0.35s ease; } diff --git a/static/js/app/26-sidebar.js b/static/js/app/26-sidebar.js index 7d9de86..254b2ba 100644 --- a/static/js/app/26-sidebar.js +++ b/static/js/app/26-sidebar.js @@ -37,9 +37,12 @@ document.addEventListener('keydown', e => { if (e.key === 'ArrowDown') { e.preventDefault(); readerSearchNext(); } if (e.key === 'ArrowUp') { e.preventDefault(); readerSearchPrev(); } } - if (readerSettings.pdfPaginated && currentPdfDoc) { - if (e.key === 'ArrowRight') { e.preventDefault(); pdfGoToPage(pdfCurrentPage + 1); } - if (e.key === 'ArrowLeft') { e.preventDefault(); pdfGoToPage(pdfCurrentPage - 1); } + // PDF page by page: arrows, Space, PageUp/PageDown turn (scrolling has its own keys below). + if (readerSettings.pdfPaginated && currentPdfDoc && !readerSearchOpen + && !/^(INPUT|TEXTAREA|SELECT)$/.test((document.activeElement && document.activeElement.tagName) || '')) { + const turn = {ArrowRight: 1, ArrowDown: 1, PageDown: 1, ' ': e.shiftKey ? -1 : 1, + ArrowLeft: -1, ArrowUp: -1, PageUp: -1}[e.key]; + if (turn) { e.preventDefault(); pdfGoToPage(pdfCurrentPage + turn); return; } } // Vim-style scroll — ignore when typing in an input diff --git a/static/js/app/40-epub.js b/static/js/app/40-epub.js index dea34dc..0a765a8 100644 --- a/static/js/app/40-epub.js +++ b/static/js/app/40-epub.js @@ -106,10 +106,12 @@ async function parseEpub(arrayBuffer) { // spine text by CHARS_PER_BOOK_PAGE (as the reader estimates it). The library's length sorts. async function bookPagesOfFile(buf, type) { if (type === 'pdf') { - const pdf = await pdfjsLib.getDocument({data: new Uint8Array(buf)}).promise; - const n = pdf.numPages; - pdf.destroy(); - return n; + const pdf = await _pdfLoad(new Uint8Array(buf)).promise; + try { + return pdf.numPages; + } finally { + try { pdf.destroy(); } catch (e) {} + } } const zip = await JSZip.loadAsync(buf); const containerXml = await zip.file('META-INF/container.xml').async('text'); diff --git a/static/js/app/52-book-list.js b/static/js/app/52-book-list.js index ff3f49d..e5003b9 100644 --- a/static/js/app/52-book-list.js +++ b/static/js/app/52-book-list.js @@ -133,10 +133,14 @@ function repairBook(bookId) { let title = file.name.replace(/\.(epub|pdf)$/i, ''), author = '', isbn = ''; try { if (isPdf) { - const pdfDoc = await pdfjsLib.getDocument({data: new Uint8Array(buf.slice(0))}).promise; - const meta = await pdfDoc.getMetadata(); - title = meta.info?.Title?.trim() || title; - author = meta.info?.Author?.trim() || ''; + const pdfDoc = await _pdfLoad(new Uint8Array(buf.slice(0))).promise; + try { + const meta = await pdfDoc.getMetadata(); + title = meta.info?.Title?.trim() || title; + author = meta.info?.Author?.trim() || ''; + } finally { + try { pdfDoc.destroy(); } catch (e) {} + } } else { const zip = await JSZip.loadAsync(buf.slice(0)); const containerXml = await zip.file('META-INF/container.xml').async('text'); @@ -785,10 +789,14 @@ async function _importEbookBuffer(buf, filename, statusEl, opts) { if (isPdf) { try { - const pdfDoc = await pdfjsLib.getDocument({data: new Uint8Array(buf.slice(0))}).promise; - const meta = await pdfDoc.getMetadata(); - title = meta.info?.Title?.trim() || title; - author = meta.info?.Author?.trim() || ''; + const pdfDoc = await _pdfLoad(new Uint8Array(buf.slice(0))).promise; + try { + const meta = await pdfDoc.getMetadata(); + title = meta.info?.Title?.trim() || title; + author = meta.info?.Author?.trim() || ''; + } finally { + try { pdfDoc.destroy(); } catch (e) {} + } } catch (e) { /* use filename as title */ } } else { try { diff --git a/static/js/app/54-issues.js b/static/js/app/54-issues.js index 6b34c29..13d58d5 100644 --- a/static/js/app/54-issues.js +++ b/static/js/app/54-issues.js @@ -279,7 +279,7 @@ const _issueNorm = s => String(s || '').toLowerCase().replace(/ß/g, 'ss').norma async function _issueIndexPdf(bytes) { if (typeof pdfjsLib === 'undefined') return null; - const pdf = await pdfjsLib.getDocument({data: new Uint8Array(bytes)}).promise; + const pdf = await _pdfLoad(new Uint8Array(bytes)).promise; try { return await _issuePdfArticles(pdf); } finally { diff --git a/static/js/app/58-pdf.js b/static/js/app/58-pdf.js index 375f688..78cd183 100644 --- a/static/js/app/58-pdf.js +++ b/static/js/app/58-pdf.js @@ -1,49 +1,145 @@ 'use strict'; +// --------------------------------------------------------------------------- +// The PDF reader: one document, shown either scrolling (every page has its +// place, only those near the view are drawn) or page by page, cropped to the +// page's text (66-pdf-paginated.js). Whichever, a page is drawn only through +// _pdfDraw: one drawing at a time per canvas, sharp at the device's pixel +// ratio but never larger than PDF_MAX_PIXELS. +// --------------------------------------------------------------------------- + +// pdf.js's own viewer caps a canvas at this many pixels: beyond it phones run out of memory. +const PDF_MAX_PIXELS = 16777216; + +// One pdf.js worker for every document (the reader, covers, uploads): starting one takes a +// moment — on an e-reader a long one. +let _pdfWorker = null; + +function _pdfSharedWorker() { + if (!_pdfWorker && typeof pdfjsLib !== 'undefined' && pdfjsLib.PDFWorker) { + try { _pdfWorker = new pdfjsLib.PDFWorker(); } catch (e) { _pdfWorker = null; } + } + return _pdfWorker; +} + +/** pdf.js's loading task for [bytes] (a Uint8Array), on the shared worker. */ +function _pdfLoad(bytes) { + const worker = _pdfSharedWorker(); + return pdfjsLib.getDocument(worker ? {data: bytes, worker} : {data: bytes}); +} + +// The table of contents: all entries resolved at once (one after the other cost a round trip +// to the worker each). A destination's page is a reference or, in some files, a plain index. async function _parsePdfOutline(pdf, items, depth) { depth = depth || 0; - const result = []; - for (const item of items) { + const parts = await Promise.all(items.map(async item => { let href = ''; if (item.dest) { try { const dest = typeof item.dest === 'string' ? await pdf.getDestination(item.dest) : item.dest; - if (dest) { - const pageIndex = await pdf.getPageIndex(dest[0]); - href = `#pdf-page-${pageIndex + 1}`; + if (dest && dest[0] != null) { + const index = typeof dest[0] === 'number' ? dest[0] : await pdf.getPageIndex(dest[0]); + href = `#pdf-page-${index + 1}`; } } catch (e) {} } - result.push({label: item.title || '(untitled)', href, depth}); - if (item.items && item.items.length) { - result.push(...await _parsePdfOutline(pdf, item.items, depth + 1)); - } - } - return result; + const kids = item.items && item.items.length ? await _parsePdfOutline(pdf, item.items, depth + 1) : []; + return [{label: item.title || '(untitled)', href, depth}].concat(kids); + })); + return [].concat.apply([], parts); } // The document being loaded (pdf.js loading task), so opening can be cancelled. let _pdfLoadingTask = null; -// The pages' drawing of the last renderPdf: an observer and its queue (stopped on the next one). +// The open document's pages and how they are drawn (renderPdf; stopped on the next one). +let _pdfView = null; +// Backwards name: the reader stops "the lazy drawing" when a book closes. let _pdfLazy = null; function _pdfStopLazy() { - if (_pdfLazy) { - _pdfLazy.stopped = true; - if (_pdfLazy.observer) _pdfLazy.observer.disconnect(); - _pdfLazy = null; + const v = _pdfView; + if (!v) return; + v.stopped = true; + if (v.observer) v.observer.disconnect(); + if (v.resize) v.resize.disconnect(); + clearTimeout(v.resizeTimer); + v.entries.forEach(e => { e.token++; if (e.task) { try { e.task.cancel(); } catch (err) {} } }); + _pdfView = _pdfLazy = null; +} + +/** + * Draw page [page] of entry [e] at [cssScale] (CSS pixels per PDF point). Cancels a drawing + * 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) { + const token = ++e.token; + const prev = e.task; + if (prev) { + try { prev.cancel(); } catch (err) {} + try { await prev.promise; } catch (err) {} + if (e.token !== token) return false; } + const viewport = page.getViewport({scale: cssScale}); + let ratio = window.devicePixelRatio || 1; + const area = viewport.width * viewport.height; + if (area * ratio * ratio > PDF_MAX_PIXELS) ratio = Math.max(0.5, Math.sqrt(PDF_MAX_PIXELS / area)); + e.canvas.width = Math.round(viewport.width * ratio); + e.canvas.height = Math.round(viewport.height * ratio); + e.canvas.style.width = viewport.width + 'px'; + e.canvas.style.height = viewport.height + 'px'; + const task = page.render({ + canvasContext: e.canvas.getContext('2d'), + viewport, + transform: ratio !== 1 ? [ratio, 0, 0, ratio, 0, 0] : null, + }); + e.task = task; + try { + await task.promise; + } catch (err) { + if (e.task === task) e.task = null; + if (err && err.name !== 'RenderingCancelledException') console.warn('PDF page', e.num, err); + return false; + } + if (e.task === task) e.task = null; + if (e.token !== token) return false; + e.drawn = cssScale; + return true; +} + +/** Let a page's pixels go (its place stays) and pdf.js forget what it parsed of it. */ +function _pdfLetGo(e, pdf) { + e.token++; + if (e.task) { try { e.task.cancel(); } catch (err) {} } + e.drawn = 0; + e.canvas.width = 0; + e.canvas.height = 0; + if (pdf) pdf.getPage(e.num).then(p => { if (!e.task) p.cleanup(); }, () => {}); +} + +/** The page shown at the top of the scrolling view (binary search, the pages are in order). */ +function _pdfTopPage(contentEl) { + const v = _pdfView; + if (!v || !v.entries.length) return pdfCurrentPage || 1; + const top = contentEl.getBoundingClientRect().top + 20; + let lo = 0, hi = v.entries.length - 1; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (v.entries[mid].wrapper.getBoundingClientRect().bottom > top) hi = mid; else lo = mid + 1; + } + return v.entries[lo].num; } // Every page gets its place at once (sized like the first page until it is drawn), but only the // pages near the view are drawn; ones far away are let go again. A book of 1000 scanned pages -// opens as fast as one of ten, and doesn't hold 1000 canvases. +// opens as fast as one of ten, and doesn't hold 1000 canvases. Page by page (pdfPaginated), +// 66-pdf-paginated.js shows and draws the pages itself. async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) { const myGen = ++_pdfRenderGen; _pdfStopLazy(); let pdf = currentPdfDoc; if (!pdf) { - _pdfLoadingTask = pdfjsLib.getDocument({data: new Uint8Array(arrayBuffer.slice(0))}); + _pdfLoadingTask = _pdfLoad(new Uint8Array(arrayBuffer.slice(0))); try { pdf = await _pdfLoadingTask.promise; } finally { @@ -77,7 +173,6 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) { ? contentEl.clientWidth - 32 : Math.min(contentEl.clientWidth - 32, 900); const pageWidth = readerSettings.pdfSpread ? (containerWidth - 8) / 2 : containerWidth; - const dpr = window.devicePixelRatio || 1; const scaleFor = vp => (scaleOverride != null ? scaleOverride : Math.max(0.5, pageWidth / vp.width) * (readerSettings.pdfZoom / 100)); @@ -102,7 +197,7 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) { canvas.style.height = firstVp.height + 'px'; inner.appendChild(canvas); wrapper.appendChild(inner); - if (!readerSettings.pdfSpread) { + if (!readerSettings.pdfSpread || readerSettings.pdfPaginated) { pdfVp.appendChild(wrapper); } else if (pageNum === 1) { wrapper.classList.add('pdf-spread-cover'); @@ -117,78 +212,86 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) { if (spreadContainer) spreadContainer.appendChild(wrapper); else pdfVp.appendChild(wrapper); } - entries.push({num: pageNum, wrapper, canvas, drawn: false, near: false, task: null}); + entries.push({num: pageNum, wrapper, inner, canvas, drawn: 0, near: false, task: null, token: 0}); } pdfTotalPages = pdf.numPages; - const pivot = Math.max(1, Math.min(pdf.numPages, pivotPage || 1)); - const pivotEl = document.getElementById(`pdf-page-${pivot}`); - if (pivotEl && pivot > 1) pivotEl.scrollIntoView({block: 'start'}); - const lazy = {stopped: false, observer: null, queue: new Set(), working: false}; - _pdfLazy = lazy; - const gone = () => lazy.stopped || _pdfRenderGen !== myGen; + const view = {pdf, contentEl, entries, gen: myGen, stopped: false, observer: null, resize: null, + resizeTimer: 0, queue: new Set(), working: false, scaleFor, showToken: 0}; + _pdfView = _pdfLazy = view; + const gone = () => view.stopped || _pdfRenderGen !== myGen; async function draw(e) { const page = await pdf.getPage(e.num); - if (gone() || !e.near) return; - const viewport = page.getViewport({scale: scaleFor(page.getViewport({scale: 1}))}); - e.canvas.style.width = viewport.width + 'px'; - e.canvas.style.height = viewport.height + 'px'; - e.canvas.width = Math.round(viewport.width * dpr); - e.canvas.height = Math.round(viewport.height * dpr); - const ctx = e.canvas.getContext('2d'); - ctx.scale(dpr, dpr); - e.task = page.render({canvasContext: ctx, viewport}); - try { - await e.task.promise; - e.drawn = true; - } catch (err) { /* cancelled: let go below */ } - e.task = null; - } - - function letGo(e) { - if (e.task) { try { e.task.cancel(); } catch (err) {} } - if (!e.drawn) return; - e.drawn = false; - e.canvas.width = 0; // frees the pixels; the CSS size keeps the page's place - e.canvas.height = 0; + if (gone() || !e.near || readerSettings.pdfPaginated) return; + await _pdfDraw(e, page, scaleFor(page.getViewport({scale: 1}))); } // One page at a time, the nearest to the current one first. async function work() { - if (lazy.working) return; - lazy.working = true; + if (view.working) return; + view.working = true; try { - while (!gone() && lazy.queue.size) { - const cur = typeof _currentScrollPage === 'function' && !readerSettings.pdfPaginated - ? _currentScrollPage(contentEl) : (pdfCurrentPage || pivot); - const next = Array.from(lazy.queue).sort((a, b) => Math.abs(a.num - cur) - Math.abs(b.num - cur))[0]; - lazy.queue.delete(next); + while (!gone() && view.queue.size && !readerSettings.pdfPaginated) { + const cur = _pdfTopPage(contentEl); + const next = Array.from(view.queue).sort((a, b) => Math.abs(a.num - cur) - Math.abs(b.num - cur))[0]; + view.queue.delete(next); if (next.near && !next.drawn) await draw(next); } } finally { - lazy.working = false; + view.working = false; } } + // Turning the device, a narrower window, the bars coming or going: laid out again at the same page. + let lastW = contentEl.clientWidth, lastH = contentEl.clientHeight; + if (typeof ResizeObserver === 'function') { + view.resize = new ResizeObserver(() => { + const w = contentEl.clientWidth, h = contentEl.clientHeight; + if (!w || !h || (w === lastW && h === lastH)) return; + const widthChanged = Math.abs(w - lastW) > 2; + lastW = w; + lastH = h; + clearTimeout(view.resizeTimer); + view.resizeTimer = setTimeout(() => { + if (gone()) return; + if (readerSettings.pdfPaginated) _pdfShowPage(pdfCurrentPage); + else if (widthChanged) reRenderPdf(_pdfTopPage(contentEl)); + }, readerSettings.pdfPaginated ? 150 : 300); + }); + view.resize.observe(contentEl); + } + + if (readerSettings.pdfPaginated) { + // Page by page: the pivot page cropped to its text, at once (66-pdf-paginated.js). + pdfCurrentPage = pivot; + await _pdfShowPage(pivot); + if (gone()) return null; + return {title: pdfTitle, author: pdfAuthor, toc, numPages: pdf.numPages}; + } + + if (pivot > 1) entries[pivot - 1].wrapper.scrollIntoView({block: 'start'}); + pdfCurrentPage = pivot; + const byEl = new Map(entries.map(e => [e.wrapper, e])); if (typeof IntersectionObserver === 'function') { - lazy.observer = new IntersectionObserver(items => { + view.observer = new IntersectionObserver(items => { + if (readerSettings.pdfPaginated) return; for (const it of items) { const e = byEl.get(it.target); if (!e) continue; e.near = it.isIntersecting; - if (e.near) { if (!e.drawn) lazy.queue.add(e); } - else { lazy.queue.delete(e); letGo(e); } + if (e.near) { if (!e.drawn) view.queue.add(e); } + else { view.queue.delete(e); if (e.drawn || e.task) _pdfLetGo(e, pdf); } } work(); }, {root: contentEl, rootMargin: '150% 0px'}); - entries.forEach(e => lazy.observer.observe(e.wrapper)); + entries.forEach(e => view.observer.observe(e.wrapper)); } else { // Very old WebViews: every page, from the pivot on (as before). - entries.forEach(e => { e.near = true; lazy.queue.add(e); }); + entries.forEach(e => { e.near = true; view.queue.add(e); }); work(); } diff --git a/static/js/app/62-reader.js b/static/js/app/62-reader.js index 72d7a0a..8110f01 100644 --- a/static/js/app/62-reader.js +++ b/static/js/app/62-reader.js @@ -34,6 +34,22 @@ function _immHandleTap(e) { // the text a click turns the page (63-pages.js), so only on the text itself. let _readerFsHidBars = false; +// What openBook hangs on the content and the page number for this book, taken off again in +// closeReader — else every book opened adds its own, and a PDF's handlers run under the next +// EPUB. (Not addEventListener's signal: the tolino's Chrome 61 ignores it.) +let _readerListeners = []; + +function _readerListen(el, type, fn, opts) { + if (!el) return; + el.addEventListener(type, fn, opts); + _readerListeners.push([el, type, fn, opts]); +} + +function _readerUnlistenAll() { + for (const [el, type, fn, opts] of _readerListeners) el.removeEventListener(type, fn, opts); + _readerListeners = []; +} + function _readerDblClick(e) { if (!matchMedia('(hover: hover) and (pointer: fine)').matches) return; if (!e.target.closest('[data-epub-src], .pdf-page-wrapper')) return; @@ -107,8 +123,10 @@ function cancelBookOpening() { if (overlay) overlay.style.display = 'none'; const contentEl = $('reader-content'); if (contentEl) contentEl.innerHTML = ''; + if (currentPdfDoc) { try { currentPdfDoc.destroy(); } catch (e) {} } currentPdfDoc = null; currentPdfBuffer = null; + _readerUnlistenAll(); _readPos = null; return true; } @@ -194,14 +212,17 @@ async function _openBookOnce(bookId) { const isPdfBook = cachedMeta.type === 'pdf'; if (isPdfBook) { - currentPdfDoc = null; // reset so renderPdf creates fresh doc + // 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); if (left() || !result) return; title = result.title || title; author = result.author || author; toc = result.toc; numPages = result.numPages; - currentPdfBuffer = plain; + // pdf.js holds the document: the decrypted bytes needn't stay as well. + currentPdfBuffer = null; } else { currentPdfBuffer = null; // Prepared before (same book file, same preparation code): only the images to unpack. @@ -270,10 +291,10 @@ async function _openBookOnce(bookId) { } // Touch: swipe (paginated) + pinch-to-zoom - contentEl.addEventListener('touchstart', _pdfTouchStart, {passive: true}); - contentEl.addEventListener('touchmove', _pdfTouchMove, {passive: false}); - contentEl.addEventListener('touchend', _pdfTouchEnd, {passive: true}); - contentEl.addEventListener('touchcancel', _pdfTouchEnd, {passive: true}); + _readerListen(contentEl, 'touchstart', _pdfTouchStart, {passive: true}); + _readerListen(contentEl, 'touchmove', _pdfTouchMove, {passive: false}); + _readerListen(contentEl, 'touchend', _pdfTouchEnd, {passive: true}); + _readerListen(contentEl, 'touchcancel', _pdfTouchEnd, {passive: true}); // Set up progress input const progressInput = $('reader-progress-input'); @@ -290,16 +311,12 @@ async function _openBookOnce(bookId) { if (progressSuffix) progressSuffix.textContent = '%'; } - progressInput.addEventListener('change', function () { + _readerListen(progressInput, 'change', function () { _suppressScrollJumpDetect(); if (isPdf) { const page = Math.min(numPages, Math.max(1, parseInt(this.value, 10) || 1)); this.value = page; - const target = contentEl.querySelector(`#pdf-page-${page}`); - if (target) { - const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top; - contentEl.scrollBy({top: top - 8, behavior: 'smooth'}); - } + pdfGoToPage(page); } else { const pct = Math.min(100, Math.max(0, _parseLocalePercent(this.value))); this.value = _formatBookPercent(pct / 100); @@ -307,7 +324,7 @@ async function _openBookOnce(bookId) { } }); - progressInput.addEventListener('click', function () { this.select(); }); + _readerListen(progressInput, 'click', function () { this.select(); }); } // Restore scroll position — must happen BEFORE auto-save timer is started @@ -341,6 +358,7 @@ async function _openBookOnce(bookId) { await new Promise(r => requestAnimationFrame(r)); if (fraction > 0) contentEl.scrollTop = fraction * (contentEl.scrollHeight - contentEl.clientHeight); + _pdfSetCurrentPage(_pdfTopPage(contentEl)); } else { // EPUB — wait for images so scrollHeight is final const imgs = Array.from(contentEl.querySelectorAll('img')); @@ -373,29 +391,24 @@ async function _openBookOnce(bookId) { // first — that could otherwise be a later, unrelated layout correction. _scrollRestTop = contentEl.scrollTop; - // Update progress input on scroll - contentEl.addEventListener('scroll', () => { - if (!progressInput) return; - if (isPdf) { - const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper'); - const cTop = contentEl.getBoundingClientRect().top; - let currentPage = 1; - for (const w of wrappers) { - if (w.getBoundingClientRect().bottom > cTop + 20) { - currentPage = parseInt(w.id.replace('pdf-page-', ''), 10) || 1; - break; - } - } - progressInput.value = currentPage; - } - // EPUB: shown from the position anchor, see the anchor tracking below. - }); + // PDF scrolling: the page shown at the top is the current one (header, bookmarks, the + // app's footer). EPUB: shown from the position anchor, see the anchor tracking below. + if (isPdf) { + let pageRaf = 0; + _readerListen(contentEl, 'scroll', () => { + if (pageRaf || readerSettings.pdfPaginated) return; + pageRaf = requestAnimationFrame(() => { + pageRaf = 0; + if (currentPdfDoc) _pdfSetCurrentPage(_pdfTopPage(contentEl)); + }); + }, {passive: true}); + } // Auto-save progress every 10s and on scroll (debounced 2s) // Started AFTER restore so an early visibilitychange can't overwrite with position 0 readerScrollSaveTimer = setInterval(saveReaderProgress, 10000); let _scrollDebounce = null; - contentEl.addEventListener('scroll', () => { + _readerListen(contentEl, 'scroll', () => { clearTimeout(_scrollDebounce); _scrollDebounce = setTimeout(saveReaderProgress, 2000); }, {passive: true}); @@ -405,7 +418,7 @@ async function _openBookOnce(bookId) { // scrolling) would otherwise restore to a stale, pre-scroll anchor. if (!isPdf) { let _anchorTrackDebounce = null; - contentEl.addEventListener('scroll', () => { + _readerListen(contentEl, 'scroll', () => { clearTimeout(_anchorTrackDebounce); _anchorTrackDebounce = setTimeout(() => { _currentPositionAnchor = getPositionAnchor(contentEl); @@ -434,7 +447,7 @@ async function _openBookOnce(bookId) { // fat-fingered 'g'/'G' keybind, etc.) — see _checkScrollJump above. _scrollRestTop = null; let _scrollJumpRaf = null; - contentEl.addEventListener('scroll', () => { + _readerListen(contentEl, 'scroll', () => { if (_scrollJumpRaf) return; _scrollJumpRaf = requestAnimationFrame(() => { _scrollJumpRaf = null; @@ -643,12 +656,12 @@ function closeReader() { // Remove touch handlers and free image blob URLs const contentEl = $('reader-content'); + _readerUnlistenAll(); if (contentEl) { - contentEl.removeEventListener('touchstart', _pdfTouchStart); - contentEl.removeEventListener('touchmove', _pdfTouchMove); - contentEl.removeEventListener('touchend', _pdfTouchEnd); - contentEl.removeEventListener('touchcancel', _pdfTouchEnd); - contentEl.classList.remove('pinch-active'); + contentEl.removeEventListener('click', _pdfPaginatedClick); + contentEl.removeEventListener('wheel', _pdfPaginatedWheel); + contentEl.classList.remove('pinch-active', 'pdf-paginated'); + contentEl.style.overflow = ''; contentEl.innerHTML = ''; } for (const url of Object.values(currentImageMap)) URL.revokeObjectURL(url); @@ -712,13 +725,9 @@ function jumpToTocEntry(href) { const contentEl = $('reader-content'); if (!contentEl) return; - // PDF page jump + // PDF page jump (scrolling or page by page) if (href.startsWith('#pdf-page-')) { - const target = contentEl.querySelector(href); - if (target) { - const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top; - contentEl.scrollBy({top: top - 16, behavior: 'smooth'}); - } + pdfGoToPage(parseInt(href.slice('#pdf-page-'.length), 10) || 1); return; } diff --git a/static/js/app/64-reader-settings.js b/static/js/app/64-reader-settings.js index f50111e..328ffdb 100644 --- a/static/js/app/64-reader-settings.js +++ b/static/js/app/64-reader-settings.js @@ -173,8 +173,10 @@ function toggleSettingsPanel() { } else { panel.innerHTML = ` + + - + `; } @@ -312,7 +314,15 @@ function toggleSettingsPanel() { } else { const zoomRange = panel.querySelector('#rs-zoom'); - zoomRange.addEventListener('input', () => applyPdfZoom(parseInt(zoomRange.value, 10))); + // While the slider moves only the number follows; the pages are drawn anew once it rests. + let zoomTimer = 0; + zoomRange.addEventListener('input', () => { + const z = parseInt(zoomRange.value, 10); + const val = panel.querySelector('#rs-zoom-val'); + if (val) val.textContent = z + '%'; + clearTimeout(zoomTimer); + zoomTimer = setTimeout(() => applyPdfZoom(z), 250); + }); panel.querySelector('#rs-zoom-minus').addEventListener('click', () => applyPdfZoom(Math.max(50, readerSettings.pdfZoom - 10))); panel.querySelector('#rs-zoom-plus').addEventListener('click', () => @@ -325,6 +335,29 @@ function toggleSettingsPanel() { saveReaderSettings(); }); + // Scrolling or page by page (phones and e-readers start with pages). + panel.querySelectorAll('[data-rs-pdfmode]').forEach(btn => { + btn.addEventListener('click', () => { + const pages = btn.dataset.rsPdfmode === 'pages'; + if (pages === !!readerSettings.pdfPaginated || !currentPdfDoc) return; + const contentEl = $('reader-content'); + const page = pages ? _pdfTopPage(contentEl) : pdfCurrentPage; + readerSettings.pdfPaginated = pages; + saveReaderSettings(); + panel.querySelectorAll('[data-rs-pdfmode]').forEach(b => b.classList.toggle('active', b === btn)); + const spread = panel.querySelector('#rs-spread'); + if (spread) spread.style.display = pages ? 'none' : ''; + if (pages) { + pdfCurrentPage = page; + // Spreads are a scrolling thing: one page at a time here. + if (readerSettings.pdfSpread) reRenderPdf(page).then(() => enterPdfPaginatedMode()); + else enterPdfPaginatedMode(); + } else { + exitPdfPaginatedMode(page); + } + }); + }); + panel.querySelector('#rs-spread').addEventListener('click', function () { readerSettings.pdfSpread = !readerSettings.pdfSpread; this.classList.toggle('active', readerSettings.pdfSpread); @@ -349,23 +382,17 @@ async function applyPdfZoom(newZoom) { } } +// The page at the top of the view (scrolling) — 58-pdf.js keeps the pages in order. function _currentScrollPage(contentEl) { - const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper'); - const viewTop = contentEl.scrollTop; - for (const w of wrappers) { - if (w.offsetTop + w.offsetHeight > viewTop) { - const n = parseInt(w.id.replace('pdf-page-', ''), 10); - if (n) return n; - } - } - return pdfCurrentPage || 1; + return _pdfTopPage(contentEl); } -async function reRenderPdf() { - if (!currentPdfBuffer) return; +/** Lay the open PDF out again (zoom, spreads, another width), at [pivot] or the page shown now. */ +async function reRenderPdf(pivot) { + if (!currentPdfDoc) return; const contentEl = $('reader-content'); if (!contentEl) return; - const pivot = readerSettings.pdfPaginated ? pdfCurrentPage : _currentScrollPage(contentEl); - await renderPdf(currentPdfBuffer, contentEl, undefined, pivot); + if (!pivot) pivot = readerSettings.pdfPaginated ? pdfCurrentPage : _pdfTopPage(contentEl); + await renderPdf(null, contentEl, undefined, pivot); if (readerSettings.pdfPaginated) enterPdfPaginatedMode(); } diff --git a/static/js/app/66-pdf-paginated.js b/static/js/app/66-pdf-paginated.js index acfd020..57744b1 100644 --- a/static/js/app/66-pdf-paginated.js +++ b/static/js/app/66-pdf-paginated.js @@ -1,41 +1,73 @@ 'use strict'; // --------------------------------------------------------------------------- -// PDF Paginated Mode +// PDF page by page (phones and e-readers by default; "Seiten" in the PDF panel): +// one page at a time, cropped to its text so the letters are as large as the +// screen allows, the next one drawn ahead for the turn. A tap on the left or +// right turns (the middle shows the bars), so do a swipe, the arrow keys, +// Space, PageUp/PageDown and the mouse wheel. Every page goes through +// _pdfDraw (58-pdf.js). // --------------------------------------------------------------------------- +// A page nearly empty (only its number) would be zoomed to fill the screen with one digit: +// at most this many times the size the whole page would have. +const PDF_CROP_MAX = 2.5; +const PDF_CROP_PAD = 12; + +let _pdfTurnSaveTimer = 0; +let _pdfWheelSum = 0; +let _pdfWheelAt = 0; +let _touchStartY = 0; + function enterPdfPaginatedMode() { const contentEl = $('reader-content'); if (!contentEl) return; contentEl.classList.add('pdf-paginated'); contentEl.style.overflow = 'hidden'; const pdfVp = document.getElementById('pdf-viewport'); - if (pdfVp) pdfVp.style.zoom = 1; - - const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper'); - wrappers.forEach((w, i) => { - w.style.display = (i + 1 === pdfCurrentPage) ? '' : 'none'; - }); - - pdfSmartZoomPage(pdfCurrentPage); - - // Tap left/right to navigate + if (pdfVp) pdfVp.style.zoom = ''; + // From scrolling: the drawing near the view stops, the pages are this mode's now. + const v = _pdfView; + if (v && v.observer) { v.observer.disconnect(); v.observer = null; v.queue.clear(); } contentEl.addEventListener('click', _pdfPaginatedClick); + contentEl.addEventListener('wheel', _pdfPaginatedWheel, {passive: false}); + _pdfShowPage(pdfCurrentPage); } -function exitPdfPaginatedMode() { +/** Back to scrolling, at [page] (the one shown now). */ +function exitPdfPaginatedMode(page) { const contentEl = $('reader-content'); if (!contentEl) return; contentEl.classList.remove('pdf-paginated'); contentEl.style.overflow = ''; contentEl.removeEventListener('click', _pdfPaginatedClick); - reRenderPdf(); + contentEl.removeEventListener('wheel', _pdfPaginatedWheel); + reRenderPdf(page || pdfCurrentPage); } function _pdfPaginatedClick(e) { - const w = e.currentTarget.clientWidth; - if (e.clientX < w * 0.4) pdfGoToPage(pdfCurrentPage - 1); - else if (e.clientX > w * 0.6) pdfGoToPage(pdfCurrentPage + 1); + if (e.target.closest('a, button, input, .pdf-text-layer span')) return; + const r = e.currentTarget.getBoundingClientRect(); + const x = e.clientX - r.left; + const dir = x < r.width * 0.4 ? -1 : x > r.width * 0.6 ? 1 : 0; + if (!dir) return; // the middle: the bars come or go (62-reader.js) + // A turn is a turn only: not also the bars. + e.stopPropagation(); + pdfGoToPage(pdfCurrentPage + dir); +} + +function _pdfPaginatedWheel(e) { + if (e.ctrlKey) return; // the browser's zoom + e.preventDefault(); + const now = Date.now(); + if (now - _pdfWheelAt > 400) _pdfWheelSum = 0; + _pdfWheelAt = now; + _pdfWheelSum += e.deltaY; + if (Math.abs(_pdfWheelSum) < 60) return; + const dir = _pdfWheelSum > 0 ? 1 : -1; + _pdfWheelSum = 0; + _pdfWheelAt = now + 250; // one turn per flick of the wheel + pdfGoToPage(pdfCurrentPage + dir); } function _pdfTouchStart(e) { @@ -49,6 +81,7 @@ function _pdfTouchStart(e) { if (ce) ce.classList.add('pinch-active'); } else { _touchStartX = e.touches[0].clientX; + _touchStartY = e.touches[0].clientY; _isPinching = false; } } @@ -71,105 +104,146 @@ function _pdfTouchEnd(e) { const ce = $('reader-content'); if (ce) ce.classList.remove('pinch-active'); const vp = document.getElementById('pdf-viewport'); - const liveZoom = vp ? parseFloat(vp.style.zoom) * 100 : readerSettings.pdfZoom; + const liveZoom = vp && vp.style.zoom ? parseFloat(vp.style.zoom) * 100 : readerSettings.pdfZoom; + // The live zoom was only a preview: the page is drawn anew at the new size. + if (vp) vp.style.zoom = ''; const snapped = Math.max(50, Math.min(200, Math.round(liveZoom / 10) * 10)); applyPdfZoom(snapped); return; } - if (!readerSettings.pdfPaginated) return; - const delta = e.changedTouches[0].clientX - _touchStartX; - if (delta > 50) pdfGoToPage(pdfCurrentPage - 1); - else if (delta < -50) pdfGoToPage(pdfCurrentPage + 1); + if (!readerSettings.pdfPaginated || !e.changedTouches.length) return; + const dx = e.changedTouches[0].clientX - _touchStartX; + const dy = e.changedTouches[0].clientY - _touchStartY; + // Sideways, clearly: a slanted scroll is not a turn. + if (Math.abs(dx) < 50 || Math.abs(dx) < Math.abs(dy) * 1.5) return; + pdfGoToPage(pdfCurrentPage + (dx > 0 ? -1 : 1)); } +/** The page number the reader shows (header input) follows the page. */ +function _pdfSetCurrentPage(n) { + pdfCurrentPage = n; + const input = $('reader-progress-input'); + if (input && currentPdfDoc && document.activeElement !== input) input.value = n; +} + +/** + * Go to page [n], scrolling or page by page: the table of contents, the page + * number in the header, bookmarks, keys and the app's "Zu Seite springen". + */ function pdfGoToPage(n) { if (!currentPdfDoc) return; - n = Math.max(1, Math.min(pdfTotalPages, n)); - if (n === pdfCurrentPage) return; + n = Math.max(1, Math.min(pdfTotalPages, n | 0)); const contentEl = $('reader-content'); if (!contentEl) return; - - const oldWrapper = contentEl.querySelector(`#pdf-page-${pdfCurrentPage}`); - if (oldWrapper) oldWrapper.style.display = 'none'; - - pdfCurrentPage = n; - const newWrapper = contentEl.querySelector(`#pdf-page-${pdfCurrentPage}`); - if (newWrapper) newWrapper.style.display = ''; - - pdfSmartZoomPage(pdfCurrentPage); - - const progressInput = $('reader-progress-input'); - if (progressInput) progressInput.value = pdfCurrentPage; + if (!readerSettings.pdfPaginated) { + const target = contentEl.querySelector(`#pdf-page-${n}`); + if (target) { + if (typeof _suppressScrollJumpDetect === 'function') _suppressScrollJumpDetect(); + contentEl.scrollTop += target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top - 8; + } + _pdfSetCurrentPage(n); + return; + } + if (n === pdfCurrentPage && _pdfView && _pdfView.shown === n) return; + _pdfShowPage(n); + _pdfTurned(); } -async function pdfSmartZoomPage(pageNum) { - if (!currentPdfDoc) return; - const contentEl = $('reader-content'); - if (!contentEl) return; - const wrapper = contentEl.querySelector(`#pdf-page-${pageNum}`); - if (!wrapper) return; - const canvas = wrapper.querySelector('canvas'); - if (!canvas) return; - - const page = await currentPdfDoc.getPage(pageNum); - const naturalVp = page.getViewport({scale: 1}); - const pageW = naturalVp.width; - const pageH = naturalVp.height; - - let bbox = _pdfPageTextBoxCache[pageNum]; - if (!bbox) { - bbox = await _computePdfTextBox(page, pageW, pageH); - _pdfPageTextBoxCache[pageNum] = bbox; - } - - const containerW = contentEl.clientWidth; - const containerH = contentEl.clientHeight; - const contentW = bbox.x2 - bbox.x1; - const contentH = bbox.y2 - bbox.y1; - const pad = 12; - const scale = Math.min( - (containerW - pad * 2) / contentW, - (containerH - pad * 2) / contentH - ) * (readerSettings.pdfZoom / 100); - - // Re-render canvas at new scale if significantly different - const currentScale = canvas.width / naturalVp.width; - if (Math.abs(scale - currentScale) / currentScale > 0.05) { - const vp = page.getViewport({scale}); - canvas.width = vp.width; - canvas.height = vp.height; - await page.render({canvasContext: canvas.getContext('2d'), viewport: vp}).promise; - } - - // Position canvas to center the text bounding box - const renderedScale = canvas.width / naturalVp.width; - const offsetX = -renderedScale * (bbox.x1 - pad) + (containerW - renderedScale * contentW - pad * 2) / 2; - // PDF y-axis is bottom-up; canvas is top-down - const offsetY = -renderedScale * (pageH - bbox.y2 - pad) + (containerH - renderedScale * contentH - pad * 2) / 2; - canvas.style.transform = `translate(${offsetX}px, ${offsetY}px)`; - wrapper.style.overflow = 'hidden'; - wrapper.style.width = containerW + 'px'; - wrapper.style.height = containerH + 'px'; +// A turn is reading: the place is saved soon (not only by the 10 s timer), and e-readers +// get their refresh (native-eink.js listens). +function _pdfTurned() { + if (typeof _noteReaderInteraction === 'function') _noteReaderInteraction(); + clearTimeout(_pdfTurnSaveTimer); + _pdfTurnSaveTimer = setTimeout(() => { if (currentPdfDoc) saveReaderProgress(); }, 1500); + try { window.dispatchEvent(new CustomEvent('diora-pdf-turn', {detail: {page: pdfCurrentPage}})); } catch (e) {} } -async function _computePdfTextBox(page, pageW, pageH) { +/** Show page [n] alone, cropped; draw the next one ahead and let those far away go. */ +async function _pdfShowPage(n) { + const v = _pdfView; + if (!v || !v.entries.length) return; + n = Math.max(1, Math.min(v.entries.length, n)); + _pdfSetCurrentPage(n); + v.shown = n; + const token = ++v.showToken; + v.entries.forEach(e => { e.wrapper.style.display = e.num === n ? '' : 'none'; }); + try { + await _pdfFitPage(v, v.entries[n - 1]); + if (token !== v.showToken || v.stopped) return; + if (n < v.entries.length) await _pdfFitPage(v, v.entries[n], true); + if (token !== v.showToken || v.stopped) return; + if (n > 1 && !v.entries[n - 2].drawn) await _pdfFitPage(v, v.entries[n - 2], true); + } catch (err) { + console.warn('PDF page', n, err); + } + if (token !== v.showToken || v.stopped) return; + v.entries.forEach(e => { if (Math.abs(e.num - n) > 2 && (e.drawn || e.task)) _pdfLetGo(e, v.pdf); }); +} + +/** + * Page [e] sized to the screen by its text (its ink, for scans): drawn when the size differs, + * then placed so the text is centred. [ahead]: for the next turn, not shown yet. + */ +async function _pdfFitPage(v, e, ahead) { + const contentEl = v.contentEl; + const page = await v.pdf.getPage(e.num); + if (v.stopped) return; + const natural = page.getViewport({scale: 1}); + let box = _pdfPageTextBoxCache[e.num]; + if (!box) { + box = await _computePdfTextBox(page, natural); + _pdfPageTextBoxCache[e.num] = box; + } + if (v.stopped) return; + const W = contentEl.clientWidth; + const H = contentEl.clientHeight; + const bw = Math.max(1, box.x2 - box.x1); + const bh = Math.max(1, box.y2 - box.y1); + const fitBox = Math.min((W - PDF_CROP_PAD * 2) / bw, (H - PDF_CROP_PAD * 2) / bh); + const fitPage = Math.min((W - PDF_CROP_PAD * 2) / natural.width, (H - PDF_CROP_PAD * 2) / natural.height); + const scale = Math.max(0.1, Math.min(fitBox, fitPage * PDF_CROP_MAX)) * (readerSettings.pdfZoom / 100); + if (!e.drawn || Math.abs(scale - e.drawn) / e.drawn > 0.05) { + if (!await _pdfDraw(e, page, scale)) return; + } + // Centre the text box (at the scale it was drawn with). + const s = e.drawn; + const offsetX = (W - s * bw) / 2 - s * box.x1; + const offsetY = (H - s * bh) / 2 - s * box.y1; + e.inner.style.transform = `translate(${offsetX}px, ${offsetY}px)`; + e.inner.style.transformOrigin = '0 0'; + e.wrapper.style.overflow = 'hidden'; + e.wrapper.style.width = W + 'px'; + e.wrapper.style.height = H + 'px'; + if (!ahead && v.shown === e.num) e.wrapper.style.display = ''; +} + +/** + * Where the page's content is, in the page's own top-down coordinates at scale 1 (as + * getViewport gives them, so turned pages and odd media boxes come out right): its text, + * or — a scan without text — what isn't white on a small drawing of it. + */ +async function _computePdfTextBox(page, natural) { + const pageW = natural.width, pageH = natural.height; // Tier 1: text-based try { const tc = await page.getTextContent(); if (tc.items && tc.items.length) { let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity; for (const item of tc.items) { - if (!item.transform) continue; + if (!item.transform || !item.str || !item.str.trim()) continue; const tx = item.transform[4], ty = item.transform[5]; - const iw = item.width || 0, ih = item.height || 0; - if (tx < x1) x1 = tx; - if (ty < y1) y1 = ty; - if (tx + iw > x2) x2 = tx + iw; - if (ty + ih > y2) y2 = ty + ih; + const iw = item.width || 0, ih = item.height || Math.abs(item.transform[3]) || 0; + for (const [px, py] of [[tx, ty], [tx + iw, ty + ih]]) { + const [vx, vy] = natural.convertToViewportPoint(px, py); + if (vx < x1) x1 = vx; + if (vx > x2) x2 = vx; + if (vy < y1) y1 = vy; + if (vy > y2) y2 = vy; + } } const area = (x2 - x1) * (y2 - y1); if (isFinite(x1) && area > pageW * pageH * 0.25) { - return {x1, y1, x2, y2}; + return {x1: Math.max(0, x1), y1: Math.max(0, y1), x2: Math.min(pageW, x2), y2: Math.min(pageH, y2)}; } } } catch (e) {} @@ -179,17 +253,18 @@ async function _computePdfTextBox(page, pageW, pageH) { const lowScale = 0.3; const vp = page.getViewport({scale: lowScale}); const offCanvas = document.createElement('canvas'); - offCanvas.width = vp.width; - offCanvas.height = vp.height; + offCanvas.width = Math.ceil(vp.width); + offCanvas.height = Math.ceil(vp.height); const ctx = offCanvas.getContext('2d'); await page.render({canvasContext: ctx, viewport: vp}).promise; - const {data, width, height} = ctx.getImageData(0, 0, vp.width, vp.height); + const {data, width, height} = ctx.getImageData(0, 0, offCanvas.width, offCanvas.height); + offCanvas.width = offCanvas.height = 0; let rMin = height, rMax = 0, cMin = width, cMax = 0; for (let r = 0; r < height; r++) { for (let c = 0; c < width; c++) { const idx = (r * width + c) * 4; - if (data[idx] + data[idx+1] + data[idx+2] < 720) { + if (data[idx] + data[idx + 1] + data[idx + 2] < 720) { if (r < rMin) rMin = r; if (r > rMax) rMax = r; if (c < cMin) cMin = c; @@ -198,12 +273,7 @@ async function _computePdfTextBox(page, pageW, pageH) { } } if (rMin < rMax && cMin < cMax) { - return { - x1: cMin / lowScale, - y1: (height - rMax) / lowScale, - x2: cMax / lowScale, - y2: (height - rMin) / lowScale, - }; + return {x1: cMin / lowScale, y1: rMin / lowScale, x2: cMax / lowScale, y2: rMax / lowScale}; } } catch (e) {} diff --git a/static/js/app/68-bookmarks.js b/static/js/app/68-bookmarks.js index 783b369..8fdd94b 100644 --- a/static/js/app/68-bookmarks.js +++ b/static/js/app/68-bookmarks.js @@ -143,15 +143,7 @@ function jumpToBookmark(id) { const contentEl = $('reader-content'); if (!contentEl) return; if (bm.anchor.startsWith('pdf-page-')) { - if (readerSettings.pdfPaginated) { - pdfGoToPage(parseInt(bm.anchor.replace('pdf-page-', ''), 10) || 1); - } else { - const target = contentEl.querySelector('#' + bm.anchor); - if (target) { - const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top; - contentEl.scrollBy({top: top - 16, behavior: 'smooth'}); - } - } + pdfGoToPage(parseInt(bm.anchor.replace('pdf-page-', ''), 10) || 1); } else { const target = Array.from(contentEl.querySelectorAll('[data-epub-src]')) .find(el => el.getAttribute('data-epub-src') === bm.anchor); diff --git a/static/js/covers.js b/static/js/covers.js index d1e02c8..d7ca566 100644 --- a/static/js/covers.js +++ b/static/js/covers.js @@ -122,16 +122,20 @@ async function pdfCover(bytes) { if (typeof pdfjsLib === 'undefined') return null; - const pdf = await pdfjsLib.getDocument({data: new Uint8Array(bytes)}).promise; - const page = await pdf.getPage(1); - const v = page.getViewport({scale: 1}); - const scale = Math.min(MAX_W / v.width, MAX_H / v.height); - const viewport = page.getViewport({scale}); + const pdf = await (typeof _pdfLoad === 'function' ? _pdfLoad(new Uint8Array(bytes)) + : pdfjsLib.getDocument({data: new Uint8Array(bytes)})).promise; const canvas = document.createElement('canvas'); - canvas.width = Math.round(viewport.width); - canvas.height = Math.round(viewport.height); - await page.render({canvasContext: canvas.getContext('2d'), viewport}).promise; - pdf.destroy(); + try { + const page = await pdf.getPage(1); + const v = page.getViewport({scale: 1}); + const scale = Math.min(MAX_W / v.width, MAX_H / v.height); + const viewport = page.getViewport({scale}); + canvas.width = Math.round(viewport.width); + canvas.height = Math.round(viewport.height); + await page.render({canvasContext: canvas.getContext('2d'), viewport}).promise; + } finally { + try { pdf.destroy(); } catch (e) {} + } return new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', QUALITY)); }