` wrapper
# of a `quarto-figure`) produces RSC-005 under strict XHTML.
_ALT_LEGAL_TAGS = frozenset({'img', 'area', 'input'})
def _rewrite_alt_on_wrapper(match):
"""Rewrite alt="..." on a non-img element to aria-label="...".
Quarto emits `fig-alt` onto the enclosing `
`
in addition to the inner `
![]()
` (the inner `
![]()
` carries alt=""
because the wrapper already has it). Epubcheck rejects `alt` on
non-image elements. aria-label is valid on any element and preserves
the accessibility data for screen readers.
If the tag already has an aria-label, we strip the alt rather than
duplicate the attribute.
"""
tag = match.group(1).lower()
if tag in _ALT_LEGAL_TAGS:
return match.group(0)
pre = match.group(2)
value = match.group(3)
post = match.group(4)
# If aria-label already present, strip alt rather than duplicate.
if 'aria-label=' in pre or 'aria-label=' in post:
return f'<{match.group(1)}{pre}{post}>'
# Otherwise rewrite alt -> aria-label, preserving attribute position.
return f'<{match.group(1)}{pre} aria-label="{value}"{post}>'
def sanitize_xml_for_epubcheck(temp_dir):
"""Run post-render passes that make the EPUB strict-XML-clean.
Fixes three FATAL(RSC-016) classes that Kindle / ClearView rejection
is triggered by, plus the RSC-020 URL backslash-escape class that
affects bibliography entries. All four are mechanical string fixes.
Returns a dict with counts of fixes applied per category.
"""
print(" Sanitizing XHTML/SVG for strict XML validity...")
counts = {
'comment_dashes': 0, # -- inside HTML comments (RSC-016 FATAL)
'bare_br': 0, #
not self-closed (RSC-016 FATAL)
'svg_aria_c0': 0, # C0 chars in aria-label (RSC-016 FATAL)
'href_rewritten': 0, # href URLs needing sanitization (RSC-020)
'alt_on_wrapper': 0, # alt="..." on non-img element (RSC-005)
}
def sanitize_xhtml(text):
"""Apply all XHTML-level passes. Returns (new_text, deltas_dict)."""
out = text
deltas = {
'comment_dashes': 0,
'bare_br': 0,
'href_rewritten': 0,
'alt_on_wrapper': 0,
}
new_out, _ = _HTML_COMMENT.subn(_sanitize_comment_body, out)
if new_out != out:
# Approximate count: how many "- -" tokens the substitution
# introduced. This undercounts when a run of 4+ dashes is
# split in stages, but the number is for reporting only.
deltas['comment_dashes'] = new_out.count('- -') - out.count('- -')
out = new_out
new_out, n = _BARE_BR.subn(r'
', out)
if n:
deltas['bare_br'] = n
out = new_out
# Count href rewrites by counting matches where the sanitizer
# actually returned a different value. Do this by walking matches.
rewrites = 0
def count_rewrite(m):
nonlocal rewrites
replacement = _sanitize_href_url(m)
if replacement != m.group(0):
rewrites += 1
return replacement
new_out = _HREF_ATTR.sub(count_rewrite, out)
if rewrites:
deltas['href_rewritten'] = rewrites
out = new_out
# Rename/strip alt="..." on wrapper elements (non-img).
alt_rewrites = 0
def count_alt_rewrite(m):
nonlocal alt_rewrites
replacement = _rewrite_alt_on_wrapper(m)
if replacement != m.group(0):
alt_rewrites += 1
return replacement
new_out = _ALT_ON_TAG.sub(count_alt_rewrite, out)
if alt_rewrites:
deltas['alt_on_wrapper'] = alt_rewrites
out = new_out
return out, deltas
# --- XHTML pass ---------------------------------------------------------
epub_text_dir = temp_dir / "EPUB" / "text"
if epub_text_dir.exists():
for xhtml_file in epub_text_dir.glob("*.xhtml"):
original = xhtml_file.read_text(encoding='utf-8')
modified, deltas = sanitize_xhtml(original)
for k, v in deltas.items():
counts[k] += v
if modified != original:
xhtml_file.write_text(modified, encoding='utf-8')
# --- nav.xhtml is a sibling of EPUB/text, handle separately -------------
nav_path = temp_dir / "EPUB" / "nav.xhtml"
if nav_path.exists():
original = nav_path.read_text(encoding='utf-8')
modified, deltas = sanitize_xhtml(original)
for k, v in deltas.items():
counts[k] += v
if modified != original:
nav_path.write_text(modified, encoding='utf-8')
# --- SVG pass -----------------------------------------------------------
epub_media_dir = temp_dir / "EPUB" / "media"
if epub_media_dir.exists():
for svg_file in epub_media_dir.glob("*.svg"):
original = svg_file.read_text(encoding='utf-8')
modified, n = _SVG_ARIA_LABEL.subn(_strip_c0_controls, original)
# Count files where stripping actually happened.
if modified != original:
# How many aria-label values contained C0 chars?
c0_before = len(_C0_CONTROL_CHARS.findall(original))
c0_after = len(_C0_CONTROL_CHARS.findall(modified))
counts['svg_aria_c0'] += (c0_before - c0_after)
svg_file.write_text(modified, encoding='utf-8')
print(f" ✅ XHTML comment `--` sanitized: {counts['comment_dashes']}")
print(f" ✅ Bare
→
: {counts['bare_br']}")
print(f" ✅ SVG aria-label C0 chars: {counts['svg_aria_c0']}")
print(f" ✅ href URLs normalized: {counts['href_rewritten']}")
print(f" ✅ alt→aria-label on wrappers: {counts['alt_on_wrapper']}")
return counts
def extract_epub(epub_path, temp_dir):
"""Extract EPUB to temporary directory."""
print(" Extracting EPUB...")
with zipfile.ZipFile(epub_path, 'r') as zip_ref:
zip_ref.extractall(temp_dir)
def fix_cross_references_in_extracted_epub(temp_dir):
"""Fix cross-references in extracted EPUB directory."""
print(" Fixing cross-references...")
# Build EPUB section mapping
epub_mapping = build_epub_section_mapping(temp_dir)
print(f" Found {len(epub_mapping)} section IDs across chapters")
# Find all XHTML files
epub_text_dir = temp_dir / "EPUB" / "text"
if not epub_text_dir.exists():
print(f" ⚠️ No EPUB/text directory found")
return 0
xhtml_files = list(epub_text_dir.glob("*.xhtml"))
print(f" Scanning {len(xhtml_files)} XHTML files...")
# Process each file
files_fixed = []
total_refs_fixed = 0
all_unmapped = set()
skip_patterns = ['nav.xhtml', 'cover.xhtml', 'title_page.xhtml']
for xhtml_file in xhtml_files:
# Skip certain files
if any(skip in xhtml_file.name for skip in skip_patterns):
continue
rel_path, fixed_count, unmapped = process_html_file(
xhtml_file,
temp_dir, # base_dir for relative paths
epub_mapping
)
if fixed_count > 0:
files_fixed.append((rel_path or xhtml_file.name, fixed_count))
total_refs_fixed += fixed_count
all_unmapped.update(unmapped)
if files_fixed:
print(f" ✅ Fixed {total_refs_fixed} cross-references in {len(files_fixed)} files")
for path, count in files_fixed:
print(f" 📄 {path}: {count} refs")
else:
print(f" ✅ No unresolved cross-references found")
if all_unmapped:
print(f" ⚠️ Unmapped references: {', '.join(sorted(list(all_unmapped)[:5]))}")
return total_refs_fixed
def declare_nav_mathml_property(temp_dir):
"""Declare `mathml` on the nav item only when nav.xhtml contains MathML.
EPUBCheck reports OPF-014 when `nav.xhtml` contains `