Compare commits

...

3 Commits

Author SHA1 Message Date
Сергей Маринкевич e3ee0aafaf Use five-digit chapter indices
Keep generated chapter filenames and displayed section numbers lexicographically ordered beyond 99 sections.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 18:19:54 +07:00
Сергей Маринкевич 72a7de6146 Include parent sections in chapter filenames
Preserve outline ancestry when generating chapter slugs so split files retain their section context.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 18:14:08 +07:00
Сергей Маринкевич 7aff901568 Use PDF outlines for configurable chapter splitting
Add outline-level chapter selection with a numbered-heading fallback and document the new CLI option.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 18:04:54 +07:00
2 changed files with 124 additions and 55 deletions
+11 -2
View File
@@ -24,7 +24,7 @@ chmod +x run.sh
out/RealTek-r8169/ out/RealTek-r8169/
├── INDEX.md # оглавление со ссылками на главы ├── INDEX.md # оглавление со ссылками на главы
├── chapters/ # одна глава = один файл ├── chapters/ # одна глава = один файл
│ ├── 01-features.md │ ├── 00001-features.md
│ └── ... │ └── ...
└── assets/ # embedded PNG + page PNG для сломанных layout-страниц └── assets/ # embedded PNG + page PNG для сломанных layout-страниц
``` ```
@@ -36,13 +36,22 @@ out/RealTek-r8169/
| Флаг | Описание | | Флаг | Описание |
|------|----------| |------|----------|
| `--single-file` | Один `{stem}.md` вместо `INDEX.md` + `chapters/` | | `--single-file` | Один `{stem}.md` вместо `INDEX.md` + `chapters/` |
| `--chapter-level N` | Уровень встроенного оглавления PDF для границ глав: `1`, `2` или `3` (default `2`) |
| `--tables` | Включить извлечение таблиц pdfplumber (по умолчанию выключено) | | `--tables` | Включить извлечение таблиц pdfplumber (по умолчанию выключено) |
| `--page-dpi N` | DPI для PNG-рендера страниц с битым текстом (default 150) | | `--page-dpi N` | DPI для PNG-рендера страниц с битым текстом (default 150) |
| `-o DIR` | Каталог вывода | | `-o DIR` | Каталог вывода |
## Grep для агента ## Grep для агента
Главы разбиты по крупным разделам документа. Каждая страница помечена HTML-комментарием: Главы разбиты по встроенному оглавлению PDF. Уровень нарезки можно выбрать:
```bash
./run.sh doc.pdf --chapter-level 1 # крупные разделы
./run.sh doc.pdf --chapter-level 2 # подразделы, default
./run.sh doc.pdf --chapter-level 3 # самые мелкие разделы
```
Каждая страница помечена HTML-комментарием:
```bash ```bash
# найти упоминание регистра в главе 6 # найти упоминание регистра в главе 6
+113 -53
View File
@@ -16,13 +16,15 @@ HEADER_FOOTER = re.compile(
r"^(RTL8169|2002/\d{2}/\d{2}|Rev\.\d+\.\d+|\d{1,3})$", r"^(RTL8169|2002/\d{2}/\d{2}|Rev\.\d+\.\d+|\d{1,3})$",
re.MULTILINE, re.MULTILINE,
) )
MAJOR_SECTION = re.compile(r"^(\d{1,2})\.\s+([A-Z][^\n]{2,})$")
TOC_LINE = re.compile(r"\.{4,}\s*\d+\s*$") TOC_LINE = re.compile(r"\.{4,}\s*\d+\s*$")
NUMBERED_HEADING = re.compile(r"^(\d{1,2}(?:\.\d{1,2})*)\s+(.+)$")
MIN_TABLE_ROWS = 2 MIN_TABLE_ROWS = 2
MIN_TABLE_CELLS = 6 MIN_TABLE_CELLS = 6
MIN_CELL_LEN_FOR_DEDUP = 4 MIN_CELL_LEN_FOR_DEDUP = 4
SPACED_LETTERS = re.compile(r"^([A-Z](?: [A-Z]){1,}|[A-Z])$") SPACED_LETTERS = re.compile(r"^([A-Z](?: [A-Z]){1,}|[A-Z])$")
DEFAULT_PAGE_DPI = 150 DEFAULT_PAGE_DPI = 150
DEFAULT_CHAPTER_LEVEL = 2
SECTION_INDEX_WIDTH = 5
def slugify(title: str) -> str: def slugify(title: str) -> str:
@@ -190,29 +192,64 @@ def render_page_png(
return f"assets/{name}" return f"assets/{name}"
def find_major_section(line: str) -> tuple[str, str] | None: def toc_sections(doc: fitz.Document, level: int) -> list[dict]:
m = MAJOR_SECTION.match(line.strip()) """Return unique page boundaries and ancestor titles from the PDF outline."""
if not m: sections: list[dict] = []
return None seen_pages: set[int] = set()
num, title = m.group(1), m.group(2).strip() parents: list[str] = []
if TOC_LINE.search(title) or "." * 3 in title: for toc_level, title, page in doc.get_toc():
return None title = title.strip()
if title.endswith(":"): if not title:
return None continue
if len(title) > 70: parents = parents[: toc_level - 1]
return None ancestors = parents.copy()
return num, title parents.append(title)
if toc_level != level or not title.strip():
continue
page = max(1, min(page, doc.page_count))
# Several outline entries can point to the same page. A page cannot
# be split without guessing from the extracted text, so keep its first
# entry and let the following headings remain in that page's content.
if page in seen_pages:
continue
seen_pages.add(page)
sections.append(
{"title": title, "parents": ancestors, "start_page": page}
)
if sections:
return sections
# Keep PDFs without bookmarks usable. This fallback only recognizes
def should_start_chapter(num: str, current: dict | None) -> bool: # numbered headings and is deliberately limited to one boundary per page.
if current is None: heading_path: list[str] = []
return True for page_index, page in enumerate(doc, start=1):
if current.get("num") == "00": text = clean_page_text(page.get_text())
return True if is_toc_page(text):
try: continue
return int(num) > int(current["num"]) for line in text.splitlines():
except ValueError: match = NUMBERED_HEADING.match(line.strip())
return False if not match:
continue
number, title = match.groups()
heading_level = number.count(".") + 1
heading_path = heading_path[: heading_level - 1]
heading_path.append(title.strip())
if heading_level != level:
continue
title = title.strip()
if not title or not title[0].isalpha() or TOC_LINE.search(title):
continue
if page_index not in seen_pages:
seen_pages.add(page_index)
sections.append(
{
"title": title,
"parents": heading_path[:-1],
"start_page": page_index,
}
)
break
return sections
def convert( def convert(
@@ -222,6 +259,7 @@ def convert(
extract_tables: bool = False, extract_tables: bool = False,
page_dpi: int = DEFAULT_PAGE_DPI, page_dpi: int = DEFAULT_PAGE_DPI,
single_file: bool = False, single_file: bool = False,
chapter_level: int = DEFAULT_CHAPTER_LEVEL,
) -> None: ) -> None:
if out_dir.exists(): if out_dir.exists():
shutil.rmtree(out_dir) shutil.rmtree(out_dir)
@@ -234,9 +272,8 @@ def convert(
doc = fitz.open(pdf_path) doc = fitz.open(pdf_path)
stem = pdf_path.stem stem = pdf_path.stem
sections: list[dict] = []
current: dict | None = None
page_png_count = 0 page_png_count = 0
page_contents: dict[int, str] = {}
with pdfplumber.open(pdf_path) as plumber: with pdfplumber.open(pdf_path) as plumber:
for page_index in range(doc.page_count): for page_index in range(doc.page_count):
@@ -271,35 +308,47 @@ def convert(
page_block.append("\n".join(f"![figure]({p})" for p in img_refs)) page_block.append("\n".join(f"![figure]({p})" for p in img_refs))
page_content = "\n\n".join(x for x in page_block if x) page_content = "\n\n".join(x for x in page_block if x)
section_hit = None page_contents[page_index + 1] = page_content
source = text or raw
for line in source.splitlines():
hit = find_major_section(line)
if hit:
section_hit = hit
if section_hit and should_start_chapter(section_hit[0], current): toc = toc_sections(doc, chapter_level)
num, title = section_hit sections: list[dict] = []
if current: first_start = toc[0]["start_page"] if toc else doc.page_count + 1
sections.append(current) preamble_pages = [
current = { page_contents[page]
"num": num, for page in sorted(page_contents)
"title": title, if page < first_start
"slug": f"{int(num):02d}-{slugify(title)}", ]
"pages": [], if preamble_pages:
} sections.append(
{
if current is None: "num": f"{0:0{SECTION_INDEX_WIDTH}d}",
current = { "title": "front-matter",
"num": "00", "slug": f"{0:0{SECTION_INDEX_WIDTH}d}-front-matter",
"title": "front-matter", "pages": preamble_pages,
"slug": "00-front-matter", }
"pages": [], )
} for index, entry in enumerate(toc, start=1):
current["pages"].append(page_content) next_start = (
toc[index]["start_page"] if index < len(toc) else doc.page_count + 1
if current: )
sections.append(current) pages = [
page_contents[page]
for page in sorted(page_contents)
if entry["start_page"] <= page < next_start
]
if not pages:
continue
sections.append(
{
"num": f"{index:0{SECTION_INDEX_WIDTH}d}",
"title": entry["title"],
"slug": (
f"{index:0{SECTION_INDEX_WIDTH}d}-"
f"{slugify(' - '.join((*entry['parents'], entry['title'])))}"
),
"pages": pages,
}
)
doc.close() doc.close()
if single_file: if single_file:
@@ -351,6 +400,16 @@ def main() -> None:
action="store_true", action="store_true",
help="write one {stem}.md instead of INDEX.md + chapters/", help="write one {stem}.md instead of INDEX.md + chapters/",
) )
parser.add_argument(
"--chapter-level",
type=int,
choices=(1, 2, 3),
default=DEFAULT_CHAPTER_LEVEL,
help=(
"PDF outline level used for chapter boundaries "
f"(default: {DEFAULT_CHAPTER_LEVEL})"
),
)
args = parser.parse_args() args = parser.parse_args()
out = args.output or Path("out") / args.pdf.stem out = args.output or Path("out") / args.pdf.stem
convert( convert(
@@ -359,6 +418,7 @@ def main() -> None:
extract_tables=args.tables, extract_tables=args.tables,
page_dpi=args.page_dpi, page_dpi=args.page_dpi,
single_file=args.single_file, single_file=args.single_file,
chapter_level=args.chapter_level,
) )