PDF merge

This commit is contained in:
bvn13 2026-04-11 12:19:45 +03:00
commit 69a38caa79
5 changed files with 294 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
троицкое

76
README.md Normal file
View File

@ -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 стр.)
```

39
merge.yaml Normal file
View File

@ -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"
# Основной текст — страницы 315 и отдельно страница 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: "приложение"

155
pdf_merge.py Executable file
View File

@ -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()

23
run.sh Executable file
View File

@ -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"