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