-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathepub_engine.py
More file actions
1610 lines (1454 loc) · 76.5 KB
/
Copy pathepub_engine.py
File metadata and controls
1610 lines (1454 loc) · 76.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
epub_engine.py
Standalone EPUB parsing/navigation engine -- no UI code.
Handles: manifest/spine parsing, table of contents (NCX + nav.xhtml),
internal hyperlink resolution (same-file anchors, cross-file anchors,
footnote/noteref pairs), inline images, and back-stack navigation.
STDLIB ONLY -- no BeautifulSoup/lxml dependency, so this runs on a bare
muOS python3 with zero pip installs. Uses xml.etree.ElementTree, which
handles the well-formed XHTML that real-world EPUB3 files (including JW
publications) produce.
Designed to be UI-agnostic so it can be driven from a terminal or an
SDL2 render loop.
Current version: v26.08.27.04 (matches main.py's date-based scheme,
YY.MM.DD.XX). Non-obvious behavior is explained via inline
"# vYY.MM.DD.XX" comments above the relevant code, same convention as
main.py -- see that file's own AI NOTES header for the full policy.
See main.py's own "CROSS-FILE ARCHITECTURE MAP" for how this file fits
into the whole project -- short version: this is the only file that
parses EPUB structure itself; main.py treats it as a black box that
turns a .epub file into navigable content.
"""
from __future__ import annotations
import zipfile
import posixpath
import bisect
import os
import json
import re
import html
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
NS = {
"container": "urn:oasis:names:tc:opendocument:xmlns:container",
"opf": "http://www.idpf.org/2007/opf",
"dc": "http://purl.org/dc/elements/1.1/",
"ncx": "http://www.daisy.org/z3986/2005/ncx/",
"xhtml": "http://www.w3.org/1999/xhtml",
"epub": "http://www.idpf.org/2007/ops",
}
# v26.07.15.17: decompression-bomb guard. zipfile.getinfo().file_size is
# read from the zip's central directory (no decompression needed to
# read it), so this check is free. A malicious EPUB could store a tiny
# compressed entry that decompresses to hundreds of MB/GB and exhaust
# the device's 1GB RAM. The largest known REAL single spine file (the
# "Track Your Bible Reading" page, ~4.5M chars) decompresses to well
# under 10MB, so 64MB leaves generous headroom for any real book while
# still refusing anything bomb-sized.
MAX_SINGLE_FILE_DECOMPRESSED_BYTES = 64 * 1024 * 1024
# v0.1.151: populated by main.py at startup (set_active_glyph_subs()),
# after it checks the ACTIVE bundled font's real cmap via
# TTF_GlyphIsProvided32 -- see the call site in main.py right after
# FONT_PATH is resolved for the full reasoning. Starts as an empty dict
# (not a hardcoded per-font table) so that if this module is ever used
# standalone/without main.py calling the setter, text passes through
# unmodified rather than guessing at substitutions for a font state it
# can't actually see.
_ACTIVE_GLYPH_SUBS = {}
def set_active_glyph_subs(subs: dict) -> None:
"""Replace the active glyph-substitution table. Called once by
main.py at startup with only the entries the active font actually
needs (i.e. codepoints TTF_GlyphIsProvided32 reported as missing)."""
global _ACTIVE_GLYPH_SUBS
_ACTIVE_GLYPH_SUBS = dict(subs)
_LOCAL_TAG_CACHE = {} # v26.07.11.08: see _local()'s docstring
def _local(tag: str) -> str:
"""v26.07.11.08: memoized. A real book's XML tree has thousands of
elements but only a few dozen DISTINCT tag names (p, div, span, sup,
table, tr, td, strong, em, ...) -- profiled at 82,094 calls for the
real 4.5M-char "Track Your Bible Reading" page, each re-running the
same split() on a tag string that's almost always been seen before.
A small dict cache turns nearly all of those into an O(1) lookup
instead. Same output as before for every input -- pure memoization
of a deterministic pure function, not a behavior change."""
cached = _LOCAL_TAG_CACHE.get(tag)
if cached is not None:
return cached
result = tag.split("}", 1)[1] if "}" in tag else tag
_LOCAL_TAG_CACHE[tag] = result
return result
def _find_all_local(elem, tagname):
return [e for e in elem.iter() if _local(e.tag) == tagname]
def _find_local(elem, tagname):
for e in elem.iter():
if _local(e.tag) == tagname:
return e
return None
def _children_local(elem, tagname):
return [e for e in elem if _local(e.tag) == tagname]
@dataclass
class TocEntry:
title: str
href: str
level: int
children: list = field(default_factory=list)
# v26.08.05.01 (Kaleb's request: "listen to this book" audio-EPUB
# linking, built to be reusable by any plugin, not just jw_fetch --
# see main.py's AI NOTES for the fuller design writeup). Deliberately
# a plain module-level function, not a plugin-specific one -- it takes
# and returns generic (title, href-ish) shapes, no jw_fetch/gutenberg_
# fetch coupling, so it can genuinely be reused for a future LibriVox
# chapter-correlation feature without duplicating this logic.
#
# Live-confirmed (real downloaded EPUBs, real GETPUBMEDIALINKS audio
# listings) across three different JW.org publication shapes --
# Watchtower Study (7 articles/7 tracks), Meeting Workbook (9 weeks/9
# tracks), and a Books-category title (6 lessons/6 tracks): the real
# article TOC titles and the real audio track titles are NEVER byte-
# identical (audio adds "(December 7-13)" date suffixes, the EPUB has
# zero-width spaces mid-title, curly vs straight quotes) -- so this
# matches by POSITION after stripping known boilerplate, not by text.
# Each publication's front/back-matter boilerplate count differs (2
# front/1 back for periodicals, 2 front/2 back for the Books title
# tested), so boilerplate is identified by LABEL, not a fixed offset.
NON_CONTENT_TOC_LABELS = {
"table of contents", "contents", "title page", "title page/publishers\u2019 page",
"title page/publishers' page", "media", "page navigation",
"bible navigation", "cover", "inside cover", "back cover", "copyright page",
"track your bible reading", "maps", "image index", "scripture index",
"index of illustrations (parables)", "the areas where jesus lived and taught",
"featured content in jw library and on jw.org",
}
# v26.08.05.04 BUG FIX (found by Kaleb explicitly asking to test more
# titles -- Walk Courageously With God and the full Enjoy Life Forever
# course both silently landed on the WRONG track before this fix, not
# an error, which is worse: "Walk Courageously With God" has an
# "Inside Cover" TOC entry (added to NON_CONTENT_TOC_LABELS above,
# confirmed live: 68 TOC entries - 5 boilerplate = 63, matching 63
# real audio tracks exactly). "Enjoy Life Forever -- An Interactive
# Bible Course" additionally has "Media for Section 1" through "Media
# for Section 4" -- a PER-BOOK, NUMBERED label (not a fixed string, so
# it can't live in the plain set above) -- confirmed live these have
# no matching audio track (72 real audio tracks; "Am I Ready?" and
# "Endnotes" DO have real tracks and must NOT be stripped, only the
# numbered "Media for Section N" pages don't). Matched via regex
# rather than guessing a fixed count of sections, since a future book
# could have any number of them.
NON_CONTENT_TOC_PATTERNS = [
re.compile(r"^media for section \d+$", re.IGNORECASE),
]
# v26.08.05.06 (Kaleb's idea: "is it possible to get information on
# the chapter title or number to match the track too" -- from within
# the actual page content, not just the TOC label). This is a genuine
# upgrade over correlate_toc_to_audio() above, not a replacement: JW.org
# publications embed a small, structured label on each content page --
# a <p class="contextTtl"> paragraph (numbers for books: "26 NATHAN",
# "SECTION 1", "LESSON 38"; date ranges for Watchtower: "DECEMBER
# 21-27, 2026"), a <p class="featureTtl"> for Awake! articles (the
# shared series theme, e.g. "COPING WITH RISING PRICES"), or for
# Meeting Workbook, the date lives directly in <h1> with no wrapper at
# all -- confirmed live by reading the raw HTML of six real downloaded
# EPUBs (Walk Courageously With God, Draw Close to Jehovah, a
# Watchtower issue, a Meeting Workbook issue, an Awake! issue, and the
# full Enjoy Life Forever course), plus the "Sing Out Joyfully" to
# Jehovah songbook ("SONG N").
#
# Why this matters: correlate_toc_to_audio() only works if the TOC
# label and the audio title agree closely enough in STRUCTURE (same
# count after stripping boilerplate) -- it can't help when a single
# entry's descriptive wording genuinely differs between the print and
# audio editions, which does happen (confirmed live: Draw Close to
# Jehovah's "Section 1" is titled "Awe-Inspiring Power" in the EPUB
# but "Vigorous in Power" in the audio -- a real rename, not a
# formatting difference). A number or date pulled from the page itself
# sidesteps that entirely: "SECTION 1" is "SECTION 1" regardless of
# what the surrounding descriptive title says.
#
# Only ever returns a match when it's UNIQUE (exactly one audio item
# shares the same extracted signal) -- ambiguous or absent signals
# return None so the caller falls back to correlate_toc_to_audio(),
# same never-guess principle as everywhere else in this file.
_CONTEXT_TTL_RE = re.compile(r'<p[^>]*class="[^"]*contextTtl[^"]*"[^>]*>(.*?)</p>',
re.IGNORECASE | re.DOTALL)
_FEATURE_TTL_RE = re.compile(r'<p[^>]*class="[^"]*featureTtl[^"]*"[^>]*>(.*?)</p>',
re.IGNORECASE | re.DOTALL)
_H1_RE = re.compile(r'<h1[^>]*>(.*?)</h1>', re.IGNORECASE | re.DOTALL)
# v26.08.05.08 (Kaleb's request: test back to 2011). The 2011-2015 era
# EPUBs use a completely different, older markup generation -- no
# <h1> tag at all, no contextTtl/featureTtl classes, confirmed on real
# downloaded issues of both "w" and "g" back to September 2011 (the
# earliest era that exists at all -- see WATCHTOWER_MONTHLY_START/
# AWAKE_MONTHLY_START in jw_fetch.py). The article title instead lives
# in a plain <p class="st"><b>Title</b></p> -- "st" confirmed stable
# across multiple real files from both publications, not a one-off.
_OLD_ERA_TITLE_RE = re.compile(r'<p[^>]*class="st"[^>]*>(.*?)</p>',
re.IGNORECASE | re.DOTALL)
# v26.08.05.08 follow-up: "st" alone isn't universal either -- confirmed
# a THIRD old-era variant on feature/travel articles, which split the
# title across TWO separately-classed paragraphs ("s8" then "s9", e.g.
# "Murchison Falls" / "Uganda's Unique Piece of the Nile") instead of
# using "st" at all. Rather than keep chasing one class name at a time,
# this uses a universal fallback instead: the page's own <title> tag
# in <head>, confirmed present with the FULL real article title on
# every era/markup variant tested (current, "st"-era, and the split-
# title "s8"/"s9" era alike) -- because it's part of the EPUB/XHTML
# spec itself, not a styling class that changed between templates.
# Always carries an old-era "D/D " day-prefix (e.g. "9/11 Murchison
# Falls...") the newer eras don't have and the AUDIO title never has
# either -- stripped before use so it doesn't break substring matching.
_HEAD_TITLE_RE = re.compile(r'<title[^>]*>(.*?)</title>', re.IGNORECASE | re.DOTALL)
_DAY_PREFIX_RE = re.compile(r'^\d{1,2}/\d{1,2}\s+')
_TAG_RE = re.compile(r'<[^>]+>')
_WS_RE = re.compile(r'\s+')
# v26.08.05.06: "SONG" added alongside CHAPTER/SECTION/LESSON after
# confirming the songbook's own contextTtl reads "SONG N" -- same
# family, same mechanism, no separate code path needed for the label
# side. _SONG_LIST_NUMBER_RE is separate: the songbook's AUDIO titles
# (from the mediator-category loader, a different lookup than every
# other pub's plain GETPUBMEDIALINKS track list -- see jw_fetch.
# find_audio_for_epub()) come back as "1. Jehovah's Attributes", a
# leading-number-plus-period list style with no "SONG" keyword at all,
# so it needs its own pattern, checked BEFORE the bare-number fallback
# (which would otherwise misread it as an unlabeled chapter number).
_LABELED_NUMBER_RE = re.compile(r'(CHAPTER|SECTION|LESSON|SONG)\s*\u00a0?\.?\s*(\d+)',
re.IGNORECASE)
_SONG_LIST_NUMBER_RE = re.compile(r'^(\d+)\.\s')
_BARE_NUMBER_RE = re.compile(r'^(\d+)\b')
_MONTH_DAY_RE = re.compile(
r'(january|february|march|april|may|june|july|august|september|'
r'october|november|december)\s+(\d{1,2})', re.IGNORECASE)
def _clean_html_fragment(fragment):
"""Strip tags, unescape entities, drop zero-width spaces (already
confirmed to appear mid-title in real JW.org markup -- see the
correlate_toc_to_audio() docstring above), collapse whitespace."""
text = _TAG_RE.sub(" ", fragment)
text = html.unescape(text)
text = text.replace("\u200b", "")
return _WS_RE.sub(" ", text).strip()
def _extract_labeled_number(text):
"""Returns (LABEL, number) e.g. ("SECTION", 1), or None. Checked in
order: an explicit keyword (CHAPTER/SECTION/LESSON/SONG) is the
most reliable signal; a leading "N. " list style is the songbook
audio-title convention specifically; a bare leading number with no
keyword (e.g. a book's own contextTtl reading just "26 NATHAN")
defaults to CHAPTER, which held true on every real book tested --
JW.org books that use unlabeled numbering are numbering chapters,
never sections (sections always carry the explicit word)."""
if not text:
return None
m = _LABELED_NUMBER_RE.search(text)
if m:
return (m.group(1).upper(), int(m.group(2)))
m = _SONG_LIST_NUMBER_RE.match(text)
if m:
return ("SONG", int(m.group(1)))
m = _BARE_NUMBER_RE.match(text)
if m:
return ("CHAPTER", int(m.group(1)))
return None
def extract_labeled_number(text):
"""Public alias for _extract_labeled_number() -- exposed so callers
outside this module (e.g. main.py's audio-list sorting for the
songbook, whose track field isn't numeric) can reuse the same
number extraction without reaching into a private name."""
return _extract_labeled_number(text)
def _extract_date_anchor(text):
"""Returns a normalized "month day" string (e.g. "december 21")
from the FIRST month+day found, or None. Deliberately just the
start day, not the full range -- enough to uniquely identify one
week/issue among a periodical's handful of others without needing
to also parse cross-month end dates like "November 30-December
6" or "December 28-January 3"."""
if not text:
return None
m = _MONTH_DAY_RE.search(text)
return f"{m.group(1).lower()} {int(m.group(2))}" if m else None
def identify_page_content(raw_html):
"""Extracts whatever structured signal is present on one content
page: contextTtl/featureTtl/h1 text, plus a derived labeled_number
and/or date_anchor from whichever of those actually contains one.
Pure text processing, no I/O -- callers read the raw page HTML
themselves (EpubDocument.identify_current_page() below is the
normal entry point, which handles that read)."""
ctx_m = _CONTEXT_TTL_RE.search(raw_html)
feat_m = _FEATURE_TTL_RE.search(raw_html)
h1_m = _H1_RE.search(raw_html)
ctx_t = _clean_html_fragment(ctx_m.group(1)) if ctx_m else None
feat_t = _clean_html_fragment(feat_m.group(1)) if feat_m else None
h1_t = _clean_html_fragment(h1_m.group(1)) if h1_m else None
if not h1_t:
# v26.08.05.08: 2011-2015 era pages have no <h1> at all -- see
# _OLD_ERA_TITLE_RE's comment above. Only used when a real <h1>
# wasn't found, so this never overrides the newer markup.
old_m = _OLD_ERA_TITLE_RE.search(raw_html)
if old_m:
h1_t = _clean_html_fragment(old_m.group(1))
if not h1_t:
# v26.08.05.08 follow-up: neither <h1> nor class="st" found --
# last resort, the universal <title> tag (see _HEAD_TITLE_RE's
# comment above for why this catches variants the two more
# specific patterns above don't).
head_m = _HEAD_TITLE_RE.search(raw_html)
if head_m:
h1_t = _DAY_PREFIX_RE.sub("", _clean_html_fragment(head_m.group(1)))
labeled_number = _extract_labeled_number(ctx_t) or _extract_labeled_number(h1_t)
date_anchor = _extract_date_anchor(ctx_t) or _extract_date_anchor(h1_t)
return {"context": ctx_t, "feature": feat_t, "h1": h1_t,
"labeled_number": labeled_number, "date_anchor": date_anchor}
def match_page_to_audio(identity, audio_items):
"""Given identify_page_content()'s output and a list of audio items
(each with a "title" key), returns the matching index into
audio_items, or None if no signal produced a UNIQUE match. Tries,
in order of confidence: (1) labeled number -- exact, sidesteps any
descriptive-title rename entirely; (2) date anchor -- exact, for
periodicals; (3) h1 EXACT title equality, case-insensitive -- the
strongest text-based signal, checked before substring containment
so a real exact match always wins even if some OTHER track's title
happens to also contain the same words (see the v26.08.05.19 fix
below); (4) h1 substring containment, normalized -- handles Awake!
and anything else with no structured wrapper at all, but only
trusted when it's unambiguous (matches exactly one track)."""
if not identity or not audio_items:
return None
if identity.get("labeled_number") is not None:
cands = [i for i, a in enumerate(audio_items)
if _extract_labeled_number(a.get("title", "")) == identity["labeled_number"]]
if len(cands) == 1:
return cands[0]
if identity.get("date_anchor"):
cands = [i for i, a in enumerate(audio_items)
if _extract_date_anchor(a.get("title", "")) == identity["date_anchor"]]
if len(cands) == 1:
return cands[0]
if identity.get("h1"):
h1n = identity["h1"].strip().lower()
if h1n:
# v26.08.05.19 BUG FIX (found testing "Imitate Their Faith":
# its real "Conclusion" chapter's h1 is literally "Conclusion",
# and audio track 26 is titled exactly "Conclusion" too -- a
# clean, unambiguous exact match -- but track 20's title,
# "She Drew 'Conclusions in Her Heart'", also happens to
# CONTAIN "conclusion" as a substring ("Conclusions"), so the
# substring-containment tier below saw TWO candidates and
# correctly refused to guess between them, even though one
# was an exact match and the other only an incidental plural
# substring collision. Exact equality is strictly stronger
# evidence than mere containment, so it's now checked FIRST,
# completely independent of whatever else in the list might
# coincidentally contain the same words.
exact_cands = [i for i, a in enumerate(audio_items)
if (a.get("title", "") or "").strip().lower() == h1n]
if len(exact_cands) == 1:
return exact_cands[0]
cands = [i for i, a in enumerate(audio_items)
if h1n in (a.get("title", "") or "").lower()]
if len(cands) == 1:
return cands[0]
return None
def correlate_toc_to_audio(toc_entries, audio_items, doc_title=None):
"""Positionally correlate a FLAT list of TocEntry objects (caller
flattens first -- main.py already has flatten_toc() for this, kept
out of this function so it stays a pure, dependency-free helper)
against a list of audio items shaped like jw_fetch's (each a dict
with "title" and "track" keys).
Strips entries whose title is a known boilerplate label (see
NON_CONTENT_TOC_LABELS/NON_CONTENT_TOC_PATTERNS above) or exactly
matches doc_title (JW.org publications repeat their own title as
the first TOC entry -- confirmed on every publication type
tested). Whatever remains is assumed to be real chapter/article
content, in document order.
Returns a list of (toc_entry, audio_item) tuples, one per real
article, in track order -- or None if the counts don't match after
stripping. Never guesses a partial or misaligned mapping: a count
mismatch means either an unexpected TOC shape (a boilerplate label
this function doesn't know about yet -- see the v26.08.05.04 fix
above for how two real ones were found and fixed) or an audio
listing that doesn't actually correspond to this EPUB, and
returning None lets the caller fall back to a plain "browse all
audio" list instead of confidently pointing someone at the wrong
track, which is worse than no match at all."""
if not toc_entries or not audio_items:
return None
normalized_title = (doc_title or "").strip()
doc_title_stripped = False # v26.08.05.07 BUG FIX (found testing a
# real 2016 Awake! issue): this used to
# strip EVERY entry matching doc_title,
# not just the front-matter cover entry
# it's meant for. Confirmed live: that
# issue's first REAL article happens to
# share its exact title with the book's
# own cover ("Attitude Makes a
# Difference!" appears both as the
# cover AND as article 1) -- stripping
# both dropped a real article, throwing
# off the count and failing correlation
# entirely. Now only the FIRST match is
# treated as the cover; any later entry
# with the same text is real content.
filtered = []
for entry in toc_entries:
title = (entry.title or "").strip()
if title.lower() in NON_CONTENT_TOC_LABELS:
continue
if any(p.match(title) for p in NON_CONTENT_TOC_PATTERNS):
continue
if normalized_title and not doc_title_stripped and title == normalized_title:
doc_title_stripped = True
continue
filtered.append(entry)
if len(filtered) != len(audio_items):
return None
audio_sorted = sorted(audio_items, key=lambda a: a.get("track") or 0)
return list(zip(filtered, audio_sorted))
@dataclass
class LinkSpan:
start: int
end: int
target_file: str
target_anchor: str | None
kind: str
href: str = "" # v0.1.98: raw href, only populated for kind="external"
# (internal links already navigate via target_file/
# target_anchor and don't need it).
@dataclass
class ImageSpan:
start: int
end: int
src: str
alt: str
@dataclass
class StyleSpan:
"""A character range that should render bold and/or italic -- from
<strong>/<b> and <em>/<i> in the source HTML (v0.1.35). Overlapping
spans (e.g. <strong><em>...) are represented as separate StyleSpan
entries covering the same range rather than one span with both flags,
which keeps get_page()'s return shape simple; the renderer merges
them per character range when building styled runs."""
start: int
end: int
bold: bool
italic: bool
@dataclass
class ParaSpan:
"""Paragraph-level formatting hint (v0.1.42). Covers an absolute text
range (start..end) and carries a 'kind' that the renderer uses to
pick font, colour, and indent. Unlike StyleSpan (character-level
bold/italic), these are whole-paragraph traits applied once per line
during draw_reader().
Kinds:
superscript -- <sup> inline marker (v0.1.42)
caption -- <figcaption> text below an image (v0.1.42)
box_rule -- synthetic rule line emitted around boxSupplement (v0.1.42)
Note: JW paragraph classes sm/sh/si/sb/sj removed in v0.1.47 --
they caused unwanted italic, indent, small font and greying.
"""
start: int
end: int
kind: str
extra: str = "" # reserved (box rule text)
def collapse_blank_line_runs(text, images, links, styles, para_spans, anchor_offsets):
"""v0.1.118: nested block-tag transitions (a </header> closing while
<div class="bodyTxt"><div class="section"><div class="pGroup"> all
open right before the first real <p>, for example) each independently
call maybe_newline(), and incidental XML pretty-printing whitespace
between sibling tags gets emitted as its own blank " " line by
emit_text() -- neither dedupes against the OTHER mechanism, so a
transition crossing several nested containers with no real content in
between can stack up 2-4 blank lines where exactly one was intended.
Confirmed on a real Awake! cover article (Kaleb's report + photos):
the </header>-to-first-<p> transition alone produced 4 blank lines,
and every <ul><li> boundary (the Anja/Delina/Gregory bullet list)
doubled up to 2 blank lines instead of 1, because the <li>'s own
block-boundary blank line stacked with its child <p>'s.
This collapses any run of 2+ consecutive whitespace-only lines down to
exactly 1, and remaps every recorded image/link/style/para/anchor
offset to match -- safe because no span or anchor is ever placed
inside pure whitespace, so nothing meaningful can fall inside a
deleted range."""
lines = text.split("\n")
line_spans = [] # (start, end) in the ORIGINAL text; end excludes the "\n"
pos = 0
for line in lines:
start = pos
end = pos + len(line)
line_spans.append((start, end))
pos = end + 1
is_blank = [line.strip() == "" for line in lines]
delete_ranges = []
for i in range(1, len(lines)):
if is_blank[i] and is_blank[i - 1]:
# drop this line's own leading "\n" + content: [end of line
# i-1, end of line i) -- the following "\n" then correctly
# becomes the sole separator before whatever comes next.
delete_ranges.append((line_spans[i - 1][1], line_spans[i][1]))
if not delete_ranges:
return text, images, links, styles, para_spans, anchor_offsets
# v26.07.09.16 BUG FIX: same underlying pattern as main.py's
# style_at()/_compute_line_style_runs() fixes (v26.07.09.15/.16) --
# remap() used to do a scan over delete_ranges (early-break once past
# the query offset, but still O(ranges before offset) per call) for
# EVERY offset being remapped. On Enjoy Life Forever's largest page
# (4.5M chars, many collapsed-blank-line ranges), this was called
# 134,097 times (once per image/link/style/anchor offset) and was the
# single largest remaining cost after the style_at() fix -- confirmed
# via profiling, ~7 of ~16s total. Fixed with a precomputed cumulative-
# shift array (delete_ranges is already naturally sorted and non-
# overlapping, built from sequential line indices) and bisect, giving
# O(log ranges) per call instead.
_ends = [de for _ds, de in delete_ranges]
_cum_shift = []
_running = 0
for _ds, _de in delete_ranges:
_running += (_de - _ds)
_cum_shift.append(_running)
def remap(offset):
idx = bisect.bisect_right(_ends, offset) - 1
if idx < 0:
return offset
shift = _cum_shift[idx]
# defensive clamp (matches original's "shouldn't occur" case):
# offset falls INSIDE the next range rather than before/after it
if idx + 1 < len(delete_ranges):
nds, nde = delete_ranges[idx + 1]
if nds < offset < nde:
shift += (offset - nds)
return offset - shift
out = []
cursor = 0
for ds, de in delete_ranges:
out.append(text[cursor:ds])
cursor = de
out.append(text[cursor:])
new_text = "".join(out)
for im in images:
im.start, im.end = remap(im.start), remap(im.end)
for ln in links:
ln.start, ln.end = remap(ln.start), remap(ln.end)
for sp in styles:
sp.start, sp.end = remap(sp.start), remap(sp.end)
for ps in para_spans:
ps.start, ps.end = remap(ps.start), remap(ps.end)
for k in list(anchor_offsets.keys()):
anchor_offsets[k] = remap(anchor_offsets[k])
return new_text, images, links, styles, para_spans, anchor_offsets
class EpubDocument:
def __init__(self, path: str, anchor_cache_path: str | None = None,
opf_cache_path: str | None = None):
self.path = path
self.zip = zipfile.ZipFile(path, "r")
self.opf_cache_path = opf_cache_path
# v26.07.19.XX (Kaleb's request, after profiling confirmed
# ET.fromstring() on the OPF is the real cost -- 34.55ms of a
# 48.94ms _parse_opf() on nwt_E.epub's 526KB/4040-item OPF,
# ~70% of the total, scaling to ~142ms of ~201ms on real ARM
# hardware per this project's confirmed 4.1x factor. Unlike
# _parse_toc() (profiled the same session: only ~10ms/~40ms
# scaled for NWT -- genuinely small, NOT the bottleneck a prior
# hypothesis this session assumed it was), this OPF-manifest
# parse is real, repeat, avoidable cost: the OPF never changes
# between opens of the same unchanged book file, so re-parsing
# its full XML tree from scratch every single open is pure
# waste after the first time. Cached to disk (mtime-fingerprint
# invalidated, identical pattern to _build_anchor_index()'s
# existing anchor_cache_path mechanism below) rather than kept
# only in RAM, so the saving persists across app restarts too,
# not just within one session.
cached = self._load_opf_cache()
if cached is not None:
(self.opf_path, self.opf_dir, self.manifest,
self.spine, self.ncx_path, self.nav_path) = cached
else:
self.opf_path, self.opf_dir = self._find_opf()
self.manifest, self.spine, self.ncx_path, self.nav_path = self._parse_opf()
self._save_opf_cache()
# v26.08.27.03 (Kaleb's request: "any other performance gains in
# this area of redundancy" -- found via real code reading, not
# guessed): spine_index() used to do self.spine.index(path), an
# O(n) linear scan of the whole spine list on EVERY call. That's
# called from _resolve_toc_entry_spine(), itself shared across 7
# sites in main.py (chapter-nav-point building, Bible/daily-text
# point lists, Chapters-screen "you are here" lookup) -- several
# of which loop over a TOC list calling it ONCE PER ENTRY. On a
# real large book this compounds: nwt_E.epub's spine alone is
# 3941 entries (see the OPF-cache comment above, same book) --
# an O(n) lookup called from inside an O(n)-sized loop is a real
# O(n^2) cost building up right where "open Chapters screen"
# already needs to feel instant. Built once here as a dict
# (spine path -> index), turning every spine_index() call into
# O(1). Same content as self.spine, so this can never disagree
# with it or need separate invalidation -- built fresh every
# __init__ alongside self.spine itself, cache-hit or not.
# v26.08.27.04 REAL BUG FIX to the ABOVE (found via an explicit
# duplicate-entry test, not assumed): a plain dict comprehension
# keeps the LAST occurrence of a repeated path, but list.index()
# (the old behavior) always returns the FIRST. No duplicate
# spine entries exist in either real book tested (wcg, nwt --
# both confirmed unique), so this never actually differed in
# practice, but building it this way guarantees the exact same
# first-match semantics regardless, so a future book with a
# genuine duplicate spine entry can't silently behave
# differently than before this optimization existed.
self._spine_index_map = {}
for i, path in enumerate(self.spine):
self._spine_index_map.setdefault(path, i)
self.toc: list[TocEntry] = self._parse_toc()
# v26.07.12.12: values can be EITHER set[str] (freshly built this
# session, from the regex/XML-parse path) or list[str] (loaded
# straight from the JSON disk cache, no conversion -- see
# _build_anchor_index()'s cache-hit branch for why that's safe).
# Every real consumer only ever does `x in ids` or iterates --
# both forms behave identically for that, so this dict is
# deliberately never normalized to one type or the other.
self._anchor_index: dict[str, set[str] | list[str]] | None = None
self.anchor_cache_path = anchor_cache_path
def _load_opf_cache(self):
"""Returns (opf_path, opf_dir, manifest, spine, ncx_path,
nav_path) from disk if a valid, up-to-date cache exists, else
None (caller falls through to the real _find_opf()/_parse_opf()
parse -- identical behavior to today whenever this misses).
Same mtime-fingerprint invalidation as _build_anchor_index()'s
existing anchor_cache_path mechanism: if the EPUB file's mtime
doesn't match what's recorded, the cache is stale (book was
replaced/updated) and is silently ignored rather than trusted."""
if not self.opf_cache_path or not os.path.exists(self.opf_cache_path):
return None
try:
mtime = os.path.getmtime(self.path)
except OSError:
return None
try:
with open(self.opf_cache_path) as f:
cached = json.load(f)
except Exception:
return None
if cached.get("mtime") != mtime:
return None
try:
return (cached["opf_path"], cached["opf_dir"], cached["manifest"],
cached["spine"], cached["ncx_path"], cached["nav_path"])
except KeyError:
return None # malformed/old-format cache -- fall through to real parse
def _save_opf_cache(self):
if not self.opf_cache_path:
return
try:
mtime = os.path.getmtime(self.path)
except OSError:
return
try:
os.makedirs(os.path.dirname(self.opf_cache_path), exist_ok=True)
payload = {
"mtime": mtime,
"opf_path": self.opf_path,
"opf_dir": self.opf_dir,
"manifest": self.manifest,
"spine": self.spine,
"ncx_path": self.ncx_path,
"nav_path": self.nav_path,
}
with open(self.opf_cache_path, "w") as f:
json.dump(payload, f)
except Exception:
pass # non-fatal -- worst case, next open just re-parses same as today
def _read(self, path: str) -> str:
# v26.07.15.17: check the (free, no-decompression) declared
# size before actually decompressing -- see
# MAX_SINGLE_FILE_DECOMPRESSED_BYTES's comment for why.
try:
declared_size = self.zip.getinfo(path).file_size
except KeyError:
declared_size = 0
if declared_size > MAX_SINGLE_FILE_DECOMPRESSED_BYTES:
raise ValueError(
f"{path} declares {declared_size} bytes uncompressed, "
f"exceeding the {MAX_SINGLE_FILE_DECOMPRESSED_BYTES}-byte safety cap"
)
with self.zip.open(path) as f:
return f.read().decode("utf-8", errors="replace")
def _parse_xml(self, text: str):
# v26.07.15.16: stdlib ElementTree doesn't guard against XML
# entity-expansion ("billion laughs") bombs -- a tiny malicious
# container.xml/opf/ncx/nav file could define nested custom
# entities that expand to gigabytes and hang/crash the app on
# 1GB RAM. Real EPUBs never define custom ENTITYs in these
# files, so refusing any DOCTYPE with an ENTITY declaration is
# a safe, zero-cost guard -- cheap substring check, no real
# book affected. Raises ValueError, which existing callers
# already handle the same way a malformed-XML ParseError would.
if "<!ENTITY" in text:
raise ValueError("XML entity declarations are not permitted in EPUB metadata files")
return ET.fromstring(text.encode("utf-8"))
def _resolve(self, base_dir: str, href: str) -> str:
href = href.split("#")[0]
if not href:
return ""
return posixpath.normpath(posixpath.join(base_dir, href))
def _find_opf(self):
container = self._read("META-INF/container.xml")
root = self._parse_xml(container)
rootfile = _find_local(root, "rootfile")
opf_path = rootfile.get("full-path")
opf_dir = posixpath.dirname(opf_path)
return opf_path, opf_dir
def _parse_opf(self):
opf_text = self._read(self.opf_path)
root = self._parse_xml(opf_text)
# v26.07.12.21 (Kaleb's loading-optimization request): this used
# to call _find_all_local(root, "item") TWICE -- once to build
# `manifest`, again further down just to find whichever item has
# properties="nav". _find_all_local() does a full elem.iter()
# walk of the whole OPF tree every time it's called, so that was
# two full tree walks over the same set of elements for every
# single book open. Merged into one pass: nav_item_id is
# recorded inline while building the manifest, same result.
manifest = {}
nav_item_id = None
for item in _find_all_local(root, "item"):
item_id = item.get("id")
href = item.get("href")
manifest[item_id] = posixpath.normpath(posixpath.join(self.opf_dir, href))
props = item.get("properties") or ""
if "nav" in props.split():
nav_item_id = item_id
spine = []
spine_tag = _find_local(root, "spine")
if spine_tag is not None:
for itemref in _children_local(spine_tag, "itemref"):
idref = itemref.get("idref")
if idref in manifest:
spine.append(manifest[idref])
ncx_path = None
nav_path = manifest.get(nav_item_id) if nav_item_id else None
if spine_tag is not None:
toc_attr = spine_tag.get("toc")
if toc_attr and toc_attr in manifest:
ncx_path = manifest[toc_attr]
return manifest, spine, ncx_path, nav_path
def _get_text(self, elem, tagname):
found = _find_local(elem, tagname)
return "".join(found.itertext()).strip() if found is not None else ""
def _parse_toc(self) -> list[TocEntry]:
if self.ncx_path:
return self._parse_ncx(self.ncx_path)
if self.nav_path:
return self._parse_nav(self.nav_path)
return [TocEntry(title=posixpath.basename(f), href=f, level=0) for f in self.spine]
def _parse_ncx(self, ncx_path: str) -> list[TocEntry]:
ncx_text = self._read(ncx_path)
root = self._parse_xml(ncx_text)
ncx_dir = posixpath.dirname(ncx_path)
def walk(nav_point_container, level):
entries = []
for np in _children_local(nav_point_container, "navPoint"):
title = self._get_text(np, "text")
content_tag = _find_local(np, "content")
src = content_tag.get("src") if content_tag is not None else ""
href = self._resolve(ncx_dir, src)
anchor = src.split("#", 1)[1] if "#" in src else None
full_href = href + (f"#{anchor}" if anchor else "")
entry = TocEntry(title=title or "(untitled)", href=full_href, level=level)
entry.children = walk(np, level + 1)
entries.append(entry)
return entries
nav_map = _find_local(root, "navMap")
return walk(nav_map, 0) if nav_map is not None else []
def _parse_nav(self, nav_path: str) -> list[TocEntry]:
nav_text = self._read(nav_path)
root = self._parse_xml(nav_text)
nav_dir = posixpath.dirname(nav_path)
toc_nav = None
for nav_el in _find_all_local(root, "nav"):
attrs = {k.split("}")[-1]: v for k, v in nav_el.attrib.items()}
if attrs.get("type") == "toc":
toc_nav = nav_el
break
if toc_nav is None:
toc_nav = _find_local(root, "nav")
if toc_nav is None:
return []
def walk(ol, level):
entries = []
if ol is None:
return entries
for li in _children_local(ol, "li"):
a = None
for child in li:
if _local(child.tag) == "a":
a = child
break
if a is None:
continue
title = "".join(a.itertext()).strip()
href_raw = a.get("href", "")
path = self._resolve(nav_dir, href_raw)
anchor = href_raw.split("#", 1)[1] if "#" in href_raw else None
full_href = path + (f"#{anchor}" if anchor else "")
entry = TocEntry(title=title, href=full_href, level=level)
sub_ol = None
for child in li:
if _local(child.tag) == "ol":
sub_ol = child
break
entry.children = walk(sub_ol, level + 1)
entries.append(entry)
return entries
top_ol = None
for child in toc_nav:
if _local(child.tag) == "ol":
top_ol = child
break
return walk(top_ol, 0)
def probe_chapter_anchor_count(self, min_needed=5):
"""v26.07.12.10: cheap pre-check for whether this book uses the
chapterN anchor convention (Bible-style books: nwt_E.epub,
bi12_E.epub) BEFORE paying for the full _build_anchor_index()
scan -- Kaleb noticed book-open is much slower than a chapter
turn, and profiling confirmed why: _build_chapter_nav_points()
unconditionally called _build_anchor_index() (full XML parse of
EVERY spine file) on every book open, just to check whether the
chapterN heuristic applies. Checked across all 9 real JW books:
only 2 (the actual Bible editions) ever have >=5 matches -- the
other 7 built the complete index and then threw it away in favor
of the TOC-based fallback path, which never needed it at all.
For nwt_E.epub (3941 spine files) that wasted scan was 1.55s of
a 1.85s cold book-open, on THIS book alone.
Does a raw-bytes regex count (id="chapterN") instead of a real
XML parse -- no ElementTree construction, no _parse_xml() call
per file. Verified byte-for-byte identical counts against the
real XML-parsed ground truth across all 9 real JW books tested
(nwt_E/bi12_E: 1189 matches each way; the other 7: 0 matches each
way) -- and 3.7-5.7x faster than the real scan even as a
standalone probe, on top of skipping the real scan entirely when
it isn't needed. Stops counting as soon as min_needed is reached
-- doesn't need an exact count, only "at least this many," so a
book that clearly qualifies (like nwt_E.epub, matches from very
early in the spine) doesn't need every remaining file probed.
Deliberately biased toward false POSITIVES over false NEGATIVES:
a book that's actually borderline just falls through to the
real, exact _build_anchor_index() path (identical to today's
behavior, zero risk of regression) -- the only thing this can
get "wrong" is occasionally doing the full scan when it turns
out not to be needed, never the reverse (skipping a real
chapterN book). Regex matches literal id="chapterN" (double-
quoted, as every real EPUB tested uses) -- doesn't need to
handle single-quotes or attribute whitespace variants some
obscure generator might produce, since the worst case for a
format this probe doesn't recognize is just falling through to
the always-correct real scan, same as before this existed."""
pattern = re.compile(rb'id="chapter\d+"')
count = 0
for fname in self.spine:
try:
raw = self.zip.read(fname)
except Exception:
continue
if len(pattern.findall(raw)) == 1:
count += 1
if count >= min_needed:
return count
return count
def _build_anchor_index(self):
if self._anchor_index is not None:
return
mtime = None
try:
mtime = os.path.getmtime(self.path)
except OSError:
pass
if self.anchor_cache_path and os.path.exists(self.anchor_cache_path):
try:
with open(self.anchor_cache_path) as f:
cached = json.load(f)
if cached.get("mtime") == mtime:
# Kept as list[str] rather than converted to set(v)
# -- the conversion alone measured ~14ms for
# nwt_E.epub's 3941-entry cache, roughly doubling the
# warm-cache-load cost. Every real consumer
# (find_file_for_anchor()'s membership checks,
# _build_chapter_nav_points()'s regex-match iteration)
# only does membership testing/iteration, which lists
# support identically to sets -- the only real cost
# difference is O(n) "in" instead of O(1). Safe trade:
# real per-file id counts top out around 617 (nwt_E.epub,
# the largest real index available), and
# find_file_for_anchor() already has a same-file
# hint_file fast path that skips the cross-file scan
# for the common case. JSON already deserializes list
# values directly, so this uses cached["index"] as-is.
self._anchor_index = cached["index"]
return
except Exception:
pass # corrupt/stale cache -- fall through and rebuild
# Extracted via direct regex on the already-decoded text rather
# than a full ET.fromstring() + root.iter() walk -- id VALUES
# only are needed here, no other DOM structure. Verified EXACT
# (byte-for-byte identical id SETS, not just counts) against the
# real ET-parsed ground truth across 6991 real spine files (27
# books) -- this index also serves find_file_for_anchor() for
# real footnote/cross-reference resolution, not just a nav-point
# heuristic with a safe fallback, so it needed exact verification.
#
# The regex uses a negative lookbehind so a naive id="..." match
# can'''t pick up false positives like data-pid="1" as a
# spurious id "1" -- it requires id="... to NOT be immediately
# preceded by a word character or hyphen, matching ElementTree'''s
# .get("id") behavior exactly (only the literal unprefixed "id"
# attribute). Falls back to a real XML parse per-file on any
# regex-path exception.
id_re = re.compile(r'(?<![\w-])id="([^"]*)"')
self._anchor_index = {}
for name in self.zip.namelist():
if name.lower().endswith((".xhtml", ".html", ".htm")):
try:
text = self._read(name)
ids = {m for m in id_re.findall(text) if m}
except Exception: