aboutsummaryrefslogtreecommitdiff
path: root/app/converter/extractors.py
diff options
context:
space:
mode:
Diffstat (limited to 'app/converter/extractors.py')
-rw-r--r--app/converter/extractors.py96
1 files changed, 79 insertions, 17 deletions
diff --git a/app/converter/extractors.py b/app/converter/extractors.py
index 8b01372..0e30ac1 100644
--- a/app/converter/extractors.py
+++ b/app/converter/extractors.py
@@ -2,11 +2,13 @@
import codecs
import logging
+import posixpath
import re
import zipfile
from html import unescape
from pathlib import Path
-from typing import List, NamedTuple, Optional
+from typing import Dict, List, NamedTuple, Optional
+from urllib.parse import unquote
try:
from bs4 import BeautifulSoup
@@ -216,8 +218,13 @@ def _epub_spine_order(epub_zip: zipfile.ZipFile, html_names: List[str]):
Parses the package OPF (located via META-INF/container.xml, else the
only *.opf member): manifest item id -> href, then the spine's
- idrefs. Returns member paths limited to HTML_NAMES; nav documents
- (``properties`` containing "nav") and non-HTML items are excluded.
+ idrefs. Hrefs are URL-encoded, may carry "./" or "../" segments and
+ XML entities, and are relative to the OPF's folder — they are
+ normalized before matching against the zip's member names, so valid
+ chapters are not silently dropped when their written form differs
+ from the archived path. Returns member paths limited to HTML_NAMES;
+ nav documents (``properties`` containing "nav") and non-HTML items
+ are excluded.
"""
container = "META-INF/container.xml"
opf_name = None
@@ -246,6 +253,25 @@ def _epub_spine_order(epub_zip: zipfile.ZipFile, html_names: List[str]):
base = "/".join(opf_name.split("/")[:-1])
item_tags = re.findall(r"<item\b[^>]*>", opf)
+ # Zip member names indexed under their normalized spellings, so an
+ # exact archive path can be found from any equivalent href form.
+ # (Percent-encoded and plain spellings both map to the same member.)
+ members: Dict[str, str] = {}
+ for name in html_names:
+ members.setdefault(posixpath.normpath(name), name)
+ members.setdefault(posixpath.normpath(unquote(name)), name)
+
+ def member_for_href(href: str) -> Optional[str]:
+ """The zip member an OPF manifest HREF points at (best effort)."""
+ path = unescape(href).strip()
+ path = path.split("#", 1)[0]
+ path = unquote(path).strip()
+ if not path:
+ return None
+ if base:
+ path = f"{base}/{path}"
+ return members.get(posixpath.normpath(path).lstrip("/"))
+
def is_nav(item_tag: str) -> bool:
properties = attr(item_tag, "properties") or ""
return "nav" in properties.split()
@@ -262,10 +288,9 @@ def _epub_spine_order(epub_zip: zipfile.ZipFile, html_names: List[str]):
href = attr(match, "href")
if not href:
continue
- path = (f"{base}/{href}" if base else href)
- path = re.sub(r"#.*$", "", path)
- if path in html_names and path not in order:
- order.append(path)
+ member = member_for_href(href)
+ if member and member not in order:
+ order.append(member)
return order or None
@@ -287,45 +312,82 @@ def _read_epub_manual(file_path: Path):
def clean_text(text: str) -> str:
- """Normalize whitespace and strip standalone page numbers.
+ """Normalize whitespace, strip standalone page numbers, and keep
+ paragraph breaks.
Page numbers are removed only when a short number (up to three digits)
appears alone on its own line (before whitespace collapsing), so inline
numbers like "42 years", "1,000" or "3.5" are preserved, as are
four-digit standalone lines, which are usually years ("1984") or
chapter numbers rather than page numbers.
+
+ Runs of blank lines survive as a single paragraph break (``\\n\\n``) so
+ the smart chunker can still end requests at paragraph boundaries and
+ reset its quotation state there; single line breaks (soft wraps) and
+ all other whitespace collapse to spaces.
"""
if not text:
return ""
# Standalone page numbers (digits alone on a line) must go BEFORE the
- # newline-collapsing step below.
+ # whitespace-collapsing steps below.
text = re.sub(r"(?m)^\s*\d{1,3}\s*$", " ", text)
- text = re.sub(r"\s+", " ", text)
+ # Normalize line breaks so the steps below see plain "\n".
+ text = re.sub(r"\r\n?", "\n", text)
+ # Any run of blank lines becomes exactly one paragraph break.
+ text = re.sub(r"\n[^\S\n]*\n(?:[^\S\n]*\n)*", "\n\n", text)
+ # Remaining single line breaks (soft wraps) become spaces.
+ text = re.sub(r"(?<!\n)\n(?!\n)", " ", text)
+ # Collapse every other whitespace run (including nbsp) to one space.
+ text = re.sub(r"[^\S\n]+", " ", text)
+ # Trim whatever sits against a paragraph break.
+ text = re.sub(r"[^\S\n]*\n\n[^\S\n]*", "\n\n", text)
return text.strip()
+# Block-level elements structure the document into paragraphs and lines:
+# replacing them with a paragraph break keeps the chunker's paragraph
+# boundaries (and headings separated from prose). Inline elements must NOT
+# break words, so they are left for text extraction to unwrap without
+# spaces ("un<em>believ</em>able" stays one word). The lookahead keeps
+# lookalike tags (<parameter>, <preheat>) out of the match.
+_BLOCK_BREAK_RE = re.compile(
+ r"</?(?:address|article|aside|blockquote|body|br|caption|center|col"
+ r"|colgroup|dd|div|dl|dt|fieldset|figcaption|figure|footer|form"
+ r"|h[1-6]|head|header|hr|html|legend|li|main|nav|ol|option|p|pre"
+ r"|section|summary|table|tbody|td|tfoot|th|thead|tr|ul)(?=[\s/>])"
+ r"[^>]*>",
+ re.IGNORECASE)
+
+
def clean_html(html_content: str) -> str:
- """Strip markup, scripts and styles from HTML content."""
+ """Strip markup, scripts and styles from HTML content.
+
+ Block-level tags become paragraph breaks (so the chunker sees the
+ document's paragraphs); inline tags vanish without adding spaces, so
+ no artificial word boundaries are injected.
+ """
if not html_content:
return ""
+ html_content = _BLOCK_BREAK_RE.sub("\n\n", html_content)
+
if BS4_AVAILABLE:
try:
soup = BeautifulSoup(html_content, "html.parser")
for tag in soup(["script", "style"]):
tag.decompose()
- text = soup.get_text(separator=" ")
- return re.sub(r"\s+", " ", text).strip()
+ return clean_text(soup.get_text(separator=""))
except Exception as exc:
logger.debug("BeautifulSoup cleaning failed, falling back to regex: %s", exc)
- # Fallback regex cleaning
+ # Fallback regex cleaning. Block tags are already paragraph breaks;
+ # every other tag (inline) disappears without adding a space, so no
+ # artificial word boundary is injected.
html_content = re.sub(r"<style[^>]*>.*?</style>", "", html_content, flags=re.DOTALL | re.IGNORECASE)
html_content = re.sub(r"<script[^>]*>.*?</script>", "", html_content, flags=re.DOTALL | re.IGNORECASE)
- html_content = re.sub(r"<[^>]+>", " ", html_content)
+ html_content = re.sub(r"<[^>]+>", "", html_content)
html_content = unescape(html_content)
- html_content = re.sub(r"\s+", " ", html_content)
- return html_content.strip()
+ return clean_text(html_content)
def _natural_key(name: str):