feat(doc): 新增文档转换与规则化归档 Skill

This commit is contained in:
zhiye.sun
2026-08-25 15:32:12 +08:00
parent dbc2f65154
commit d255bdd524
20 changed files with 735 additions and 20 deletions
+35
View File
@@ -0,0 +1,35 @@
---
name: md-to-docx
description: 将 Markdown 文档转换为 Word .docx,保留常见标题、段落、列表、表格、代码块、链接和本地图片。适用于需要可编辑 Word 版本的 Markdown;修订留痕、复杂排版复刻或旧版 .doc 不应触发本 Skill。
---
# Markdown 转 Word
使用 `scripts/convert.py` 生成可编辑的 `.docx`。转换以语义结构清晰为目标,不承诺像素级复刻 Markdown 渲染结果。
## 执行边界
- 只接受存在的 `.md` 或 `.markdown` 文件。
- 默认在输入文件旁生成同名 `.docx`;文件已存在时停止,只有用户明确同意覆盖后才传入 `--force`。
- 用户提供 `.docx` 模板时可传入 `--template`,转换器沿用模板样式并在文档末尾追加内容,不替换模板中的占位符。
- 不自动下载图片、字体或依赖;远程图片保留为文字提示,本地缺失图片产生警告。
- 不伪造修订记录、批注或目录。需要人工审阅留痕时应使用独立的文档修订流程。
## 转换流程
1. 确认输入、输出位置和是否使用模板。
2. 使用工作区依赖运行时执行:
```text
python scripts/convert.py <input.md> [--output <file.docx>] [--template <template.docx>] [--force]
```
3. 重新打开生成文件,检查标题、列表、表格和图片统计。
4. 如当前环境具备 DOCX 渲染能力,渲染页面并目视检查;否则明确说明只完成了结构校验。
5. 报告输出路径、转换统计和降级内容。
## 支持范围
转换器支持 ATX 标题、普通段落、粗体、斜体、行内代码、链接、图片、引用、围栏代码块、水平线、无序列表、有序列表和基础 Markdown 表格。
嵌套混合列表、原始 HTML、脚注、公式、任务列表、复杂表格合并和主题级样式可能降级。输入依赖特定 Markdown 扩展时,应先说明差异,不得声称无损转换。
@@ -0,0 +1,4 @@
interface:
display_name: "Markdown to DOCX"
short_description: "将 Markdown 转换为结构清晰的 Word 文档"
default_prompt: "使用 $md-to-docx 将这份 Markdown 转换为 Word,并报告降级内容。"
@@ -0,0 +1,10 @@
# 转换支持说明
| Markdown 内容 | DOCX 表达 | 说明 |
| --- | --- | --- |
| 标题 | Heading 1 至 Heading 6 | 沿用模板同名样式 |
| 有序、无序列表 | Word 列表样式 | 复杂混排需复核 |
| 表格 | Word 表格 | 不支持单元格合并 |
| 围栏代码块 | 等宽字体段落 | 不做语法高亮 |
| 本地图片 | 内嵌图片 | 相对输入文件定位 |
| 远程图片 | 文字提示 | 不访问网络 |
@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""将常见 Markdown 结构转换为可编辑 DOCX。"""
from __future__ import annotations
import argparse
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
from docx import Document
from docx.shared import Inches, Pt
@dataclass
class Result:
"""记录生成物与转换统计。"""
output: Path
headings: int = 0
paragraphs: int = 0
lists: int = 0
tables: int = 0
images: int = 0
warnings: list[str] = field(default_factory=list)
def cells(line: str) -> list[str]:
"""拆分基础表格行并还原转义竖线。"""
return [item.strip().replace(r"\|", "|") for item in re.split(r"(?<!\\)\|", line.strip().strip("|"))]
def separator(line: str) -> bool:
"""判断 Markdown 表格分隔行。"""
values = cells(line)
return bool(values) and all(re.fullmatch(r":?-{3,}:?", item) for item in values)
class Converter:
"""使用可预测的小型解析器转换常见 Markdown。"""
def __init__(self, source: Path, output: Path, template: Path | None) -> None:
self.source = source
self.doc = Document(template) if template else Document()
self.result = Result(output)
def convert(self) -> Result:
"""按块转换、保存并重新打开产物完成结构校验。"""
lines = self.source.read_text(encoding="utf-8-sig").splitlines()
index = 0
while index < len(lines):
if lines[index].lstrip().startswith("```"):
index = self.add_code(lines, index)
elif index + 1 < len(lines) and "|" in lines[index] and separator(lines[index + 1]):
index = self.add_table(lines, index)
else:
self.add_line(lines[index])
index += 1
self.doc.save(self.result.output)
Document(self.result.output)
return self.result
def add_code(self, lines: list[str], start: int) -> int:
"""读取围栏代码块;未闭合时输出其余内容并记录警告。"""
index = start + 1
content: list[str] = []
while index < len(lines) and not lines[index].lstrip().startswith("```"):
content.append(lines[index])
index += 1
if index == len(lines):
self.result.warnings.append("代码围栏未闭合")
paragraph = self.doc.add_paragraph()
run = paragraph.add_run("\n".join(content))
run.font.name = "Consolas"
run.font.size = Pt(9)
self.result.paragraphs += 1
return min(index + 1, len(lines))
def add_table(self, lines: list[str], start: int) -> int:
"""将连续表格行转换为统一列数的 Word 表格。"""
rows = [cells(lines[start])]
index = start + 2
while index < len(lines) and lines[index].strip() and "|" in lines[index]:
rows.append(cells(lines[index]))
index += 1
width = max(map(len, rows))
table = self.doc.add_table(rows=len(rows), cols=width)
table.style = "Table Grid"
for row_no, values in enumerate(rows):
for col_no in range(width):
table.cell(row_no, col_no).text = (values[col_no] if col_no < len(values) else "").replace("<br>", "\n")
self.result.tables += 1
return index
def add_line(self, line: str) -> None:
"""识别标题、列表、引用、分隔线和普通段落。"""
value = line.strip()
if not value:
return
heading = re.match(r"^(#{1,6})\s+(.+)$", value)
if heading:
self.add_inline(self.doc.add_heading(level=len(heading.group(1))), heading.group(2))
self.result.headings += 1
return
unordered = re.match(r"^(\s*)[-+*]\s+(.+)$", line)
ordered = re.match(r"^(\s*)\d+[.)]\s+(.+)$", line)
if unordered or ordered:
match = unordered or ordered
paragraph = self.doc.add_paragraph(style="List Bullet" if unordered else "List Number")
paragraph.paragraph_format.left_indent = Inches(min(len(match.group(1)) // 2, 4) * 0.25)
self.add_inline(paragraph, match.group(2))
self.result.lists += 1
return
if value.startswith(">"):
paragraph = self.doc.add_paragraph(value.lstrip("> "))
paragraph.paragraph_format.left_indent = Inches(0.3)
for run in paragraph.runs:
run.italic = True
self.result.paragraphs += 1
return
if re.fullmatch(r"(?:-{3,}|\*{3,}|_{3,})", value):
self.doc.add_paragraph("────────")
return
paragraph = self.doc.add_paragraph()
self.add_inline(paragraph, value)
self.result.paragraphs += 1
def add_inline(self, paragraph, text: str) -> None:
"""转换图片与基础强调;链接保留为可读地址文本。"""
pattern = re.compile(r"(!?\[[^\]]*\]\([^)]*\)|\*\*[^*]+\*\*|`[^`]+`|\*[^*]+\*)")
cursor = 0
for match in pattern.finditer(text):
paragraph.add_run(text[cursor:match.start()])
token = match.group(0)
image = re.fullmatch(r"!\[([^\]]*)\]\(([^)]+)\)", token)
link = re.fullmatch(r"\[([^\]]+)\]\(([^)]+)\)", token)
if image:
self.add_image(paragraph, image.group(1), image.group(2))
elif link:
paragraph.add_run(f"{link.group(1)}({link.group(2)})")
else:
run = paragraph.add_run(token[2:-2] if token.startswith("**") else token[1:-1])
run.bold = token.startswith("**")
run.italic = token.startswith("*") and not token.startswith("**")
if token.startswith("`"):
run.font.name = "Consolas"
cursor = match.end()
paragraph.add_run(text[cursor:])
def add_image(self, paragraph, alt: str, target: str) -> None:
"""只处理本地图片,防止转换过程产生隐式网络访问。"""
if re.match(r"^[a-z][a-z0-9+.-]*://", target, re.I):
paragraph.add_run(f"[远程图片:{alt or target}]")
self.result.warnings.append(f"未下载远程图片:{target}")
return
path = (self.source.parent / target).resolve()
if not path.is_file():
paragraph.add_run(f"[缺失图片:{alt or target}]")
self.result.warnings.append(f"本地图片不存在:{target}")
return
try:
paragraph.add_run().add_picture(str(path), width=Inches(5.8))
self.result.images += 1
except (OSError, ValueError) as error:
paragraph.add_run(f"[无法嵌入图片:{alt or target}]")
self.result.warnings.append(f"图片无法嵌入:{target}({error})")
def main(argv: list[str] | None = None) -> int:
"""校验输入、覆盖权限与模板后执行转换。"""
parser = argparse.ArgumentParser(description="将 Markdown 转换为 DOCX")
parser.add_argument("input", type=Path)
parser.add_argument("--output", type=Path)
parser.add_argument("--template", type=Path)
parser.add_argument("--force", action="store_true")
args = parser.parse_args(argv)
source = args.input.resolve()
output = (args.output or source.with_suffix(".docx")).resolve()
template = args.template.resolve() if args.template else None
if not source.is_file() or source.suffix.lower() not in {".md", ".markdown"}:
print("错误:输入必须是存在的 .md 或 .markdown 文件", file=sys.stderr); return 2
if output.suffix.lower() != ".docx" or (output.exists() and not args.force):
print("错误:输出必须是可写的 .docx;覆盖需使用 --force", file=sys.stderr); return 1
if template and (not template.is_file() or template.suffix.lower() != ".docx"):
print("错误:模板必须是存在的 .docx 文件", file=sys.stderr); return 2
output.parent.mkdir(parents=True, exist_ok=True)
try:
result = Converter(source, output, template).convert()
except (OSError, ValueError) as error:
print(f"错误:{error}", file=sys.stderr); return 1
print(f"DOCX:{result.output}")
print(f"标题:{result.headings},段落:{result.paragraphs},列表:{result.lists},表格:{result.tables},图片:{result.images}")
for warning in dict.fromkeys(result.warnings): print(f"警告:{warning}")
return 0
if __name__ == "__main__":
raise SystemExit(main())