Files
bindery/bindery/rules.py
T
tim 6c2574cf0f Store chapters, merge chapter lists, diff and apply rules
The SQLite store keeps every chapter revision, so a chapter that the site
removes stays in the book. The merge puts new chapters after the chapter
that comes before them on the site. Diffs compare text, not HTML, and
replacement rules run on chapter HTML when a book is built.
2026-09-27 21:23:20 +02:00

193 lines
6.3 KiB
Python

"""Regex search-and-replace rules applied to chapter HTML when a book is built.
Rules never change the stored chapters. They run on a copy while the epub is
written, so a rule can be edited or removed and the book rebuilt without a new
download. An optional first step turns line breaks into paragraphs.
"""
import functools
from collections.abc import Callable
from typing import Any
import regex
from bs4 import BeautifulSoup, NavigableString, Tag
FLAGS = {"i": regex.IGNORECASE, "s": regex.DOTALL, "m": regex.MULTILINE, "x": regex.VERBOSE}
# The longest time in seconds that one rule can take on one chapter. Some patterns backtrack for hours.
TIMEOUT = 10
# One step of the work on a chapter: HTML in, HTML out.
Step = Callable[[str], str]
BLOCK_TAGS = {
"address",
"article",
"aside",
"blockquote",
"center",
"dd",
"details",
"div",
"dl",
"dt",
"fieldset",
"figcaption",
"figure",
"footer",
"form",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"header",
"hr",
"li",
"main",
"nav",
"ol",
"p",
"pre",
"section",
"table",
"ul",
}
# Blocks whose loose text can go into paragraphs. A <p> is split into several <p>s instead.
CONTAINERS = ["article", "aside", "blockquote", "dd", "div", "li", "main", "p", "section", "td"]
class RuleError(ValueError):
def __init__(self, index: int, message: str) -> None:
super().__init__(f"Rule {index + 1}: {message}")
self.index = index
def normalize_rules(rules: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Check a list of rules from the UI and return clean copies.
Raises RuleError for the first rule with a bad pattern, replacement or flag.
"""
clean = []
for index, rule in enumerate(rules):
pattern = str(rule.get("pattern", ""))
if not pattern:
raise RuleError(index, "the pattern is empty")
flags = "".join(sorted(set(str(rule.get("flags", "")).lower())))
bad = [f for f in flags if f not in FLAGS]
if bad:
raise RuleError(index, f"unknown flag {''.join(bad)!r}, use i, s, m or x")
replacement = str(rule.get("replacement", ""))
try:
regex.compile(pattern, _flag_bits(flags))
# regex reads a replacement only at a match. An empty match with the same groups finds bad group
# references now. The newline ends a comment of the x flag.
regex.compile(f"(?:{pattern}\n)|", _flag_bits(flags)).match("").expand(replacement)
except (regex.error, IndexError) as e:
raise RuleError(index, str(e)) from e
clean.append(
{
"pattern": pattern,
"replacement": replacement,
"flags": flags,
"enabled": bool(rule.get("enabled", True)),
"note": str(rule.get("note", "")),
}
)
return clean
def _flag_bits(flags: str) -> int:
bits = 0
for f in flags:
bits |= FLAGS[f]
return bits
def compile_rules(*rule_lists: list[dict[str, Any]], paragraphs: bool = False) -> list[Step]:
"""Return the steps for the enabled rules of each list, in order.
With `paragraphs`, line breaks become paragraphs before the first rule runs.
"""
steps: list[Step] = [breaks_to_paragraphs] if paragraphs else []
for rules in rule_lists:
for rule in rules:
if rule.get("enabled", True):
pattern = regex.compile(rule["pattern"], _flag_bits(rule.get("flags", "")))
steps.append(functools.partial(_substitute, pattern, rule.get("replacement", "")))
return steps
def _substitute(pattern: regex.Pattern, replacement: str, html: str) -> str:
try:
return pattern.sub(replacement, html, timeout=TIMEOUT)
except TimeoutError as e:
raise ValueError(
f"The rule {pattern.pattern!r} takes longer than {TIMEOUT} seconds on one chapter. "
"Change the rule or turn it off."
) from e
def apply_rules(html: str, steps: list[Step]) -> str:
for step in steps:
html = step(html)
return html
def _is_empty(nodes: list[Any]) -> bool:
for node in nodes:
if isinstance(node, Tag):
if node.name == "img" or node.find("img") or node.get_text().strip():
return False
elif isinstance(node, NavigableString) and node.strip():
return False
return True
def breaks_to_paragraphs(html: str) -> str:
"""Turn text that <br> tags split inside a block into one paragraph per piece.
Only a <br> that is a direct child of a block counts, so a <br> inside
inline markup such as <em> stays. Empty pieces are dropped, so a run of
<br> tags makes one paragraph break. A <p> becomes several <p>s with the
same attributes. In other blocks, the pieces go into new <p>s, and child
blocks stay as they are.
"""
if "<br" not in html.lower():
return html
soup = BeautifulSoup(html, "html.parser")
for block in soup.find_all(CONTAINERS):
if not any(isinstance(c, Tag) and c.name == "br" for c in block.children):
continue
parts: list[Any] = []
run: list[Any] = []
for child in list(block.children):
child.extract()
if isinstance(child, Tag) and child.name == "br":
parts.append(run)
run = []
elif isinstance(child, Tag) and child.name in BLOCK_TAGS:
parts.extend([run, child])
run = []
else:
run.append(child)
parts.append(run)
nodes = []
for part in parts:
if isinstance(part, Tag):
nodes.append(part)
elif not _is_empty(part):
attrs = dict(block.attrs) if block.name == "p" else {}
if any(n.name == "p" for n in nodes if isinstance(n, Tag)):
attrs.pop("id", None) # an id must stay unique
paragraph = soup.new_tag("p", attrs=attrs)
paragraph.extend(part)
nodes.append(paragraph)
if block.name == "p":
for node in nodes:
block.insert_before(node)
block.decompose()
else:
block.extend(nodes)
return str(soup)