Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e3ee0aafaf | |||
| 72a7de6146 | |||
| 7aff901568 |
@@ -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
@@ -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"" for p in img_refs))
|
page_block.append("\n".join(f"" 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user