PDF merge
This commit is contained in:
commit
69a38caa79
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
троицкое
|
||||||
76
README.md
Normal file
76
README.md
Normal 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
39
merge.yaml
Normal 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"
|
||||||
|
|
||||||
|
# Основной текст — страницы 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: "приложение"
|
||||||
155
pdf_merge.py
Executable file
155
pdf_merge.py
Executable 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
23
run.sh
Executable 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"
|
||||||
Loading…
x
Reference in New Issue
Block a user