Files
abogen/abogen/text_extractor.py
T

929 lines
34 KiB
Python

from __future__ import annotations
import datetime
import logging
import mimetypes
import re
import textwrap
import urllib.parse
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple, cast
import ebooklib # type: ignore[import]
import fitz # type: ignore[import]
import markdown # type: ignore[import]
from bs4 import BeautifulSoup, NavigableString # type: ignore[import]
from ebooklib import epub # type: ignore[import]
from .utils import calculate_text_length, clean_text, detect_encoding
logger = logging.getLogger(__name__)
METADATA_PATTERN = re.compile(r"<<METADATA_([A-Z_]+):(.*?)>>", re.DOTALL)
CHAPTER_PATTERN = re.compile(r"<<CHAPTER_MARKER:(.*?)>>", re.IGNORECASE)
METADATA_KEY_MAP: Dict[str, str] = {
"TITLE": "title",
"ARTIST": "artist",
"ALBUM": "album",
"YEAR": "year",
"ALBUM_ARTIST": "album_artist",
"ALBUMARTIST": "album_artist",
"COMPOSER": "composer",
"GENRE": "genre",
"DATE": "date",
"PUBLISHER": "publisher",
"COMMENT": "comment",
"LANGUAGE": "language",
}
@dataclass
class ExtractedChapter:
title: str
text: str
@property
def characters(self) -> int:
return calculate_text_length(self.text)
@dataclass
class ExtractionResult:
chapters: List[ExtractedChapter]
metadata: Dict[str, str] = field(default_factory=dict)
cover_image: Optional[bytes] = None
cover_mime: Optional[str] = None
@property
def combined_text(self) -> str:
return "\n\n".join(chapter.text for chapter in self.chapters)
@property
def total_characters(self) -> int:
return sum(chapter.characters for chapter in self.chapters)
@dataclass
class MetadataSource:
title: Optional[str] = None
authors: List[str] = field(default_factory=list)
description: Optional[str] = None
publisher: Optional[str] = None
publication_year: Optional[str] = None
language: Optional[str] = None
@dataclass
class NavEntry:
src: str
title: str
doc_href: str
position: int
doc_order: int
def extract_from_path(path: Path) -> ExtractionResult:
suffix = path.suffix.lower()
if suffix == ".txt":
return _extract_plaintext(path)
if suffix == ".pdf":
return _extract_pdf(path)
if suffix in {".md", ".markdown"}:
return _extract_markdown(path)
if suffix == ".epub":
return _extract_epub(path)
raise ValueError(f"Unsupported input type: {suffix}")
def _extract_plaintext(path: Path) -> ExtractionResult:
encoding = detect_encoding(str(path))
raw = path.read_text(encoding=encoding, errors="replace")
return _extract_from_string(raw, default_title=path.stem)
def _extract_from_string(raw: str, default_title: str) -> ExtractionResult:
raw_metadata, body = _strip_metadata(raw)
chapters = _split_chapters(body, default_title)
normalized_tags = _normalize_metadata_keys(raw_metadata)
chapter_count = len(chapters)
artist_value = normalized_tags.get("artist")
authors = [name.strip() for name in artist_value.split(",") if name.strip()] if artist_value else []
metadata_source = MetadataSource(
title=normalized_tags.get("title") or default_title,
authors=authors,
publication_year=normalized_tags.get("year"),
)
metadata = _build_metadata_payload(metadata_source, chapter_count, "text", default_title)
metadata.update(normalized_tags)
if not chapters:
chapters = [ExtractedChapter(title=default_title, text="")]
return ExtractionResult(chapters=chapters, metadata=metadata)
def _strip_metadata(content: str) -> Tuple[Dict[str, str], str]:
metadata: Dict[str, str] = {}
def _replacer(match: re.Match) -> str:
key = match.group(1).strip().upper()
value = match.group(2).strip()
if value:
metadata[key] = value
return ""
stripped = METADATA_PATTERN.sub(_replacer, content)
return metadata, stripped
def _split_chapters(content: str, default_title: str) -> List[ExtractedChapter]:
matches = list(CHAPTER_PATTERN.finditer(content))
if not matches:
cleaned = clean_text(content)
return [ExtractedChapter(title=default_title, text=cleaned)]
chapters: List[ExtractedChapter] = []
last_index = 0
current_title = default_title
for match in matches:
segment = content[last_index:match.start()]
if segment.strip():
chapters.append(ExtractedChapter(title=current_title, text=clean_text(segment)))
current_title = match.group(1).strip() or default_title
last_index = match.end()
tail = content[last_index:]
if tail.strip():
chapters.append(ExtractedChapter(title=current_title, text=clean_text(tail)))
return chapters
def _normalize_metadata_keys(metadata: Dict[str, str]) -> Dict[str, str]:
normalized: Dict[str, str] = {}
for key, value in metadata.items():
if not value:
continue
mapped = METADATA_KEY_MAP.get(key.upper(), key.lower())
normalized[mapped] = value
return normalized
def _build_metadata_payload(
metadata_source: MetadataSource,
chapter_count: int,
file_type: str,
default_title: str,
) -> Dict[str, str]:
now_year = str(datetime.datetime.now().year)
title = metadata_source.title.strip() if metadata_source.title else default_title
if not title:
title = default_title
authors = [author for author in metadata_source.authors if author.strip()]
if not authors:
authors = ["Unknown"]
authors_text = ", ".join(authors)
if chapter_count <= 0:
chapter_count = 1
chapter_label = "Chapters" if file_type in {"epub", "markdown"} else "Pages"
metadata = {
"TITLE": title,
"ARTIST": authors_text,
"ALBUM": title,
"YEAR": metadata_source.publication_year or now_year,
"ALBUM_ARTIST": authors_text,
"COMPOSER": authors_text,
"GENRE": "Audiobook",
"CHAPTER_COUNT": str(chapter_count),
}
if metadata_source.publisher:
metadata["PUBLISHER"] = metadata_source.publisher
if metadata_source.description:
metadata["COMMENT"] = metadata_source.description
if metadata_source.language:
metadata["LANGUAGE"] = metadata_source.language
normalized = _normalize_metadata_keys(metadata)
# Ensure chapter_count survives normalization even if upstream metadata provided it
normalized.setdefault("chapter_count", str(chapter_count))
return normalized
def _extract_pdf(path: Path) -> ExtractionResult:
metadata_source = MetadataSource()
chapters: List[ExtractedChapter] = []
with fitz.open(str(path)) as document:
metadata_source = _collect_pdf_metadata(document)
pages = cast(Iterable[fitz.Page], document)
for index, page in enumerate(pages):
page_obj = cast(Any, page)
text = _clean_pdf_text(page_obj.get_text())
if not text:
continue
title = f"Page {index + 1}"
chapters.append(ExtractedChapter(title=title, text=text))
if not chapters:
chapters.append(ExtractedChapter(title=path.stem, text=""))
metadata = _build_metadata_payload(metadata_source, len(chapters), "pdf", path.stem)
return ExtractionResult(chapters=chapters, metadata=metadata)
def _collect_pdf_metadata(document: fitz.Document) -> MetadataSource:
metadata = MetadataSource()
info = document.metadata or {}
if info.get("title"):
metadata.title = info["title"]
if info.get("author"):
metadata.authors = [info["author"]]
if info.get("subject"):
metadata.description = info["subject"]
if info.get("keywords"):
keywords = info["keywords"]
if metadata.description:
metadata.description = f"{metadata.description}\n\nKeywords: {keywords}"
else:
metadata.description = f"Keywords: {keywords}"
if info.get("creator"):
metadata.publisher = info["creator"]
for key in ("creationDate", "modDate"):
value = info.get(key)
if not value:
continue
match = re.search(r"D:(\d{4})", value)
if match:
metadata.publication_year = match.group(1)
break
return metadata
def _clean_pdf_text(text: str) -> str:
cleaned = clean_text(text)
cleaned = re.sub(r"\[\s*\d+\s*\]", "", cleaned)
cleaned = re.sub(r"^\s*\d+\s*$", "", cleaned, flags=re.MULTILINE)
cleaned = re.sub(r"\s+\d+\s*$", "", cleaned, flags=re.MULTILINE)
cleaned = re.sub(r"\s+[-–—]\s*\d+\s*[-–—]?\s*$", "", cleaned, flags=re.MULTILINE)
return cleaned.strip()
def _extract_markdown(path: Path) -> ExtractionResult:
encoding = detect_encoding(str(path))
raw = path.read_text(encoding=encoding, errors="replace")
metadata_source, chapters = _parse_markdown(raw, path.stem)
if not chapters:
chapters = [ExtractedChapter(title=metadata_source.title or path.stem, text=clean_text(raw))]
metadata = _build_metadata_payload(metadata_source, len(chapters), "markdown", path.stem)
return ExtractionResult(chapters=chapters, metadata=metadata)
def _parse_markdown(raw: str, default_title: str) -> Tuple[MetadataSource, List[ExtractedChapter]]:
metadata = MetadataSource()
text = textwrap.dedent(raw)
frontmatter_match = re.match(r"^---\s*\n(.*?)\n---\s*\n", text, re.DOTALL)
if frontmatter_match:
frontmatter = frontmatter_match.group(1)
_parse_markdown_frontmatter(frontmatter, metadata)
text_body = text[frontmatter_match.end():]
else:
text_body = text
md = markdown.Markdown(extensions=["toc", "fenced_code"])
html = md.convert(text_body)
toc_tokens = getattr(md, "toc_tokens", None) or []
if not toc_tokens:
cleaned = clean_text(text_body)
title = metadata.title or default_title
chapters = [ExtractedChapter(title=title, text=cleaned)] if cleaned else []
return metadata, chapters
headers: List[dict] = []
def _flatten_tokens(tokens):
for token in tokens:
headers.append(token)
if token.get("children"):
_flatten_tokens(token["children"])
_flatten_tokens(toc_tokens)
header_positions: List[Tuple[str, int, str]] = []
for header in headers:
header_id = header.get("id")
if not header_id:
continue
id_pattern = f'id="{header_id}"'
pos = html.find(id_pattern)
if pos == -1:
continue
tag_start = html.rfind("<", 0, pos)
name = str(header.get("name", header_id))
header_positions.append((header_id, tag_start, name))
header_positions.sort(key=lambda item: item[1])
chapters: List[ExtractedChapter] = []
for index, (header_id, start, name) in enumerate(header_positions):
end = header_positions[index + 1][1] if index + 1 < len(header_positions) else len(html)
section_html = html[start:end]
section_soup = BeautifulSoup(section_html, "html.parser")
header_tag = section_soup.find(attrs={"id": header_id})
if header_tag:
header_tag.decompose()
section_text = clean_text(section_soup.get_text()).strip()
if not section_text:
continue
chapters.append(ExtractedChapter(title=name.strip(), text=section_text))
if not metadata.title:
first_h1 = next((header for header in headers if header.get("level") == 1 and header.get("name")), None)
if first_h1:
metadata.title = str(first_h1["name"])
return metadata, chapters
def _parse_markdown_frontmatter(frontmatter: str, metadata: MetadataSource) -> None:
title_match = re.search(r"^title:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE)
if title_match:
metadata.title = title_match.group(1).strip().strip('"\'')
author_match = re.search(r"^author:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE)
if author_match:
metadata.authors = [author_match.group(1).strip().strip('"\'')]
desc_match = re.search(r"^description:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE)
if desc_match:
metadata.description = desc_match.group(1).strip().strip('"\'')
date_match = re.search(r"^date:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE)
if date_match:
date_str = date_match.group(1).strip().strip('\"\'')
year_match = re.search(r"\b(19|20)\d{2}\b", date_str)
if year_match:
metadata.publication_year = year_match.group(0)
def _extract_epub(path: Path) -> ExtractionResult:
extractor = EpubExtractor(path)
return extractor.extract()
class EpubExtractor:
def __init__(self, path: Path) -> None:
self.path = path
self.book = epub.read_epub(str(path))
self.doc_content: Dict[str, str] = {}
self.spine_docs: List[str] = []
def extract(self) -> ExtractionResult:
metadata_source = self._collect_metadata()
try:
chapters = self._process_nav()
except Exception as exc:
logger.warning(
"EPUB navigation processing failed for %s: %s. Falling back to spine order.",
self.path.name,
exc,
exc_info=True,
)
chapters = self._process_spine_fallback()
if not chapters:
chapters = [ExtractedChapter(title=self.path.stem, text="")]
metadata = _build_metadata_payload(metadata_source, len(chapters), "epub", self.path.stem)
metadata.setdefault("chapter_count", str(len(chapters)))
cover_image, cover_mime = self._extract_cover()
return ExtractionResult(
chapters=chapters,
metadata=metadata,
cover_image=cover_image,
cover_mime=cover_mime,
)
def _collect_metadata(self) -> MetadataSource:
metadata = MetadataSource()
try:
title_items = self.book.get_metadata("DC", "title")
if title_items:
metadata.title = title_items[0][0]
except Exception as exc:
logger.debug("Failed to extract EPUB title metadata: %s", exc)
try:
author_items = self.book.get_metadata("DC", "creator")
if author_items:
metadata.authors = [author[0] for author in author_items if author and author[0]]
except Exception as exc:
logger.debug("Failed to extract EPUB author metadata: %s", exc)
try:
desc_items = self.book.get_metadata("DC", "description")
if desc_items:
metadata.description = desc_items[0][0]
except Exception as exc:
logger.debug("Failed to extract EPUB description metadata: %s", exc)
try:
publisher_items = self.book.get_metadata("DC", "publisher")
if publisher_items:
metadata.publisher = publisher_items[0][0]
except Exception as exc:
logger.debug("Failed to extract EPUB publisher metadata: %s", exc)
try:
date_items = self.book.get_metadata("DC", "date")
if date_items:
date_str = date_items[0][0]
year_match = re.search(r"\b(19|20)\d{2}\b", date_str)
metadata.publication_year = year_match.group(0) if year_match else date_str
except Exception as exc:
logger.debug("Failed to extract EPUB publication year metadata: %s", exc)
try:
language_items = self.book.get_metadata("DC", "language")
if language_items:
metadata.language = language_items[0][0]
except Exception as exc:
logger.debug("Failed to extract EPUB language metadata: %s", exc)
return metadata
def _extract_cover(self) -> Tuple[Optional[bytes], Optional[str]]:
try:
for item in self.book.get_items_of_type(ebooklib.ITEM_COVER):
data = item.get_content()
if data:
media_type = getattr(item, "media_type", None)
return data, media_type
except Exception as exc:
logger.debug("Failed to read dedicated EPUB cover image: %s", exc)
try:
for item in self.book.get_items_of_type(ebooklib.ITEM_IMAGE):
name = item.get_name().lower()
if "cover" not in name and "front" not in name:
continue
data = item.get_content()
if not data:
continue
media_type = getattr(item, "media_type", None)
if not media_type:
media_type = mimetypes.guess_type(name)[0]
return data, media_type
except Exception as exc:
logger.debug("Failed to locate fallback EPUB cover image: %s", exc)
return None, None
def _process_nav(self) -> List[ExtractedChapter]:
nav_item, nav_type = self._find_navigation_item()
if not nav_item or not nav_type:
raise ValueError("No navigation document found")
parser_type = "html.parser" if nav_type == "html" else "xml"
nav_content = nav_item.get_content().decode("utf-8", errors="ignore")
nav_soup = BeautifulSoup(nav_content, parser_type)
self.spine_docs = self._build_spine_docs()
doc_order = {href: index for index, href in enumerate(self.spine_docs)}
doc_order_decoded = {urllib.parse.unquote(href): index for href, index in doc_order.items()}
nav_targets = self._collect_nav_targets(nav_soup, nav_type)
self._cache_relevant_documents(doc_order, nav_targets)
ordered_entries: List[NavEntry] = []
if nav_type == "ncx":
nav_map = nav_soup.find("navMap")
if not nav_map:
raise ValueError("NCX navigation missing <navMap>")
for nav_point in nav_map.find_all("navPoint", recursive=False):
self._parse_ncx_navpoint(nav_point, ordered_entries, doc_order, doc_order_decoded)
else:
toc_nav = nav_soup.find("nav", attrs={"epub:type": "toc"})
if toc_nav is None:
for nav in nav_soup.find_all("nav"):
if nav.find("ol"):
toc_nav = nav
break
if toc_nav is None:
raise ValueError("NAV HTML missing TOC structure")
top_ol = toc_nav.find("ol", recursive=False)
if top_ol is None:
raise ValueError("TOC navigation missing <ol>")
for li in top_ol.find_all("li", recursive=False):
self._parse_html_nav_li(li, ordered_entries, doc_order, doc_order_decoded)
if not ordered_entries:
raise ValueError("No navigation entries found")
ordered_entries.sort(key=lambda entry: (entry.doc_order, entry.position))
chapters = self._slice_entries(ordered_entries)
self._append_prefix_content(ordered_entries, chapters)
return chapters
def _process_spine_fallback(self) -> List[ExtractedChapter]:
chapters: List[ExtractedChapter] = []
self.spine_docs = self._build_spine_docs()
self.doc_content = {}
for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT):
href = item.get_name()
if href not in self.spine_docs:
continue
try:
html_content = item.get_content().decode("utf-8", errors="ignore")
except Exception as exc:
logger.error("Error decoding EPUB document %s: %s", href, exc)
html_content = ""
self.doc_content[href] = html_content
for index, doc_href in enumerate(self.spine_docs):
html_content = self.doc_content.get(doc_href, "")
if not html_content:
continue
text = self._html_to_text(html_content)
if not text:
continue
title = self._resolve_document_title(html_content, fallback=f"Untitled Chapter {index + 1}")
chapters.append(ExtractedChapter(title=title, text=text))
return chapters
def _find_navigation_item(self) -> Tuple[Optional[epub.EpubItem], Optional[str]]:
nav_item: Optional[epub.EpubItem] = None
nav_type: Optional[str] = None
nav_items = list(self.book.get_items_of_type(ebooklib.ITEM_NAVIGATION))
if nav_items:
preferred = next(
(
item
for item in nav_items
if "nav" in item.get_name().lower() and item.get_name().lower().endswith((".xhtml", ".html"))
),
None,
)
if preferred:
nav_item = preferred
nav_type = "html"
else:
html_nav = next(
(
item
for item in nav_items
if item.get_name().lower().endswith((".xhtml", ".html"))
),
None,
)
if html_nav:
nav_item = html_nav
nav_type = "html"
if not nav_item and nav_items:
ncx_candidate = next(
(item for item in nav_items if item.get_name().lower().endswith(".ncx")),
None,
)
if ncx_candidate:
nav_item = ncx_candidate
nav_type = "ncx"
if not nav_item:
ncx_constant = getattr(epub, "ITEM_NCX", None)
if ncx_constant is not None:
ncx_items = list(self.book.get_items_of_type(ncx_constant))
if ncx_items:
nav_item = ncx_items[0]
nav_type = "ncx"
if not nav_item:
for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT):
try:
html_content = item.get_content().decode("utf-8", errors="ignore")
except Exception:
continue
if "<nav" in html_content and 'epub:type="toc"' in html_content:
soup = BeautifulSoup(html_content, "html.parser")
if soup.find("nav", attrs={"epub:type": "toc"}):
nav_item = item
nav_type = "html"
break
return nav_item, nav_type
def _build_spine_docs(self) -> List[str]:
docs: List[str] = []
for spine_entry in self.book.spine:
item_id = spine_entry[0]
item = self.book.get_item_with_id(item_id)
if item:
docs.append(item.get_name())
return docs
def _collect_nav_targets(self, nav_soup: BeautifulSoup, nav_type: str) -> List[str]:
targets: List[str] = []
if nav_type == "ncx":
for content_node in nav_soup.find_all("content"):
src = content_node.get("src")
if src:
src_value = str(src)
targets.append(src_value.split("#", 1)[0])
else:
for link in nav_soup.find_all("a"):
href = link.get("href")
if href:
href_value = str(href)
targets.append(href_value.split("#", 1)[0])
return targets
def _cache_relevant_documents(self, doc_order: Dict[str, int], nav_targets: List[str]) -> None:
needed: set[str] = set(doc_order.keys())
for target in nav_targets:
needed.add(target)
needed.add(urllib.parse.unquote(target))
self.doc_content = {}
for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT):
href = item.get_name()
if href not in needed and urllib.parse.unquote(href) not in needed:
continue
try:
html_content = item.get_content().decode("utf-8", errors="ignore")
except Exception as exc:
logger.error("Error decoding EPUB document %s: %s", href, exc)
html_content = ""
self.doc_content[href] = html_content
def _parse_ncx_navpoint(
self,
nav_point,
ordered_entries: List[NavEntry],
doc_order: Dict[str, int],
doc_order_decoded: Dict[str, int],
) -> None:
nav_label = nav_point.find("navLabel")
content = nav_point.find("content")
title = (
nav_label.find("text").get_text(strip=True)
if nav_label and nav_label.find("text")
else "Untitled Section"
)
src = content.get("src") if content and content.has_attr("src") else None
if src:
base_href, fragment = src.split("#", 1) if "#" in src else (src, None)
doc_key, doc_idx = self._find_doc_key(base_href, doc_order, doc_order_decoded)
if doc_key is not None and doc_idx is not None:
position = self._find_position_robust(doc_key, fragment)
ordered_entries.append(
NavEntry(
src=src,
title=title,
doc_href=doc_key,
position=position,
doc_order=doc_idx,
)
)
else:
logger.warning(
"Navigation entry '%s' points to '%s', which is not in the spine.",
title,
base_href,
)
for child_navpoint in nav_point.find_all("navPoint", recursive=False):
self._parse_ncx_navpoint(child_navpoint, ordered_entries, doc_order, doc_order_decoded)
def _parse_html_nav_li(
self,
li_element,
ordered_entries: List[NavEntry],
doc_order: Dict[str, int],
doc_order_decoded: Dict[str, int],
) -> None:
link = li_element.find("a", recursive=False)
span_text = li_element.find("span", recursive=False)
title = "Untitled Section"
if link and link.has_attr("href"):
src = link["href"]
title = link.get_text(strip=True) or title
else:
src = None
if span_text:
title = span_text.get_text(strip=True) or title
else:
text = "".join(t for t in li_element.stripped_strings)
if text:
title = text
title = title.strip() or "Untitled Section"
if src:
base_href, fragment = src.split("#", 1) if "#" in src else (src, None)
doc_key, doc_idx = self._find_doc_key(base_href, doc_order, doc_order_decoded)
if doc_key is not None and doc_idx is not None:
position = self._find_position_robust(doc_key, fragment)
ordered_entries.append(
NavEntry(
src=src,
title=title,
doc_href=doc_key,
position=position,
doc_order=doc_idx,
)
)
else:
logger.warning(
"Navigation entry '%s' points to '%s', which is not in the spine.",
title,
base_href,
)
for child_ol in li_element.find_all("ol", recursive=False):
for child_li in child_ol.find_all("li", recursive=False):
self._parse_html_nav_li(child_li, ordered_entries, doc_order, doc_order_decoded)
def _find_doc_key(
self,
base_href: str,
doc_order: Dict[str, int],
doc_order_decoded: Dict[str, int],
) -> Tuple[Optional[str], Optional[int]]:
candidates = {base_href, urllib.parse.unquote(base_href)}
base_name = urllib.parse.unquote(base_href).split("/")[-1].lower()
for key in list(doc_order.keys()) + list(doc_order_decoded.keys()):
if key.split("/")[-1].lower() == base_name:
candidates.add(key)
for candidate in candidates:
if candidate in doc_order:
return candidate, doc_order[candidate]
if candidate in doc_order_decoded:
return candidate, doc_order_decoded[candidate]
return None, None
def _find_position_robust(self, doc_href: str, fragment_id: Optional[str]) -> int:
if doc_href not in self.doc_content:
logger.warning("Document '%s' not found in cached EPUB content.", doc_href)
return 0
html_content = self.doc_content[doc_href]
if not fragment_id:
return 0
try:
temp_soup = BeautifulSoup(f"<div>{html_content}</div>", "html.parser")
target_element = temp_soup.find(id=fragment_id)
if target_element:
tag_str = str(target_element)
pos = html_content.find(tag_str[: min(len(tag_str), 200)])
if pos != -1:
return pos
except Exception:
logger.debug("BeautifulSoup failed to locate id '%s' in %s", fragment_id, doc_href)
safe_fragment_id = re.escape(fragment_id)
id_name_pattern = re.compile(
f"<[^>]+(?:id|name)\\s*=\\s*[\"']{safe_fragment_id}[\"']",
re.IGNORECASE,
)
match = id_name_pattern.search(html_content)
if match:
return match.start()
id_pos = html_content.find(f'id="{fragment_id}"')
name_pos = html_content.find(f'name="{fragment_id}"')
candidates = [pos for pos in (id_pos, name_pos) if pos != -1]
if candidates:
pos = min(candidates)
tag_start = html_content.rfind("<", 0, pos)
return tag_start if tag_start != -1 else pos
logger.warning("Anchor '%s' not found in %s. Defaulting to start.", fragment_id, doc_href)
return 0
def _slice_entries(self, ordered_entries: List[NavEntry]) -> List[ExtractedChapter]:
chapters: List[ExtractedChapter] = []
for index, entry in enumerate(ordered_entries):
next_entry = ordered_entries[index + 1] if index + 1 < len(ordered_entries) else None
slice_html = self._slice_entry(entry, next_entry)
text = self._html_to_text(slice_html)
if not text:
continue
title = entry.title or "Untitled Section"
chapters.append(ExtractedChapter(title=title, text=text))
return chapters
def _slice_entry(
self,
current_entry: NavEntry,
next_entry: Optional[NavEntry],
) -> str:
current_doc = current_entry.doc_href
current_pos = current_entry.position
current_html = self.doc_content.get(current_doc, "")
if not current_html:
return ""
if next_entry and next_entry.doc_href == current_doc:
return current_html[current_pos : next_entry.position]
slice_html = current_html[current_pos:]
if next_entry:
docs_between = self._docs_between(current_doc, next_entry.doc_href)
for doc_href in docs_between:
slice_html += self.doc_content.get(doc_href, "")
next_doc_html = self.doc_content.get(next_entry.doc_href, "")
slice_html += next_doc_html[: next_entry.position]
else:
for doc_href in self._docs_between(current_doc, None):
slice_html += self.doc_content.get(doc_href, "")
if not slice_html.strip():
logger.warning(
"No content found for navigation source '%s'. Using full document fallback.",
current_entry.src,
)
return current_html
return slice_html
def _docs_between(self, current_doc: str, next_doc: Optional[str]) -> List[str]:
docs: List[str] = []
try:
current_idx = self.spine_docs.index(current_doc)
except ValueError:
return docs
if next_doc is None:
docs.extend(self.spine_docs[current_idx + 1 :])
return docs
try:
next_idx = self.spine_docs.index(next_doc)
except ValueError:
return docs
if current_idx < next_idx:
docs.extend(self.spine_docs[current_idx + 1 : next_idx])
elif current_idx > next_idx:
docs.extend(self.spine_docs[current_idx + 1 :])
docs.extend(self.spine_docs[:next_idx])
return docs
def _append_prefix_content(
self,
ordered_entries: List[NavEntry],
chapters: List[ExtractedChapter],
) -> None:
if not ordered_entries:
return
first_entry = ordered_entries[0]
first_doc = first_entry.doc_href
first_pos = first_entry.position
if first_pos <= 0:
return
prefix_html = ""
try:
first_idx = self.spine_docs.index(first_doc)
except ValueError:
first_idx = -1
if first_idx > 0:
for doc_href in self.spine_docs[:first_idx]:
prefix_html += self.doc_content.get(doc_href, "")
prefix_html += self.doc_content.get(first_doc, "")[:first_pos]
prefix_text = self._html_to_text(prefix_html)
if prefix_text and (not chapters or prefix_text != chapters[0].text):
chapters.insert(0, ExtractedChapter(title="Introduction", text=prefix_text))
def _html_to_text(self, html: str) -> str:
if not html:
return ""
soup = BeautifulSoup(html, "html.parser")
for tag in soup.find_all(["p", "div"]):
tag.append("\n\n")
for ol in soup.find_all("ol"):
start_attr = ol.get("start")
try:
start = int(str(start_attr)) if start_attr is not None else 1
except (TypeError, ValueError):
start = 1
for idx, li in enumerate(ol.find_all("li", recursive=False)):
number_text = f"{start + idx}) "
existing = li.string
if isinstance(existing, NavigableString):
existing.replace_with(NavigableString(number_text + str(existing)))
else:
li.insert(0, NavigableString(number_text))
for tag in soup.find_all(["sup", "sub"]):
tag.decompose()
text = clean_text(soup.get_text())
return text.strip()
def _resolve_document_title(self, html_content: str, fallback: str) -> str:
soup = BeautifulSoup(html_content, "html.parser")
if soup.title and soup.title.string:
return soup.title.string.strip()
for heading_tag in ("h1", "h2", "h3"):
heading = soup.find(heading_tag)
if heading and heading.get_text(strip=True):
return heading.get_text(strip=True)
return fallback