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.
193 lines
6.3 KiB
Python
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)
|