commit 69a38caa799b04f1a66e46d4794d9cce29822925 Author: bvn13 Date: Sat Apr 11 12:19:45 2026 +0300 PDF merge diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..61b589b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +троицкое diff --git a/README.md b/README.md new file mode 100644 index 0000000..085b001 --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# pdf-merge + +Скрипт для сборки PDF из страниц нескольких файлов по YAML-конфигу. +Форматы страниц (A4, A3 и др.) сохраняются без изменений. + +## Требования + +- [uv](https://docs.astral.sh/uv/getting-started/installation/) — менеджер Python-окружений + ```bash + curl -LsSf https://astral.sh/uv/install.sh | sh + # или на Arch: + sudo pacman -S uv + ``` + +Зависимости (`pypdf`, `pyyaml`) устанавливаются автоматически при первом запуске. + +## Использование + +```bash +chmod +x run.sh + +# Запуск с конфигом по умолчанию (merge.yaml в текущей папке) +./run.sh + +# Явно указать конфиг +./run.sh my_project.yaml + +# Или напрямую через uv +uv run pdf_merge.py my_project.yaml +``` + +## Конфиг (merge.yaml) + +```yaml +output: result.pdf # куда сохранить результат + +pages: + - file: document_a4.pdf + pages: "1-5" # диапазон + + - file: drawing_a3.pdf + pages: "1" # одна страница + + - file: document_a4.pdf + pages: "3-15,20" # диапазон + отдельная страница + + - file: appendix.pdf + pages: [1, 2, 5] # YAML-список + label: "приложение" # опциональная метка для читаемости + + - file: other.pdf # pages не указан — берёт все страницы +``` + +### Форматы поля `pages` + +| Запись | Результат | +|----------------|----------------------------------| +| `"1-5"` | страницы 1, 2, 3, 4, 5 | +| `"1,3,7"` | страницы 1, 3, 7 | +| `"1-3,7,9-11"` | страницы 1, 2, 3, 7, 9, 10, 11 | +| `[1, 3, 5]` | страницы 1, 3, 5 | +| `4` | страница 4 | +| `"all"` / пусто| все страницы файла | + +Нумерация с **1** (как в PDF-ридере). + +## Пример вывода + +``` +Конфиг: merge.yaml + + document_a4.pdf [титул + оглавление]: страниц 2 (из 42) — индексы [1, 2] + + drawing_a3.pdf [чертёж А3]: страниц 1 (из 3) — индексы [1] + + document_a4.pdf [основной текст]: страниц 14 (из 42) — индексы [3, 4, ..., 20] + +Готово: result.pdf (17 стр.) +``` diff --git a/merge.yaml b/merge.yaml new file mode 100644 index 0000000..fd1bf30 --- /dev/null +++ b/merge.yaml @@ -0,0 +1,39 @@ +# Итоговый файл (путь относительно этого конфига или абсолютный) +output: result.pdf + +# Порядок страниц в итоговом PDF. +# Поле "pages" может быть: +# - "all" — все страницы файла (значение по умолчанию если поле опущено) +# - "1-5" — диапазон +# - "1,3,7" — конкретные страницы через запятую +# - "1-3,7,9-11" — смешанный формат +# - [1, 3, 5] — список чисел (YAML-список) +# - 4 — одна страница числом +# Нумерация страниц — с 1 (как в PDF-ридере). +# Поле "label" опциональное — только для читаемости конфига. + +pages: + # Титульный лист и оглавление из первого документа + - file: document_a4.pdf + pages: "1-2" + label: "титул + оглавление" + + # Разворот чертежа в формате A3 + - file: drawing_a3.pdf + pages: "1" + label: "чертёж А3" + + # Основной текст — страницы 3–15 и отдельно страница 20 + - file: document_a4.pdf + pages: "3-15,20" + label: "основной текст" + + # Ещё один чертёж А3 — все страницы + - file: drawing_a3.pdf + pages: all + label: "все чертежи" + + # Приложение из третьего файла — конкретные страницы списком + - file: appendix.pdf + pages: [1, 2, 5] + label: "приложение" diff --git a/pdf_merge.py b/pdf_merge.py new file mode 100755 index 0000000..f3176cd --- /dev/null +++ b/pdf_merge.py @@ -0,0 +1,155 @@ +#!/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() diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..977d25d --- /dev/null +++ b/run.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# run.sh — запуск pdf_merge через uv +# +# Использование: +# ./run.sh # берёт merge.yaml из текущей директории +# ./run.sh my_config.yaml # указать конфиг явно +# ./run.sh path/to/config.yaml # любой путь + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="$SCRIPT_DIR/pdf_merge.py" + +# Проверяем наличие uv +if ! command -v uv &>/dev/null; then + echo "uv не найден. Установи: https://docs.astral.sh/uv/getting-started/installation/" + exit 1 +fi + +CONFIG="${1:-merge.yaml}" + +echo "=== pdf-merge ===" +uv run "$SCRIPT" "$CONFIG"