Fix nested chapter anchors and Chinese ordinal navigation

This commit is contained in:
Kai Tan
2026-10-08 18:18:49 +08:00
parent 5dbd7bd240
commit 7f1ae23fe2
4 changed files with 105 additions and 4 deletions
+26 -4
View File
@@ -98,6 +98,12 @@ def _headings(soup, target, label):
anchor = soup.find(id=fragment) if fragment else None
if fragment and anchor is None:
return [], 'missing anchor'
# Publisher TOCs often target an <a>/<span> inside a heading. The
# enclosing heading owns the text; subsequent prose is not the target.
if anchor and anchor.name not in HEADINGS:
enclosing = anchor.find_parent(HEADINGS)
if enclosing is not None:
anchor = enclosing
identity = title_identity(label)
# Only inspect the opening leaf blocks or blocks at the explicit anchor.
# An exact match across adjacent blocks also recognizes Calibre-converted
@@ -108,7 +114,7 @@ def _headings(soup, target, label):
nearby = [anchor] + anchor.find_all_next(HEADINGS + ['p','div'], limit=16)
block_ids = {id(t) for t in blocks}
blocks = [t for t in nearby if id(t) in block_ids]
blocks = [t for t in blocks if plain(t.get_text())][:12]
blocks = [t for t in blocks if any(c.isalnum() for c in plain(t.get_text()))][:12]
matches = []
for start in range(min(len(blocks), 6)):
for end in range(start+1, min(start+4, len(blocks))+1):
@@ -220,7 +226,10 @@ def normalize_heading_html(record, html):
prefix=ordinal(record)
if not prefix: return html
soup=parse_html('<div>'+html+'</div>');root=soup.body.div
nodes=[n for n in root.find_all(string=True) if plain(str(n))]
hidden = [n for n in root.find_all(string=True) if plain(str(n)) and
n.find_parent(class_=re.compile(r'^(?:sr-only|visually-hidden|screen-reader-only)$'))]
hidden_ids = {id(n) for n in hidden}
nodes=[n for n in root.find_all(string=True) if plain(str(n)) and id(n) not in hidden_ids]
if not nodes: return html
node=nodes[0];text=plain(str(node));compact=re.sub(r'\s+', '', text).strip('::')
# Some publishers put the digit in its own styled span: 第<span>1</span>部.
@@ -228,11 +237,18 @@ def normalize_heading_html(record, html):
and re.fullmatch(r'[章部篇][::]?',plain(str(nodes[2])))):
nodes[1].replace_with(chinese_number(record['number']))
nodes[2].replace_with('章' if record['role']=='chapter' else '部')
elif re.fullmatch(r'(?:\d+|[IVXLCDM]+|第[零一二三四五六七八九十百千万\d]+(?:章|部分?|篇))',compact,re.I):
elif re.fullmatch(r'(?:\d+|[IVXLCDM]+|[零一二三四五六七八九十百千万]+(?:章|部分?|篇)?|第[零一二三四五六七八九十百千万\d]+(?:章|部分?|篇))',compact,re.I):
node.replace_with(prefix)
if len(nodes)>1 and plain(str(nodes[1])) in {':',':'}: nodes[1].replace_with('')
elif len(nodes)>1 and plain(str(nodes[1])) in {'章','部','部分','篇'}: nodes[1].replace_with('')
elif re.match(r'^第[零一二三四五六七八九十百千万\d]+[章部篇]',compact):
node.replace_with(re.sub(r'^第[零一二三四五六七八九十百千万\d]+(?:章|部分?|篇)',prefix,compact))
visible_now = [n for n in root.find_all(string=True) if plain(str(n)) and id(n) not in hidden_ids]
if visible_now and plain(str(visible_now[0])).startswith(prefix):
# The visible ordinal now includes "chapter/part"; the hidden prefix
# would otherwise announce it a second time to assistive readers.
for text_node in hidden:
text_node.parent['aria-hidden'] = 'true'
return root.decode_contents()
@@ -242,7 +258,13 @@ def navigation_labels(structure, entries):
units=[by_id.get(key) for key in record.get('entry_ids',[record['entry_id']])]
if any(not e or not e.translated_html or e.validation_errors for e in units): continue
units[0].translated_html=normalize_heading_html(record,units[0].translated_html)
label=plain(' '.join(parse_html(e.translated_html).get_text(' ',strip=True) for e in units))
visible = []
for entry in units:
soup = parse_html(entry.translated_html)
for hidden in soup.select('[aria-hidden="true"], .sr-only, .visually-hidden, .screen-reader-only'):
hidden.decompose()
visible.append(soup.get_text(' ', strip=True))
label=plain(' '.join(visible))
prefix=ordinal(record)
# Standardize punctuation only for the navigation label; the rich
# heading can keep its number and subtitle on separate lines.