Metadata-Version: 2.5
Name: pdf-sign-kit
Version: 0.5.2
Summary: Framework-agnostic Python toolkit for stamping signatures and text onto PDF: automatic table-cell detection, text-anchor placement, seam stamps, watermarks, placement templates, a paperless workflow rule engine with configurable signing levels, log tables, cross-platform CJK fonts, page preview and integrity hashing.
Author-email: 兰衣 <iehjli@163.com>
License-Expression: MIT
License-File: LICENSE
Keywords: chinese,cjk,paperless,pdf,pymupdf,sign,signature,stamp
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Office/Business
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.9
Requires-Dist: numpy>=1.24
Requires-Dist: opencv-python-headless>=4.8
Requires-Dist: pillow>=10
Requires-Dist: pymupdf>=1.23
Provides-Extra: dev
Requires-Dist: build>=1.2; extra == 'dev'
Requires-Dist: pytest>=7; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Requires-Dist: twine>=5; extra == 'dev'
Description-Content-Type: text/markdown

# pdf-sign-kit

[![PyPI version](https://img.shields.io/badge/pypi-v0.5.2-blue)](https://pypi.org/project/pdf-sign-kit/)
[![Python](https://img.shields.io/badge/python-3.9%2B-success)](https://www.python.org/)
[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)

[English](#english) | [中文文档](#中文文档)

---

<a id="english"></a>
## English

pdf-sign-kit is a framework-agnostic Python toolkit for stamping **signatures,
seals and machine text** onto PDF documents. It works directly with **local
files**: no web framework, no database, and no network connection are required.

### Feature map

| Capability | Module | Key API |
|---|---|---|
| Inspect pages (count / size / raster preview) | `preview` | `page_count`, `page_size`, `render_page`, `render_page_png` |
| Detect table cells from borders | `geometry` | `find_table_cell`, `TableCell` |
| Stamp images onto pages | `placement` | `stamp`, `stamp_batch`, `StampResult` |
| Cross-platform font discovery | `fonts` | `find_cjk_font`, `load_cjk_font`, … |
| Raster text at 600 DPI, wrap support | `text` | `render_text_image`, `render_wrapped_text_image`, `stamp_text` |
| Built-in "同意" / check-mark stamps | `stamps` | `make_agree_image`, `make_checkmark_image`, `stamp_agree`, `stamp_checkmark` |
| Make near-white backgrounds transparent | `background` | `remove_background` |
| Bitmap concatenation / scaling | `compose` | `hconcat`, `vconcat`, `scale_to_height`, `scale_to_width` |
| Locate stamps by visible text | `anchor` | `find_text`, `find_all_text`, `stamp_at_anchor` |
| Diagonal watermarks | `watermark` | `render_watermark_overlay`, `stamp_watermark` |
| Cross-page seam stamps (骑缝章) | `seam` | `stamp_seam` |
| Configurable signing schemes | `scheme` | `FlowScheme`, `LevelSpec`, `Initiator`, `build_metadata` |
| Signing rule engine | `workflow` | `can_sign`, `can_sign_level`, `all_completed`, `all_signed_strict` |
| Identity normalization | `identity` | `bare_id`, `identity_matches` |
| Signature log tables | `logtable` | `render_log_image`, `render_log_png_bytes`, `render_log_data_url` |
| Chinese keyword extraction | `keywords` | `extract_keywords` |
| Reusable placement templates | `template` | `PlacementTemplate`, `OpSpec`, `save_template`, `load_template` |
| Exception hierarchy | `exceptions` | `PdfSignKitError`, `InvalidPdfError`, … |

### Installation

```bash
pip install pdf-sign-kit
```

Requirements (installed automatically): Python ≥ 3.9, PyMuPDF, Pillow,
opencv-python-headless, NumPy.

### Quickstart

```python
import pdf_sign_kit as psk

# 1. Inspect the document
print(psk.page_count("form.pdf"))                 # e.g. 2
print(psk.page_size("form.pdf", page=0))          # (595.0, 842.0)

# 2. Stamp an existing signature image; the file is edited in place
result = psk.stamp("form.pdf", "signature.png", x=200, y=127)
print(result.center_x, result.center_y, result.page)

# 3. Built-in stamps
psk.stamp_agree("form.pdf", x=200, y=227)
psk.stamp_checkmark("form.pdf", x=200, y=327)

# 4. Mixed Chinese/Latin machine text
psk.stamp_text("form.pdf", "编号 XX-001", x=100, y=700, font_size=12)

# 5. Clean a scan, render a preview, compute an integrity hash
transparent = psk.remove_background("scan.png", threshold=235)
transparent.save("stamp.png")
preview = psk.render_page("form.pdf", page=0, zoom=3)
digest = psk.hash_file("form.pdf")                # SHA-256 hex string
```

---

### Core concepts

**Coordinate system.** All public stamping functions take `x` / `y` in **PDF
points** (1 pt = 1/72 inch) measured from the page's **bottom-left corner**
in the orientation the page is displayed in. Page indices are **zero-based**.

**Page rotation is handled internally.** On pages rotated by 90/180/270
degrees you still pass display-orientation coordinates; the library maps them
through the page's de-rotation matrix automatically.

**Participant metadata.** The workflow engine consumes a plain list of dict
records. Fields produced by `build_metadata`:

| Field | Meaning |
|---|---|
| `person` | signer identifier (ID string) |
| `name` | display name |
| `department` | department/display text |
| `role` | `initiator` / `signer` |
| `state` | status code (see below) |
| `sign_time`, `upload_time` | ISO timestamps |
| `sign_order`, `level` | ordering / signing level |
| `sign_type` | `personal` / `group` / `department` |
| `is_proxy` | proxy signature flag |
| `sign_group` | present on GROUP levels (personal countersign) |
| `department_sign_group` | present on DEPARTMENT levels |
| `rejected_reason` | reason text after rejection |

**State codes.** `"0"` pending · `"1"` signed · `"2"` completed by another
member of the same group · `"3"` rejected.

**Stamp modes.** `GENERAL` ordinary signature · `HANDWRITTEN` loose
handwriting fit · `TEXT` rasterized text · `AGREE` fixed "同意" stamp ·
`CHECKMARK` check mark.

---

### Module guide

#### 1. `preview` — inspect and render pages

```python
psk.page_count("form.pdf")                        # -> int
psk.page_size("form.pdf", page=0)                 # -> (width_pt, height_pt)
img = psk.render_page("form.pdf", page=0, zoom=3, alpha=False)   # PIL image
png = psk.render_page_png("form.pdf", page=0, zoom=3)            # bytes
```

`zoom` scales relative to 72 DPI (3 → 216 DPI). `alpha=True` renders RGBA.

#### 2. `geometry` — automatic table-cell detection

```python
cell = psk.find_table_cell("form.pdf", page=0, x=200, y=127, dpi=300)
# TableCell(left, top, width, height, raw_left) or None
```

The page is rasterized once as grayscale; only the row/column crossing the
click is analyzed (1-D Otsu). `raw_left` is the exact detected border;
`left` carries a small inward offset. Searches that would exceed
`max_width` / `max_height` (defaults 2000 / 40 pt) return `None`.

#### 3. `placement` — the stamping core

```python
result = psk.stamp(
    "form.pdf", "signature.png",
    x=200, y=127,
    page=0,
    out_path=None,          # None -> edit in place via a temp file + atomic commit
    width=None, height=None,
    mode=psk.GENERAL,
    detect_cell=True,       # False -> place at natural size, skip cell detection
)
# StampResult(center_x, center_y, page, pdf_path)
```

Batch many stamps in a single open/save cycle — one failed operation yields
`None` at its position without aborting the rest:

```python
results = psk.stamp_batch(
    "form.pdf",
    [
        {"image": "signature.png", "x": 200, "y": 127, "page": 0},
        {"image": "date.png", "x": 200, "y": 227, "page": 0,
         "mode": psk.TEXT, "detect_cell": False},
    ],
)
```

Operation keys: `image`, `x`, `y`, optional `page`, `mode`, `width`,
`height`, `logical_size`, `cell`, `detect_cell`. `image` accepts a path,
PIL image, or bytes.

#### 4. `fonts` — cross-platform font discovery

```python
psk.find_cjk_font()       # serif-first path (SimSun / Noto Serif), or None
psk.find_ui_cjk_font()    # sans-serif-first (Microsoft YaHei / PingFang / Noto Sans)
psk.find_latin_font()     # Arial / Times / Liberation / DejaVu
font = psk.load_cjk_font(24)                 # cached FreeTypeFont
font = psk.load_ui_cjk_font(24)
```

Explicit paths are honored first. No font is bundled. `resolve_cjk_font()`
raises `FontNotFoundError` (with install guidance) when nothing usable
exists.

#### 5. `text` — high-resolution raster text (600 DPI)

```python
single = psk.render_text_image(
    "编号 XX-001", font_size=12, color=(0, 0, 128),
)
# TextImage(image, width, height) — logical size in PDF points

wrapped = psk.render_wrapped_text_image(
    "本文件仅用于……", font_size=12, max_width=200, line_spacing=1.3,
)

psk.stamp_text("form.pdf", "编号 XX-001", x=100, y=700)
```

Chinese/Latin runs are split and drawn with their respective fonts.
`horizontal_scale` stretches the image horizontally when provided.

#### 6. `stamps` — built-in images and convenience stamps

```python
mark = psk.make_checkmark_image(size=30, color=(0, 0, 0, 255), stroke=1)
agree = psk.make_agree_image("同意", font_size=14)

psk.stamp_checkmark("form.pdf", x=200, y=327)
psk.stamp_agree("form.pdf", x=200, y=227)
```

#### 7. `background` — transparent stamps from scans

```python
img = psk.remove_background("scan.png", threshold=235)   # -> RGBA image
```

Pixels whose RGB channels are all ≥ `threshold` become fully transparent.
Accepts a path, PIL image, or bytes.

#### 8. `compose` — bitmap assembly

```python
strip = psk.hconcat([name_img, date_img], gap=8, align="middle")
stack = psk.vconcat([title_img, body_img], gap=4, align="center")
bigger = psk.scale_to_height(small_img, target_height=60)
wider = psk.scale_to_width(small_img, target_width=200)
```

Cross-axis alignment: hconcat `top`/`middle`/`bottom`; vconcat
`left`/`center`/`right`. Inputs are never modified.

#### 9. `anchor` — text-anchor placement (borderless documents)

```python
anchor = psk.find_text("contract.pdf", "签字：")            # first match
anchors = psk.find_all_text("contract.pdf", "签字：")       # every match
# restrict search: pages=[0,1]; pick occurrence=2

psk.stamp_at_anchor(
    "contract.pdf", "signature.png", anchor,
    position=psk.AFTER,     # AFTER / BELOW / OVER the matched text
    gap=3,
)
```

The anchor exposes `left/top/width/height` and `point(page_height,
position, gap)` for custom geometry; `to_cell()` returns a `TableCell`.

#### 10. `watermark` — diagonal watermarks

```python
overlay = psk.render_watermark_overlay(
    595, 842, "作废", font_size=72, angle=45,
    color=(220, 38, 38, 110), tile=False,
)

psk.stamp_watermark(
    "contract.pdf", "作废", pages=[0, 1],
    color=(220, 38, 38, 90), tile=True,
)
```

`color` is RGBA — alpha directly controls opacity. Tiling repeats the
caption over the whole page instead of one centered mark.

#### 11. `seam` — cross-page seam stamps

```python
results = psk.stamp_seam(
    "contract.pdf", "seal.png",
    start_page=0,
    page_count=None,             # None -> all remaining pages
    edge=psk.RIGHT,              # or psk.LEFT
    visible_width_pt=None,       # default: 45% of seal width
    y_center_pt=None,            # default: page vertical center (top-origin)
)
```

The seal is sliced into one strip per page; strips sit partly outside the
page boundary and reassemble when the pages are fanned. Rotated pages are
supported.

#### 12. `scheme` — configurable signing schemes

```python
from pdf_sign_kit import PERSONAL, GROUP, DEPARTMENT

# Build a scheme directly
scheme = psk.FlowScheme(
    levels=(
        psk.LevelSpec(level=1, name="起草", mode=PERSONAL),
        psk.LevelSpec(level=2, name="会签", mode=GROUP),
        psk.LevelSpec(level=3, name="部门会签", mode=DEPARTMENT),
    )
)

# Or use the factory: N contiguous levels with name/mode overrides
scheme = psk.FlowScheme.create(
    3,
    names={1: "起草", 2: "会签", 3: "部门会签"},
    modes={2: GROUP, 3: DEPARTMENT},
)
psk.FlowScheme.default()          # built-in 8-level scheme
```

- `PERSONAL` — everyone assigned to the level must sign;
- `GROUP` — any one member signs and the level completes (a `sign_group`
  field is generated);
- `DEPARTMENT` — each department needs one signer; all departments must
  finish (uses `department_sign_group`).

`LevelSpec` and `FlowScheme` are immutable, validated, and round-trip
through `to_dict()` / `from_dict()`.

The `Initiator` describes the document originator — either a level-0
marker only, or a participant who signs at a given level:

```python
initiator = psk.Initiator(id="I", name="发起人", level=0, require_signature=False)
```

`build_metadata(scheme, assignments, initiator=None)` produces the
participant records; assignments map level → list of person descriptors
(`{"id", "name"}`; DEPARTMENT people also carry `group` / `group_name`).
Pass `now=<callable>` for deterministic timestamps.

#### 13. `workflow` — signing rule engine

```python
can, message = psk.workflow.can_sign(metadata, "A")
can, message = psk.workflow.can_sign_level(metadata, "A", target_level=2)
done, message = psk.workflow.all_completed(metadata)
strict = psk.workflow.all_signed_strict(metadata)
```

The engine enforces level ordering, in-level `sign_order`, group/department
gating, and blocks any document containing a `"3"` rejected record.

#### 14. `identity` — identifier normalization

```python
psk.bare_id("123 - Zhang")        # -> "123"
psk.identity_matches("00123", "123")   # -> True (numeric, leading zeros)
```

#### 15. `logtable` — signature log tables

```python
sig_logs = [
    {"工号": "A", "姓名": "甲", "等级": "Lv1", "签字时间": "2026-09-28T10:00"},
]
rejection_logs = []

image = psk.render_log_image("contract.pdf", sig_logs, rejection_logs)
buffer = psk.render_log_png_bytes("contract.pdf", sig_logs, rejection_logs)
url = psk.render_log_data_url("contract.pdf", sig_logs, rejection_logs)
# "data:image/png;base64,...."
```

State colors: signed = green, pending = amber, rejected = red.

#### 16. `keywords` — Chinese keyword extraction

```python
psk.extract_keywords("图纸标注不清晰，请核对")   # -> ["图纸", "标注", "清晰", "核对"]
```

Extracts 2–4 character CJK runs, filters a built-in stop-word set, and
falls back to a truncated slice of the input when nothing matches.

#### 17. `template` — reusable placement templates

```python
template = psk.PlacementTemplate.capture(
    [{"image": "signature.png", "x": 200, "y": 127, "page": 0}],
    name="Form-A",
)
psk.save_template("form_a.json", template)

template = psk.load_template("form_a.json")
operations = template.instantiate({"sign": "new_signature.png"})
psk.stamp_batch("another_file.pdf", operations)
```

Templates store logical `ref` names instead of concrete image paths;
`refs` lists the required names, and `instantiate()` accepts an optional
`page_offset`.

#### 18. `exceptions` — error hierarchy

`PdfSignKitError` is the base. Subclasses: `InvalidPdfError`,
`InvalidImageError`, `PageOutOfRangeError`, `FontNotFoundError`,
`PlacementError`.

---

### End-to-end: a complete signing workflow

The following script is fully self-contained and runnable:

```python
import os
import tempfile
from datetime import datetime

import fitz
from PIL import Image, ImageDraw

import pdf_sign_kit as psk
from pdf_sign_kit import FlowScheme, Initiator, LevelSpec, PERSONAL, GROUP

work = tempfile.mkdtemp(prefix="capstone_")
pdf_path = os.path.join(work, "contract.pdf")

# Step 1 — create a two-page PDF with two bordered signing cells
doc = fitz.open()
page = doc.new_page(width=595, height=842)
page.draw_rect(fitz.Rect(100, 700, 300, 730))
page.draw_rect(fitz.Rect(100, 600, 300, 630))
page.insert_text((100, 690), "Lv1 Sign:", fontsize=10)
page.insert_text((100, 590), "Lv2 Sign:", fontsize=10)
doc.new_page(width=595, height=842)
doc.save(pdf_path)
doc.close()

# Step 2 — draw a signature, remove its white background
sig = Image.new("RGB", (120, 50), (255, 255, 255))
ImageDraw.Draw(sig).line(
    [(10, 40), (40, 10), (70, 35), (110, 15)], fill=(0, 0, 0), width=3
)
sig_path = os.path.join(work, "sign.png")
psk.remove_background(sig, threshold=235).save(sig_path)

# Step 3 — define a two-level scheme: personal draft, then countersign
scheme = FlowScheme(
    levels=(
        LevelSpec(level=1, name="起草", mode=PERSONAL),
        LevelSpec(level=2, name="会签", mode=GROUP),
    )
)
metadata = build_metadata(
    scheme,
    {
        1: [{"id": "A", "name": "甲"}],
        2: [{"id": "B", "name": "乙"}, {"id": "C", "name": "丙"}],
    },
    initiator=Initiator(id="I", name="发起人"),
)

# Click points at each cell's center (bottom-left origin)
clicks = {1: (200.0, 842 - 715.0), 2: (200.0, 842 - 615.0)}

# Step 4 — sign each level: ask the engine, stamp, update the records
for level in (1, 2):
    pending = [r for r in metadata if r["level"] == level and r["state"] == "0"]
    signer = pending[0]
    can, message = psk.workflow.can_sign(metadata, signer["person"])
    assert can, message
    x, y = clicks[level]
    psk.stamp(pdf_path, sig_path, x=x, y=y, page=0)
    signer["state"] = "1"
    signer["sign_time"] = datetime.now().isoformat()
    for other in pending[1:]:                      # remaining GROUP members
        other["state"] = "2"
        other["sign_group_completed_by"] = signer["person"]

done, message = psk.workflow.all_completed(metadata)
assert done

# Step 5 — seam stamp across both pages, watermark the first page
psk.stamp_seam(pdf_path, sig_path)
psk.stamp_watermark(pdf_path, "作废", pages=[0], color=(220, 38, 38, 80))

# Step 6 — render the signature log
sig_logs = [
    {"工号": r["person"], "姓名": r["name"], "等级": f'Lv{r["level"]}',
     "签字时间": r["sign_time"]}
    for r in metadata if r["role"] == "signer" and r.get("sign_time")
]
log_url = psk.render_log_data_url("contract.pdf", sig_logs, [])

# Step 7 — integrity hash; save/reuse a placement template
digest = psk.hash_file(pdf_path)
assert psk.verify_file_hash(pdf_path, digest)

template = psk.PlacementTemplate.capture(
    [{"image": sig_path, "x": 200.0, "y": 127.0, "page": 0}],
    name="contract-form",
)
psk.save_template(os.path.join(work, "tpl.json"), template)
```

### Troubleshooting / FAQ

- **`FontNotFoundError` on a minimal Linux image** — install a CJK font
  (`apt-get install fonts-noto-cjk`) or pass `cjk_font_path` /
  `font_path` explicitly.
- **`find_table_cell` returns `None`** — the point is not enclosed by
  detectable borders. Use `find_text` (text anchor), or stamp with
  `detect_cell=False` and explicit `width` / `height`.
- **My transparent stamp shows a white box on the page** — run
  `remove_background()` on the image before stamping.
- **The stamp lands at the wrong spot on a rotated page** — coordinates
  must be in the page's *display* orientation; do not pre-rotate them
  yourself.
- **GROUP level does not complete after one signature** — the records
  must carry `sign_group` (use `build_metadata` with a `GROUP` level);
  setting only `sign_type="group"` is insufficient.
- **`PageOutOfRangeError`** — pages are zero-based; check against
  `page_count()` first.

### Notes & limitations

- Stamps are **visual ("electronic") signatures** — raster images placed
  on the page — not PKI/certificate-based ("digital") signatures with
  legal non-repudiation guarantees.
- Raster text is flattened (not selectable text).
- No fonts are bundled; they are auto-detected on the host system.

### License

[MIT](LICENSE) © 兰衣

---

<a id="中文文档"></a>
## 中文文档

pdf-sign-kit 是一个与框架无关的 Python 工具包，用于在 PDF 文档上加盖
**签名、印章与机打文字**。库直接操作**本地文件**：不依赖 Web 框架、数据库
或网络连接。

### 功能对照表

| 能力 | 模块 | 主要 API |
|---|---|---|
| 页面检查（页数/尺寸/栅格预览） | `preview` | `page_count`、`page_size`、`render_page`、`render_page_png` |
| 按边框检测表格单元格 | `geometry` | `find_table_cell`、`TableCell` |
| 图片盖章 | `placement` | `stamp`、`stamp_batch`、`StampResult` |
| 跨平台字体发现 | `fonts` | `find_cjk_font`、`load_cjk_font` 等 |
| 600 DPI 机打文字、自动换行 | `text` | `render_text_image`、`render_wrapped_text_image`、`stamp_text` |
| 内置「同意」/打勾章 | `stamps` | `make_agree_image`、`make_checkmark_image`、`stamp_agree`、`stamp_checkmark` |
| 近白背景转透明 | `background` | `remove_background` |
| 位图拼接/缩放 | `compose` | `hconcat`、`vconcat`、`scale_to_height`、`scale_to_width` |
| 按可见文字定位 | `anchor` | `find_text`、`find_all_text`、`stamp_at_anchor` |
| 斜向水印 | `watermark` | `render_watermark_overlay`、`stamp_watermark` |
| 骑缝章 | `seam` | `stamp_seam` |
| 可配置签署方案 | `scheme` | `FlowScheme`、`LevelSpec`、`Initiator`、`build_metadata` |
| 签署规则引擎 | `workflow` | `can_sign`、`can_sign_level`、`all_completed`、`all_signed_strict` |
| 身份归一化 | `identity` | `bare_id`、`identity_matches` |
| 签字日志表 | `logtable` | `render_log_image`、`render_log_png_bytes`、`render_log_data_url` |
| 中文关键词提取 | `keywords` | `extract_keywords` |
| 可复用签署位置模板 | `template` | `PlacementTemplate`、`OpSpec`、`save_template`、`load_template` |
| 异常体系 | `exceptions` | `PdfSignKitError`、`InvalidPdfError` 等 |

### 安装

```bash
pip install pdf-sign-kit
```

依赖（自动安装）：Python ≥ 3.9、PyMuPDF、Pillow、opencv-python-headless、
NumPy。

### 快速上手

```python
import pdf_sign_kit as psk

# 1. 检查文件
print(psk.page_count("表单.pdf"))                 # 如 2
print(psk.page_size("表单.pdf", page=0))          # (595.0, 842.0)

# 2. 加盖已有签名图片，原地修改文件
result = psk.stamp("表单.pdf", "签名.png", x=200, y=127)
print(result.center_x, result.center_y, result.page)

# 3. 内置图章
psk.stamp_agree("表单.pdf", x=200, y=227)
psk.stamp_checkmark("表单.pdf", x=200, y=327)

# 4. 中英混排机打文字
psk.stamp_text("表单.pdf", "编号 XX-001", x=100, y=700, font_size=12)

# 5. 扫描件透视、渲染预览、计算完整性哈希
transparent = psk.remove_background("扫描件.png", threshold=235)
transparent.save("图章.png")
preview = psk.render_page("表单.pdf", page=0, zoom=3)
digest = psk.hash_file("表单.pdf")                # SHA-256 十六进制串
```

---

### 核心概念

**坐标系。** 所有盖章函数的 `x` / `y` 单位为 **PDF 点**（1 pt = 1/72
英寸），以页面显示方向的**左下角**为原点。页码从 **0** 开始。

**页面旋转由库内部处理。** 对旋转 90/180/270 度的页面，仍传显示方向
坐标，库会自动通过页面反旋转矩阵换算。

**参与者 metadata。** 工作流引擎使用一个由字典组成的普通列表。
`build_metadata` 生成的字段：

| 字段 | 含义 |
|---|---|
| `person` | 签署人标识（ID 字符串） |
| `name` | 显示姓名 |
| `department` | 部门/显示文本 |
| `role` | `initiator`（发起人）/ `signer`（签署人） |
| `state` | 状态码（见下） |
| `sign_time`、`upload_time` | ISO 时间戳 |
| `sign_order`、`level` | 排序/签署等级 |
| `sign_type` | `personal` / `group` / `department` |
| `is_proxy` | 是否代签 |
| `sign_group` | GROUP（个人会签）等级上存在 |
| `department_sign_group` | DEPARTMENT（部门会签）等级上存在 |
| `rejected_reason` | 驳回后的理由文本 |

**状态码。** `"0"` 待签署 · `"1"` 已签署 · `"2"` 由同组其他成员代签完成
· `"3"` 已驳回。

**盖章模式。** `GENERAL` 普通签名 · `HANDWRITTEN` 手写宽松适配 · `TEXT`
机打文字 · `AGREE` 固定「同意」章 · `CHECKMARK` 打勾。

---

### 模块详解

#### 1. `preview` —— 页面检查与渲染

```python
psk.page_count("表单.pdf")                        # -> int
psk.page_size("表单.pdf", page=0)                 # -> (宽pt, 高pt)
img = psk.render_page("表单.pdf", page=0, zoom=3, alpha=False)   # PIL 图像
png = psk.render_page_png("表单.pdf", page=0, zoom=3)            # bytes
```

`zoom` 相对 72 DPI 缩放（3 即 216 DPI）；`alpha=True` 输出 RGBA。

#### 2. `geometry` —— 表格单元格自动检测

```python
cell = psk.find_table_cell("表单.pdf", page=0, x=200, y=127, dpi=300)
# TableCell(left, top, width, height, raw_left)，找不到为 None
```

页面只渲染一次灰度图，仅分析穿过点击点的一行一列（1D Otsu 自适应
阈值）。`raw_left` 是精确检测到的边框，`left` 带少量内缩。检测结果
超过 `max_width` / `max_height`（默认 2000 / 40 pt）时返回 `None`。

#### 3. `placement` —— 盖章核心

```python
result = psk.stamp(
    "表单.pdf", "签名.png",
    x=200, y=127,
    page=0,
    out_path=None,          # None → 临时文件 + 原子提交，原地修改
    width=None, height=None,
    mode=psk.GENERAL,
    detect_cell=True,       # False → 按自然尺寸放置，跳过单元格检测
)
# StampResult(center_x, center_y, page, pdf_path)
```

批量盖章只需一次打开/保存；单个操作失败时该位返回 `None`，不影响
其他操作：

```python
results = psk.stamp_batch(
    "表单.pdf",
    [
        {"image": "签名.png", "x": 200, "y": 127, "page": 0},
        {"image": "日期.png", "x": 200, "y": 227, "page": 0,
         "mode": psk.TEXT, "detect_cell": False},
    ],
)
```

操作字典支持的键：`image`、`x`、`y`，以及可选的 `page`、`mode`、
`width`、`height`、`logical_size`、`cell`、`detect_cell`。`image`
接受路径、PIL 图像或字节。

#### 4. `fonts` —— 跨平台字体发现

```python
psk.find_cjk_font()       # 衬线优先（宋体 / Noto Serif），找不到为 None
psk.find_ui_cjk_font()    # 无衬线优先（雅黑 / PingFang / Noto Sans）
psk.find_latin_font()     # Arial / Times / Liberation / DejaVu
font = psk.load_cjk_font(24)                 # 带缓存的 FreeTypeFont
font = psk.load_ui_cjk_font(24)
```

显式路径优先；包不捆绑字体。无可用字体时 `resolve_cjk_font()` 抛出
`FontNotFoundError`（附安装指引）。

#### 5. `text` —— 高清机打文字（600 DPI）

```python
single = psk.render_text_image(
    "编号 XX-001", font_size=12, color=(0, 0, 128),
)
# TextImage(image, width, height)——逻辑尺寸以 PDF 点计

wrapped = psk.render_wrapped_text_image(
    "本文件仅用于……", font_size=12, max_width=200, line_spacing=1.3,
)

psk.stamp_text("表单.pdf", "编号 XX-001", x=100, y=700)
```

连续中文/西文分段后分别使用对应字体；提供 `horizontal_scale` 时可
横向拉伸。

#### 6. `stamps` —— 内置图像与便捷盖章

```python
mark = psk.make_checkmark_image(size=30, color=(0, 0, 0, 255), stroke=1)
agree = psk.make_agree_image("同意", font_size=14)

psk.stamp_checkmark("表单.pdf", x=200, y=327)
psk.stamp_agree("表单.pdf", x=200, y=227)
```

#### 7. `background` —— 扫描件透视

```python
img = psk.remove_background("扫描件.png", threshold=235)   # -> RGBA 图像
```

RGB 三通道均 ≥ `threshold` 的像素变为完全透明。接受路径、PIL 图像或
字节。

#### 8. `compose` —— 位图拼接

```python
strip = psk.hconcat([name_img, date_img], gap=8, align="middle")
stack = psk.vconcat([title_img, body_img], gap=4, align="center")
bigger = psk.scale_to_height(small_img, target_height=60)
wider = psk.scale_to_width(small_img, target_width=200)
```

交叉轴对齐方式：hconcat 用 `top`/`middle`/`bottom`；vconcat 用
`left`/`center`/`right`。输入图像不会被修改。

#### 9. `anchor` —— 文本锚点定位（无表格线文档）

```python
anchor = psk.find_text("合同.pdf", "签字：")            # 第一处匹配
anchors = psk.find_all_text("合同.pdf", "签字：")       # 全部匹配
# 可限定 pages=[0,1]；用 occurrence=2 选择第 3 处

psk.stamp_at_anchor(
    "合同.pdf", "签名.png", anchor,
    position=psk.AFTER,     # 匹配文字的 AFTER / BELOW / OVER
    gap=3,
)
```

锚点提供 `left/top/width/height` 与 `point(page_height, position,
gap)` 供自定义几何使用；`to_cell()` 可转为 `TableCell`。

#### 10. `watermark` —— 斜向水印

```python
overlay = psk.render_watermark_overlay(
    595, 842, "作废", font_size=72, angle=45,
    color=(220, 38, 38, 110), tile=False,
)

psk.stamp_watermark(
    "合同.pdf", "作废", pages=[0, 1],
    color=(220, 38, 38, 90), tile=True,
)
```

`color` 为 RGBA——alpha 直接决定透明度；平铺模式把文字重复布满整页，
否则只在中央放一个。

#### 11. `seam` —— 骑缝章

```python
results = psk.stamp_seam(
    "合同.pdf", "印章.png",
    start_page=0,
    page_count=None,             # None → 覆盖之后所有页
    edge=psk.RIGHT,              # 或 psk.LEFT
    visible_width_pt=None,       # 默认印章宽度的 45%
    y_center_pt=None,            # 默认页面垂直中心（自页面顶部起算）
)
```

印章按页数切成条，每条部分位于页外，展开拼合后还原完整印章；支持
旋转页面。

#### 12. `scheme` —— 可配置签署方案

```python
from pdf_sign_kit import PERSONAL, GROUP, DEPARTMENT

# 直接构造方案
scheme = psk.FlowScheme(
    levels=(
        psk.LevelSpec(level=1, name="起草", mode=PERSONAL),
        psk.LevelSpec(level=2, name="会签", mode=GROUP),
        psk.LevelSpec(level=3, name="部门会签", mode=DEPARTMENT),
    )
)

# 或用工厂：N 个连续等级，指定名称/方式覆盖
scheme = psk.FlowScheme.create(
    3,
    names={1: "起草", 2: "会签", 3: "部门会签"},
    modes={2: GROUP, 3: DEPARTMENT},
)
psk.FlowScheme.default()          # 内置 8 级默认方案
```

- `PERSONAL`：该级所有被指派的人都必须签署；
- `GROUP`：任一人签署整级即完成（自动生成 `sign_group` 字段）；
- `DEPARTMENT`：每个部门需要一人签署，所有部门完成才整级完成（使用
  `department_sign_group`）。

`LevelSpec` 与 `FlowScheme` 不可变、带校验，可通过 `to_dict()` /
`from_dict()` 双向序列化。

`Initiator` 描述文件发起人——要么只留 0 级标记，要么在指定等级参与
签署：

```python
initiator = psk.Initiator(id="I", name="发起人", level=0, require_signature=False)
```

`build_metadata(scheme, assignments, initiator=None)` 生成参与者记录；
assignments 为「等级 → 人员描述符列表」（`{"id", "name"}`；DEPARTMENT
人员还需 `group` / `group_name`）。传入 `now=<可调用对象>` 可得到
确定性时间戳。

#### 13. `workflow` —— 签署规则引擎

```python
can, message = psk.workflow.can_sign(metadata, "A")
can, message = psk.workflow.can_sign_level(metadata, "A", target_level=2)
done, message = psk.workflow.all_completed(metadata)
strict = psk.workflow.all_signed_strict(metadata)
```

引擎执行等级先后、等级内 `sign_order`、会签/部门会签门控，并对任何
含 `"3"` 驳回记录的文件整体阻断。

#### 14. `identity` —— 身份归一化

```python
psk.bare_id("123 - 张三")             # -> "123"
psk.identity_matches("00123", "123")  # -> True（纯数字，前导零等价）
```

#### 15. `logtable` —— 签字日志表

```python
sig_logs = [
    {"工号": "A", "姓名": "甲", "等级": "Lv1", "签字时间": "2026-09-28T10:00"},
]
rejection_logs = []

image = psk.render_log_image("合同.pdf", sig_logs, rejection_logs)
buffer = psk.render_log_png_bytes("合同.pdf", sig_logs, rejection_logs)
url = psk.render_log_data_url("合同.pdf", sig_logs, rejection_logs)
# "data:image/png;base64,...."
```

状态颜色：已签署绿色、待签署橙色、已驳回红色。

#### 16. `keywords` —— 中文关键词提取

```python
psk.extract_keywords("图纸标注不清晰，请核对")   # -> ["图纸", "标注", "清晰", "核对"]
```

提取 2–4 字中文连续片段，过滤内置停用词；无匹配时回退为截断原文。

#### 17. `template` —— 可复用签署位置模板

```python
template = psk.PlacementTemplate.capture(
    [{"image": "签名.png", "x": 200, "y": 127, "page": 0}],
    name="表单A",
)
psk.save_template("表单_a.json", template)

template = psk.load_template("表单_a.json")
operations = template.instantiate({"sign": "新签名.png"})
psk.stamp_batch("另一个文件.pdf", operations)
```

模板保存的是逻辑引用名 `ref` 而非具体图片路径；`refs` 列出所需引用名，
`instantiate()` 支持 `page_offset` 页码偏移。

#### 18. `exceptions` —— 异常体系

`PdfSignKitError` 为基类，子类包括：`InvalidPdfError`、
`InvalidImageError`、`PageOutOfRangeError`、`FontNotFoundError`、
`PlacementError`。

---

### 端到端实战：完整签署流程

以下脚本完全自包含、可直接运行：

```python
import os
import tempfile
from datetime import datetime

import fitz
from PIL import Image, ImageDraw

import pdf_sign_kit as psk
from pdf_sign_kit import FlowScheme, Initiator, LevelSpec, PERSONAL, GROUP

work = tempfile.mkdtemp(prefix="capstone_")
pdf_path = os.path.join(work, "contract.pdf")

# 第 1 步 —— 创建两页 PDF，页面上画两个带边框的签署单元格
doc = fitz.open()
page = doc.new_page(width=595, height=842)
page.draw_rect(fitz.Rect(100, 700, 300, 730))
page.draw_rect(fitz.Rect(100, 600, 300, 630))
page.insert_text((100, 690), "Lv1 Sign:", fontsize=10)
page.insert_text((100, 590), "Lv2 Sign:", fontsize=10)
doc.new_page(width=595, height=842)
doc.save(pdf_path)
doc.close()

# 第 2 步 —— 绘制签名并去除白底
sig = Image.new("RGB", (120, 50), (255, 255, 255))
ImageDraw.Draw(sig).line(
    [(10, 40), (40, 10), (70, 35), (110, 15)], fill=(0, 0, 0), width=3
)
sig_path = os.path.join(work, "sign.png")
psk.remove_background(sig, threshold=235).save(sig_path)

# 第 3 步 —— 定义两级方案：1 级个人起草，2 级会签
scheme = FlowScheme(
    levels=(
        LevelSpec(level=1, name="起草", mode=PERSONAL),
        LevelSpec(level=2, name="会签", mode=GROUP),
    )
)
metadata = build_metadata(
    scheme,
    {
        1: [{"id": "A", "name": "甲"}],
        2: [{"id": "B", "name": "乙"}, {"id": "C", "name": "丙"}],
    },
    initiator=Initiator(id="I", name="发起人"),
)

# 每个单元格中心对应的点击坐标（左下原点）
clicks = {1: (200.0, 842 - 715.0), 2: (200.0, 842 - 615.0)}

# 第 4 步 —— 逐级签署：询问引擎 → 物理盖章 → 更新记录
for level in (1, 2):
    pending = [r for r in metadata if r["level"] == level and r["state"] == "0"]
    signer = pending[0]
    can, message = psk.workflow.can_sign(metadata, signer["person"])
    assert can, message
    x, y = clicks[level]
    psk.stamp(pdf_path, sig_path, x=x, y=y, page=0)
    signer["state"] = "1"
    signer["sign_time"] = datetime.now().isoformat()
    for other in pending[1:]:                      # 会签组其余成员
        other["state"] = "2"
        other["sign_group_completed_by"] = signer["person"]

done, message = psk.workflow.all_completed(metadata)
assert done

# 第 5 步 —— 两页骑缝章；首页斜向水印
psk.stamp_seam(pdf_path, sig_path)
psk.stamp_watermark(pdf_path, "作废", pages=[0], color=(220, 38, 38, 80))

# 第 6 步 —— 渲染签字日志
sig_logs = [
    {"工号": r["person"], "姓名": r["name"], "等级": f'Lv{r["level"]}',
     "签字时间": r["sign_time"]}
    for r in metadata if r["role"] == "signer" and r.get("sign_time")
]
log_url = psk.render_log_data_url("contract.pdf", sig_logs, [])

# 第 7 步 —— 完整性哈希；保存/复用签署位置模板
digest = psk.hash_file(pdf_path)
assert psk.verify_file_hash(pdf_path, digest)

template = psk.PlacementTemplate.capture(
    [{"image": sig_path, "x": 200.0, "y": 127.0, "page": 0}],
    name="contract-form",
)
psk.save_template(os.path.join(work, "tpl.json"), template)
```

### 常见问题 / FAQ

- **最小化 Linux 镜像上报 `FontNotFoundError`** —— 安装中文字体
  （`apt-get install fonts-noto-cjk`），或显式传入 `cjk_font_path` /
  `font_path`。
- **`find_table_cell` 返回 `None`** —— 该位置没有可检测的边框包围。
  改用 `find_text`（文本锚点），或用 `detect_cell=False` 配合显式
  `width` / `height` 盖章。
- **透明印章盖上去后出现白底方框** —— 盖章前先用 `remove_background()`
  处理图片。
- **旋转页上盖章位置不对** —— 坐标必须按页面*显示方向*给出，不要
  自己先做旋转换算。
- **会签等级一人签署后没有完成** —— 记录必须带 `sign_group`（用
  `build_metadata` 并把该级设为 `GROUP`）；只设置
  `sign_type="group"` 不生效。
- **`PageOutOfRangeError`** —— 页码从 0 开始，先通过 `page_count()`
  确认范围。

### 注意与限制

- 本包提供的是**可视化（"电子"）签名**——放置到页面上的光栅图像，
  而非基于 PKI/证书、具备法律防抵赖能力的"数字签名"。
- 机打文字以栅格方式写入（不可选中复制）。
- 包不携带字体，在宿主系统上自动探测。

### 许可证

[MIT](LICENSE) © 兰衣
