
Img2pdf
- 30 installs
- 543 repo stars
- Updated August 5, 2026
- cat-xierluo/legal-skills
Arrange images or PDF pages N-per-page into standardized A4 PDFs, or render a long screenshot into a single adaptive-height PDF.
About
Lays out images or existing PDF pages N-per-page into standardized A4 PDFs, or renders long screenshots (chat logs, hearing records) into a single adaptive-height PDF. A developer or lawyer uses it to compile screenshots, photos, or PDF pages into a compact PDF, primarily for legal evidence.
- N-per-page A4 arrangement of images/PDF pages
- Long-screenshot to single adaptive-height PDF
Img2pdf by the numbers
- 30 all-time installs (skills.sh)
- Ranked #411 of 688 Office & Documents skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cat-xierluo/legal-skills --skill img2pdfAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 30 |
|---|---|
| repo stars | ★ 543 |
| Last updated | August 5, 2026 |
| Repository | cat-xierluo/legal-skills ↗ |
What it does
Arrange images or PDF pages N-per-page into standardized A4 PDFs, or render a long screenshot into a single adaptive-height PDF.
Files
img2pdf
定位
本技能解决"大量截图/照片需要编排为紧凑 PDF 提交"以及"超长截图(微信聊天、庭审笔录)需要保留上下逻辑转为 PDF"的问题。核心场景是法律证据材料整理。
核心职责:
1. 将图片目录或多个图片文件编排为 A4 PDF,支持 1/2/3/4 张每页。 2. 将已有 PDF 的每页重新编排为 N 张每页的紧凑布局。 3. 自动检测图片横竖方向,选择合适的 A4 页面方向。 4. 可配置页边距,确保打印效果良好。 5. v1.2.0 长截图模式:按 A4 比例自动切割超长图再编排(微信聊天场景),或将整张长图渲染为单张自适应高度 PDF(庭审笔录场景)。
本技能不做 OCR、不编辑 PDF 内容、不处理视频文件。若需要从视频提取截图,先使用 video-screenshot。
与其他技能配合
- 上游:video-screenshot 提取视频截图后,用本技能编排为 PDF。
- 上游:截图工具(手机截图、浏览器截图)产出的图片文件。
- 下游:pdf-organizer 可对编排后的 PDF 做进一步整理(拆分、合并、命名)。
- 替代:pdf-organizer 的
--normalize-a4只做页面标准化,不做多图编排。
依赖
系统依赖
无额外系统依赖。
Python 包
| 包名 | 用途 | 安装命令 |
|---|---|---|
pypdf>=4.0.0 | PDF 页面变换与合并 | python3 -m pip install -r scripts/requirements.txt |
Pillow>=10.0.0 | 图片格式检测 | 同上 |
PyMuPDF>=1.24.0 | 图片转 PDF 页面 | 同上 |
输入/输出
输入
- 图片目录:扫描目录下所有 JPG/PNG/WebP 文件。
- 多个图片文件:直接列出图片路径。
- 已有 PDF:将 PDF 每页当作图片重新编排。
输出
- 单个 A4 PDF 文件,每页包含 1-4 张图片,等比缩放居中。
工作流程
1. 收集输入
根据 --input 参数收集图片或 PDF 文件。如果是目录,扫描其中所有支持格式的图片。按文件名或修改时间排序。
2. 转换为页面
- 图片文件:通过 PyMuPDF 转为单页 PDF。
- PDF 文件:读取每一页作为独立页面。
3. 计算布局
根据 --per-page 和页面方向计算每张图片的可用区域:
per-page=1:整页减去边距,横竖由图片方向决定。per-page=1:整页减去边距,横竖由图片方向决定。per-page=2:A4 横版,左右两列。per-page=3:A4 横版,三列并排。per-page=4:A4 横版或竖版,2×2 网格。per-page=auto(默认):竖版图多 → 3张/页,横版图多 → 1张/页。
每张图片等比缩放适配其可用区域,居中放置。
4. 生成 PDF
将编排后的页面写入输出 PDF。不修改任何原始文件。
执行脚本
首次使用时安装依赖:
python3 -m pip install -r scripts/requirements.txt手机截图(自动 3 张/页)
python3 scripts/img_to_pdf.py \
--input /path/to/screenshots/ \
--output /path/to/output.pdf
# 自动检测:竖版图多 → 3张/页电脑截图(自动 1 张/页)
python3 scripts/img_to_pdf.py \
--input /path/to/desktop-screenshots/ \
--output /path/to/output.pdf
# 自动检测:横版图多 → 1张/页(A4横版)手机截图 2 张/页(A4 横版左右并排)
python3 scripts/img_to_pdf.py \
--input /path/to/screenshots/ \
--output /path/to/output.pdf \
--per-page 2视频截图 3 张/页
python3 scripts/img_to_pdf.py \
--input /path/to/frames/ \
--output /path/to/output.pdf \
--per-page 3已有 PDF 重新编排
python3 scripts/img_to_pdf.py \
--input /path/to/original.pdf \
--output /path/to/repacked.pdf \
--per-page 2多个图片文件
python3 scripts/img_to_pdf.py \
--input img1.jpg img2.jpg img3.png \
--output /path/to/output.pdf \
--per-page 3微信聊天长截图(v1.2.0,按 A4 比例自动切 + 3 张/页)
python3 scripts/img_to_pdf.py \
--input /path/to/wechat_long.png \
--output /path/to/wechat.pdf \
--split \
--per-page 3
# 1080×6000 → 按 1080×√2≈1527px 切 4 段 → 2 页 A4 横版微信聊天长截图(显式切割段高)
python3 scripts/img_to_pdf.py \
--input /path/to/wechat_long.png \
--output /path/to/wechat.pdf \
--split \
--split-height 1500 \
--per-page 3庭审笔录长截图(v1.2.0 vertical 模式,整图一长页)
python3 scripts/img_to_pdf.py \
--input /path/to/transcript.png \
--output /path/to/transcript.pdf \
--mode vertical
# 不切割,1080×5000 → 1 页 595×2573pt
# 页面高度按图等比缩放,保留上下逻辑预览(不写入文件)
python3 scripts/img_to_pdf.py \
--input /path/to/dir/ \
--per-page 2 \
--dry-run常用参数
| 参数 | 说明 | 默认值 |
|---|---|---|
--input / -i | 图片文件、PDF 文件或目录(必填) | - |
--output / -o | 输出 PDF 路径 | <输入名>_编排.pdf |
--mode | 编排模式:nup(N 张/页)或 vertical(单图一长页) | nup |
--per-page / -n | nup 模式下每页图片数:1/2/3/4,或省略自动 | auto(竖版3张,横版1张) |
--margin / -m | 页边距(pt) | 25 |
--orientation | nup 模式页面方向:auto/landscape/portrait | auto |
--sort | 排序:name/time/none | name |
--split | 启用长截图切割(nup 模式) | 关闭 |
--split-height | 切割段高(px);不传 = 按 A4 比例(图宽 × √2);vertical 模式忽略 | A4 比例 |
--dry-run | 仅预览不输出 | false |
两种模式对照
| 维度 | nup | vertical |
|---|---|---|
| 是否切割 | 视 --split 而定 | 不切(强制) |
| 每页图数 | 1/2/3/4 | 必为 1 |
| 页面尺寸 | A4 固定 | 宽度固定 A4 595pt,高度按图等比 |
| 适用场景 | 微信聊天、视频截图、证据照片 | 庭审笔录、单页长截图 |
交付检查
完成后检查:
1. 输出 PDF 页数 = ceil(总图片数 / per-page)(nup 模式)或 = 图片数(vertical 模式)。 2. 每页图片清晰可读,没有超出页面边界。 3. 页边距合理,打印时不会裁切内容。 4. 横竖方向正确(手机截图横版并排,视频截图三列等)。 5. 原始图片和 PDF 未被修改或删除。 6. 长截图模式:切割段高符合 --split-height 或 A4 比例默认;vertical 模式页面高度 = 图高 × (A4 宽 - 2×margin) / 图宽 + 2×margin。 7. vertical 模式:临时目录已清理(/tmp/img2pdf-splits-* 不残留)。
Changelog
v1.2.0 (2026-06-11)
长截图模式:解决"超长截图(微信聊天、庭审笔录)→ PDF"的两种典型需求。
新增
- `--mode {nup, vertical}` 编排模式切换
nup(默认):复用现有 N 张/页编排vertical:单图成单页,页面高度按图等比自适应,强制 portrait- `--split` 长截图切割开关(nup 模式)
- 启用后按
--split-height把超长图切成多段再走 N 张/页 - 切割段存临时目录(
/tmp/img2pdf-splits-*),流程结束自动清理 - `--split-height N` 显式覆盖切割段高(像素)
- 不传时按 A4 比例自动算(
图宽 × √2 ≈ 图宽 × 1.414),让每段宽高比 1:1.414 - 适合 N 张/页编排不留白
- 短图自动跳过切割:图高 ≤ 段高时不切,打印
图片 xxx.png 高度 Hpx,未触发切割 - 健康检查:总切出段数 > 原图数 × 5 时打印警告,建议调大
--split-height - vertical 模式 + 误用提示:vertical 模式下传
--split/--split-height静默忽略 + 打印⚠️ vertical 模式不支持切割,--split / --split-height 被忽略 - vertical 模式 + PDF 跳过:vertical 模式不处理 PDF 输入(PDF 自带分页),跳过并提示
适用场景
- 微信聊天记录长截图:
--split --per-page 3(按 A4 比例自动切,3 张/页编排) - 庭审笔录长截图:
--mode vertical(不切,整图一长页,保留上下逻辑)
兼容性
- 默认行为完全不变:
--mode nup、未传--split,所有 v1.0.0 / v1.1.0 用法零迁移 - 现有测试用例继续通过
文档
SKILL.md增加 vertical 模式 + 长截图切割 examples、参数表、模式对照表references/layout-examples.md增加 vertical 模式与长截图切割示意图DECISIONS.mdDEC-004 记录设计决策
实机验证
5 个测试用例全部通过(详见 TASKS.md v1.2.0 实机测试清单):
1. 微信场景(1080×6000 + --split --per-page 3)→ 切 4 段、2 页 A4 横版 2. 笔录场景(1080×5000 + --mode vertical)→ 不切、1 页 595×2573pt 3. vertical + --split-height 1500 → 提示忽略,仍不切 4. 短图(1080×1500 + --split)→ 提示未触发切割 5. vertical + PDF 输入 → PDF 跳过,仅图出页
v1.1.0 (2026-06-01)
--per-page默认改为 auto 模式:竖版图多 → 3张/页,横版图多 → 1张/页- 修复
process()中重复处理输入文件的 bug
v1.0.0 (2026-06-01)
- 初始版本
- 支持图片目录、多图片文件、已有 PDF 三种输入模式
- 支持 1/2/3/4 张每页编排
- 自动检测横竖方向选择 A4 横版或竖版
- 可配置页边距
- 支持 dry-run 预览
MIT License
Copyright (c) 2026 杨卫薪律师
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
布局示例
per-page=2(A4 横版,左右并排)
适用于手机截图(竖版 1206×2622 等)。
┌─────────────────────────────────────────┐
│ A4 横版 842×595 pt │
│ ┌──────────┐ margin ┌──────────┐ │
│ │ │ │ │ │
│ │ 图 1 │ │ 图 2 │ │
│ │ │ │ │ │
│ │ │ │ │ │
│ └──────────┘ └──────────┘ │
│ margin │
└─────────────────────────────────────────┘per-page=3(A4 横版,三列并排)
适用于视频截图(竖版手机录制画面)。
┌───────────────────────────────────────────────┐
│ A4 横版 842×595 pt │
│ ┌────┐ ┌────┐ ┌────┐ │
│ │ │ │ │ │ │ │
│ │ 1 │ │ 2 │ │ 3 │ │
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │
│ └────┘ └────┘ └────┘ │
└───────────────────────────────────────────────┘per-page=1(A4 单张,自动横竖)
根据图片方向自动选择 A4 横版或竖版。
竖版图片: 横版图片:
┌──────────┐ ┌─────────────────────┐
│ │ │ │
│ │ │ │
│ 图片 │ │ 图片 │
│ │ │ │
│ │ └─────────────────────┘
│ │
└──────────┘
A4 竖版 A4 横版margin 效果
--margin 控制图片与页面边缘的距离。默认 25pt(约 8.8mm)。
--margin 0:图片紧贴页面边缘(不推荐打印)。--margin 25:标准页边距(默认)。--margin 40:较宽页边距,适合装订。
vertical 模式(v1.2.0,--mode vertical)
适用于"必须保持上下连贯"的长截图(庭审笔录、合同条款全文截图等)。
- 不切割,整张图直接成单页
- 页面宽度固定 A4 portrait 宽(595pt 减 margin)
- 页面高度按图等比缩放(高比宽大几倍到几十倍)
- 强制 portrait(页面高度 > 宽度)
原图(1080×5000 px): 输出 PDF 单页:
┌──────────┐ ┌────────────────────────────┐
│ 庭审 │ │ margin │
│ 笔录 │ │ ┌──────────────────────┐ │
│ 内容 │ │ │ │ │
│ ... │ │ │ 整张长图等比缩放 │ │
│ │ → │ │ 高 = 5000×缩放 │ │
│ │ │ │ │ │
│ │ │ └──────────────────────┘ │
│ │ │ margin │
│ │ │ (页面高度自适应) │
│ ... │ │ │
│ (5000 │ │ 页面总高 ≈ 2573pt │
│ px 高) │ │ (5000 × 545/1080 + 50) │
└──────────┘ └────────────────────────────┘长截图切割(v1.2.0,--split + --per-page)
适用于"内容允许被切"的超长截图(微信聊天记录、视频连续截图等)。
--split启用切割- 切割段高默认按 A4 比例(
图宽 × √2)计算,让每段宽高比 1:1.414 - 切完段按
--per-page编排到 A4(与原有 N 张/页完全一致)
原图(1080×6000 px): 按 A4 比例 1527px 切 4 段: A4 横版 3 张/页 → 2 页 PDF:
┌──────────┐ ┌──────────┐ ┌──────────────────────┐
│ WeChat │ │ 段 1 │ 1527px │ 段 1 段 2 段 3 │
│ 聊天 │ │ │ │ │
│ 内容 │ → ├──────────┤ ├──────────────────────┤
│ ... │ │ 段 2 │ 1527px │ 段 4 (空) (空) │
│ │ │ │ │ │
│ │ ├──────────┤ └──────────────────────┘
│ │ │ 段 3 │ 1527px 2 页 PDF
│ │ │ │ (ceil(4/3) = 2)
│ │ ├──────────┤
│ │ │ 段 4 │ 1420px
│ │ │ (余 1520)│
└──────────┘ └──────────┘
6000px 4 段切割段命名:{原 stem}_{001..N}.png(3 位补零),存临时目录,流程结束清理。
#!/usr/bin/env python3
"""Arrange images or PDF pages into a new PDF with N items per A4 page."""
from __future__ import annotations
import argparse
import io
import math
import shutil
import sys
import tempfile
import uuid
from datetime import datetime
from pathlib import Path
from typing import Any
def load_deps() -> None:
try:
import pypdf # noqa: F401
import fitz # noqa: F401
from PIL import Image # noqa: F401
except ImportError as exc:
print(f"Missing dependency: {exc}", file=sys.stderr)
print("Install: python3 -m pip install -r scripts/requirements.txt", file=sys.stderr)
raise SystemExit(1) from exc
# A4 sizes in points
A4_PORTRAIT = (595.0, 842.0)
A4_LANDSCAPE = (842.0, 595.0)
A4_RATIO = math.sqrt(2) # height / width ≈ 1.414
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
def collect_inputs(paths: list[str], sort: str) -> list[Path]:
"""Expand directories and filter to image/PDF files."""
from PIL import Image as PILImage
result: list[Path] = []
for raw in paths:
p = Path(raw).expanduser().resolve()
if p.is_dir():
for ext in IMAGE_EXTENSIONS:
result.extend(p.glob(f"*{ext}"))
result.extend(p.glob(f"*{ext.upper()}"))
elif p.suffix.lower() in IMAGE_EXTENSIONS:
result.append(p)
elif p.suffix.lower() == ".pdf":
result.append(p)
else:
print(f"Warning: skipping unsupported file: {p}", file=sys.stderr)
if sort == "name":
result.sort(key=lambda x: x.name)
elif sort == "time":
result.sort(key=lambda x: x.stat().st_mtime)
return result
def compute_split_height(img_w: int, img_h: int, override: int | None) -> tuple[int, str]:
"""Compute the per-segment height in pixels.
If override is given, use it directly. Otherwise, return (img_w * A4_RATIO)
so that each cropped segment keeps the 1:√2 aspect ratio matching A4.
Returns (height_px, source_label) where source_label is "explicit" or "a4_ratio".
"""
if override is not None:
if override <= 0:
raise ValueError(f"--split-height must be a positive integer, got {override}")
return int(override), "explicit"
return int(round(img_w * A4_RATIO)), "a4_ratio"
def split_image(img_path: Path, split_height: int, tmp_dir: Path) -> list[Path]:
"""Split a single image into N segments of split_height pixels each.
Short images (height <= split_height) are returned as-is (single element list).
Output paths are inside tmp_dir; the caller is responsible for tmp_dir lifecycle.
"""
from PIL import Image
img = Image.open(img_path)
w, h = img.size
if h <= split_height:
img.close()
return [img_path]
segments: list[Path] = []
n_segments = math.ceil(h / split_height)
stem = img_path.stem
# Preserve original extension for the split files
ext = img_path.suffix if img_path.suffix else ".png"
for i in range(n_segments):
top = i * split_height
bottom = min(top + split_height, h)
crop = img.crop((0, top, w, bottom))
seg_path = tmp_dir / f"{stem}_{i + 1:03d}{ext}"
crop.save(seg_path)
segments.append(seg_path)
img.close()
return segments
def img_to_pdf_page(img_path: Path) -> tuple[Any, float, float]:
"""Convert an image file to a single-page PDF. Returns (page_object, width, height)."""
import fitz
from pypdf import PdfReader
doc = fitz.open(str(img_path))
img_page = doc[0]
w, h = img_page.rect.width, img_page.rect.height
pdf_doc = fitz.open()
pdf_page = pdf_doc.new_page(width=w, height=h)
pdf_page.insert_image(pdf_page.rect, filename=str(img_path))
buf = io.BytesIO()
pdf_doc.save(buf)
pdf_doc.close()
doc.close()
buf.seek(0)
reader = PdfReader(buf)
page_obj = reader.pages[0]
# Keep reader alive via page_obj attribute to prevent GC of BytesIO
page_obj._reader_ref = reader # type: ignore[attr-defined]
page_obj._buf_ref = buf # type: ignore[attr-defined]
return page_obj, w, h
def pdf_to_page_readers(pdf_path: Path) -> list[tuple[Any, float, float]]:
"""Read each page of a PDF as a (reader, w, h) tuple."""
from pypdf import PdfReader
reader = PdfReader(str(pdf_path))
pages = []
for page in reader.pages:
w, h = float(page.mediabox.width), float(page.mediabox.height)
pages.append((page, w, h))
return pages
def img_to_vertical_page(img_path: Path, page_w_pt: float, margin_pt: float) -> tuple[Any, float, float]:
"""Build a single-page PDF sized as page_w_pt wide × image-aspect-scaled tall.
Treats image pixels as PDF points (1 px = 1 pt) — typical phone screenshot
is 1080×5000 px, which becomes a 1080×5000 pt page when fully scaled. The image
is scaled to fill the usable width (page_w_pt - 2*margin_pt), and the page
height is the scaled image height + 2*margin_pt. Force portrait orientation
(height is always >= usable_w because the source is a long screenshot).
"""
from PIL import Image as PILImage
import fitz
from pypdf import PdfReader
pil_img = PILImage.open(img_path)
img_w, img_h = pil_img.size
pil_img.close()
usable_w = page_w_pt - 2 * margin_pt
if usable_w <= 0:
raise ValueError(f"margin={margin_pt} too large for page width={page_w_pt}")
scale = usable_w / img_w
scaled_h = img_h * scale
page_h = scaled_h + 2 * margin_pt
doc = fitz.open()
page = doc.new_page(width=page_w_pt, height=page_h)
img_rect = fitz.Rect(margin_pt, margin_pt, margin_pt + usable_w, margin_pt + scaled_h)
page.insert_image(img_rect, filename=str(img_path))
buf = io.BytesIO()
doc.save(buf)
doc.close()
buf.seek(0)
reader = PdfReader(buf)
page_obj = reader.pages[0]
page_obj._reader_ref = reader # type: ignore[attr-defined]
page_obj._buf_ref = buf # type: ignore[attr-defined]
return page_obj, page_w_pt, page_h
def build_pdf_vertical(
image_paths: list[Path],
output_path: Path,
margin: float,
dry_run: bool,
) -> dict[str, Any]:
"""Vertical mode: one image per page, page height follows image aspect ratio.
image_paths: list of image file paths. PDF inputs are not supported in vertical
mode (PDFs already have natural page boundaries — caller should split PDFs to
images first if they want each PDF page to be its own long page).
"""
from pypdf import PdfWriter
writer = PdfWriter()
page_w = A4_PORTRAIT[0]
total = len(image_paths)
output_pages = 0
total_page_h = 0.0
for img_path in image_paths:
page_obj, pw, ph = img_to_vertical_page(img_path, page_w, margin)
writer.add_page(page_obj)
output_pages += 1
total_page_h += ph
if dry_run:
avg_h = total_page_h / output_pages if output_pages else 0
print(f"Dry run (vertical): {total} items → {output_pages} pages")
print(f" Page width: {page_w}pt, avg height: {avg_h:.0f}pt")
return {"total_items": total, "output_pages": output_pages, "dry_run": True}
output_path.parent.mkdir(parents=True, exist_ok=True)
with output_path.open("wb") as f:
writer.write(f)
print(f"✅ {total}张 → {output_pages}页PDF(vertical 模式,每页一张,页面高度自适应)")
print(f" {output_path}")
return {"total_items": total, "output_pages": output_pages, "output": str(output_path)}
def compute_grid(per_page: int, page_size: tuple[float, float], margin: float) -> dict[str, Any]:
"""Compute cell layout for given per-page count and page size."""
a4_w, a4_h = page_size
if per_page == 1:
return {"cols": 1, "rows": 1, "cell_w": a4_w - 2 * margin, "cell_h": a4_h - 2 * margin}
# For per_page >= 2, use columns layout
cols = per_page
gap = margin
cell_w = (a4_w - (cols + 1) * gap) / cols
cell_h = a4_h - 2 * margin
return {"cols": cols, "gap": gap, "cell_w": cell_w, "cell_h": cell_h}
def pick_page_size(items: list[tuple[Any, float, float]], per_page: int, orientation: str) -> tuple[float, float]:
"""Determine output page size based on content orientation."""
if orientation == "landscape":
return A4_LANDSCAPE
if orientation == "portrait":
return A4_PORTRAIT
# auto: for per_page >= 2, default landscape; for 1, match content majority
if per_page >= 2:
return A4_LANDSCAPE
landscape_count = sum(1 for _, w, h in items if w > h)
portrait_count = len(items) - landscape_count
return A4_LANDSCAPE if landscape_count > portrait_count else A4_PORTRAIT
def build_pdf(
items: list[tuple[Any, float, float]],
output_path: Path,
per_page: int,
margin: float,
orientation: str,
dry_run: bool,
) -> dict[str, Any]:
"""Build the output PDF with N items per page."""
from pypdf import PdfWriter, Transformation
writer = PdfWriter()
total = len(items)
output_pages = 0
stats: dict[str, int] = {"landscape": 0, "portrait": 0}
for start in range(0, total, per_page):
chunk = items[start:start + per_page]
# per-page=1 且 orientation=auto 时,每页独立判断横竖
if per_page == 1 and orientation == "auto":
_, img_w, img_h = chunk[0]
if img_w > img_h:
cur_page_size = A4_LANDSCAPE
else:
cur_page_size = A4_PORTRAIT
else:
cur_page_size = pick_page_size(items, per_page, orientation)
grid = compute_grid(per_page, cur_page_size, margin)
a4_w, a4_h = cur_page_size
orient_label = "横版" if a4_w > a4_h else "竖版"
stats[orient_label] = stats.get(orient_label, 0) + 1
new_page = writer.add_blank_page(width=a4_w, height=a4_h)
for col_idx, (page_obj, img_w, img_h) in enumerate(chunk):
cell_w = grid["cell_w"]
cell_h = grid["cell_h"]
gap = grid.get("gap", margin)
scale = min(cell_w / img_w, cell_h / img_h)
scaled_w = img_w * scale
scaled_h = img_h * scale
tx = gap + col_idx * (cell_w + gap) + (cell_w - scaled_w) / 2
ty = margin + (cell_h - scaled_h) / 2
op = Transformation().scale(scale, scale).translate(tx, ty)
new_page.merge_transformed_page(page_obj, op)
output_pages += 1
if dry_run:
print(f"Dry run: {total} items → {output_pages} pages ({per_page}/page)")
for orient, count in stats.items():
if count:
print(f" A4 {orient}: {count} 页")
return {"total_items": total, "output_pages": output_pages, "dry_run": True}
output_path.parent.mkdir(parents=True, exist_ok=True)
with output_path.open("wb") as f:
writer.write(f)
orient_summary = "、".join(f"{k}{v}页" for k, v in stats.items() if v)
print(f"✅ {total}张 → {output_pages}页PDF(每页{per_page}张,{orient_summary},margin={margin}pt)")
print(f" {output_path}")
return {"total_items": total, "output_pages": output_pages, "output": str(output_path)}
def process(
paths: list[str],
output: str | None,
per_page: int,
margin: float,
orientation: str,
sort: str,
dry_run: bool,
mode: str = "nup",
split: bool = False,
split_height: int | None = None,
) -> int:
load_deps()
collected = collect_inputs(paths, sort)
if not collected:
print("No image or PDF files found.", file=sys.stderr)
return 1
print(f"Found {len(collected)} item(s)")
# Determine output path (used by both modes)
if output:
out_path = Path(output).expanduser().resolve()
else:
first = collected[0]
if len(collected) == 1 and first.is_dir():
out_path = first / "output.pdf"
else:
out_path = first.parent / f"{first.stem}_编排.pdf"
# --- Vertical mode (--mode vertical) ---
if mode == "vertical":
if split or split_height is not None:
print("⚠️ vertical 模式不支持切割,--split / --split-height 被忽略")
image_paths = [p for p in collected if p.suffix.lower() in IMAGE_EXTENSIONS]
pdf_paths = [p for p in collected if p.suffix.lower() == ".pdf"]
if pdf_paths:
print(
f"⚠️ vertical 模式不处理 PDF 输入({len(pdf_paths)} 个 PDF 已跳过):"
"PDF 自带分页,如需长页请先转图片"
)
if not image_paths:
print("No image files to process in vertical mode.", file=sys.stderr)
return 1
build_pdf_vertical(image_paths, out_path, margin, dry_run)
return 0
# --- N-up mode (default) ---
split_tmp_dir: Path | None = None
processed_paths: list[Path] = list(collected)
if split:
try:
split_tmp_dir = Path(tempfile.mkdtemp(prefix="img2pdf-splits-"))
except OSError as exc:
print(f"Failed to create temp dir: {exc}", file=sys.stderr)
return 1
from PIL import Image as PILImage
new_paths: list[Path] = []
for p in collected:
if p.suffix.lower() == ".pdf":
new_paths.append(p)
continue
try:
with PILImage.open(p) as probe:
img_w, img_h = probe.size
except Exception as exc:
print(f"Warning: 跳过 {p}({exc})", file=sys.stderr)
continue
try:
actual_h, source = compute_split_height(img_w, img_h, split_height)
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
if split_tmp_dir is not None:
shutil.rmtree(split_tmp_dir, ignore_errors=True)
return 1
if img_h <= actual_h:
print(f"图片 {p.name} 高度 {img_h}px,未触发切割")
new_paths.append(p)
continue
segments = split_image(p, actual_h, split_tmp_dir)
print(
f" 切割 {p.name} ({img_w}×{img_h}px) → {len(segments)} 段 "
f"({source} 段高={actual_h}px)"
)
new_paths.extend(segments)
if len(collected) > 0 and len(new_paths) > len(collected) * 5:
print(
f"⚠️ 切割后段数 {len(new_paths)} 远超原图数 {len(collected)} × 5,"
"建议调大 --split-height"
)
processed_paths = new_paths
try:
items: list[tuple[Any, float, float]] = []
for p in processed_paths:
if p.suffix.lower() == ".pdf":
items.extend(pdf_to_page_readers(p))
else:
items.append(img_to_pdf_page(p))
if not items:
print("No valid pages to process.", file=sys.stderr)
return 1
if per_page == 0:
portrait_count = sum(1 for _, w, h in items if h > w)
landscape_count = len(items) - portrait_count
per_page = 3 if portrait_count >= landscape_count else 1
print(f"Auto: 竖版{portrait_count}张、横版{landscape_count}张 → 每页{per_page}张")
build_pdf(items, out_path, per_page, margin, orientation, dry_run)
return 0
finally:
if split_tmp_dir is not None and split_tmp_dir.exists():
shutil.rmtree(split_tmp_dir, ignore_errors=True)
def main() -> int:
parser = argparse.ArgumentParser(
description="Arrange images/PDF pages into a multi-per-page A4 PDF, or render a single long screenshot as one tall PDF page."
)
parser.add_argument("--input", "-i", nargs="+", required=True, help="Image files, PDF files, or directories")
parser.add_argument("--output", "-o", help="Output PDF path (default: <first_input>_编排.pdf)")
parser.add_argument("--mode", choices=["nup", "vertical"], default="nup", help="Layout mode: nup (N items/page) or vertical (one image/page, page height follows image aspect). Default: nup")
parser.add_argument("--per-page", "-n", type=int, default=0, help="Items per page in nup mode: 1/2/3/4, or omit for auto (default: auto, 3 for portrait images, 1 for landscape)")
parser.add_argument("--margin", "-m", type=float, default=25, help="Page margin in pt (default: 25)")
parser.add_argument("--orientation", choices=["auto", "landscape", "portrait"], default="auto", help="Page orientation in nup mode (default: auto)")
parser.add_argument("--sort", choices=["name", "time", "none"], default="name", help="Sort order for directory inputs (default: name)")
parser.add_argument("--split", action="store_true", help="Split long screenshots into segments before layout (nup mode only)")
parser.add_argument("--split-height", type=int, default=None, help="Override split segment height in px (default: A4 ratio = img_w × √2). Ignored in vertical mode.")
parser.add_argument("--dry-run", action="store_true", help="Preview without writing")
args = parser.parse_args()
if args.margin < 0:
parser.error("--margin must be >= 0")
if args.per_page not in (0, 1, 2, 3, 4):
parser.error("--per-page must be 1, 2, 3, 4, or omit for auto")
if args.split_height is not None and args.split_height <= 0:
parser.error("--split-height must be a positive integer")
return process(
args.input,
args.output,
args.per_page,
args.margin,
args.orientation,
args.sort,
args.dry_run,
mode=args.mode,
split=args.split,
split_height=args.split_height,
)
if __name__ == "__main__":
raise SystemExit(main())
pypdf>=4.0.0
Pillow>=10.0.0
PyMuPDF>=1.24.0