import argparse import datetime as dt import json import pathlib import re import sqlite3 import sys from pypdf import PdfReader TOC_MARKERS = ("目录", "目 录", "contents", "table of contents") TOC_LINE = re.compile(r"^(?P.{2,160}?)(?:[..·…]{2,}|\s{2,})(?P<page>-?\d{1,4})\s*$") def compact(value: str) -> str: return re.sub(r"\s+", " ", value or "").strip() def text_lines(value: str) -> list[str]: return [compact(line) for line in (value or "").splitlines() if compact(line)] def is_toc_page(text: str) -> bool: lowered = text.lower() if any(marker in lowered for marker in TOC_MARKERS): return True return sum(1 for line in text_lines(text) if TOC_LINE.match(line)) >= 4 def parse_toc_entries(text: str, pdf_page: int) -> list[dict[str, object]]: entries = [] for line in text_lines(text): match = TOC_LINE.match(line) if not match: continue title = compact(match.group("title")).strip("..·… ") if len(title) < 2 or title in TOC_MARKERS: continue entries.append( { "title": title, "printed_page": int(match.group("page")), "toc_pdf_page": pdf_page, } ) return entries def open_index(path: pathlib.Path) -> sqlite3.Connection: path.parent.mkdir(parents=True, exist_ok=True) if path.exists(): path.unlink() connection = sqlite3.connect(path) connection.execute("PRAGMA journal_mode=OFF") connection.execute("PRAGMA synchronous=OFF") connection.execute("PRAGMA temp_store=MEMORY") connection.execute( "CREATE TABLE manuals (" "filename TEXT PRIMARY KEY, page_count INTEGER NOT NULL, size_bytes INTEGER NOT NULL, " "toc_pdf_pages TEXT NOT NULL)" ) connection.execute( "CREATE VIRTUAL TABLE page_search USING fts5(" "manual UNINDEXED, filename UNINDEXED, pdf_page UNINDEXED, text, tokenize='trigram')" ) return connection def build_index(manual_dir: pathlib.Path, database_path: pathlib.Path, toc_scan_pages: int) -> dict: connection = open_index(database_path) manuals = [] page_total = 0 try: for pdf_path in sorted(manual_dir.glob("*.pdf"), key=lambda item: item.name.casefold()): reader = PdfReader(str(pdf_path)) toc_pages = [] toc_entries = [] print(f"indexing {pdf_path.name}: {len(reader.pages)} pages", flush=True) for page_number, page in enumerate(reader.pages, start=1): text = page.extract_text() or "" normalized = compact(text) connection.execute( "INSERT INTO page_search(manual, filename, pdf_page, text) VALUES (?, ?, ?, ?)", (pdf_path.stem, pdf_path.name, page_number, normalized), ) if page_number <= toc_scan_pages and is_toc_page(text): toc_pages.append(page_number) toc_entries.extend(parse_toc_entries(text, page_number)) toc_entries = toc_entries[:300] connection.execute( "INSERT INTO manuals(filename, page_count, size_bytes, toc_pdf_pages) VALUES (?, ?, ?, ?)", (pdf_path.name, len(reader.pages), pdf_path.stat().st_size, json.dumps(toc_pages)), ) manuals.append( { "filename": pdf_path.name, "page_count": len(reader.pages), "size_bytes": pdf_path.stat().st_size, "toc_pdf_pages": toc_pages, "toc_entries": toc_entries, } ) page_total += len(reader.pages) connection.commit() connection.execute("VACUUM") finally: connection.close() return { "generated_at": dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat(), "manual_count": len(manuals), "page_count": page_total, "manuals": manuals, } def write_toc_markdown(index: dict, path: pathlib.Path) -> None: lines = [ "# Manual Chapter Index", "", "This generated index covers all PDFs currently under `assets/manuals/`. `手册页` is the printed page number extracted from the table of contents; `目录 PDF 页` is the physical PDF page where the entry was found. Use `search_xinje_manuals.py` for exact PDF-page results.", "", f"Generated: `{index['generated_at']}`. Manuals: `{index['manual_count']}`. PDF pages: `{index['page_count']}`.", "", ] for manual in index["manuals"]: lines.append(f"## {manual['filename']}") toc_pages = ", ".join(str(item) for item in manual["toc_pdf_pages"]) or "not detected" lines.append("") lines.append(f"PDF pages: `{manual['page_count']}`. Detected TOC PDF pages: `{toc_pages}`.") entries = manual["toc_entries"] if not entries: lines.extend(["", "No reliable TOC lines were extracted. Use the full-text index or inspect the first 40 PDF pages.", ""]) continue lines.extend(["", "| 手册页 | 章节/条目 | 目录 PDF 页 |", "|---:|---|---:|"]) seen = set() for entry in entries: key = (entry["title"], entry["printed_page"]) if key in seen: continue seen.add(key) title = str(entry["title"]).replace("|", "\\|") lines.append(f"| {entry['printed_page']} | {title} | {entry['toc_pdf_page']} |") lines.append("") path.parent.mkdir(parents=True, exist_ok=True) path.write_text("\n".join(lines), encoding="utf-8") def main() -> None: parser = argparse.ArgumentParser(description="Build a page-level full-text and table-of-contents index for Xinje manuals.") parser.add_argument("--manual-dir", required=True, type=pathlib.Path) parser.add_argument("--output-db", required=True, type=pathlib.Path) parser.add_argument("--output-toc-json", required=True, type=pathlib.Path) parser.add_argument("--output-toc-md", required=True, type=pathlib.Path) parser.add_argument("--toc-scan-pages", type=int, default=40) args = parser.parse_args() if args.toc_scan_pages < 1: raise SystemExit("--toc-scan-pages must be positive") index = build_index(args.manual_dir, args.output_db, args.toc_scan_pages) args.output_toc_json.parent.mkdir(parents=True, exist_ok=True) args.output_toc_json.write_text(json.dumps(index, ensure_ascii=False, indent=2), encoding="utf-8") write_toc_markdown(index, args.output_toc_md) print( f"indexed {index['manual_count']} manuals and {index['page_count']} pages; " f"database: {args.output_db}", flush=True, ) if __name__ == "__main__": if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") main()