feat: improve skill import and runtime delivery

This commit is contained in:
lofyer
2026-08-07 21:01:17 +08:00
parent 954b42ef55
commit 417a9fccb6
63 changed files with 6136 additions and 527 deletions
+168
View File
@@ -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.txtPDF 核验需 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. 二级标题
支持**粗体**、*斜体*、`行内代码`、列表和表格。
| 项目 | 说明 |
|---|---|
| 示例 | 表格会按内容长度分配列宽 |
![示例架构图](assets/example.png)
*图 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"
"![架构图](diagram.png)\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(
"说明文字 ![架构图](diagram.png)\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()