feat: improve skill import and runtime delivery
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
---
|
||||
name: longdoc-docx
|
||||
version: 1.0.0
|
||||
description: |
|
||||
将多章节 Markdown 构建为排版规范的 Word 长文,并通过临时 PDF 核验排版。用于
|
||||
投标方案、技术方案、白皮书、验收报告等包含封面、目录、表格、图片、代码块和
|
||||
分页规则的中文正式文档。不要用于只需简单复制文本的短文档。
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
- Execute
|
||||
compatibility: Python 3.9+;DOCX 构建需 requirements.txt,PDF 核验需 LibreOffice Writer
|
||||
---
|
||||
|
||||
# Markdown 长文转 Word
|
||||
|
||||
以 Markdown 和图表生成脚本为唯一信源。不要手工修改生成的 DOCX/PDF,修订应回到
|
||||
源文件后重新构建,避免正文、图表、编号和交叉引用失去同步。
|
||||
|
||||
`<skill-dir>` 指本 `SKILL.md` 所在目录,不要假定技能安装在固定路径。
|
||||
|
||||
## 首次准备
|
||||
|
||||
先探测可用的 Python 3 解释器:Windows 优先使用 `python`,macOS/Linux
|
||||
优先使用 `python3`。下文 `<python>` 表示探测成功的解释器命令。
|
||||
|
||||
```bash
|
||||
<python> -m pip install -r "<skill-dir>/requirements.txt"
|
||||
cp "<skill-dir>/templates/document.example.json" ./document.json
|
||||
```
|
||||
|
||||
编辑 `document.json`,至少填写:
|
||||
|
||||
- `title`、`subtitle`、`author`、`date`
|
||||
- `output`,生成的 DOCX 路径
|
||||
- `chapters`,按最终顺序显式列出 Markdown 文件
|
||||
- 每章的 `page_break_before`,只在真正的一级章节前设为 `true`
|
||||
|
||||
不得依赖目录排序自动拼接正文。大纲、README、评审记录等内部文件不要加入
|
||||
`chapters`。
|
||||
|
||||
## 目录约定
|
||||
|
||||
交付物与核验中间产物必须分处不同目录,避免整目录拷贝时把中间产物一并发出:
|
||||
|
||||
```text
|
||||
build/ # 草稿与中间产物,可随时重建
|
||||
document.json # 构建配置
|
||||
chapters/ # 正文章节,按 01- 02- 前缀命名
|
||||
01-overview.md
|
||||
02-design.md
|
||||
assets/ # 图片与图表脚本产出的 PNG
|
||||
drafts/ # 大纲、评审记录、废弃稿,永不进入 chapters
|
||||
check/ # 核验用 PDF、verification.json、页面 PNG
|
||||
dist/ # 交付物,只存放 DOCX
|
||||
document.docx
|
||||
```
|
||||
|
||||
`output` 指向 `dist/`;PDF、`--json`、`--render-dir` 一律指向 `build/check/`。
|
||||
目录名可随项目调整,但交付物目录内不得出现 PDF、PNG 和核验报告。
|
||||
|
||||
分章节时另有三条约束:
|
||||
|
||||
- 图片路径相对**引用它的 Markdown 文件**解析,不是相对 `document.json`。章节在
|
||||
`chapters/` 而图片在 `assets/` 时,需回退一级再进入 assets 目录。
|
||||
- 章节文件名前缀只用于人工排序,构建顺序完全由 `chapters` 数组决定。改动章节
|
||||
顺序必须改数组,重命名文件不会生效。
|
||||
- `drafts/` 与 `chapters/` 必须分开存放。混在一起时,评审记录和废弃稿极易被
|
||||
误加入 `chapters`,且无法通过目视区分。
|
||||
|
||||
## 标准工作流
|
||||
|
||||
### 1. 核对源文件
|
||||
|
||||
1. 固定标题层级和编号体系,再开始合并。
|
||||
2. 检查 Markdown 图片路径都相对当前 Markdown 文件所在目录可解析。
|
||||
3. 搜索残留 ASCII 流程图和重复代码块,已有正式图片时删除旧占位图。
|
||||
4. 关键设计变化后同步修改图表生成脚本。
|
||||
5. 逐条比对 `chapters` 数组与 `chapters/` 内的实际文件:数组遗漏会静默少章,
|
||||
多余路径会直接构建失败。章节数和顺序都要与目录核对一次。
|
||||
|
||||
如需脚本化绘制中文架构图,可导入 `diagram_kit.py`;先检查字体:
|
||||
|
||||
```bash
|
||||
<python> "<skill-dir>/diagram_kit.py" --check-font
|
||||
```
|
||||
|
||||
### 2. 构建 DOCX
|
||||
|
||||
```bash
|
||||
<python> "<skill-dir>/scripts/build_docx.py" --config ./document.json
|
||||
```
|
||||
|
||||
构建器支持标题、普通段落、粗体/斜体/行内代码、嵌套列表、表格、图片、图注、
|
||||
围栏代码块、引用块、封面、目录域和页脚页码。表格按各列内容长度分配宽度,避免
|
||||
长文本列过窄导致页数异常增长。
|
||||
|
||||
目录由 Word 域生成。首次在 Microsoft Word 或 LibreOffice Writer 中打开后需更新
|
||||
目录域,未更新时看到提示文字属于正常情况。
|
||||
|
||||
### 3. 转换 PDF(仅用于核验)
|
||||
|
||||
PDF 是校验中间件,不是交付物。交付物为 DOCX;PDF 只用于第 4、5 步的乱码、
|
||||
空白页和视觉复核,核验通过后应删除,除非用户明确要求交付 PDF。
|
||||
|
||||
```bash
|
||||
soffice --headless --convert-to pdf --outdir ./build/check ./dist/document.docx
|
||||
```
|
||||
|
||||
如果目标路径中已有同名 PDF,先确认它是可重建产物,再由 Agent 按当前工具安全
|
||||
规则处理。不要覆盖用户手工维护的文件。
|
||||
|
||||
### 4. 程序化核验
|
||||
|
||||
```bash
|
||||
<python> "<skill-dir>/scripts/verify_pdf.py" ./build/check/document.pdf \
|
||||
--forbid "我方" "我们" \
|
||||
--json ./build/check/verification.json \
|
||||
--render-dir ./build/check/pages
|
||||
```
|
||||
|
||||
核验器检查页数、乱码替换符、禁用词和疑似空白页,并可按 300 DPI 渲染逐页 PNG。
|
||||
程序化文本抽取不能证明视觉排版正确,跨页表格尤其可能出现抽取顺序异常。
|
||||
|
||||
### 5. 人工门禁
|
||||
|
||||
- 逐页检查标题孤行、表格跨页、图片清晰度、图注和异常留白。
|
||||
- 可疑文字必须查看 300 DPI 页面图,必要时裁剪放大,不能依据缩略图判断错字。
|
||||
- 核对标题编号、图号、表号、交叉引用和正文设计是否一致。
|
||||
- 检查事实边界、责任主体和前后逻辑,关键词清零不代表内容正确。
|
||||
- 如安装了 `deai-writing` 技能,在 Markdown 源文件上完成扫描和定向改写后,
|
||||
重新走完整构建链路。
|
||||
|
||||
## 完成标准
|
||||
|
||||
只有以下条件全部满足才可交付:
|
||||
|
||||
1. DOCX 可打开,标题、表格、图片和代码块数量符合源文件。
|
||||
2. 核验用 PDF 转换成功,无非预期空白页和 `\ufffd` 乱码。
|
||||
3. 禁用词与项目质量门禁通过。
|
||||
4. 300 DPI 视觉复核通过,图文、编号和交叉引用一致。
|
||||
5. 所有修改已回写 Markdown 或图表脚本,生成产物可重复构建。
|
||||
6. 交付目录只有 DOCX,核验 PDF、报告和页面 PNG 都在中间产物目录内。
|
||||
|
||||
## 文件构成
|
||||
|
||||
```text
|
||||
longdoc-docx/
|
||||
SKILL.md
|
||||
requirements.txt
|
||||
diagram_kit.py
|
||||
scripts/
|
||||
build_docx.py
|
||||
verify_pdf.py
|
||||
templates/
|
||||
document.example.json
|
||||
chapter.example.md
|
||||
tests/
|
||||
test_build_docx.py
|
||||
test_verify_pdf.py
|
||||
```
|
||||
|
||||
## 验证技能
|
||||
|
||||
```bash
|
||||
<python> -m unittest discover -s "<skill-dir>/tests" -p "test_*.py"
|
||||
```
|
||||
@@ -0,0 +1,169 @@
|
||||
"""matplotlib 架构图/流程图通用工具箱(中文可用)。
|
||||
|
||||
不用画图工具手绘,用脚本画方框和箭头:改文字就是改字符串;配色字体统一由
|
||||
常量控制;图表能进 git diff,方便 review 措辞变更。
|
||||
|
||||
用法:在你自己的 gen_diagrams.py 里
|
||||
import sys, os
|
||||
sys.path.insert(0, "<skill_dir>")
|
||||
from diagram_kit import box, arrow, new_fig, save, row_layout, NAVY, RED
|
||||
|
||||
def diagram_architecture():
|
||||
fig, ax = new_fig(13, 9.2)
|
||||
box(ax, 0.5, 8.0, 12, 0.8, "接入层")
|
||||
...
|
||||
save(fig, "diagram1-总体技术架构图.png", out_dir=OUT_DIR)
|
||||
|
||||
自检字体:
|
||||
python3 diagram_kit.py --check-font
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg") # 无显示环境必须
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.font_manager as fm
|
||||
from matplotlib.patches import FancyBboxPatch, FancyArrowPatch
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 中文字体:matplotlib 默认字体不含中文字形,必须显式指定字体文件
|
||||
# 按优先级探测;找不到时报错并给出安装提示,而不是静默输出方块字
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FONT_CANDIDATES = [
|
||||
("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc"),
|
||||
("/usr/share/fonts/opentype/noto/NotoSansCJK-VF.otf.ttc",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-VF.otf.ttc"),
|
||||
("/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
|
||||
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc"),
|
||||
("/System/Library/Fonts/PingFang.ttc",
|
||||
"/System/Library/Fonts/PingFang.ttc"),
|
||||
("C:/Windows/Fonts/msyh.ttc", "C:/Windows/Fonts/msyhbd.ttc"),
|
||||
]
|
||||
|
||||
FONT_HINT = (
|
||||
"未找到中文字体,图中中文会渲染成方块。安装:\n"
|
||||
" Debian/Ubuntu: apt-get install fonts-noto-cjk\n"
|
||||
" RHEL/CentOS: yum install google-noto-sans-cjk-ttc-fonts\n"
|
||||
"确认:fc-list | grep -i 'noto sans cjk'\n"
|
||||
"也可设环境变量 CJK_FONT_REGULAR / CJK_FONT_BOLD 指向字体文件。"
|
||||
)
|
||||
|
||||
|
||||
def _resolve_fonts():
|
||||
reg = os.environ.get("CJK_FONT_REGULAR")
|
||||
bold = os.environ.get("CJK_FONT_BOLD", reg)
|
||||
if reg and os.path.exists(reg):
|
||||
return reg, (bold if bold and os.path.exists(bold) else reg)
|
||||
for r, b in FONT_CANDIDATES:
|
||||
if os.path.exists(r):
|
||||
return r, (b if os.path.exists(b) else r)
|
||||
return None, None
|
||||
|
||||
|
||||
FONT_PATH, FONT_PATH_BOLD = _resolve_fonts()
|
||||
if FONT_PATH is None:
|
||||
print("WARN: " + FONT_HINT, file=sys.stderr)
|
||||
zh_font = fm.FontProperties()
|
||||
zh_bold = fm.FontProperties(weight="bold")
|
||||
else:
|
||||
zh_font = fm.FontProperties(fname=FONT_PATH)
|
||||
zh_bold = fm.FontProperties(fname=FONT_PATH_BOLD)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 配色:中文商务文档惯例(藏青主色 + 红色强调 + 灰阶)
|
||||
# 换主题只改这几个常量,所有图一起变
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
NAVY = "#1F3864"
|
||||
NAVY_LIGHT = "#DCE6F1"
|
||||
RED = "#C00000"
|
||||
RED_LIGHT = "#FBE4E4"
|
||||
GRAY = "#595959"
|
||||
GRAY_LIGHT = "#F2F2F2"
|
||||
WHITE = "#FFFFFF"
|
||||
TEXT = "#1a1a1a"
|
||||
|
||||
|
||||
def box(ax, x, y, w, h, text, fc=WHITE, ec=NAVY, lw=1.4, fontsize=10.5,
|
||||
font=None, textcolor=TEXT,
|
||||
boxstyle="round,pad=0.02,rounding_size=0.06", zorder=2):
|
||||
"""圆角方框 + 居中文字。linespacing 保证多行文字换行后不挤在一起。"""
|
||||
b = FancyBboxPatch((x, y), w, h, boxstyle=boxstyle, linewidth=lw,
|
||||
edgecolor=ec, facecolor=fc, zorder=zorder)
|
||||
ax.add_patch(b)
|
||||
ax.text(x + w / 2, y + h / 2, text, ha="center", va="center",
|
||||
fontsize=fontsize, fontproperties=font or zh_font,
|
||||
color=textcolor, zorder=zorder + 1, linespacing=1.4)
|
||||
return b
|
||||
|
||||
|
||||
def arrow(ax, xy_from, xy_to, color=GRAY, lw=1.6, style="-|>",
|
||||
connectionstyle="arc3,rad=0.0", zorder=3):
|
||||
a = FancyArrowPatch(xy_from, xy_to, arrowstyle=style, mutation_scale=14,
|
||||
linewidth=lw, color=color,
|
||||
connectionstyle=connectionstyle, zorder=zorder)
|
||||
ax.add_patch(a)
|
||||
return a
|
||||
|
||||
|
||||
def label(ax, x, y, text, fontsize=9.5, color=GRAY, ha="center", va="center",
|
||||
font=None, zorder=4):
|
||||
"""箭头旁的说明文字、图内小标注。"""
|
||||
return ax.text(x, y, text, ha=ha, va=va, fontsize=fontsize,
|
||||
fontproperties=font or zh_font, color=color, zorder=zorder)
|
||||
|
||||
|
||||
def new_fig(w, h, dpi=200):
|
||||
"""画布坐标系直接等于英寸尺寸,摆位时按网格心算即可。dpi=200 保证放大不糊。"""
|
||||
fig, ax = plt.subplots(figsize=(w, h), dpi=dpi)
|
||||
ax.set_xlim(0, w)
|
||||
ax.set_ylim(0, h)
|
||||
ax.axis("off")
|
||||
return fig, ax
|
||||
|
||||
|
||||
def save(fig, name, out_dir="."):
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
path = os.path.join(out_dir, name)
|
||||
fig.savefig(path, bbox_inches="tight", facecolor="white")
|
||||
plt.close(fig)
|
||||
print("saved:", path)
|
||||
return path
|
||||
|
||||
|
||||
def row_layout(n, start_x, total_w, gap=0.25):
|
||||
"""横向等宽切分:返回 n 个 (x, width),用于并列分支摆放。
|
||||
|
||||
for (x, w), g in zip(row_layout(len(groups), 0.5, 12.0), groups):
|
||||
box(ax, x, y0, w, h, g)
|
||||
"""
|
||||
w = (total_w - gap * (n - 1)) / n
|
||||
return [(start_x + i * (w + gap), w) for i in range(n)]
|
||||
|
||||
|
||||
def col_layout(n, top_y, total_h, gap=0.2):
|
||||
"""纵向等高切分:返回 n 个 (y, height),自上而下。"""
|
||||
h = (total_h - gap * (n - 1)) / n
|
||||
return [(top_y - h - i * (h + gap), h) for i in range(n)]
|
||||
|
||||
|
||||
def _check_font():
|
||||
if FONT_PATH is None:
|
||||
print("中文字体:未找到\n" + FONT_HINT)
|
||||
return 1
|
||||
print(f"中文字体:{FONT_PATH}")
|
||||
print(f"粗体: {FONT_PATH_BOLD}")
|
||||
fig, ax = new_fig(6, 2)
|
||||
box(ax, 0.3, 0.5, 5.4, 1.0, "中文字体自检 CJK Font OK 123")
|
||||
out = save(fig, "font_check.png", out_dir="/tmp")
|
||||
print(f"已生成 {out},打开确认中文不是方块。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--check-font" in sys.argv:
|
||||
sys.exit(_check_font())
|
||||
print(__doc__)
|
||||
@@ -0,0 +1,6 @@
|
||||
beautifulsoup4>=4.12,<5
|
||||
Markdown>=3.5,<4
|
||||
matplotlib>=3.8,<4
|
||||
Pillow>=10,<13
|
||||
PyMuPDF>=1.24,<2
|
||||
python-docx>=1.1,<2
|
||||
@@ -0,0 +1,598 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a styled DOCX from an explicit Markdown chapter manifest."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
|
||||
import markdown
|
||||
from bs4 import BeautifulSoup
|
||||
from docx import Document
|
||||
from docx.enum.section import WD_ORIENT
|
||||
from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT, WD_TABLE_ALIGNMENT
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
from docx.oxml import OxmlElement
|
||||
from docx.oxml.ns import qn
|
||||
from docx.shared import Cm, Pt, RGBColor
|
||||
from PIL import Image
|
||||
|
||||
|
||||
DEFAULTS = {
|
||||
"body_font_zh": "宋体",
|
||||
"body_font_en": "Times New Roman",
|
||||
"heading_font_zh": "黑体",
|
||||
"heading_color": "1F3864",
|
||||
"body_size": 11.5,
|
||||
"toc_depth": 3,
|
||||
"max_image_width_cm": 14.66,
|
||||
}
|
||||
HEADING_SIZES = {1: 18, 2: 15, 3: 13, 4: 12, 5: 11.5, 6: 11.5}
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="将多章节 Markdown 构建为 DOCX")
|
||||
parser.add_argument("--config", required=True, help="document.json 路径")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_config(path):
|
||||
config_path = Path(path).expanduser().resolve()
|
||||
with config_path.open(encoding="utf-8") as handle:
|
||||
config = json.load(handle)
|
||||
if not isinstance(config, dict):
|
||||
raise ValueError("配置根节点必须是 JSON 对象")
|
||||
for key in ("title", "output"):
|
||||
if not isinstance(config.get(key), str) or not config[key].strip():
|
||||
raise ValueError(f"{key} 必须是非空字符串")
|
||||
if not isinstance(config.get("chapters"), list) or not config["chapters"]:
|
||||
raise ValueError("chapters 必须是非空数组")
|
||||
merged = {**DEFAULTS, **config}
|
||||
if not isinstance(merged["toc_depth"], int) or isinstance(merged["toc_depth"], bool):
|
||||
raise ValueError("toc_depth 必须是整数")
|
||||
for key in ("body_size", "max_image_width_cm"):
|
||||
value = merged[key]
|
||||
if not isinstance(value, (int, float)) or isinstance(value, bool) or value <= 0:
|
||||
raise ValueError(f"{key} 必须是正数")
|
||||
return config_path, merged
|
||||
|
||||
|
||||
def rgb(value):
|
||||
value = value.lstrip("#")
|
||||
if not re.fullmatch(r"[0-9a-fA-F]{6}", value):
|
||||
raise ValueError(f"颜色必须是六位十六进制值:{value!r}")
|
||||
return RGBColor.from_string(value.upper())
|
||||
|
||||
|
||||
def add_font(
|
||||
run,
|
||||
config,
|
||||
size=None,
|
||||
bold=False,
|
||||
italic=False,
|
||||
color=None,
|
||||
code=False,
|
||||
en_font=None,
|
||||
zh_font=None,
|
||||
):
|
||||
en_font = en_font or ("Consolas" if code else config["body_font_en"])
|
||||
zh_font = zh_font or ("Consolas" if code else config["body_font_zh"])
|
||||
run.font.name = en_font
|
||||
run.font.size = Pt(size or config["body_size"])
|
||||
run.font.bold = bold
|
||||
run.font.italic = italic
|
||||
if color is not None:
|
||||
run.font.color.rgb = color
|
||||
rpr = run._element.get_or_add_rPr()
|
||||
rfonts = rpr.find(qn("w:rFonts"))
|
||||
if rfonts is None:
|
||||
rfonts = OxmlElement("w:rFonts")
|
||||
rpr.append(rfonts)
|
||||
rfonts.set(qn("w:ascii"), en_font)
|
||||
rfonts.set(qn("w:hAnsi"), en_font)
|
||||
rfonts.set(qn("w:eastAsia"), zh_font)
|
||||
|
||||
|
||||
def add_field(paragraph, instruction, placeholder=None):
|
||||
run = paragraph.add_run()
|
||||
begin = OxmlElement("w:fldChar")
|
||||
begin.set(qn("w:fldCharType"), "begin")
|
||||
instr = OxmlElement("w:instrText")
|
||||
instr.set(qn("xml:space"), "preserve")
|
||||
instr.text = instruction
|
||||
separate = OxmlElement("w:fldChar")
|
||||
separate.set(qn("w:fldCharType"), "separate")
|
||||
end = OxmlElement("w:fldChar")
|
||||
end.set(qn("w:fldCharType"), "end")
|
||||
run._r.append(begin)
|
||||
run._r.append(instr)
|
||||
run._r.append(separate)
|
||||
if placeholder:
|
||||
text = OxmlElement("w:t")
|
||||
text.text = placeholder
|
||||
run._r.append(text)
|
||||
run._r.append(end)
|
||||
|
||||
|
||||
def add_shading(target, fill):
|
||||
properties = (
|
||||
target._tc.get_or_add_tcPr()
|
||||
if hasattr(target, "_tc")
|
||||
else target._p.get_or_add_pPr()
|
||||
)
|
||||
shading = OxmlElement("w:shd")
|
||||
shading.set(qn("w:val"), "clear")
|
||||
shading.set(qn("w:color"), "auto")
|
||||
shading.set(qn("w:fill"), fill)
|
||||
properties.append(shading)
|
||||
|
||||
|
||||
def set_table_borders(table):
|
||||
borders = OxmlElement("w:tblBorders")
|
||||
for edge in ("top", "left", "bottom", "right", "insideH", "insideV"):
|
||||
element = OxmlElement(f"w:{edge}")
|
||||
element.set(qn("w:val"), "single")
|
||||
element.set(qn("w:sz"), "4")
|
||||
element.set(qn("w:space"), "0")
|
||||
element.set(qn("w:color"), "B0B0B0")
|
||||
borders.append(element)
|
||||
table._tbl.tblPr.append(borders)
|
||||
|
||||
|
||||
def setup_document(doc, config):
|
||||
section = doc.sections[0]
|
||||
if config.get("orientation", "portrait") == "landscape":
|
||||
section.orientation = WD_ORIENT.LANDSCAPE
|
||||
section.page_width = Cm(29.7)
|
||||
section.page_height = Cm(21)
|
||||
else:
|
||||
section.page_width = Cm(21)
|
||||
section.page_height = Cm(29.7)
|
||||
section.top_margin = Cm(config.get("margin_top_cm", 2.54))
|
||||
section.bottom_margin = Cm(config.get("margin_bottom_cm", 2.54))
|
||||
section.left_margin = Cm(config.get("margin_left_cm", 3.17))
|
||||
section.right_margin = Cm(config.get("margin_right_cm", 3.17))
|
||||
|
||||
normal = doc.styles["Normal"]
|
||||
normal.font.name = config["body_font_en"]
|
||||
normal.font.size = Pt(config["body_size"])
|
||||
normal.paragraph_format.line_spacing = config.get("line_spacing", 1.4)
|
||||
normal.paragraph_format.space_after = Pt(8)
|
||||
rpr = normal.element.get_or_add_rPr()
|
||||
rfonts = rpr.find(qn("w:rFonts"))
|
||||
if rfonts is None:
|
||||
rfonts = OxmlElement("w:rFonts")
|
||||
rpr.append(rfonts)
|
||||
rfonts.set(qn("w:eastAsia"), config["body_font_zh"])
|
||||
|
||||
footer = section.footer.paragraphs[0]
|
||||
footer.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
add_field(footer, "PAGE")
|
||||
|
||||
|
||||
def add_cover(doc, config):
|
||||
if config.get("cover", True) is False:
|
||||
return
|
||||
landscape = config.get("orientation", "portrait") == "landscape"
|
||||
for _ in range(config.get("cover_top_spacers", 3 if landscape else 6)):
|
||||
doc.add_paragraph()
|
||||
for text, size in (
|
||||
(config["title"], 26),
|
||||
(config.get("subtitle", ""), 22),
|
||||
):
|
||||
if not text:
|
||||
continue
|
||||
paragraph = doc.add_paragraph()
|
||||
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
run = paragraph.add_run(text)
|
||||
add_font(
|
||||
run,
|
||||
config,
|
||||
size=size,
|
||||
bold=True,
|
||||
color=rgb(config["heading_color"]),
|
||||
zh_font=config["heading_font_zh"],
|
||||
)
|
||||
for _ in range(config.get("cover_middle_spacers", 4 if landscape else 8)):
|
||||
doc.add_paragraph()
|
||||
for field in ("author", "date"):
|
||||
text = config.get(field, "")
|
||||
if text:
|
||||
paragraph = doc.add_paragraph()
|
||||
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
add_font(paragraph.add_run(text), config, size=14)
|
||||
doc.add_page_break()
|
||||
|
||||
|
||||
def add_toc(doc, config):
|
||||
depth = int(config.get("toc_depth", 3))
|
||||
if depth <= 0:
|
||||
return
|
||||
heading = doc.add_paragraph()
|
||||
heading.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
run = heading.add_run(config.get("toc_title", "目 录"))
|
||||
add_font(
|
||||
run,
|
||||
config,
|
||||
size=18,
|
||||
bold=True,
|
||||
color=rgb(config["heading_color"]),
|
||||
zh_font=config["heading_font_zh"],
|
||||
)
|
||||
doc.add_paragraph()
|
||||
paragraph = doc.add_paragraph()
|
||||
add_field(
|
||||
paragraph,
|
||||
f'TOC \\o "1-{depth}" \\h \\z \\u',
|
||||
"右键点击此处选择“更新域”以生成目录",
|
||||
)
|
||||
doc.add_page_break()
|
||||
|
||||
|
||||
def add_inline_runs(paragraph, node, config, bold=False, italic=False):
|
||||
for child in node.children:
|
||||
name = getattr(child, "name", None)
|
||||
if name is None:
|
||||
text = str(child).replace("\n", "")
|
||||
if text:
|
||||
add_font(
|
||||
paragraph.add_run(text),
|
||||
config,
|
||||
bold=bold,
|
||||
italic=italic,
|
||||
)
|
||||
elif name in ("strong", "b"):
|
||||
add_inline_runs(paragraph, child, config, bold=True, italic=italic)
|
||||
elif name in ("em", "i"):
|
||||
add_inline_runs(paragraph, child, config, bold=bold, italic=True)
|
||||
elif name == "code":
|
||||
run = paragraph.add_run(child.get_text())
|
||||
add_font(
|
||||
run,
|
||||
config,
|
||||
size=config["body_size"] - 0.5,
|
||||
bold=bold,
|
||||
italic=italic,
|
||||
color=RGBColor(0xA0, 0x30, 0x30),
|
||||
code=True,
|
||||
)
|
||||
elif name == "br":
|
||||
paragraph.add_run().add_break()
|
||||
else:
|
||||
add_inline_runs(paragraph, child, config, bold=bold, italic=italic)
|
||||
|
||||
|
||||
def add_heading(doc, level, text, config):
|
||||
paragraph = doc.add_paragraph(style=f"Heading {min(level, 9)}")
|
||||
paragraph.paragraph_format.keep_with_next = True
|
||||
paragraph.paragraph_format.space_before = Pt(14 if level == 1 else 10)
|
||||
paragraph.paragraph_format.space_after = Pt(8 if level == 1 else 6)
|
||||
run = paragraph.add_run(text)
|
||||
add_font(
|
||||
run,
|
||||
config,
|
||||
size=HEADING_SIZES.get(level, 11.5),
|
||||
bold=True,
|
||||
color=rgb(config["heading_color"]) if level <= 2 else RGBColor(0, 0, 0),
|
||||
zh_font=config["heading_font_zh"],
|
||||
)
|
||||
|
||||
|
||||
def add_paragraph(doc, node, config):
|
||||
paragraph = doc.add_paragraph()
|
||||
add_inline_runs(paragraph, node, config)
|
||||
paragraph.paragraph_format.line_spacing = config.get("line_spacing", 1.4)
|
||||
paragraph.paragraph_format.space_after = Pt(8)
|
||||
|
||||
|
||||
def add_list(doc, node, config, level=0):
|
||||
ordered = node.name == "ol"
|
||||
style = "List Number" if ordered else "List Bullet"
|
||||
for item in node.find_all("li", recursive=False):
|
||||
paragraph = doc.add_paragraph(style=style)
|
||||
paragraph.paragraph_format.left_indent = Cm(0.5 + level * 0.6)
|
||||
paragraph.paragraph_format.space_after = Pt(4)
|
||||
for child in item.children:
|
||||
if getattr(child, "name", None) in ("ul", "ol"):
|
||||
continue
|
||||
if getattr(child, "name", None) is None:
|
||||
text = str(child).replace("\n", "")
|
||||
if text:
|
||||
add_font(paragraph.add_run(text), config)
|
||||
else:
|
||||
add_inline_runs(paragraph, child, config)
|
||||
for nested in item.find_all(["ul", "ol"], recursive=False):
|
||||
add_list(doc, nested, config, level + 1)
|
||||
|
||||
|
||||
def display_width(text):
|
||||
return sum(2 if unicodedata.east_asian_width(char) in ("W", "F") else 1 for char in text)
|
||||
|
||||
|
||||
def em_width(text):
|
||||
total = 0.0
|
||||
for char in text:
|
||||
if unicodedata.east_asian_width(char) in ("W", "F"):
|
||||
total += 1.0
|
||||
elif char.isupper() or char.isdigit():
|
||||
total += 0.62
|
||||
else:
|
||||
total += 0.5
|
||||
return total
|
||||
|
||||
|
||||
def compute_col_widths(rows, ncols, content_width_cm, body_size_pt=10.5):
|
||||
lengths = [1] * ncols
|
||||
longest_word = [1] * ncols
|
||||
for row in rows:
|
||||
for index, cell in enumerate(row.find_all(["th", "td"], recursive=False)):
|
||||
if index < ncols:
|
||||
text = cell.get_text(" ", strip=True)
|
||||
lengths[index] = max(lengths[index], min(display_width(text), 160))
|
||||
longest_word[index] = max(
|
||||
longest_word[index],
|
||||
max((em_width(word) for word in text.split()), default=1.0),
|
||||
)
|
||||
em_cm = body_size_pt / 28.35
|
||||
padding_cm = 0.4
|
||||
floors = [
|
||||
min(em_cm * word + padding_cm, content_width_cm / ncols)
|
||||
for word in longest_word
|
||||
]
|
||||
maximum = max(max(floors), content_width_cm * 0.55)
|
||||
widths = [None] * ncols
|
||||
remaining = content_width_cm
|
||||
pending = set(range(ncols))
|
||||
while pending:
|
||||
weight = sum(lengths[i] for i in pending)
|
||||
clamped = False
|
||||
for index in sorted(pending):
|
||||
share = remaining * lengths[index] / weight
|
||||
floor = floors[index]
|
||||
bound = floor if share < floor else (maximum if share > maximum else None)
|
||||
if bound is not None:
|
||||
widths[index] = bound
|
||||
remaining -= bound
|
||||
pending.discard(index)
|
||||
clamped = True
|
||||
break
|
||||
if not clamped:
|
||||
for index in pending:
|
||||
widths[index] = remaining * lengths[index] / weight
|
||||
break
|
||||
total = sum(widths)
|
||||
if total > content_width_cm:
|
||||
widths = [width * content_width_cm / total for width in widths]
|
||||
return widths
|
||||
|
||||
|
||||
def set_col_widths(table, widths):
|
||||
table.autofit = False
|
||||
grid = table._tbl.find(qn("w:tblGrid"))
|
||||
if grid is None:
|
||||
grid = OxmlElement("w:tblGrid")
|
||||
table._tbl.insert(0, grid)
|
||||
else:
|
||||
for child in list(grid):
|
||||
grid.remove(child)
|
||||
for width in widths:
|
||||
column = OxmlElement("w:gridCol")
|
||||
column.set(qn("w:w"), str(int(Cm(width).twips)))
|
||||
grid.append(column)
|
||||
for row in table.rows:
|
||||
cells = row.cells
|
||||
for index, width in enumerate(widths):
|
||||
if index < len(cells):
|
||||
cells[index].width = Cm(width)
|
||||
|
||||
|
||||
def add_table(doc, node, config):
|
||||
rows = node.find_all("tr")
|
||||
if not rows:
|
||||
return
|
||||
ncols = max(len(row.find_all(["th", "td"], recursive=False)) for row in rows)
|
||||
table = doc.add_table(rows=len(rows), cols=ncols)
|
||||
table.alignment = WD_TABLE_ALIGNMENT.CENTER
|
||||
section = doc.sections[-1]
|
||||
content_width = (
|
||||
section.page_width.cm - section.left_margin.cm - section.right_margin.cm
|
||||
)
|
||||
table_size = float(config.get("table_size", config["body_size"]))
|
||||
widths = compute_col_widths(rows, ncols, content_width, table_size)
|
||||
set_table_borders(table)
|
||||
for row_index, (row_node, table_row) in enumerate(zip(rows, table.rows)):
|
||||
cell_nodes = row_node.find_all(["th", "td"], recursive=False)
|
||||
table_cells = table_row.cells
|
||||
for column_index, cell_node in enumerate(cell_nodes):
|
||||
if column_index >= len(table_cells):
|
||||
break
|
||||
cell = table_cells[column_index]
|
||||
cell.text = ""
|
||||
cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
|
||||
paragraph = cell.paragraphs[0]
|
||||
is_header = row_index == 0
|
||||
add_inline_runs(paragraph, cell_node, config, bold=is_header)
|
||||
for run in paragraph.runs:
|
||||
add_font(
|
||||
run,
|
||||
config,
|
||||
size=table_size,
|
||||
bold=is_header or bool(run.font.bold),
|
||||
italic=bool(run.font.italic),
|
||||
color=RGBColor(255, 255, 255) if is_header else None,
|
||||
)
|
||||
if is_header:
|
||||
add_shading(cell, config["heading_color"].lstrip("#"))
|
||||
elif row_index % 2 == 0:
|
||||
add_shading(cell, "F2F2F2")
|
||||
set_col_widths(table, widths)
|
||||
doc.add_paragraph().paragraph_format.space_after = Pt(4)
|
||||
|
||||
|
||||
def add_code_block(doc, text, config):
|
||||
paragraph = doc.add_paragraph()
|
||||
paragraph.paragraph_format.left_indent = Cm(0.5)
|
||||
paragraph.paragraph_format.space_before = Pt(4)
|
||||
paragraph.paragraph_format.space_after = Pt(10)
|
||||
lines = text.rstrip("\n").split("\n")
|
||||
for index, line in enumerate(lines):
|
||||
run = paragraph.add_run(line or " ")
|
||||
add_font(
|
||||
run,
|
||||
config,
|
||||
size=9.5,
|
||||
color=RGBColor(0x33, 0x33, 0x33),
|
||||
code=True,
|
||||
)
|
||||
if index < len(lines) - 1:
|
||||
run.add_break()
|
||||
add_shading(paragraph, "F5F5F5")
|
||||
|
||||
|
||||
def add_blockquote(doc, node, config):
|
||||
blocks = [
|
||||
child.get_text(" ", strip=True)
|
||||
for child in node.find_all("p", recursive=False)
|
||||
]
|
||||
if not blocks:
|
||||
blocks = [node.get_text(" ", strip=True)]
|
||||
blocks = [text for text in blocks if text]
|
||||
for index, text in enumerate(blocks):
|
||||
paragraph = doc.add_paragraph()
|
||||
paragraph.paragraph_format.left_indent = Cm(0.8)
|
||||
paragraph.paragraph_format.space_after = Pt(
|
||||
10 if index == len(blocks) - 1 else 4
|
||||
)
|
||||
run = paragraph.add_run(text)
|
||||
add_font(
|
||||
run, config, size=10.5, italic=True, color=RGBColor(0x40, 0x40, 0x40)
|
||||
)
|
||||
add_shading(paragraph, "F7F7F7")
|
||||
|
||||
|
||||
def add_image(doc, src, base_dir, config):
|
||||
image_path = (base_dir / src).resolve()
|
||||
if not image_path.is_file():
|
||||
raise FileNotFoundError(f"图片不存在:{image_path}")
|
||||
with Image.open(image_path) as image:
|
||||
width_px = image.width
|
||||
dpi = image.info.get("dpi", (150, 150))[0] or 150
|
||||
natural_width_cm = width_px / dpi * 2.54
|
||||
width_cm = min(natural_width_cm, float(config["max_image_width_cm"]))
|
||||
paragraph = doc.add_paragraph()
|
||||
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
paragraph.add_run().add_picture(str(image_path), width=Cm(width_cm))
|
||||
paragraph.paragraph_format.space_before = Pt(6)
|
||||
paragraph.paragraph_format.space_after = Pt(2)
|
||||
|
||||
|
||||
def add_caption(doc, text, config):
|
||||
paragraph = doc.add_paragraph()
|
||||
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
run = paragraph.add_run(text)
|
||||
add_font(run, config, size=10, italic=True, color=RGBColor(0x40, 0x40, 0x40))
|
||||
paragraph.paragraph_format.space_after = Pt(12)
|
||||
|
||||
|
||||
def render_markdown(doc, chapter_path, config):
|
||||
text = chapter_path.read_text(encoding="utf-8")
|
||||
html = markdown.markdown(text, extensions=["tables", "fenced_code"])
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
previous_was_image = False
|
||||
|
||||
for node in soup.find_all(recursive=False):
|
||||
name = node.name
|
||||
if name in ("h1", "h2", "h3", "h4", "h5", "h6"):
|
||||
add_heading(doc, int(name[1]), node.get_text(" ", strip=True), config)
|
||||
elif name == "p":
|
||||
image = node.find("img")
|
||||
if image is not None:
|
||||
if node.get_text(strip=True):
|
||||
raise ValueError(
|
||||
f"图片必须独占 Markdown 段落:{chapter_path}"
|
||||
)
|
||||
add_image(doc, image.get("src", ""), chapter_path.parent, config)
|
||||
previous_was_image = True
|
||||
continue
|
||||
text_value = node.get_text()
|
||||
emphasis = node.find("em")
|
||||
if (
|
||||
previous_was_image
|
||||
and emphasis is not None
|
||||
and node.get_text(strip=True) == emphasis.get_text(strip=True)
|
||||
):
|
||||
add_caption(doc, emphasis.get_text(" ", strip=True), config)
|
||||
else:
|
||||
if text_value.strip():
|
||||
add_paragraph(doc, node, config)
|
||||
elif name in ("ul", "ol"):
|
||||
add_list(doc, node, config)
|
||||
elif name == "table":
|
||||
add_table(doc, node, config)
|
||||
elif name == "blockquote":
|
||||
add_blockquote(doc, node, config)
|
||||
elif name == "pre":
|
||||
add_code_block(doc, node.get_text(), config)
|
||||
elif name == "hr":
|
||||
paragraph = doc.add_paragraph("─" * 40)
|
||||
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
else:
|
||||
if node.get_text(strip=True):
|
||||
add_paragraph(doc, node, config)
|
||||
previous_was_image = False
|
||||
|
||||
|
||||
def chapter_entries(config, base_dir):
|
||||
entries = []
|
||||
for raw in config["chapters"]:
|
||||
if isinstance(raw, str):
|
||||
raw = {"path": raw}
|
||||
if (
|
||||
not isinstance(raw, dict)
|
||||
or not isinstance(raw.get("path"), str)
|
||||
or not raw["path"].strip()
|
||||
):
|
||||
raise ValueError("chapters 的每一项必须是路径字符串或包含 path 的对象")
|
||||
if "page_break_before" in raw and not isinstance(
|
||||
raw["page_break_before"], bool
|
||||
):
|
||||
raise ValueError("page_break_before 必须是布尔值")
|
||||
path = (base_dir / raw["path"]).resolve()
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"章节不存在:{path}")
|
||||
entries.append((path, bool(raw.get("page_break_before", False))))
|
||||
return entries
|
||||
|
||||
|
||||
def build(config_path, config):
|
||||
base_dir = config_path.parent
|
||||
chapters = chapter_entries(config, base_dir)
|
||||
output = (base_dir / config["output"]).resolve()
|
||||
if output.suffix.lower() != ".docx":
|
||||
raise ValueError("output 必须使用 .docx 扩展名")
|
||||
protected_paths = {config_path, *(chapter for chapter, _ in chapters)}
|
||||
if output in protected_paths:
|
||||
raise ValueError("output 不能覆盖配置文件或 Markdown 源文件")
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
doc = Document()
|
||||
setup_document(doc, config)
|
||||
add_cover(doc, config)
|
||||
add_toc(doc, config)
|
||||
for index, (chapter, page_break_before) in enumerate(chapters):
|
||||
if page_break_before and index > 0:
|
||||
doc.add_page_break()
|
||||
render_markdown(doc, chapter, config)
|
||||
doc.save(output)
|
||||
return output
|
||||
|
||||
|
||||
def main():
|
||||
config_path, config = load_config(parse_args().config)
|
||||
output = build(config_path, config)
|
||||
print(f"saved: {output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify PDF text gates, blank pages, and optionally render page previews."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import fitz
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="核验长文 PDF 产物")
|
||||
parser.add_argument("pdf", help="待核验 PDF")
|
||||
parser.add_argument("--forbid", nargs="*", default=[], help="禁用关键词")
|
||||
parser.add_argument(
|
||||
"--allow-blank-page",
|
||||
action="append",
|
||||
type=int,
|
||||
default=[],
|
||||
help="允许为空白的页码,可重复指定",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--min-text-chars",
|
||||
type=int,
|
||||
default=30,
|
||||
help="无图片页面低于该文本长度时视为疑似空白",
|
||||
)
|
||||
parser.add_argument("--json", dest="json_path", help="JSON 报告输出路径")
|
||||
parser.add_argument("--render-dir", help="逐页 PNG 输出目录")
|
||||
parser.add_argument("--dpi", type=int, default=300, help="页面渲染 DPI")
|
||||
parser.add_argument(
|
||||
"--no-fail",
|
||||
action="store_true",
|
||||
help="发现乱码、禁用词或非豁免空白页时仍返回 0",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def inspect_document(
|
||||
document,
|
||||
path,
|
||||
forbidden=(),
|
||||
min_text_chars=30,
|
||||
allowed_blank_pages=(),
|
||||
):
|
||||
allowed = set(allowed_blank_pages)
|
||||
terms = [term for term in dict.fromkeys(forbidden) if term]
|
||||
forbidden_hits = {term: {"count": 0, "pages": []} for term in terms}
|
||||
pages = []
|
||||
replacement_characters = 0
|
||||
suspicious_blank_pages = []
|
||||
for index, page in enumerate(document):
|
||||
page_number = index + 1
|
||||
text = page.get_text().strip()
|
||||
image_count = len(page.get_images(full=True))
|
||||
pages.append(
|
||||
{
|
||||
"page": page_number,
|
||||
"text_chars": len(text),
|
||||
"images": image_count,
|
||||
}
|
||||
)
|
||||
replacement_characters += text.count("\ufffd")
|
||||
if (
|
||||
len(text) < min_text_chars
|
||||
and image_count == 0
|
||||
and page_number not in allowed
|
||||
):
|
||||
suspicious_blank_pages.append(page_number)
|
||||
for term in terms:
|
||||
count = text.count(term)
|
||||
if count:
|
||||
forbidden_hits[term]["count"] += count
|
||||
forbidden_hits[term]["pages"].append(page_number)
|
||||
|
||||
return {
|
||||
"file": str(path),
|
||||
"page_count": len(document),
|
||||
"replacement_characters": replacement_characters,
|
||||
"forbidden": {
|
||||
term: result
|
||||
for term, result in forbidden_hits.items()
|
||||
if result["count"]
|
||||
},
|
||||
"suspicious_blank_pages": suspicious_blank_pages,
|
||||
"allowed_blank_pages": sorted(allowed),
|
||||
"pages": pages,
|
||||
}
|
||||
|
||||
|
||||
def inspect_pdf(pdf_path, forbidden=(), min_text_chars=30, allowed_blank_pages=()):
|
||||
path = Path(pdf_path).expanduser().resolve()
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"PDF 不存在:{path}")
|
||||
document = fitz.open(path)
|
||||
try:
|
||||
return inspect_document(
|
||||
document,
|
||||
path,
|
||||
forbidden=forbidden,
|
||||
min_text_chars=min_text_chars,
|
||||
allowed_blank_pages=allowed_blank_pages,
|
||||
)
|
||||
finally:
|
||||
document.close()
|
||||
|
||||
|
||||
def render_document(document, output_dir, dpi=300):
|
||||
if dpi < 72:
|
||||
raise ValueError("dpi 不能低于 72")
|
||||
output = Path(output_dir).expanduser().resolve()
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
existing = sorted(output.glob("page-*.png"))
|
||||
if existing:
|
||||
raise FileExistsError(
|
||||
f"渲染目录已有页面图,请改用空目录:{output}"
|
||||
)
|
||||
scale = dpi / 72
|
||||
matrix = fitz.Matrix(scale, scale)
|
||||
digits = max(3, len(str(len(document))))
|
||||
rendered = []
|
||||
for index, page in enumerate(document):
|
||||
target = output / f"page-{index + 1:0{digits}d}.png"
|
||||
page.get_pixmap(matrix=matrix, alpha=False).save(target)
|
||||
rendered.append(str(target))
|
||||
return rendered
|
||||
|
||||
|
||||
def render_pages(pdf_path, output_dir, dpi=300):
|
||||
document = fitz.open(Path(pdf_path).expanduser().resolve())
|
||||
try:
|
||||
return render_document(document, output_dir, dpi=dpi)
|
||||
finally:
|
||||
document.close()
|
||||
|
||||
|
||||
def has_failures(report):
|
||||
return bool(
|
||||
report["replacement_characters"]
|
||||
or report["forbidden"]
|
||||
or report["suspicious_blank_pages"]
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
pdf_path = Path(args.pdf).expanduser().resolve()
|
||||
if not pdf_path.is_file():
|
||||
raise FileNotFoundError(f"PDF 不存在:{pdf_path}")
|
||||
json_path = (
|
||||
Path(args.json_path).expanduser().resolve()
|
||||
if args.json_path
|
||||
else None
|
||||
)
|
||||
render_dir = (
|
||||
Path(args.render_dir).expanduser().resolve()
|
||||
if args.render_dir
|
||||
else None
|
||||
)
|
||||
if json_path == pdf_path:
|
||||
raise ValueError("JSON 报告路径不能覆盖输入 PDF")
|
||||
if render_dir == pdf_path:
|
||||
raise ValueError("渲染目录不能与输入 PDF 同路径")
|
||||
|
||||
document = fitz.open(pdf_path)
|
||||
try:
|
||||
report = inspect_document(
|
||||
document,
|
||||
pdf_path,
|
||||
forbidden=args.forbid,
|
||||
min_text_chars=args.min_text_chars,
|
||||
allowed_blank_pages=args.allow_blank_page,
|
||||
)
|
||||
if render_dir:
|
||||
report["rendered_pages"] = render_document(
|
||||
document,
|
||||
render_dir,
|
||||
args.dpi,
|
||||
)
|
||||
finally:
|
||||
document.close()
|
||||
|
||||
output = json.dumps(report, ensure_ascii=False, indent=2)
|
||||
if json_path:
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
json_path.write_text(output + "\n", encoding="utf-8")
|
||||
print(f"report: {json_path}")
|
||||
else:
|
||||
print(output)
|
||||
|
||||
if has_failures(report) and not args.no_fail:
|
||||
print("PDF 核验失败:存在乱码、禁用词或疑似空白页。", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,19 @@
|
||||
# 一、章节标题
|
||||
|
||||
本章正文使用 Markdown 编写。图片路径相对于当前 Markdown 文件所在目录。
|
||||
|
||||
## 1. 二级标题
|
||||
|
||||
支持**粗体**、*斜体*、`行内代码`、列表和表格。
|
||||
|
||||
| 项目 | 说明 |
|
||||
|---|---|
|
||||
| 示例 | 表格会按内容长度分配列宽 |
|
||||
|
||||

|
||||
|
||||
*图 1 示例架构图*
|
||||
|
||||
```python
|
||||
print("围栏代码块会保留缩进和换行")
|
||||
```
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "项目名称",
|
||||
"subtitle": "技术方案",
|
||||
"author": "编制单位:____________________",
|
||||
"date": "编制日期:____________________",
|
||||
"output": "../dist/document.docx",
|
||||
"toc_depth": 3,
|
||||
"chapters": [
|
||||
{
|
||||
"path": "chapters/01-overview.md",
|
||||
"page_break_before": false
|
||||
},
|
||||
{
|
||||
"path": "chapters/02-design.md",
|
||||
"page_break_before": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from docx import Document
|
||||
from PIL import Image
|
||||
|
||||
|
||||
SKILL_DIR = Path(__file__).resolve().parents[1]
|
||||
SCRIPT_PATH = SKILL_DIR / "scripts" / "build_docx.py"
|
||||
SPEC = importlib.util.spec_from_file_location("build_docx", SCRIPT_PATH)
|
||||
build_docx = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(build_docx)
|
||||
|
||||
|
||||
class BuildDocxTests(unittest.TestCase):
|
||||
def write_config(self, root, config):
|
||||
path = root / "document.json"
|
||||
path.write_text(json.dumps(config, ensure_ascii=False), encoding="utf-8")
|
||||
return path
|
||||
|
||||
def test_builds_supported_markdown_elements(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
image_path = root / "diagram.png"
|
||||
Image.new("RGB", (400, 200), "white").save(image_path, dpi=(200, 200))
|
||||
chapter = root / "chapter.md"
|
||||
chapter.write_text(
|
||||
"# 一、概述\n\n"
|
||||
"正文包含**粗体**、*斜体*和`代码`。\n\n"
|
||||
"- 列表一\n- 列表二\n\n"
|
||||
"| 项目 | 详细说明 |\n|---|---|\n| A | 一段较长的内容 |\n\n"
|
||||
"\n\n"
|
||||
"*图 1 架构图*\n\n"
|
||||
"```python\nprint('ok')\n```\n\n"
|
||||
"> 引用说明\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
config_path = self.write_config(
|
||||
root,
|
||||
{
|
||||
"title": "测试文档",
|
||||
"subtitle": "构建验证",
|
||||
"output": "out/test.docx",
|
||||
"toc_depth": 0,
|
||||
"chapters": [{"path": "chapter.md"}],
|
||||
},
|
||||
)
|
||||
loaded_path, config = build_docx.load_config(config_path)
|
||||
output = build_docx.build(loaded_path, config)
|
||||
|
||||
self.assertTrue(output.is_file())
|
||||
document = Document(output)
|
||||
text = "\n".join(paragraph.text for paragraph in document.paragraphs)
|
||||
self.assertIn("一、概述", text)
|
||||
self.assertIn("图 1 架构图", text)
|
||||
self.assertIn("print('ok')", text)
|
||||
self.assertEqual(len(document.tables), 1)
|
||||
images = [
|
||||
rel
|
||||
for rel in document.part.rels.values()
|
||||
if "image" in rel.reltype
|
||||
]
|
||||
self.assertEqual(len(images), 1)
|
||||
|
||||
def test_landscape_widens_tables(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
header = "| " + " | ".join(f"列{i}" for i in range(11)) + " |"
|
||||
divider = "|" + "---|" * 11
|
||||
row = "| " + " | ".join(f"值{i}" for i in range(11)) + " |"
|
||||
(root / "wide.md").write_text(
|
||||
f"# 宽表\n\n{header}\n{divider}\n{row}\n", encoding="utf-8"
|
||||
)
|
||||
widths = {}
|
||||
for mode in ("portrait", "landscape"):
|
||||
config_path = self.write_config(
|
||||
root,
|
||||
{
|
||||
"title": "宽表测试",
|
||||
"output": f"out/{mode}.docx",
|
||||
"toc_depth": 0,
|
||||
"orientation": mode,
|
||||
"chapters": [{"path": "wide.md"}],
|
||||
},
|
||||
)
|
||||
loaded, config = build_docx.load_config(config_path)
|
||||
document = Document(build_docx.build(loaded, config))
|
||||
widths[mode] = sum(
|
||||
cell.width.cm for cell in document.tables[0].rows[0].cells
|
||||
)
|
||||
self.assertGreater(widths["landscape"], widths["portrait"] + 5)
|
||||
|
||||
def test_wide_table_fits_longest_word_in_every_column(self):
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
headers = [
|
||||
"需求编号", "需求出处", "需求原文", "类别", "强制/评分", "响应状态",
|
||||
"实现说明", "偏离说明", "证据编号", "方案章节", "验证方法",
|
||||
]
|
||||
body = [
|
||||
"REQ-001", "示例技术要求 3.1.1",
|
||||
"系统应提供与 OpenAI 接口兼容的统一调用入口。", "功能", "强制",
|
||||
"compliant", "网关提供模型列表、对话补全和向量化四类接口,统一鉴权",
|
||||
"无", "FEAT-001", "三.1、四.1", "依次调用四类接口并核对返回结构",
|
||||
]
|
||||
head = "".join(f"<th>{h}</th>" for h in headers)
|
||||
cells = "".join(f"<td>{c}</td>" for c in body)
|
||||
rows = BeautifulSoup(
|
||||
f"<table><tr>{head}</tr><tr>{cells}</tr></table>", "html.parser"
|
||||
).find_all("tr")
|
||||
widths = build_docx.compute_col_widths(rows, 11, 25.7, 9.5)
|
||||
|
||||
self.assertAlmostEqual(sum(widths), 25.7, places=3)
|
||||
em_cm = 9.5 / 28.35
|
||||
for index, (header, cell) in enumerate(zip(headers, body)):
|
||||
longest = max(
|
||||
build_docx.em_width(word)
|
||||
for text in (header, cell)
|
||||
for word in text.split()
|
||||
)
|
||||
self.assertGreaterEqual(
|
||||
widths[index] + 1e-6,
|
||||
min(em_cm * longest, 25.7 / 11),
|
||||
f"column {index} ({header}) truncates its longest word",
|
||||
)
|
||||
self.assertGreater(widths[6], widths[3])
|
||||
|
||||
def test_blockquote_keeps_paragraph_breaks(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
(root / "quote.md").write_text(
|
||||
"# 引用\n\n> 第一段说明。\n>\n> 第二段说明。\n", encoding="utf-8"
|
||||
)
|
||||
config_path = self.write_config(
|
||||
root,
|
||||
{
|
||||
"title": "引用测试",
|
||||
"output": "out/quote.docx",
|
||||
"toc_depth": 0,
|
||||
"chapters": [{"path": "quote.md"}],
|
||||
},
|
||||
)
|
||||
loaded, config = build_docx.load_config(config_path)
|
||||
document = Document(build_docx.build(loaded, config))
|
||||
texts = [p.text for p in document.paragraphs]
|
||||
self.assertIn("第一段说明。", texts)
|
||||
self.assertIn("第二段说明。", texts)
|
||||
|
||||
def test_only_forced_chapter_boundary_adds_page_break(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
(root / "one.md").write_text("# 第一章\n", encoding="utf-8")
|
||||
(root / "two.md").write_text("# 第二章\n", encoding="utf-8")
|
||||
config_path = self.write_config(
|
||||
root,
|
||||
{
|
||||
"title": "分页测试",
|
||||
"cover": False,
|
||||
"toc_depth": 0,
|
||||
"output": "test.docx",
|
||||
"chapters": [
|
||||
{"path": "one.md", "page_break_before": False},
|
||||
{"path": "two.md", "page_break_before": True},
|
||||
],
|
||||
},
|
||||
)
|
||||
loaded_path, config = build_docx.load_config(config_path)
|
||||
output = build_docx.build(loaded_path, config)
|
||||
document = Document(output)
|
||||
self.assertEqual(document._element.xml.count('w:type="page"'), 1)
|
||||
|
||||
def test_rejects_missing_chapters(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
config_path = self.write_config(
|
||||
root,
|
||||
{"title": "无章节", "output": "test.docx", "chapters": []},
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "chapters"):
|
||||
build_docx.load_config(config_path)
|
||||
|
||||
def test_rejects_non_boolean_page_break(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
(root / "chapter.md").write_text("# 章节\n", encoding="utf-8")
|
||||
config_path = self.write_config(
|
||||
root,
|
||||
{
|
||||
"title": "错误分页配置",
|
||||
"output": "test.docx",
|
||||
"chapters": [
|
||||
{"path": "chapter.md", "page_break_before": "false"}
|
||||
],
|
||||
},
|
||||
)
|
||||
loaded_path, config = build_docx.load_config(config_path)
|
||||
with self.assertRaisesRegex(ValueError, "page_break_before"):
|
||||
build_docx.build(loaded_path, config)
|
||||
|
||||
def test_rejects_mixed_text_and_image_paragraph(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
Image.new("RGB", (100, 50), "white").save(root / "diagram.png")
|
||||
(root / "chapter.md").write_text(
|
||||
"说明文字 \n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
config_path = self.write_config(
|
||||
root,
|
||||
{
|
||||
"title": "图片格式测试",
|
||||
"cover": False,
|
||||
"toc_depth": 0,
|
||||
"output": "test.docx",
|
||||
"chapters": ["chapter.md"],
|
||||
},
|
||||
)
|
||||
loaded_path, config = build_docx.load_config(config_path)
|
||||
with self.assertRaisesRegex(ValueError, "图片必须独占"):
|
||||
build_docx.build(loaded_path, config)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,76 @@
|
||||
import importlib.util
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import fitz
|
||||
|
||||
|
||||
SKILL_DIR = Path(__file__).resolve().parents[1]
|
||||
SCRIPT_PATH = SKILL_DIR / "scripts" / "verify_pdf.py"
|
||||
SPEC = importlib.util.spec_from_file_location("verify_pdf", SCRIPT_PATH)
|
||||
verify_pdf = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(verify_pdf)
|
||||
|
||||
|
||||
class VerifyPdfTests(unittest.TestCase):
|
||||
def create_pdf(self, path):
|
||||
document = fitz.open()
|
||||
text_page = document.new_page()
|
||||
text_page.insert_text(
|
||||
(72, 72),
|
||||
"This page contains enough verification text and a forbidden term.",
|
||||
)
|
||||
document.new_page()
|
||||
document.save(path)
|
||||
document.close()
|
||||
|
||||
def test_reports_forbidden_terms_and_blank_pages(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
pdf = Path(tmp) / "sample.pdf"
|
||||
self.create_pdf(pdf)
|
||||
report = verify_pdf.inspect_pdf(
|
||||
pdf,
|
||||
forbidden=["forbidden"],
|
||||
min_text_chars=30,
|
||||
)
|
||||
self.assertEqual(report["page_count"], 2)
|
||||
self.assertEqual(report["forbidden"]["forbidden"]["pages"], [1])
|
||||
self.assertEqual(report["suspicious_blank_pages"], [2])
|
||||
self.assertTrue(verify_pdf.has_failures(report))
|
||||
|
||||
def test_allows_known_blank_page(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
pdf = Path(tmp) / "sample.pdf"
|
||||
self.create_pdf(pdf)
|
||||
report = verify_pdf.inspect_pdf(
|
||||
pdf,
|
||||
min_text_chars=30,
|
||||
allowed_blank_pages=[2],
|
||||
)
|
||||
self.assertEqual(report["suspicious_blank_pages"], [])
|
||||
self.assertFalse(verify_pdf.has_failures(report))
|
||||
|
||||
def test_renders_pages(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
pdf = root / "sample.pdf"
|
||||
self.create_pdf(pdf)
|
||||
rendered = verify_pdf.render_pages(pdf, root / "pages", dpi=72)
|
||||
self.assertEqual(len(rendered), 2)
|
||||
self.assertTrue(all(Path(path).is_file() for path in rendered))
|
||||
|
||||
def test_rejects_render_directory_with_old_pages(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
pdf = root / "sample.pdf"
|
||||
pages = root / "pages"
|
||||
pages.mkdir()
|
||||
(pages / "page-999.png").write_bytes(b"old")
|
||||
self.create_pdf(pdf)
|
||||
with self.assertRaisesRegex(FileExistsError, "空目录"):
|
||||
verify_pdf.render_pages(pdf, pages, dpi=72)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user