import io
import re
import sys
import html
import zipfile
import posixpath
import urllib.parse
from pathlib import Path
import xml.etree.ElementTree as ET
from html.parser import HTMLParser
from typing import Tuple, List, Dict
def count_words(text: str) -> int:
"""
Counts words in text across multiple languages, ignoring isolated punctuation.
"""
if not text:
return 0
return len(re.findall(r"\b[\w]+(?:['’][\w]+)?\b", text))
class HTMLTextExtractor(HTMLParser):
"""
Strips HTML tags cleanly while preserving paragraph and heading breaks with newlines.
Also extracts the first heading and/or
tag for chapter naming.
"""
def __init__(self):
super().__init__()
self.text_parts: List[str] = []
self.ignore_tags = {"style", "script", "noscript"}
self.ignore_depth = 0
self.heading: str | None = None
self.in_heading = False
self.heading_parts: List[str] = []
self.title: str | None = None
self.in_title = False
self.title_parts: List[str] = []
def handle_starttag(self, tag: str, attrs):
t = tag.lower()
if t in self.ignore_tags:
self.ignore_depth += 1
elif t == "title":
self.in_title = True
self.title_parts = []
elif t in {"h1", "h2", "h3", "h4", "h5", "h6"}:
self.text_parts.append("\n\n")
if not self.heading:
self.in_heading = True
self.heading_parts = []
elif t in {"p", "div", "section", "article", "blockquote", "header", "footer"}:
self.text_parts.append("\n\n")
elif t in {"br", "hr", "li", "tr"}:
self.text_parts.append("\n")
def handle_endtag(self, tag: str):
t = tag.lower()
if t in self.ignore_tags:
self.ignore_depth = max(0, self.ignore_depth - 1)
elif t == "title":
self.in_title = False
self.title = "".join(self.title_parts).strip()
elif t in {"h1", "h2", "h3", "h4", "h5", "h6"}:
self.text_parts.append("\n\n")
if self.in_heading:
self.in_heading = False
if not self.heading:
self.heading = "".join(self.heading_parts).strip()
elif t in {"p", "div", "section", "article", "blockquote", "header", "footer"}:
self.text_parts.append("\n\n")
elif t in {"li", "tr"}:
self.text_parts.append("\n")
def handle_data(self, data: str):
if self.in_title:
self.title_parts.append(data)
if self.ignore_depth == 0 and not self.in_title:
# Replace non-breaking spaces with standard space
cleaned_data = data.replace("\xa0", " ")
self.text_parts.append(cleaned_data)
if self.in_heading:
self.heading_parts.append(cleaned_data)
def get_text(self) -> str:
raw = "".join(self.text_parts)
lines = [line.strip() for line in raw.split("\n")]
clean = "\n".join(lines)
return re.sub(r"\n{3,}", "\n\n", clean).strip()
def parse_epub(file_bytes: bytes) -> Tuple[str, str, List[Dict[str, str]]]:
"""
Parses an EPUB file archive and extracts:
- title: book title
- author: creator/author
- chapters: list of {"title": str, "content": str}
"""
try:
z = zipfile.ZipFile(io.BytesIO(file_bytes))
except Exception as e:
raise ValueError(f"Invalid EPUB archive: {e}")
# 1. Locate root OPF file from META-INF/container.xml
opf_path = None
try:
container_data = z.read("META-INF/container.xml")
root = ET.fromstring(container_data)
rootfile = root.find(".//{*}rootfile")
if rootfile is not None:
opf_path = rootfile.attrib.get("full-path")
except Exception:
pass
if not opf_path:
for name in z.namelist():
if name.lower().endswith(".opf"):
opf_path = name
break
if not opf_path:
raise ValueError("Invalid EPUB: no OPF file found")
opf_path = opf_path.replace("\\", "/").lstrip("/")
opf_dir = posixpath.dirname(opf_path)
try:
opf_content = z.read(opf_path)
except KeyError:
# Try lowercase matching
name_map = {n.lower(): n for n in z.namelist()}
if opf_path.lower() in name_map:
opf_content = z.read(name_map[opf_path.lower()])
else:
raise ValueError(f"OPF file not found in archive: {opf_path}")
# 2. Extract metadata (title, author)
title = "Untitled"
author = ""
opf_tree = None
try:
opf_tree = ET.fromstring(opf_content)
title_el = opf_tree.find(".//{*}title")
if title_el is not None and title_el.text:
title = title_el.text.strip()
creator_el = opf_tree.find(".//{*}creator")
if creator_el is not None and creator_el.text:
author = creator_el.text.strip()
except Exception:
opf_str = opf_content.decode("utf-8", errors="ignore")
tm = re.search(r"<[^:]*:?title[^>]*>(.*?)[^:]*:?title>", opf_str, re.I | re.S)
if tm:
title = re.sub(r"<[^>]+>", "", tm.group(1)).strip()
cm = re.search(r"<[^:]*:?creator[^>]*>(.*?)[^:]*:?creator>", opf_str, re.I | re.S)
if cm:
author = re.sub(r"<[^>]+>", "", cm.group(1)).strip()
# 3. Parse Manifest (id -> href, media-type)
manifest: Dict[str, Dict[str, str]] = {}
if opf_tree is not None:
for item in opf_tree.findall(".//{*}item"):
iid = item.attrib.get("id")
href = item.attrib.get("href")
media_type = item.attrib.get("media-type", "").lower()
if iid and href:
manifest[iid] = {"href": href, "media_type": media_type}
else:
opf_str = opf_content.decode("utf-8", errors="ignore")
for m in re.finditer(r'- ]*id=["\']([^"\']+)["\'][^>]*href=["\']([^"\']+)["\']', opf_str, re.I):
manifest[m.group(1)] = {"href": m.group(2), "media_type": ""}
# 4. Spine items in reading order
spine_ids: List[str] = []
if opf_tree is not None:
for itemref in opf_tree.findall(".//{*}itemref"):
idref = itemref.attrib.get("idref")
if idref:
spine_ids.append(idref)
else:
opf_str = opf_content.decode("utf-8", errors="ignore")
for m in re.finditer(r']*idref=["\']([^"\']+)["\']', opf_str, re.I):
spine_ids.append(m.group(1))
# Fallback if spine is empty: find all html/xhtml files in manifest
candidate_paths: List[str] = []
if spine_ids:
for idref in spine_ids:
item = manifest.get(idref)
if item:
candidate_paths.append(item["href"])
else:
for item in manifest.values():
href = item["href"]
if href.lower().endswith((".xhtml", ".html", ".htm")):
candidate_paths.append(href)
namelist_map = {name.lower(): name for name in z.namelist()}
chapters: List[Dict[str, str]] = []
chapter_num = 1
for href in candidate_paths:
unquoted = urllib.parse.unquote(href)
rel_path = posixpath.normpath(posixpath.join(opf_dir, unquoted)) if opf_dir else unquoted
rel_path = rel_path.replace("\\", "/").lstrip("/")
actual_path = None
if rel_path in z.namelist():
actual_path = rel_path
elif rel_path.lower() in namelist_map:
actual_path = namelist_map[rel_path.lower()]
if not actual_path:
continue
try:
raw_bytes = z.read(actual_path)
try:
html_str = raw_bytes.decode("utf-8")
except UnicodeDecodeError:
html_str = raw_bytes.decode("latin-1", errors="replace")
except Exception:
continue
parser = HTMLTextExtractor()
parser.feed(html_str)
text = parser.get_text()
# Skip chapters with little or no readable text
if count_words(text) < 1:
continue
ch_title = parser.heading or parser.title or f"Chapter {chapter_num}"
# If ch_title is identical to book title or very long, format nicely
if len(ch_title) > 120:
ch_title = f"Chapter {chapter_num}"
chapters.append({"title": ch_title, "content": text})
chapter_num += 1
if not chapters:
chapters = [{"title": title if title != "Untitled" else "Chapter 1", "content": ""}]
return title, author, chapters
def split_long_text(text: str, target_words: int = 3000) -> List[str]:
"""
Splits text into chunks of approximately target_words on paragraph boundaries.
"""
paragraphs = [p.strip() for p in re.split(r"\n{2,}", text) if p.strip()]
if not paragraphs:
return [text.strip()] if text.strip() else []
chunks: List[str] = []
current_paras: List[str] = []
current_count = 0
for p in paragraphs:
w_count = count_words(p)
# If single paragraph is massive (> target_words * 1.5), split by words
if w_count > target_words * 1.5:
if current_paras:
chunks.append("\n\n".join(current_paras))
current_paras = []
current_count = 0
words = p.split()
for i in range(0, len(words), target_words):
chunk = " ".join(words[i : i + target_words])
if chunk.strip():
chunks.append(chunk)
continue
if current_count + w_count > target_words and current_count > 0:
chunks.append("\n\n".join(current_paras))
current_paras = [p]
current_count = w_count
else:
current_paras.append(p)
current_count += w_count
if current_paras:
chunks.append("\n\n".join(current_paras))
return chunks
def parse_text(content: str, title: str = "Untitled", author: str = "") -> Tuple[str, str, List[Dict[str, str]]]:
"""
Parses plain text or Markdown into readable chapters.
Splits by explicit chapter markers (e.g. 'Chapter X', '# Chapter') or chunks of ~3000 words.
"""
content = content.replace("\r\n", "\n").replace("\r", "\n").strip()
if not content:
return title, author, [{"title": title if title != "Untitled" else "Chapter 1", "content": ""}]
lines = content.split("\n")
first_line = lines[0].strip()
# Extract title from first line or # Heading / if default or generic README
if title == "Untitled" or title.lower().startswith("readme"):
if first_line.startswith("# "):
title = first_line.lstrip("# ").strip()
if len(lines) > 1:
content = "\n".join(lines[1:]).strip()
elif first_line.lower().startswith("title:"):
title = first_line.split(":", 1)[1].strip()
if len(lines) > 1:
content = "\n".join(lines[1:]).strip()
else:
h1_match = re.search(r"
]*>(.*?)
", content[:1500], re.IGNORECASE)
if h1_match:
detected = re.sub(r"<[^>]+>", "", h1_match.group(1)).strip()
if detected:
title = detected
# Extract author from next line if missing
if not author:
lines = content.split("\n")
if lines and lines[0].lower().startswith(("author:", "by:")):
author = lines[0].split(":", 1)[1].strip()
if len(lines) > 1:
content = "\n".join(lines[1:]).strip()
def detect_text_headings(content: str) -> List[Dict]:
"""
Detects section and chapter headings with high accuracy across Markdown,
numbered sections, explicit keywords, bold titles, and Setext styles.
"""
lines = content.split("\n")
line_starts = []
curr = 0
for l in lines:
line_starts.append(curr)
curr += len(l) + 1
kw_regex = re.compile(
r"^[ \t]*(?:\*\*)?(?:Chapter|CHAPTER|Глава|ГЛАВА|Part|PART|Часть|ЧАСТЬ|Section|SECTION|Раздел|РАЗДЕЛ|Module|MODULE|Модуль|МОДУЛЬ|Unit|UNIT|Lesson|LESSON|Урок|УРОК|Topic|TOPIC|Тема|ТЕМА|Appendix|APPENDIX|Приложение|ПРИЛОЖЕНИЕ|Act|ACT|Scene|SCENE)[ \t]+(?:[0-9IVXLCDMivxlcdm\w\.\-]+)(?:[ \t]*[:\-–—\.][ \t]*[^\n\r*]+|[ \t]+[^\n\r*]+)?(?:\*\*)?[ \t]*$"
)
headings = []
for i, line in enumerate(lines):
trimmed = line.strip()
if not trimmed:
continue
prev_empty = (i == 0) or (not lines[i - 1].strip())
next_empty = (i == len(lines) - 1) or (not lines[i + 1].strip())
# 1. Markdown # heading (# to #####)
m_md = re.match(r"^#{1,5}[ \t]+(.+)$", trimmed)
if m_md:
clean_title = m_md.group(1).strip("#").strip()
headings.append({
"line_idx": i,
"start": line_starts[i],
"title": clean_title,
"type": "markdown",
})
continue
# 2. Setext heading: Title followed by === or ---
if i + 1 < len(lines):
next_line = lines[i + 1].strip()
if prev_empty and len(trimmed) <= 80 and not trimmed.startswith(("-", "*", "#", "`")):
if re.match(r"^={3,}$", next_line) or re.match(r"^-{3,}$", next_line):
headings.append({
"line_idx": i,
"start": line_starts[i],
"title": trimmed,
"type": "setext",
})
continue
# 3. Explicit keywords (Chapter 1, Глава 2, Module 3, etc.)
if kw_regex.match(trimmed):
clean_title = trimmed.strip("*").strip()
headings.append({
"line_idx": i,
"start": line_starts[i],
"title": clean_title,
"type": "keyword",
})
continue
# 4. Bold heading on standalone line: **Title**
bold_m = re.match(r"^\*\*([A-ZА-ЯЁ0-9][^\n\r*]{2,70})\*\*$", trimmed)
if bold_m and prev_empty:
headings.append({
"line_idx": i,
"start": line_starts[i],
"title": bold_m.group(1).strip(),
"type": "bold",
})
continue
# 5. Numbered sections: e.g. 1. Title, 1.1 Title, 1. Title.
num_m = re.match(r"^([0-9]{1,2}(?:\.[0-9]{1,2})*\.?|[0-9]{1,2}\))[ \t]+([A-ZА-ЯЁ][^\n\r]{2,70})$", trimmed)
if num_m and prev_empty:
is_list = False
if i + 1 < len(lines):
next_t = lines[i + 1].strip()
if re.match(r"^[0-9]{1,2}(?:\.[0-9]{1,2})*\.?[ \t]+", next_t):
is_list = True
if not is_list and not trimmed.endswith((",", ";")):
headings.append({
"line_idx": i,
"start": line_starts[i],
"title": trimmed,
"type": "numbered",
})
continue
# 6. Standalone ALL CAPS line (e.g. INTRODUCTION TO NETWORKS)
next_is_allcaps = (i + 1 < len(lines)) and bool(lines[i + 1].strip()) and lines[i + 1].strip().isupper()
if prev_empty and not next_is_allcaps and 4 <= len(trimmed) <= 60 and not trimmed.endswith((",", ";")):
if re.match(r"^[A-ZА-ЯЁ0-9\s:\-–—\.]+$", trimmed):
letters = re.findall(r"[A-ZА-ЯЁ]", trimmed)
words = trimmed.split()
if len(letters) >= 4 and (len(words) >= 2 or len(trimmed) >= 6):
headings.append({
"line_idx": i,
"start": line_starts[i],
"title": trimmed,
"type": "allcaps",
})
continue
return headings
def parse_text(content: str, title: str = "Untitled", author: str = "") -> Tuple[str, str, List[Dict[str, str]]]:
"""
Parses plain text or Markdown into readable chapters.
Splits by intelligent heading detection (Markdown, numbered sections, keywords)
or chunks of ~1000 words.
"""
content = content.replace("\r\n", "\n").replace("\r", "\n").strip()
if not content:
return title, author, [{"title": title if title != "Untitled" else "Chapter 1", "content": ""}]
lines = content.split("\n")
first_line = lines[0].strip()
# Extract title from first line or # Heading / if default or generic README
if title == "Untitled" or title.lower().startswith("readme"):
if first_line.startswith("# "):
title = first_line.lstrip("# ").strip()
if len(lines) > 1:
content = "\n".join(lines[1:]).strip()
elif first_line.lower().startswith("title:"):
title = first_line.split(":", 1)[1].strip()
if len(lines) > 1:
content = "\n".join(lines[1:]).strip()
else:
h1_match = re.search(r"]*>(.*?)
", content[:1500], re.IGNORECASE)
if h1_match:
detected = re.sub(r"<[^>]+>", "", h1_match.group(1)).strip()
if detected:
title = detected
# Extract author from next line if missing
if not author:
lines = content.split("\n")
if lines and lines[0].lower().startswith(("author:", "by:")):
author = lines[0].split(":", 1)[1].strip()
if len(lines) > 1:
content = "\n".join(lines[1:]).strip()
detected_headings = detect_text_headings(content)
chapters: List[Dict[str, str]] = []
if detected_headings:
# Pre-chapter intro text
intro = content[: detected_headings[0]["start"]].strip()
if intro and count_words(intro) > 10:
is_ru = any(ord(c) > 127 for c in intro[:200])
chapters.append({"title": "Введение" if is_ru else "Introduction", "content": intro})
for i, h in enumerate(detected_headings):
start = h["start"]
end = detected_headings[i + 1]["start"] if i + 1 < len(detected_headings) else len(content)
body = content[start:end].strip()
if body:
chapters.append({"title": h["title"], "content": body})
if not chapters:
# No headings detected: split by ~1000 words chunks if long enough
total_w = count_words(content)
if total_w > 1200:
chunks = split_long_text(content, target_words=1000)
is_ru = any(ord(c) > 127 for c in content[:200])
prefix = "Часть" if is_ru else "Chapter"
for idx, chunk in enumerate(chunks, start=1):
chapters.append({"title": f"{prefix} {idx}", "content": chunk})
else:
chapters = [{"title": title if title != "Untitled" else "Chapter 1", "content": content}]
return title, author, chapters
# ---------------------------------------------------------------------------
# PDF Parsing & Chapter Extraction (pypdf, Bookmarks/Outline, Cisco CLI Blocks)
# ---------------------------------------------------------------------------
CLI_PROMPT_RE = re.compile(
r"^(?:[A-Za-z0-9_.\-]+(?:\([a-zA-Z0-9_\-\.\s]+\))?[>#]|"
r"[A-Za-z0-9_.\-]+@[A-Za-z0-9_.\-]+:[~/\w\.\-]*[$#]|"
r"(?:[A-Z]):\\[^>\n\r]*>|"
r"PS\s+[A-Z]:\\[^>\n\r]*>)"
)
def extract_outline_entries(outline, reader) -> List[Tuple[str, int]]:
"""
Recursively extracts bookmarks / outline entries from PDF respecting hierarchy depth.
For books with deep trees (Parts -> Chapters -> Sections -> Subsections),
prioritizes major structural headings and prevents fragmenting chapters into
hundreds of tiny 1-page snippets.
"""
raw_entries: List[Tuple[str, int, int]] = []
def _traverse(items, depth=0):
for item in items:
if isinstance(item, list):
_traverse(item, depth + 1)
else:
try:
title = getattr(item, "title", None) or (item.get("/Title") if hasattr(item, "get") else None)
page_num = reader.get_destination_page_number(item)
if title and page_num is not None:
clean_title = str(title).strip()
if clean_title:
raw_entries.append((clean_title, int(page_num), depth))
except Exception:
continue
if outline:
_traverse(outline, 0)
if not raw_entries:
return []
# If total entries is reasonable (<= 50), return all
if len(raw_entries) <= 50:
return [(t, p) for t, p, _ in raw_entries]
# Check depth distribution
d0_entries = [e for e in raw_entries if e[2] == 0]
d1_entries = [e for e in raw_entries if e[2] <= 1]
# If top-level (depth 0) already has >= 10 chapters, use depth 0
if len(d0_entries) >= 10:
return [(t, p) for t, p, _ in d0_entries]
# Otherwise, if depth <= 1 gives a good number of chapters (10 to 80), use depth <= 1
if len(d1_entries) <= 80:
return [(t, p) for t, p, _ in d1_entries]
# If still too many entries (> 80), filter by major chapter keywords
major_re = re.compile(
r"^(cover|title\s+page|copyright|contents|introduction|your\s+study\s+plan|"
r"part\s+[0-9ivxlcdm]+|chapter\s+\d+|appendix\s+[a-z]|glossary|index)",
re.IGNORECASE
)
major_entries = [e for e in d1_entries if major_re.match(e[0])]
if len(major_entries) >= 10:
return [(t, p) for t, p, _ in major_entries]
return [(t, p) for t, p, _ in d1_entries[:80]]
def clean_page_lines(raw_pages_text: List[str]) -> List[str]:
"""
Filters running headers, footers, isolated page numbers, and copyright noise.
"""
if not raw_pages_text:
return []
num_pages = len(raw_pages_text)
top_candidates: Dict[str, int] = {}
bottom_candidates: Dict[str, int] = {}
for pt in raw_pages_text:
lines = [l.strip() for l in pt.split("\n") if l.strip()]
if not lines:
continue
if len(lines) >= 1:
top_candidates[lines[0]] = top_candidates.get(lines[0], 0) + 1
bottom_candidates[lines[-1]] = bottom_candidates.get(lines[-1], 0) + 1
# Lines repeated on >= 20% of pages (min 3 pages) are running headers/footers
threshold = max(3, int(num_pages * 0.20))
common_headers = {l for l, c in top_candidates.items() if c >= threshold}
common_footers = {l for l, c in bottom_candidates.items() if c >= threshold}
page_num_re = re.compile(r"^(?:page\s+)?\d+(?:\s*(?:of|/)\s*\d+)?$", re.IGNORECASE)
url_re = re.compile(r"^(?:https?://|www\.)\S+$", re.IGNORECASE)
copyright_re = re.compile(r"^(?:©|\(c\)|copyright)\s*\d{4}.*$", re.IGNORECASE)
cleaned_pages: List[str] = []
for pt in raw_pages_text:
lines = pt.split("\n")
filtered: List[str] = []
n_lines = len(lines)
for idx, l in enumerate(lines):
stripped = l.strip()
if not stripped:
filtered.append("")
continue
is_top = (idx <= 2)
is_bottom = (idx >= n_lines - 3)
if (is_top or is_bottom) and page_num_re.match(stripped):
continue
if is_top and stripped in common_headers:
continue
if is_bottom and (stripped in common_footers or url_re.match(stripped) or copyright_re.match(stripped)):
continue
filtered.append(l)
cleaned_pages.append("\n".join(filtered))
return cleaned_pages
def format_cli_blocks(text: str) -> str:
"""
Detects Cisco IOS / CLI terminal sessions in text and wraps them
in Markdown fenced code blocks (```text ... ```) for syntax highlighting and copying.
"""
if not text:
return ""
lines = text.split("\n")
out: List[str] = []
in_cli = False
cli_lines: List[str] = []
def is_likely_cli_line(line: str) -> bool:
s = line.strip()
if not s:
return True
if CLI_PROMPT_RE.match(s):
return True
if s.startswith(("%", "!", "--", ">>", "Enter configuration", "Building configuration", "Current configuration")):
return True
if len(re.findall(r"\S\s{2,}\S", line)) >= 2:
return True
if re.match(r"^(?:show|ip|no|interface|switchport|vlan|router|network|exit|end|enable|disable|configure|ping|traceroute|debug)\s+", s, re.IGNORECASE):
return True
return False
for line in lines:
s = line.strip()
if not in_cli:
if CLI_PROMPT_RE.match(s):
in_cli = True
cli_lines = [line]
else:
out.append(line)
else:
if is_likely_cli_line(line):
cli_lines.append(line)
else:
while cli_lines and not cli_lines[-1].strip():
cli_lines.pop()
if cli_lines:
out.append("```text")
out.extend(cli_lines)
out.append("```")
in_cli = False
cli_lines = []
out.append(line)
if in_cli and cli_lines:
while cli_lines and not cli_lines[-1].strip():
cli_lines.pop()
if cli_lines:
out.append("```text")
out.extend(cli_lines)
out.append("```")
return "\n".join(out)
def parse_pdf(file_bytes: bytes, filename: str = "") -> Tuple[str, str, List[Dict[str, str]]]:
"""
Parses a PDF file into readable chapters:
- Extracts title and author from metadata or filename.
- Enforces Scanned PDF Guard (raises ValueError if total_words < 100).
- Cleans repetitive headers, footers, and page numbers.
- Wraps Cisco IOS / CLI terminal blocks in Markdown code blocks.
- Splits chapters by PDF outline / bookmarks (Cisco Press, O'Reilly).
- Fallback to heading regex or ~10-15 page chunks if no bookmarks exist.
"""
try:
import pypdf
except ImportError:
vendor_dir = Path(__file__).resolve().parent / "vendor"
if str(vendor_dir) not in sys.path:
sys.path.insert(0, str(vendor_dir))
import pypdf
try:
reader = pypdf.PdfReader(io.BytesIO(file_bytes))
except Exception as e:
raise ValueError(f"Не удалось открыть PDF документ: {e}")
if reader.is_encrypted:
try:
reader.decrypt("")
except Exception:
raise ValueError("Этот PDF документ защищен паролем. Пожалуйста, снимите пароль для чтения.")
num_pages = len(reader.pages)
if num_pages == 0:
raise ValueError("PDF документ не содержит страниц")
# Metadata extraction
meta = reader.metadata or {}
meta_title = getattr(meta, "title", None) or (meta.get("/Title") if hasattr(meta, "get") else None) or ""
meta_author = getattr(meta, "author", None) or (meta.get("/Author") if hasattr(meta, "get") else None) or ""
stem = Path(filename).stem if filename else "Untitled Document"
clean_title = str(meta_title).strip()
if not clean_title or clean_title.lower() in {"untitled", "untitled document", "default", "temp", "output"}:
title = stem
else:
title = clean_title
author = str(meta_author).strip()
# Page-by-page text extraction
raw_pages_text: List[str] = []
for p in reader.pages:
try:
pt = p.extract_text() or ""
except Exception:
pt = ""
raw_pages_text.append(pt)
# Scanned PDF Guard (< 100 words)
total_words = sum(count_words(t) for t in raw_pages_text)
if total_words < 100:
raise ValueError(
"Этот PDF-документ содержит сканы или изображения без текстового слоя (OCR). "
"Пожалуйста, используйте документ с распознанным текстом."
)
# Clean noise lines (headers, footers, page numbers)
cleaned_pages = clean_page_lines(raw_pages_text)
# Apply CLI formatting to each page
formatted_pages = [format_cli_blocks(p) for p in cleaned_pages]
# Chapter splitting:
# 1. Try outline / bookmarks
outline_entries = extract_outline_entries(getattr(reader, "outline", None), reader)
chapters: List[Dict[str, str]] = []
if outline_entries:
valid_entries = [(t, p) for t, p in outline_entries if 0 <= p < num_pages]
valid_entries.sort(key=lambda x: x[1])
# Filter duplicates pointing to the same page
deduped_entries: List[Tuple[str, int]] = []
for t, p in valid_entries:
if not deduped_entries or deduped_entries[-1][1] != p:
deduped_entries.append((t, p))
if deduped_entries:
first_p = deduped_entries[0][1]
if first_p > 0:
intro_content = "\n\n".join(formatted_pages[p].strip() for p in range(0, first_p) if formatted_pages[p].strip())
if count_words(intro_content) >= 20:
is_ru = any(ord(c) > 127 for c in intro_content[:300])
chapters.append({
"title": "Введение" if is_ru else "Introduction",
"content": intro_content
})
for i, (entry_title, start_p) in enumerate(deduped_entries):
end_p = deduped_entries[i + 1][1] if i + 1 < len(deduped_entries) else num_pages
ch_content = "\n\n".join(formatted_pages[p].strip() for p in range(start_p, end_p) if formatted_pages[p].strip())
if ch_content:
chapters.append({
"title": entry_title,
"content": ch_content
})
# 2. Fallback: No outline found, check for page-level chapter headings
if not chapters:
chapter_heading_re = re.compile(
r"^\s*(?:chapter|глава|part|часть|section|раздел|module|модуль)\s+([0-9ivxlcdm]+)[\s:.\-–—\n]+(.*)$",
re.IGNORECASE
)
detected_pages: List[Tuple[str, int]] = []
for p_idx, p_text in enumerate(formatted_pages):
first_lines = [l.strip() for l in p_text.split("\n") if l.strip()][:3]
for l in first_lines:
m = chapter_heading_re.match(l)
if m:
h_title = l.strip("#").strip()
detected_pages.append((h_title, p_idx))
break
if detected_pages:
if detected_pages[0][1] > 0:
intro_content = "\n\n".join(formatted_pages[p].strip() for p in range(0, detected_pages[0][1]) if formatted_pages[p].strip())
if count_words(intro_content) >= 20:
is_ru = any(ord(c) > 127 for c in intro_content[:300])
chapters.append({
"title": "Введение" if is_ru else "Introduction",
"content": intro_content
})
for i, (h_title, start_p) in enumerate(detected_pages):
end_p = detected_pages[i + 1][1] if i + 1 < len(detected_pages) else num_pages
ch_content = "\n\n".join(formatted_pages[p].strip() for p in range(start_p, end_p) if formatted_pages[p].strip())
if ch_content:
chapters.append({
"title": h_title,
"content": ch_content
})
# 3. Fallback: Group pages in chunks of 12-15 pages
if not chapters:
chunk_size = 12
sample_text = "".join(formatted_pages[:3])
is_ru = any(ord(c) > 127 for c in sample_text[:300])
prefix = "Страницы" if is_ru else "Pages"
for start_p in range(0, num_pages, chunk_size):
end_p = min(start_p + chunk_size, num_pages)
ch_content = "\n\n".join(formatted_pages[p].strip() for p in range(start_p, end_p) if formatted_pages[p].strip())
if ch_content:
chapters.append({
"title": f"{prefix} {start_p + 1}–{end_p}",
"content": ch_content
})
if not chapters:
full_content = "\n\n".join(p.strip() for p in formatted_pages if p.strip())
chapters = [{"title": title, "content": full_content}]
return title, author, chapters