166 lines
5.5 KiB
Python
Executable File
166 lines
5.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
# /// script
|
||
# requires-python = ">=3.10"
|
||
# dependencies = [
|
||
# "pypdf>=4.0.0",
|
||
# "pyyaml>=6.0",
|
||
# ]
|
||
# ///
|
||
"""
|
||
pdf_merge.py — собирает PDF из кусков других PDF по YAML-конфигу.
|
||
|
||
Использование:
|
||
uv run pdf_merge.py [config.yaml]
|
||
|
||
Если конфиг не указан явно — ищет merge.yaml в текущей директории.
|
||
"""
|
||
|
||
import sys
|
||
import re
|
||
from pathlib import Path
|
||
|
||
import yaml
|
||
from pypdf import PdfReader, PdfWriter
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Парсинг спецификации страниц
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def parse_pages(spec: str | int | list, total: int) -> list[int]:
|
||
"""
|
||
Принимает спецификацию страниц и возвращает список 0-based индексов.
|
||
|
||
Форматы (1-based нумерация в конфиге):
|
||
"1-5" -> [0, 1, 2, 3, 4]
|
||
"1,3,5" -> [0, 2, 4]
|
||
"1-3,7,9-11" -> [0, 1, 2, 6, 8, 9, 10]
|
||
"all" -> все страницы
|
||
3 -> [2] (одно число как int)
|
||
[1, 3, 5] -> [0, 2, 4] (список чисел)
|
||
"""
|
||
if spec is None or str(spec).strip().lower() == "all":
|
||
return list(range(total))
|
||
|
||
if isinstance(spec, int):
|
||
spec = str(spec)
|
||
|
||
if isinstance(spec, list):
|
||
spec = ",".join(str(s) for s in spec)
|
||
|
||
pages = []
|
||
for part in re.split(r"[,;]\s*", str(spec).strip()):
|
||
part = part.strip()
|
||
if not part:
|
||
continue
|
||
m = re.fullmatch(r"(\d+)\s*-\s*(\d+)", part)
|
||
if m:
|
||
start, end = int(m.group(1)), int(m.group(2))
|
||
if start < 1 or end > total or start > end:
|
||
raise ValueError(
|
||
f"Диапазон {part} выходит за границы документа (всего {total} стр.)"
|
||
)
|
||
pages.extend(range(start - 1, end))
|
||
elif re.fullmatch(r"\d+", part):
|
||
n = int(part)
|
||
if n < 1 or n > total:
|
||
raise ValueError(
|
||
f"Страница {n} не существует (всего {total} стр.)"
|
||
)
|
||
pages.append(n - 1)
|
||
else:
|
||
raise ValueError(f"Не удалось разобрать спецификацию страниц: '{part}'")
|
||
|
||
return pages
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Основная логика
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def merge(config_path: Path) -> None:
|
||
with open(config_path, encoding="utf-8") as f:
|
||
cfg = yaml.safe_load(f)
|
||
|
||
output_path = Path(cfg.get("output", "output.pdf"))
|
||
base_dir = config_path.parent # файлы ищем относительно конфига
|
||
|
||
steps: list[dict] = cfg.get("pages", [])
|
||
if not steps:
|
||
print("Ошибка: в конфиге нет секции 'pages'.", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
writer = PdfWriter()
|
||
total_added = 0
|
||
bookmarks: list[tuple[str, int]] = [] # (label, 0-based page index в результате)
|
||
|
||
for i, step in enumerate(steps, 1):
|
||
if "file" not in step:
|
||
print(f"Шаг {i}: нет поля 'file', пропускаю.", file=sys.stderr)
|
||
continue
|
||
|
||
pdf_path = base_dir / step["file"]
|
||
if not pdf_path.exists():
|
||
print(f"Шаг {i}: файл не найден: {pdf_path}", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
reader = PdfReader(str(pdf_path))
|
||
total_pages = len(reader.pages)
|
||
spec = step.get("pages", "all")
|
||
|
||
try:
|
||
indices = parse_pages(spec, total_pages)
|
||
except ValueError as e:
|
||
print(f"Шаг {i} ({step['file']}): {e}", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
label = step.get("label", "")
|
||
if label:
|
||
bookmarks.append((label, total_added))
|
||
|
||
for idx in indices:
|
||
writer.add_page(reader.pages[idx])
|
||
|
||
label_str = f" [{label}]" if label else ""
|
||
print(
|
||
f" + {step['file']}{label_str}: "
|
||
f"страниц {len(indices)} (из {total_pages}) — "
|
||
f"индексы {[x+1 for x in indices]}"
|
||
)
|
||
total_added += len(indices)
|
||
|
||
# Добавляем закладки (содержание в боковой панели PDF-ридера)
|
||
if bookmarks:
|
||
for bm_label, page_idx in bookmarks:
|
||
writer.add_outline_item(bm_label, page_idx)
|
||
print(f"\n Содержание: {len(bookmarks)} закладок")
|
||
|
||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||
with open(output_path, "wb") as out:
|
||
writer.write(out)
|
||
|
||
print(f"\nГотово: {output_path} ({total_added} стр.)")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Entrypoint
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def main() -> None:
|
||
if len(sys.argv) > 1:
|
||
config_path = Path(sys.argv[1])
|
||
else:
|
||
config_path = Path("merge.yaml")
|
||
|
||
if not config_path.exists():
|
||
print(f"Конфиг не найден: {config_path}", file=sys.stderr)
|
||
print("Использование: uv run pdf_merge.py [config.yaml]", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
print(f"Конфиг: {config_path}")
|
||
merge(config_path)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|