#!/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 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) for idx in indices: writer.add_page(reader.pages[idx]) label = step.get("label", "") 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) 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()