import io
import re
import html
import zipfile
import posixpath
import urllib.parse
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