Files
cs249r_book/book/cli/commands/_epub_checks.py
Vijay Janapa Reddi a7575ebb2b fix(epub-check): skip Quarto build-artifact SVGs in source hygiene
The EPUB hygiene check walked book/quarto/contents/**/*.svg with an
indiscriminate rglob, picking up Quarto's per-render output under
<chapter>_files/mediabag/. Those SVGs are gitignored, regenerated each
render with whatever the TikZ->SVG pipeline (dvisvgm) emits, and already
sanitized in the final EPUB by epub_postprocess.py. Inspecting them at
the source level produced spurious pre-commit failures on aria-label
C0 control chars that the post-processor fixes downstream anyway.

Restrict _iter_svgs to authored SVGs (images/svg/*.svg etc.).
2026-05-21 13:54:24 -07:00

791 lines
30 KiB
Python

"""EPUB check primitives used by the `binder check epub` command group.
This module holds the pure-Python logic for the two `check epub` scopes
so that `validate.py` can call it as ordinary Python (not via a
subprocess-to-a-script, which is what the older delegated-script
pattern used). All checks here return a list of
`(file, line, col, code, severity, message)` tuples that the caller
wraps into `ValidationIssue` objects.
Scopes surfaced to the user (via `./binder check epub --scope X`):
hygiene
Source-level invariants that cause epubcheck to reject the built
EPUB. Fast (<1s): regex over SVG and BibTeX files only; no EPUB
build required. Suitable for pre-commit. Catches the four error
categories that broke vol1/vol2 builds in April 2026.
epubcheck
Run the W3C epubcheck validator (https://github.com/w3c/epubcheck)
against the most recently built EPUBs under `_build/epub-vol*/`.
Requires the `epubcheck` binary in PATH (or the `epubcheck` Python
package). Slow (~30s per volume): must be invoked post-build, so
it belongs in CI, not pre-commit. Fails on `FATAL` by default;
`--max-errors` controls the `ERROR` threshold.
structure
Legacy custom-check script (validate_epub.py). Checks mimetype,
container.xml, CSS variables, XML comments, etc. Retained because
it runs without Java, so it is the right choice for a local
smoke check when epubcheck is unavailable. Does NOT replace
epubcheck — some of its checks overlap, but the strict schema
validation only epubcheck does is not replicated here.
Layer relationship: hygiene catches source issues *before* the build,
epubcheck catches rendered-EPUB issues *after* the build, and
structure catches a subset of epubcheck issues when Java is not
available. Together they form a defense-in-depth net so issues like
"--" in TikZ comments, C0 chars in SVG aria-labels, duplicate marker
ids, and BibTeX URL escapes cannot silently ship to Kindle again.
"""
from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
import tempfile
import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Iterator
# ---------------------------------------------------------------------------
# Dataclass used for hygiene and epubcheck issues alike. The fields match
# what `ValidationIssue` in validate.py expects; the wrapper in validate.py
# constructs a ValidationIssue from each record.
# ---------------------------------------------------------------------------
@dataclass
class EpubIssue:
file: str # repo-relative path
line: int # 1-indexed line number (0 = unknown)
col: int # 1-indexed column (0 = unknown)
code: str # short stable code ("svg-c0", "bib-url-escape", "RSC-016", etc.)
severity: str # "error" | "warning" | "info" | "fatal"
message: str # human-readable message for the user
# Optional: the exact source snippet so the operator can grep for it.
context: str = ""
# ---------------------------------------------------------------------------
# Hygiene: regex invariants over SVG and BibTeX source files.
# ---------------------------------------------------------------------------
# C0 control chars that XML 1.0 forbids in attribute values
# (TAB 0x09, LF 0x0A, CR 0x0D are allowed).
_C0_CONTROL_RE = re.compile(r'[\x00-\x08\x0B\x0C\x0E-\x1F]')
# aria-label="..." inside an SVG attribute list.
_SVG_ARIA_LABEL_RE = re.compile(r'aria-label="([^"]*)"')
# <marker ... id="X" ...> — just the opening tag with the id attribute.
_MARKER_ID_RE = re.compile(r'<marker\b[^>]*\bid="([^"]+)"')
# BibTeX: a single-braced `url = { ... }` or `doi = { ... }` field.
# We deliberately only match when the value does not contain a nested `{`
# or `}`, which covers every real entry in the book's .bib files.
_BIB_URL_FIELD_RE = re.compile(
r'^(\s*)(url|doi)(\s*=\s*\{)([^{}]*)(\})',
flags=re.MULTILINE,
)
def _line_of(text: str, offset: int) -> int:
"""Return 1-indexed line number of *offset* in *text*."""
return text.count('\n', 0, offset) + 1
def _iter_svgs(contents_dir: Path) -> Iterator[Path]:
# Skip Quarto's per-chapter render output under `<chapter>_files/`.
# Those SVGs are gitignored (.gitignore: `book/quarto/**/*_files/`),
# regenerated on every render, and already sanitized in the final
# EPUB by book/quarto/scripts/epub_postprocess.py. The source-level
# check should only inspect authored SVGs.
if not contents_dir.is_dir():
return
for svg in contents_dir.rglob('*.svg'):
if any(p.endswith('_files') for p in svg.parts):
continue
yield svg
def _iter_bibs(quarto_dir: Path) -> Iterator[Path]:
if quarto_dir.is_dir():
yield from quarto_dir.rglob('*.bib')
def _read(path: Path) -> str | None:
"""Read a text file, returning None on error (never raise to the caller)."""
try:
return path.read_text(encoding='utf-8')
except (UnicodeDecodeError, OSError):
return None
def check_svg_aria_label_c0(svg_file: Path, repo_root: Path) -> list[EpubIssue]:
"""Return issues for C0 control chars in SVG aria-label attribute values."""
issues: list[EpubIssue] = []
text = _read(svg_file)
if text is None:
return issues
for match in _SVG_ARIA_LABEL_RE.finditer(text):
value = match.group(1)
bad = _C0_CONTROL_RE.findall(value)
if not bad:
continue
codepoints = ", ".join(f"U+{ord(c):04X}" for c in bad[:3])
issues.append(EpubIssue(
file=str(svg_file.relative_to(repo_root)),
line=_line_of(text, match.start()),
col=0,
code="svg-c0",
severity="error",
message=(
f"SVG aria-label contains C0 control char(s) {codepoints}; "
"XML 1.0 forbids these in attribute values (epubcheck FATAL RSC-016)"
),
context=match.group(0)[:80],
))
return issues
def check_svg_duplicate_markers(svg_file: Path, repo_root: Path) -> list[EpubIssue]:
"""Return issues for duplicate `<marker id=...>` inside one SVG file."""
issues: list[EpubIssue] = []
text = _read(svg_file)
if text is None:
return issues
seen: dict[str, int] = {}
for match in _MARKER_ID_RE.finditer(text):
mid = match.group(1)
line = _line_of(text, match.start())
if mid in seen:
issues.append(EpubIssue(
file=str(svg_file.relative_to(repo_root)),
line=line,
col=0,
code="svg-dupe-marker",
severity="error",
message=(
f'duplicate <marker id="{mid}"/>; '
f"first defined at line {seen[mid]} "
"(epubcheck RSC-005 'Duplicate id')"
),
context=match.group(0)[:80],
))
else:
seen[mid] = line
return issues
def check_bibtex_url_escapes(bib_file: Path, repo_root: Path) -> list[EpubIssue]:
r"""Return issues for \_ / \% escapes and raw <> in bib URL/DOI fields."""
issues: list[EpubIssue] = []
text = _read(bib_file)
if text is None:
return issues
rel = str(bib_file.relative_to(repo_root))
for match in _BIB_URL_FIELD_RE.finditer(text):
_prefix, field, _eq, value, _close = match.groups()
# Skip DOI-only fields without http; citeproc constructs their URL
# itself from the raw DOI and has special-case handling for escapes.
is_urlish = 'http' in value.lower() or field == 'url'
if not is_urlish:
continue
line = _line_of(text, match.start())
if r'\_' in value:
issues.append(EpubIssue(
file=rel, line=line, col=0,
code="bib-url-escape-underscore",
severity="error",
message=(
rf"{field}= value contains BibTeX '\_' escape; "
"citeproc leaks this as a literal backslash into the "
"rendered href (epubcheck RSC-020)"
),
context=value[:80],
))
if r'\%' in value:
issues.append(EpubIssue(
file=rel, line=line, col=0,
code="bib-url-escape-percent",
severity="error",
message=(
rf"{field}= value contains BibTeX '\%' escape; "
"citeproc leaks this as a literal backslash into the "
"rendered href (epubcheck RSC-020)"
),
context=value[:80],
))
if '<' in value or '>' in value:
issues.append(EpubIssue(
file=rel, line=line, col=0,
code="bib-url-raw-angle",
severity="error",
message=(
f"{field}= value contains raw '<' or '>'; "
"strict URI syntax forbids these in path segments "
"(percent-encode as %3C / %3E; epubcheck RSC-020)"
),
context=value[:80],
))
return issues
# ---------------------------------------------------------------------------
# Hygiene auto-repair. Mirrors the rewriters in
# `book/quarto/scripts/epub_postprocess.py` but applied to SOURCE files,
# not to the extracted EPUB. Running the fixer once against a stale
# checkout removes every legacy occurrence of the four invariant
# classes at source; future regressions are caught at commit time by
# the hygiene scope.
#
# All rewrites are deterministic and idempotent. Running `--fix` twice
# produces the same output as running it once.
# ---------------------------------------------------------------------------
def _fix_svg_aria_label_c0(svg_file: Path) -> int:
"""Strip C0 control chars from aria-label values. Returns bytes removed."""
text = _read(svg_file)
if text is None:
return 0
removed = 0
def strip(m):
nonlocal removed
value = m.group(1)
cleaned = _C0_CONTROL_RE.sub('', value)
if cleaned == value:
return m.group(0)
removed += len(value) - len(cleaned)
return f'aria-label="{cleaned}"'
new_text = _SVG_ARIA_LABEL_RE.sub(strip, text)
if new_text != text:
svg_file.write_text(new_text, encoding='utf-8')
return removed
# Regex that captures an entire `<marker ...>...</marker>` block (compact
# or expanded) for deduplication. DOTALL so it spans lines.
_MARKER_BLOCK_RE = re.compile(
r'<marker\b[^>]*\bid="([^"]+)"[^>]*>(?:(?!<marker\b).)*?</marker>',
flags=re.DOTALL,
)
def _fix_svg_duplicate_markers(svg_file: Path) -> int:
"""Remove duplicate `<marker id="X"/>` blocks; keep the first occurrence."""
text = _read(svg_file)
if text is None:
return 0
seen: set[str] = set()
removed = 0
def dedup(m):
nonlocal removed
mid = m.group(1)
if mid in seen:
removed += 1
return "" # drop duplicate
seen.add(mid)
return m.group(0)
new_text = _MARKER_BLOCK_RE.sub(dedup, text)
if removed == 0:
return 0
# Collapse blank-line runs left by removed blocks for readable diffs.
new_text = re.sub(r'\n\s*\n\s*\n+', '\n\n', new_text)
svg_file.write_text(new_text, encoding='utf-8')
return removed
def _fix_bibtex_url_escapes(bib_file: Path) -> int:
r"""Rewrite URL fields to strip BibTeX escapes and percent-encode < >."""
text = _read(bib_file)
if text is None:
return 0
fixes = 0
def sanitize(m):
nonlocal fixes
prefix, field, eq, value, close = m.groups()
is_urlish = 'http' in value.lower() or field == 'url'
if not is_urlish:
return m.group(0)
new_value = value
if r'\_' in new_value:
fixes += new_value.count(r'\_')
new_value = new_value.replace(r'\_', '_')
if r'\%' in new_value:
fixes += new_value.count(r'\%')
new_value = new_value.replace(r'\%', '%')
if '<' in new_value:
fixes += new_value.count('<')
new_value = new_value.replace('<', '%3C')
if '>' in new_value:
fixes += new_value.count('>')
new_value = new_value.replace('>', '%3E')
if new_value == value:
return m.group(0)
return f"{prefix}{field}{eq}{new_value}{close}"
new_text = _BIB_URL_FIELD_RE.sub(sanitize, text)
if new_text != text:
bib_file.write_text(new_text, encoding='utf-8')
return fixes
def fix_hygiene_issues(repo_root: Path) -> dict[str, int]:
"""Auto-repair every hygiene issue at source.
Returns a counts dict with keys:
svg_c0_chars_removed: characters stripped across all SVGs
svg_duplicate_markers: <marker> blocks removed across all SVGs
bib_url_rewrites: BibTeX URL-field substitutions performed
The fix is deterministic and idempotent; the counts reflect how much
work was done on this invocation, not the total number of remaining
legacy occurrences (running a second time yields zeros).
"""
contents_dir = repo_root / "book" / "quarto" / "contents"
quarto_dir = repo_root / "book" / "quarto"
counts = {
"svg_c0_chars_removed": 0,
"svg_duplicate_markers": 0,
"bib_url_rewrites": 0,
}
for svg in _iter_svgs(contents_dir):
counts["svg_c0_chars_removed"] += _fix_svg_aria_label_c0(svg)
counts["svg_duplicate_markers"] += _fix_svg_duplicate_markers(svg)
for bib in _iter_bibs(quarto_dir):
counts["bib_url_rewrites"] += _fix_bibtex_url_escapes(bib)
return counts
def find_hygiene_issues(repo_root: Path) -> tuple[list[EpubIssue], int]:
"""Walk repo source and return (issues, num_files_checked).
Parameters
----------
repo_root : Path
Absolute path to the repository root (the one that contains
`book/`). Used to make issue file paths repo-relative.
Returns
-------
(issues, num_files) where num_files is the total count of SVG+BIB
files scanned, used for the `files_checked` field of the
ValidationRunResult.
"""
issues: list[EpubIssue] = []
files_checked = 0
contents_dir = repo_root / "book" / "quarto" / "contents"
quarto_dir = repo_root / "book" / "quarto"
for svg in _iter_svgs(contents_dir):
files_checked += 1
issues.extend(check_svg_aria_label_c0(svg, repo_root))
issues.extend(check_svg_duplicate_markers(svg, repo_root))
for bib in _iter_bibs(quarto_dir):
files_checked += 1
issues.extend(check_bibtex_url_escapes(bib, repo_root))
return issues, files_checked
# ---------------------------------------------------------------------------
# Epubcheck wrapper: runs the W3C validator and maps JSON output to EpubIssues.
# ---------------------------------------------------------------------------
# Mapping from epubcheck severity names to our severity strings.
_EPUBCHECK_SEVERITY_MAP = {
"FATAL": "fatal",
"ERROR": "error",
"WARNING": "warning",
"INFO": "info",
"USAGE": "info",
"SUPPRESSED": "info",
}
def _find_epubcheck_executable() -> list[str] | None:
"""Return a subprocess argv prefix for invoking epubcheck, or None.
Preference order:
1. `java -jar <bundled-jar>` when the `epubcheck` PyPI package is
importable. The PyPI package ships the official W3C epubcheck
jar and exposes its location as `epubcheck.const.EPUBCHECK`.
Its own `epubcheck` / `python -m epubcheck` entry points are a
thin wrapper with a reduced CLI (`[-x XLS] [-c CSV] [-r]`) that
does NOT support `--json`, so we bypass the wrapper and invoke
the jar directly to get the full Java CLI (including `--json -`
for JSON on stdout). This is what CI hits.
2. `epubcheck` on PATH (brew / apt install) — the real Java CLI.
3. None — caller must emit an "epubcheck not available" message.
"""
try:
from epubcheck import const as _ec_const # type: ignore
jar = getattr(_ec_const, "EPUBCHECK", None)
java = getattr(_ec_const, "JAVA", "java") or "java"
if jar and Path(jar).is_file():
return [java, "-jar", jar]
except ImportError:
pass
if shutil.which("epubcheck"):
return ["epubcheck"]
return None
def _discover_built_epubs(repo_root: Path) -> list[Path]:
"""Return the most recent EPUB per-volume under `_build/epub-vol*/`."""
build_root = repo_root / "book" / "quarto" / "_build"
if not build_root.is_dir():
return []
results: list[Path] = []
for vol_dir in sorted(build_root.glob("epub-vol*")):
candidates = sorted(vol_dir.glob("*.epub"), key=lambda p: p.stat().st_mtime, reverse=True)
if candidates:
results.append(candidates[0])
return results
def run_epubcheck_on(
epub_path: Path,
*,
repo_root: Path,
timeout_seconds: int = 180,
) -> tuple[list[EpubIssue], dict[str, int]]:
"""Run epubcheck against *epub_path* and return (issues, severity counts).
If epubcheck is not available on the system, returns a single
`epubcheck-missing` issue so the caller can surface an install hint.
Severity counts include FATAL, ERROR, WARNING keys so the caller can
compare against MAX_FATAL / MAX_ERRORS thresholds without re-counting.
"""
cmd_prefix = _find_epubcheck_executable()
if cmd_prefix is None:
return (
[EpubIssue(
file=str(epub_path.relative_to(repo_root)) if epub_path.is_relative_to(repo_root) else str(epub_path),
line=0, col=0,
code="epubcheck-missing",
severity="error",
message=(
"epubcheck is not installed. Install with "
"`pip install epubcheck` (requires JRE 8+) or via your OS "
"package manager (`brew install epubcheck` / "
"`apt install epubcheck`)."
),
)],
{"FATAL": 0, "ERROR": 1, "WARNING": 0},
)
cmd = cmd_prefix + ["--json", "-", str(epub_path)]
try:
completed = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout_seconds,
check=False,
)
except FileNotFoundError:
return (
[EpubIssue(
file=str(epub_path), line=0, col=0,
code="epubcheck-missing",
severity="error",
message="epubcheck executable vanished between lookup and invocation",
)],
{"FATAL": 0, "ERROR": 1, "WARNING": 0},
)
except subprocess.TimeoutExpired:
return (
[EpubIssue(
file=str(epub_path), line=0, col=0,
code="epubcheck-timeout",
severity="error",
message=f"epubcheck timed out after {timeout_seconds}s",
)],
{"FATAL": 1, "ERROR": 0, "WARNING": 0},
)
# epubcheck prints JSON to stdout when `--json -` is used. Non-zero
# exit code (1 or 2) is expected when errors are found — we only
# treat JSON-parse failure as a real invocation error.
issues, severity_counts = _parse_epubcheck_json(
completed.stdout, epub_path, repo_root,
)
if issues or completed.returncode == 0:
return issues, severity_counts
# No issues parsed but non-zero exit: surface stderr for diagnostics.
stderr = (completed.stderr or "").strip()[:500]
return (
[EpubIssue(
file=str(epub_path), line=0, col=0,
code="epubcheck-invocation-error",
severity="error",
message=(
f"epubcheck exited with code {completed.returncode} but "
f"produced no JSON output. stderr: {stderr!r}"
),
)],
{"FATAL": 0, "ERROR": 1, "WARNING": 0},
)
def _parse_epubcheck_json(
stdout: str, epub_path: Path, repo_root: Path,
) -> tuple[list[EpubIssue], dict[str, int]]:
"""Convert epubcheck's JSON message list to EpubIssue records."""
issues: list[EpubIssue] = []
counts = {"FATAL": 0, "ERROR": 0, "WARNING": 0, "INFO": 0, "USAGE": 0}
if not stdout.strip():
return issues, counts
try:
payload = json.loads(stdout)
except json.JSONDecodeError:
# Some epubcheck versions print a banner before the JSON. Look for
# the first `{` and try again.
brace = stdout.find("{")
if brace < 0:
return issues, counts
try:
payload = json.loads(stdout[brace:])
except json.JSONDecodeError:
return issues, counts
messages = payload.get("messages", []) or []
epub_rel = str(epub_path.relative_to(repo_root)) if epub_path.is_relative_to(repo_root) else str(epub_path)
for m in messages:
severity_raw = (m.get("severity") or "").upper()
counts[severity_raw] = counts.get(severity_raw, 0) + 1
severity = _EPUBCHECK_SEVERITY_MAP.get(severity_raw, "error")
code = m.get("ID") or "UNKNOWN"
message_text = m.get("message") or ""
# Epubcheck emits one message with a list of locations. We flatten
# to one issue per location so the caller can surface each one.
locations = m.get("locations") or [{}]
for loc in locations:
path = loc.get("path") or epub_rel
line = int(loc.get("line") or 0)
col = int(loc.get("column") or 0)
# The path inside epubcheck is a zip-internal path (e.g.
# "EPUB/text/ch008.xhtml"). Prefix the epub file so users can
# tell which volume the issue is from.
displayed_file = f"{epub_rel}!{path}" if path and path != epub_rel else epub_rel
issues.append(EpubIssue(
file=displayed_file,
line=line,
col=col,
code=code,
severity=severity,
message=message_text,
))
return issues, counts
# ---------------------------------------------------------------------------
# Smoke check: reader-compatibility invariants epubcheck does not cover.
#
# Epubcheck enforces EPUB 3 spec conformance, but some readers enforce a
# stricter subset. The patterns checked here produced real user-reported
# breakage before the `fix/epub-issues` work:
#
# CSS custom properties (`--var` declarations, `var(--x)` usage)
# Valid per CSS 4 spec but not implemented in older / embedded EPUB
# renderers (ClearView, Tolino firmware pre-2023, some Kobo builds).
# Root cause of issue #1052 before the CSS was rewritten without
# custom properties.
#
# External resource references (src=, href= pointing off-device)
# EPUB readers typically do not fetch external resources — an
# `<img src="https://...">` shows a broken-image icon on every
# reader that enforces offline rendering. Zero false positives
# in well-authored EPUBs; this is always a bug.
#
# The smoke check runs in <1s against a built EPUB and does not require
# Java, so it is useful when epubcheck is not installed locally and as
# a belt-and-suspenders confirmation alongside epubcheck.
# ---------------------------------------------------------------------------
# CSS custom property declarations like `--accent-color: #333;`
_CSS_CUSTOM_PROP_DECL_RE = re.compile(r'^\s*(--[\w-]+)\s*:', flags=re.MULTILINE)
# CSS custom property consumption like `color: var(--accent-color);`
_CSS_CUSTOM_PROP_USE_RE = re.compile(r'\bvar\(\s*(--[\w-]+)')
# External resource references in XHTML. We match href= and src= values
# that begin with a scheme (http, https, ftp, data: except for known safe
# data URIs) or with `//`. In-EPUB paths start with a letter or `../`.
_EXTERNAL_RESOURCE_RE = re.compile(
r'\b(?:href|src)="((?:https?|ftp)://[^"]*|//[^"]*)"',
)
def run_smoke_checks_on(
epub_path: Path,
*,
repo_root: Path,
) -> list[EpubIssue]:
"""Run reader-compatibility smoke checks against a built EPUB.
Unlike `run_epubcheck_on`, this does not require Java — it unzips the
EPUB into a temp directory, walks the CSS and XHTML, and returns
issues for patterns epubcheck does not catch:
* CSS custom property declarations and usage
* External resource references in XHTML href / src
Returns an empty list on a clean EPUB.
"""
issues: list[EpubIssue] = []
if not epub_path.exists():
return [EpubIssue(
file=str(epub_path), line=0, col=0,
code="smoke-missing-epub",
severity="error",
message=f"EPUB file not found: {epub_path}",
)]
epub_rel = (
str(epub_path.relative_to(repo_root))
if epub_path.is_relative_to(repo_root)
else str(epub_path)
)
tmp = Path(tempfile.mkdtemp(prefix="epub-smoke-"))
try:
try:
with zipfile.ZipFile(epub_path, "r") as zf:
zf.extractall(tmp)
except zipfile.BadZipFile:
return [EpubIssue(
file=epub_rel, line=0, col=0,
code="smoke-bad-zip",
severity="error",
message="EPUB is not a valid zip archive",
)]
# --- CSS custom properties -------------------------------------
for css in tmp.rglob("*.css"):
text = _read(css)
if text is None:
continue
rel_in_epub = css.relative_to(tmp).as_posix()
displayed = f"{epub_rel}!{rel_in_epub}"
for m in _CSS_CUSTOM_PROP_DECL_RE.finditer(text):
issues.append(EpubIssue(
file=displayed,
line=_line_of(text, m.start()),
col=0,
code="smoke-css-custom-property-decl",
# Treated as error because it produced real user-
# reported EPUB-load failure on ClearView (issue #1052).
severity="error",
message=(
f"CSS custom property declaration '{m.group(1)}'; "
"older EPUB readers (ClearView, Tolino pre-2023) "
"do not support these. Inline the value."
),
))
for m in _CSS_CUSTOM_PROP_USE_RE.finditer(text):
issues.append(EpubIssue(
file=displayed,
line=_line_of(text, m.start()),
col=0,
code="smoke-css-custom-property-use",
severity="error",
message=(
f"var({m.group(1)}) reference; older EPUB readers "
"do not resolve CSS custom properties. Inline "
"the literal value."
),
))
# --- External resource references -----------------------------
for xhtml in tmp.rglob("*.xhtml"):
text = _read(xhtml)
if text is None:
continue
rel_in_epub = xhtml.relative_to(tmp).as_posix()
displayed = f"{epub_rel}!{rel_in_epub}"
for m in _EXTERNAL_RESOURCE_RE.finditer(text):
url = m.group(1)
# Allowlist: external hyperlinks (<a href="https://...">)
# are fine; only flag when the match's *attribute* is src=
# or the href= is on a <link> element (external stylesheet).
# We approximate this by looking at the 60 chars before
# the match for 'src=' or '<link'.
context_before = text[max(0, m.start() - 60):m.start()]
is_src_attr = "src=\"" in m.group(0)
is_link_href = "<link" in context_before.lower()
if not (is_src_attr or is_link_href):
continue
issues.append(EpubIssue(
file=displayed,
line=_line_of(text, m.start()),
col=0,
code="smoke-external-resource",
severity="error",
message=(
f"External resource reference: {url} — EPUB readers "
"do not fetch remote resources. Inline the asset "
"or remove the reference."
),
))
return issues
finally:
shutil.rmtree(tmp, ignore_errors=True)
def emit_github_annotations(issues: Iterable[EpubIssue]) -> None:
"""When running under GitHub Actions, echo `::error` annotations so
epubcheck findings appear inline on the PR diff view.
No-op when `GITHUB_ACTIONS` is not set, so it's safe to call locally.
"""
if os.environ.get("GITHUB_ACTIONS") != "true":
return
# GitHub's annotation format only has file/line/col, not zip-internal
# paths. For epubcheck issues the file is `epub!path` — we still emit
# the annotation (keyed on the epub file) so the workflow log shows it
# in the annotations panel; reviewers who need to find the XHTML line
# follow the message text.
for issue in issues:
level = "error" if issue.severity in ("fatal", "error") else "warning"
extra = []
if issue.line > 0:
extra.append(f"line={issue.line}")
if issue.col > 0:
extra.append(f"col={issue.col}")
loc = "," + ",".join(extra) if extra else ""
# The file field must not contain shell-meaningful characters like
# `!` for the annotation to render; strip to the EPUB path only.
file_field = issue.file.split("!", 1)[0]
print(
f"::{level} file={file_field}{loc},title={issue.code}"
f"::{issue.message}"
)