Claude Skill

md-to-word

将一个或多个 Markdown 文档转换为格式优美的 Word(.docx),基于 Pandoc + 内置 reference.docx 模板(可选自定义模板),并确保不修改任何原始 Markdown 文件。内置模板已修复命名空间兼容性问题,支持 RGBA 图片自动转换。

LLM Mart · 0 points · 20 views 1 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download huangwb8-skills-skills_beta_md-to-word-dd1fab8.zip · 48 KB
Part of huangwb8/skills — 22 skills

Install

skills CLI npx skills add https://github.com/huangwb8/skills/tree/main/skills/beta/md-to-word
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install huangwb8-skills@llmmart
Git git clone https://github.com/huangwb8/skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole huangwb8/skills collection as a plugin from our marketplace. Git is the plain clone.

README

md-to-word

这个 skill 用来把一个或多个 Markdown 文档转换成可交付的 Word .docx 文件,强调模板、美观排版、安全输出和兼容性;它不会修改原始 Markdown,也不会默认覆盖已有输出。

用法

最推荐用法

请使用 md-to-word skill: `xx.md`
输入:一个或多个 Markdown 文件;可选模板 `default` / `cn-modern` / `compact`
输出:对应的 `.docx` 文件,默认不覆盖已有输出,也不修改原始 Markdown

进阶用法

请使用 md-to-word skill: `report.md`
输入:`report.md`
输出:`report.docx`
另外,还有下列参数约束:
- 模板:`cn-modern`
- 自动修复 RGBA 图片:是
- 输出目录:`./docx-out`

能做什么

  • 把 Markdown 转成交付级 .docx,而不是只调用一次最小 Pandoc 命令。
  • 提供内置模板,适合通用、中文友好和紧凑排版三种常见风格。
  • 处理 Word 兼容性问题,尤其是图片模式和模板命名空间问题。
  • 默认保持输入文件不变,也默认不覆盖已有 docx。
  • 不适合编辑现有 Word 文档,也不是通用排版设计器。

使用示例

示例 1:转换单个 Markdown

请使用 md-to-word skill: `proposal.md`
输入:`proposal.md`
输出:`proposal.docx`

示例 2:批量转换并指定模板

请使用 md-to-word skill 批量转换这些 Markdown。
输入:`a.md`、`b.md`
输出:对应的 `.docx` 文件
另外,还有下列参数约束:
- 模板:`compact`
- 输出目录:`./out`

示例 3:强调图片兼容性

请使用 md-to-word skill: `report.md`
输入:`report.md`
输出:`.docx`
另外,还有下列参数约束:
- 自动修复 RGBA 图片:是
- 如果输出已存在:不要覆盖

输出

  • 默认输出为输入文件同目录下的同名 .docx。
  • 默认不覆盖已有输出。
  • 默认不修改、重命名或删除原始 Markdown。
  • 开启图片修复时,会在工作目录下创建 .bensz-api/task-{yyyymmdd-hhmm}-{简短描述}/md-to-word/{yyyy-mm-dd-hh-mm}/ 作为中间工作区。

配置

  • 配置文件:md-to-word/config.yaml
  • 内置模板:
    • default
    • cn-modern
    • compact
  • 默认模板:default
  • 允许输入扩展名:.md、.markdown
  • 最大输入数量:200

备选用法(脚本/硬编码)

如果你明确知道输入文件和模板,脚本方式通常是最稳、最高效的做法。

查看模板

python3 md-to-word/scripts/md_to_word.py --list-templates

基础转换

python3 md-to-word/scripts/md_to_word.py proposal.md

指定模板和输出目录

python3 md-to-word/scripts/md_to_word.py \
  --template cn-modern \
  --output-dir ./out \
  proposal.md

修复图片并批量转换

python3 md-to-word/scripts/md_to_word.py \
  --template compact \
  --fix-images \
  a.md b.md

常见问题

Q:它会修改我的 Markdown 吗?

A:不会。这个 skill 的安全边界之一就是“只读取输入 Markdown,输出新的 .docx”。

Q:如果同名 .docx 已经存在怎么办?

A:默认会报错并停止,除非你显式使用 --overwrite。

Q:什么时候该用 --fix-images?

A:当你的 Markdown 里有 RGBA PNG、透明图或 Word 打开后提示图片兼容性问题时,优先开启它。

Q:内置模板和自定义 reference.docx 怎么选?

A:大多数场景先用内置模板即可;只有你已经有成熟的 Word 样式体系时,再显式传入自定义 reference.docx。

Skill manifest

md-to-word(Markdown 转 Word)

目标

将一个或多个 Markdown 文档转换为格式优美的 Word(.docx),基于 Pandoc + 内置 reference.docx 模板(可选自定义模板),并确保不修改任何原始 Markdown 文件。内置模板已修复命名空间兼容性问题,支持 RGBA 图片自动转换。

流程

输入

输入为一个或多个 Markdown 文件;可选输入包括内置或自定义 reference.docx、输出目录、Pandoc 参数和图片处理选项。转换不得修改原始 Markdown,输出路径须在用户授权范围内。

执行步骤

你要解决的问题

用户给你一个或多个标准 Markdown 文档,希望把它们转换成排版美观、可审查、可交付的 Word(.docx),并且能在不同项目里复用同一套转换流程与样式模板。

常见问题解决方案:

  • RGBA PNG 导致 Word 警告:使用 --fix-images 自动转换为 RGB 模式
  • 图片路径问题:脚本自动处理相对路径资源引用
  • 中文排版问题:使用 --template cn-modern 获得更好的中文样式

内置模板(Pandoc reference.docx)

内置模板文件位于 assets/:

  • default:assets/reference-default.docx
  • cn-modern:assets/reference-cn-modern.docx(中文更友好字体/样式)
  • compact:assets/reference-compact.docx(更紧凑段落间距)

推荐执行方式

优先运行确定性脚本 scripts/md_to_word.py,避免 AI 手写 Pandoc 命令导致参数缺失或误覆盖。

示例:

python3 md-to-word/scripts/md_to_word.py \
  --template cn-modern \
  --output-dir /path/to/out \
  /path/to/a.md /path/to/b.md

如用户需要自定义样式,允许:

  • 使用 --reference-doc /path/to/reference.docx 覆盖内置模板(用户自带)。
  • 需要用同一份 Markdown 生成多套风格时,使用 --output-suffix 避免覆盖(默认不覆盖)。
  • 用户不确定模板可选项时,先运行 python3 md-to-word/scripts/md_to_word.py --list-templates。

核心工作流

步骤 0:预检查(不写任何输出前)
  1. 校验 md_files 均存在且为文件。
    • 默认仅接受 .md/.markdown;如用户确实给了其他扩展名,必须显式使用 --allow-any-extension。
  2. 确认 Pandoc 可用(默认执行 pandoc --version);不可用时给出明确安装提示,并停止。
  3. 选择模板:
    • 优先 --reference-doc(用户显式指定);
    • 否则使用 --template(默认 default)。
  4. 计算输出路径:
    • 默认:{input_dir}/{basename}.docx
    • 单输入且用户想指定输出文件名:使用 --output /path/to/out.docx
    • 指定 --output-dir:{output_dir}/{basename}.docx
    • 若输出已存在:默认报错并停止(除非用户明确要求 --overwrite)。
步骤 1:逐文件转换(必须覆盖全部输入)

对每个 Markdown 文件:

  • 图片处理(可选,--fix-images):
    • 在 MD 所在目录创建 .bensz-api/task-{yyyymmdd-hhmm}-{简短描述}/md-to-word/{yyyy-mm-dd-hh-mm}/ 隐藏工作目录
    • 创建 .bensz-api/task-{yyyymmdd-hhmm}-{简短描述}/md-to-word/{yyyy-mm-dd-hh-mm}/output/images-rgb/ 存放转换后的图片
    • 创建 MD 副本,更新所有图片链接指向 RGB 版本
    • 仅转换非 RGB 模式的图片(RGBA/P/L 等),RGB 图片直接复制
  • 以非 shell方式调用 Pandoc(防止命令注入)。
  • 自动设置 --resource-path,包含 .bensz-api/task-{yyyymmdd-hhmm}-{简短描述}/md-to-word/{yyyy-mm-dd-hh-mm}/ 目录。
  • 生成 .docx 到目标输出路径。
  • 可选:使用 --clean 转换后清理 .bensz-api/task-{yyyymmdd-hhmm}-{简短描述}/md-to-word/{yyyy-mm-dd-hh-mm}/ 工作目录(默认保留便于增量转换)
步骤 2:轻量自检(输出后必须做)
  • 输入 Markdown 文件的内容未被修改(可选:对关键输入做 hash 前后对比)
  • 输出 .docx 均成功生成且路径符合预期
  • 未发生意外覆盖(除非用户明确要求)
  • 如存在图片/链接,Word 中渲染正常(无法验证时说明原因与建议)

输出

输入输出

输入

  • md_files:一个或多个 Markdown 文件路径(建议 .md / .markdown)
  • 可选:template(内置模板名)或 reference_doc(自定义 reference.docx 路径)
  • 可选:output_dir(输出目录)

输出

  • 对每个输入 Markdown,生成一个同名 .docx(默认输出到输入文件同目录;也可输出到 output_dir)

输出管理

BenszAPI 任务工作区

校验

转换前检查输入扩展名、文件存在性、Pandoc/Pillow 可用性和模板;转换后核对每个 .docx 存在、可打开、图片/链接渲染正常(无法验证时明确说明),且源 Markdown 未被覆盖。

失败与恢复

Word 兼容性问题与解决方案

问题 1:Word 打开时提示"发现无法读取的内容"(模板命名空间问题)

原因:自定义 Word 模板使用了非标准的 XML 命名空间前缀(ns0:),与 Pandoc 的 --reference-doc 参数结合时可能导致 Word 兼容性问题。

解决方案:

  1. 内置模板已修复:所有内置模板(cn-modern、compact、default)已更新为使用标准命名空间
  2. 自动兼容性参数:脚本自动添加 --markdown-headings=atx 参数提高兼容性
  3. 自定义模板修复:使用 scripts/fix_template_namespace.py 修复自定义模板
# 修复自定义模板
python3 md-to-word/scripts/fix_template_namespace.py \
  --input /path/to/custom-template.docx \
  --output /path/to/custom-template-fixed.docx \
  --verify
问题 2:RGBA PNG 图片导致 Word 警告

原因:Markdown 中引用的 PNG 图片使用 RGBA 模式(带透明通道),这种格式在嵌入 Word 文档时可能导致兼容性问题。

解决方案:使用 --fix-images 参数自动转换

python3 md-to-word/scripts/md_to_word.py \
  --fix-images \
  --template cn-modern \
  your-document.md

工作原理:

  1. 在 MD 所在目录创建 .bensz-api/task-{yyyymmdd-hhmm}-{简短描述}/md-to-word/{yyyy-mm-dd-hh-mm}/ 隐藏工作目录
  2. 创建 .bensz-api/task-{yyyymmdd-hhmm}-{简短描述}/md-to-word/{yyyy-mm-dd-hh-mm}/output/images-rgb/ 存放转换后的图片
  3. 扫描 Markdown 中引用的所有图片(支持 PNG/JPG/GIF/BMP/WebP)
  4. 检测图片模式,仅转换非 RGB 模式的图片
    • RGBA → RGB(白色背景)
    • P/PA/LA 等 → RGB
    • RGB/L → 直接复制
  5. 创建 MD 副本,更新图片链接指向 .bensz-api/task-{yyyymmdd-hhmm}-{简短描述}/md-to-word/{yyyy-mm-dd-hh-mm}/output/images-rgb/
  6. 使用 MD 副本执行 Pandoc 转换
  7. 默认保留 .bensz-api/task-{yyyymmdd-hhmm}-{简短描述}/md-to-word/{yyyy-mm-dd-hh-mm}/ 便于增量转换,使用 --clean 清理

工作目录结构:

your-doc.md
your-doc.docx
.bensz-api/task-{yyyymmdd-hhmm}-{简短描述}/md-to-word/{yyyy-mm-dd-hh-mm}/              # 隐藏工作目录(默认保留)
├── your-doc.md          # MD 副本(图片链接已更新)
└── output/
    └── images-rgb/      # RGB 模式图片
        ├── figure1.png  # 转换后(RGBA→RGB)
        └── photo.jpg    # 直接复制(已是 RGB)

依赖:

  • 需要 Pillow 库:pip install Pillow
  • 如未安装,脚本会跳过图片修复并给出提示

清理选项:

  • 默认保留 .bensz-api/task-{yyyymmdd-hhmm}-{简短描述}/md-to-word/{yyyy-mm-dd-hh-mm}/ 工作目录,便于后续增量转换
  • 使用 --clean 转换后自动清理工作目录
  • 手动清理:rm -rf /path/to/md/.bensz-api/task-{yyyymmdd-hhmm}-{简短描述}/md-to-word

约束

公共硬约束

本块由 docs/templates/skill-common-constraints.md 统一维护;每个 SKILL.md 的 ## 约束 必须逐字同步本块,不得在副本中改写公共规则。

  • 任务需要落盘时,使用唯一的 ./.bensz-api/task-{yyyymmdd-hhmm}-{简短描述}/ 根目录;共享材料放入 shared/,Skill 专属材料放入该 Skill 的 input/、output/、log/。
  • 正式交付物、源代码和正式计划按项目约定保存,不写入任务工作区;未经授权不覆盖、删除、迁移或远程写入。
  • 项目维护变更检查 BAC 可用性并记录需求、AI 产出、工具结果、文件改动和验证摘要;BAC 只做过程审计,不替代署名、责任或合规判断。
  • 不记录 API Key、访问令牌、密码、Cookie、环境/凭据文件、私有 Prompt、身份信息、本地用户名、主机名或不必要的大体积原始数据。
  • 文件路径必须规范化并限制在授权项目范围内;外部 URL、子进程和网络访问遵循最小权限,防止路径遍历、SSRF 和命令注入。
  • Skill 版本唯一记录在自身 config.yaml:skill_info.version;公开 API、协议、目录或配置变更同步文档与 CHANGELOG.md。
  • bensz-collect-bugs 是一个 Agent Skill;仅将 Bensz Agent Skill 或 Bensz 基础设施本身的设计缺陷交给它。先脱敏写入 ~/.bensz-skills/bugs/,当前任务不中断,只有用户明确要求才公开上报,禁止直接修改用户已安装的 Skill 源码。

Skill 专属约束

安全约束

  • 你只能读取用户提供的 Markdown 文件及其引用资源(如图片)。
  • 你绝不能修改/覆盖/重命名/删除任何输入 Markdown 文件或其同目录已有文件。
  • 默认不覆盖任何已存在的输出 .docx;除非用户明确要求覆盖,才可使用 --overwrite。
  • 输出文件只能是新生成的 .docx(以及测试目录中的中间产物)。
Files (skills)
  • assets
    • reference-cn-modern.docx 10.7 KB · in bundle
    • reference-compact.docx 10.7 KB · in bundle
    • reference-default.docx 10.7 KB · in bundle
  • scripts
    • build_reference_templates.py 4 KB
      #!/usr/bin/env python3
      from __future__ import annotations
      
      import argparse
      import shutil
      import sys
      import zipfile
      from pathlib import Path
      from typing import NoReturn
      from xml.etree import ElementTree as ET
      
      
      NS = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
      
      
      def _die(message: str) -> NoReturn:
          raise SystemExit(message)
      
      
      def _set_run_fonts(style: ET.Element, east_asia: str | None) -> None:
          rpr = style.find("w:rPr", NS)
          if rpr is None:
              rpr = ET.SubElement(style, f"{{{NS['w']}}}rPr")
      
          rfonts = rpr.find("w:rFonts", NS)
          if rfonts is None:
              rfonts = ET.SubElement(rpr, f"{{{NS['w']}}}rFonts")
      
          if east_asia:
              rfonts.set(f"{{{NS['w']}}}eastAsia", east_asia)
      
      
      def _set_paragraph_spacing(style: ET.Element, before: int | None, after: int | None, line: int | None) -> None:
          ppr = style.find("w:pPr", NS)
          if ppr is None:
              ppr = ET.SubElement(style, f"{{{NS['w']}}}pPr")
      
          spacing = ppr.find("w:spacing", NS)
          if spacing is None:
              spacing = ET.SubElement(ppr, f"{{{NS['w']}}}spacing")
      
          if before is not None:
              spacing.set(f"{{{NS['w']}}}before", str(before))
          if after is not None:
              spacing.set(f"{{{NS['w']}}}after", str(after))
          if line is not None:
              spacing.set(f"{{{NS['w']}}}line", str(line))
              spacing.set(f"{{{NS['w']}}}lineRule", "auto")
      
      
      def patch_styles_xml(styles_xml: bytes, *, east_asia_font: str | None, compact: bool) -> bytes:
          tree = ET.ElementTree(ET.fromstring(styles_xml))
          root = tree.getroot()
      
          for style in root.findall("w:style", NS):
              style_id = style.get(f"{{{NS['w']}}}styleId", "")
              if style_id in {"Normal", "Heading1", "Heading2", "Heading3", "Heading4"}:
                  _set_run_fonts(style, east_asia_font)
                  if compact:
                      _set_paragraph_spacing(style, before=0, after=120, line=240)
      
          return ET.tostring(root, encoding="utf-8", xml_declaration=True)
      
      
      def build_variant(src_docx: Path, dst_docx: Path, *, east_asia_font: str | None, compact: bool) -> None:
          tmp_dir = dst_docx.parent / (dst_docx.stem + "_tmp")
          if tmp_dir.exists():
              shutil.rmtree(tmp_dir)
          tmp_dir.mkdir(parents=True)
      
          with zipfile.ZipFile(src_docx, "r") as zin:
              zin.extractall(tmp_dir)
      
          styles_path = tmp_dir / "word/styles.xml"
          if not styles_path.exists():
              _die(f"未找到 styles.xml:{styles_path}")
      
          patched = patch_styles_xml(styles_path.read_bytes(), east_asia_font=east_asia_font, compact=compact)
          styles_path.write_bytes(patched)
      
          if dst_docx.exists():
              dst_docx.unlink()
          with zipfile.ZipFile(dst_docx, "w", compression=zipfile.ZIP_DEFLATED) as zout:
              for file_path in sorted(tmp_dir.rglob("*")):
                  if file_path.is_dir():
                      continue
                  arcname = file_path.relative_to(tmp_dir)
                  zout.write(file_path, arcname.as_posix())
      
          shutil.rmtree(tmp_dir)
      
      
      def main(argv: list[str]) -> int:
          parser = argparse.ArgumentParser(description="Build md-to-word reference.docx variants from a base file.")
          parser.add_argument("--base", required=True, help="Base reference docx path (default template).")
          parser.add_argument("--out-dir", required=True, help="Output directory (usually md-to-word/assets).")
          args = parser.parse_args(argv)
      
          base = Path(args.base).expanduser().resolve()
          out_dir = Path(args.out_dir).expanduser().resolve()
          out_dir.mkdir(parents=True, exist_ok=True)
      
          if not base.exists():
              _die(f"Base docx 不存在:{base}")
      
          (out_dir / "reference-default.docx").write_bytes(base.read_bytes())
      
          build_variant(
              out_dir / "reference-default.docx",
              out_dir / "reference-cn-modern.docx",
              east_asia_font="Microsoft YaHei",
              compact=False,
          )
      
          build_variant(
              out_dir / "reference-default.docx",
              out_dir / "reference-compact.docx",
              east_asia_font=None,
              compact=True,
          )
      
          print("OK:", out_dir)
          return 0
      
      
      if __name__ == "__main__":
          raise SystemExit(main(sys.argv[1:]))
      
    • fix_template_namespace.py 4.9 KB
      #!/usr/bin/env python3
      """
      修复 Word 模板的命名空间问题
      
      将非标准的 ns0: 命名空间前缀替换为标准的 w: 前缀
      解决 Pandoc 使用 --reference-doc 时导致的 Word 警告问题
      """
      import argparse
      import zipfile
      import sys
      from pathlib import Path
      
      
      def fix_template_namespace(input_docx: Path, output_docx: Path) -> tuple[bool, bool]:
          """
          修复模板文件的命名空间
      
          Args:
              input_docx: 输入模板路径
              output_docx: 输出模板路径
      
          Returns:
              (是否成功修复, 是否需要修复)
          """
          try:
              with zipfile.ZipFile(input_docx, 'r') as z_in:
                  with zipfile.ZipFile(output_docx, 'w', zipfile.ZIP_DEFLATED) as z_out:
                      fixed_count = 0
                      needs_fix = False
      
                      for item in z_in.infolist():
                          content = z_in.read(item.filename)
      
                          # 修复 styles.xml 的命名空间
                          if item.filename == 'word/styles.xml':
                              content_str = content.decode('utf-8')
      
                              # 检查是否需要修复
                              if 'ns0:' not in content_str:
                                  print(f"✅ 模板已使用标准命名空间,无需修复")
                                  # 仍然复制所有文件
                              else:
                                  needs_fix = True
                                  # 替换命名空间
                                  original_ns0_count = content_str.count('ns0:')
                                  content_str = content_str.replace('xmlns:ns0=', 'xmlns:w=')
                                  content_str = content_str.replace('ns0:', 'w:')
      
                                  # 验证修复
                                  new_ns0_count = content_str.count('ns0:')
                                  fixed_count = original_ns0_count - new_ns0_count
                                  print(f"✅ 已修复 {fixed_count} 处命名空间引用")
      
                              content = content_str.encode('utf-8')
      
                          z_out.writestr(item, content)
      
              return True, needs_fix
      
          except Exception as e:
              print(f"❌ 修复失败: {e}", file=sys.stderr)
              return False, False
      
      
      def verify_template(template_path: Path) -> bool:
          """
          验证模板是否使用标准命名空间
      
          Args:
              template_path: 模板路径
      
          Returns:
              是否通过验证
          """
          try:
              with zipfile.ZipFile(template_path, 'r') as z:
                  # 检查 word/styles.xml 是否存在
                  if 'word/styles.xml' not in z.namelist():
                      # 尝试其他可能的路径
                      styles_content = None
                      for name in z.namelist():
                          if 'styles' in name.lower() and name.endswith('.xml'):
                              styles_content = z.read(name).decode('utf-8')
                              break
      
                      if styles_content is None:
                          print(f"⚠️  未找到 styles.xml 文件,跳过验证")
                          return True
                  else:
                      styles_content = z.read('word/styles.xml').decode('utf-8')
      
                  # 检查命名空间
                  ns0_count = styles_content.count('ns0:')
                  w_count = styles_content.count('w:')
      
                  print(f"\n验证结果:")
                  print(f"  ns0: 使用次数: {ns0_count}")
                  print(f"  w: 使用次数: {w_count}")
      
                  if ns0_count > 0:
                      print(f"  ❌ 模板使用非标准命名空间")
                      return False
                  else:
                      print(f"  ✅ 模板使用标准命名空间")
                      return True
      
          except Exception as e:
              print(f"❌ 验证失败: {e}", file=sys.stderr)
              return False
      
      
      def main():
          parser = argparse.ArgumentParser(
              description="修复 Word 模板的命名空间问题(解决 Word 警告)"
          )
          parser.add_argument(
              '--input',
              type=Path,
              required=True,
              help='输入模板文件路径'
          )
          parser.add_argument(
              '--output',
              type=Path,
              required=True,
              help='输出模板文件路径'
          )
          parser.add_argument(
              '--verify',
              action='store_true',
              help='修复后验证输出文件'
          )
      
          args = parser.parse_args()
      
          if not args.input.exists():
              print(f"❌ 输入文件不存在: {args.input}", file=sys.stderr)
              sys.exit(1)
      
          print(f"正在修复模板: {args.input}")
          print(f"输出到: {args.output}")
      
          success, needs_fix = fix_template_namespace(args.input, args.output)
          if success:
              print(f"✅ 处理完成: {args.output}")
      
              if args.verify:
                  if verify_template(args.output):
                      sys.exit(0)
                  else:
                      print(f"⚠️  修复后的模板未通过验证", file=sys.stderr)
                      sys.exit(1)
              else:
                  sys.exit(0)
          else:
              print(f"❌ 修复失败", file=sys.stderr)
              sys.exit(1)
      
      
      if __name__ == '__main__':
          main()
      
    • md_to_word.py 23.3 KB
      #!/usr/bin/env python3
      from __future__ import annotations
      
      import argparse
      import os
      import re
      import shlex
      import subprocess
      import sys
      import tempfile
      from dataclasses import dataclass
      from dataclasses import field
      from datetime import datetime
      from pathlib import Path
      from typing import Any, NoReturn
      
      
      FALLBACK_TEMPLATES = {
          "default": Path("assets/reference-default.docx"),
          "cn-modern": Path("assets/reference-cn-modern.docx"),
          "compact": Path("assets/reference-compact.docx"),
      }
      
      
      @dataclass
      class Options:
          pandoc: str
          template: str | None
          reference_doc: Path | None
          output_dir: Path | None
          output: Path | None
          output_suffix: str
          overwrite: bool
          dry_run: bool
          toc: bool
          toc_depth: int
          allow_any_extension: bool
          list_templates: bool
          config_path: Path | None
          extract_media: str | None
          fix_images: bool
          keep_temp_files: bool
          clean: bool
      
      
      @dataclass(frozen=True)
      class EffectiveConfig:
          max_inputs: int = 200
          allowed_input_extensions: frozenset[str] = frozenset({".md", ".markdown"})
          pandoc_from: str = "markdown+smart"
          pandoc_to: str = "docx"
          pandoc_wrap: str = "preserve"
          pandoc_standalone: bool = True
          default_template: str = "default"
          templates: dict[str, Path] = field(default_factory=lambda: dict(FALLBACK_TEMPLATES))
      
      
      def _die(message: str, code: int = 2) -> NoReturn:
          raise SystemExit(message)
      
      
      def _check_pillow_available() -> bool:
          """检查 Pillow 是否可用于图片处理"""
          try:
              import PIL  # type: ignore
              return True
          except Exception:
              return False
      
      
      def _apply_exif_orientation(img: Any) -> Any:
          """
          应用 EXIF 方向信息到图片。
      
          EXIF Orientation 标准值:
          1: 无旋转
          2: 水平翻转
          3: 旋转180°
          4: 垂直翻转
          5: 逆时针90° + 水平翻转
          6: 顺时针90°
          7: 顺时针90° + 水平翻转
          8: 逆时针90°
          """
          try:
              from PIL import ImageOps
              # ImageOps.exif_transpose() 会自动读取并应用 EXIF 方向
              return ImageOps.exif_transpose(img)
          except Exception:
              # Pillow 版本过低或没有 EXIF 信息,返回原图
              return img
      
      
      def _allocate_bensz_run_dir(base: Path, skill_name: str) -> Path:
          root = base / ".bensz-api" / "skills" / skill_name
          stamp = datetime.now().strftime("%Y-%m-%d-%H-%M")
          candidate = root / stamp
          if not candidate.exists():
              return candidate
          for idx in range(2, 100):
              candidate = root / f"{stamp}-{idx:02d}"
              if not candidate.exists():
                  return candidate
          _die(f"无法分配唯一中间工作目录:{root / stamp}")
      
      
      def _prepare_md_with_rgb_images(input_md: Path, keep_temp: bool = False) -> tuple[Path | None, list[str]]:
          """
          在 .bensz-api/skills/md-to-word/<timestamp>/ 中准备 MD 副本,并将所有图片转换为 RGB 模式。
      
          工作流:
          1. 创建 .bensz-api/skills/md-to-word/<timestamp>/ 隐藏目录
          2. 创建 output/images-rgb/ 存放转换后的图片
          3. 创建 MD 副本,更新所有图片链接指向 RGB 版本
          4. 应用 EXIF 方向信息
          5. 转换非 RGB 模式的图片(RGBA/P/L 等)
      
          返回: (MD 副本路径, 转换的图片列表)
          如果没有图片或无需转换,返回 (None, [])
          """
          try:
              from PIL import Image
          except Exception:
              # Pillow 不可用,跳过图片处理
              return None, []
      
          md_content = input_md.read_text(encoding="utf-8")
          md_dir = input_md.parent.resolve()
      
          # 查找所有图片引用:![alt](path)
          # 支持多种格式:png, jpg, jpeg, gif, bmp, webp
          img_pattern = re.compile(
              r'!\[([^\]]*)\]\(([^)]+\.(png|PNG|jpe?g|JPE?G|gif|GIF|bmp|BMP|webp|WEBP))\)'
          )
      
          # 收集需要处理的图片
          images_to_process = []  # (相对路径, 完整路径, 扩展名)
          for match in img_pattern.finditer(md_content):
              img_rel_path = match.group(2)
              img_ext = match.group(3).lower()
              img_full_path = (md_dir / img_rel_path).resolve()
      
              if not img_full_path.exists() or not img_full_path.is_file():
                  continue
      
              images_to_process.append((img_rel_path, img_full_path, img_ext))
      
          if not images_to_process:
              # 没有图片,直接返回
              return None, []
      
          # 确认有图片后,创建统一中间工作目录。
          work_dir = _allocate_bensz_run_dir(md_dir, "md-to-word")
          (work_dir / "input").mkdir(parents=True, exist_ok=True)
          (work_dir / "log").mkdir(parents=True, exist_ok=True)
      
          # 创建 RGB 图片目录
          rgb_images_dir = work_dir / "output" / "images-rgb"
          rgb_images_dir.mkdir(parents=True, exist_ok=True)
      
          # 处理图片:转换为 RGB 模式
          fixed_images = []
          replacement_map = {}  # 原始相对路径 -> RGB 图片相对路径
      
          for rel_path, full_path, ext in images_to_process:
              try:
                  with Image.open(full_path) as img:
                      # 首先应用 EXIF 方向信息
                      img = _apply_exif_orientation(img)
      
                      # 检查是否需要转换
                      needs_conversion = img.mode not in ("RGB", "L")  # L 是灰度,通常无需转换
      
                      if needs_conversion:
                          # 转换为 RGB
                          if img.mode == "RGBA":
                              # RGBA -> RGB(白色背景)
                              background = Image.new("RGB", img.size, (255, 255, 255))
                              background.paste(img, mask=img.split()[3])  # alpha 通道作为 mask
                              img = background
                          else:
                              # 其他模式(P/PA/LA等)直接转换
                              img = img.convert("RGB")
      
                      # 计算输出路径(保持原文件名)
                      rgb_filename = full_path.name
                      rgb_path = rgb_images_dir / rgb_filename
                      # 根据扩展名确定保存格式
                      save_format = "JPEG" if ext.lower() in ("jpg", "jpeg") else ext.upper()
                      img.save(rgb_path, format=save_format)
      
                      # 更新替换映射(相对路径)
                      # 在 MD 副本中,图片链接指向同一 run 目录下的 output/images-rgb/
                      replacement_map[rel_path] = f"output/images-rgb/{rgb_filename}"
      
                      if needs_conversion:
                          fixed_images.append(str(full_path))
      
              except Exception as e:
                  # 转换失败,记录但不中断
                  print(f"⚠️  警告:无法处理图片 {full_path.name}: {e}")
                  continue
      
          if not replacement_map:
              # 没有成功处理的图片,清理空的工作目录
              rgb_images_dir.rmdir()
              (work_dir / "output").rmdir()
              (work_dir / "input").rmdir()
              (work_dir / "log").rmdir()
              work_dir.rmdir()
              return None, []
      
          # 创建 MD 副本,更新图片链接
          new_content = md_content
          for original, replacement in replacement_map.items():
              # 转义特殊字符,精确匹配
              original_escaped = re.escape(original)
              # 使用反向引用保留 alt 文本
              new_content = re.sub(
                  rf'!\[([^\]]*)\]\({original_escaped}\)',
                  rf'![\1]({replacement})',
                  new_content
              )
      
          # 保存 MD 副本
          md_copy = work_dir / input_md.name
          md_copy.write_text(new_content, encoding="utf-8")
      
          return md_copy, fixed_images
      
      
      def _resolve_reference_doc(opts: Options, skill_root: Path, cfg: EffectiveConfig) -> Path | None:
          if opts.reference_doc is not None:
              ref = opts.reference_doc.expanduser().resolve()
              if not ref.exists() or not ref.is_file():
                  _die(f"reference.docx 不存在或不是文件:{ref}")
              return ref
      
          template_name = opts.template or cfg.default_template
          if template_name not in cfg.templates:
              _die(
                  "未知模板:"
                  f"{template_name};可选:{', '.join(sorted(cfg.templates.keys()))}"
              )
      
          ref = (skill_root / cfg.templates[template_name]).resolve()
          if not ref.exists():
              _die(f"内置模板文件缺失:{ref}")
          return ref
      
      
      def _compute_output_path(input_md: Path, output_dir: Path | None, output_suffix: str) -> Path:
          out_dir = output_dir if output_dir is not None else input_md.parent
          return (out_dir / (input_md.stem + output_suffix + ".docx")).resolve()
      
      
      def _ensure_no_overwrite(output_path: Path, overwrite: bool) -> None:
          if output_path.exists() and not overwrite:
              _die(
                  "输出文件已存在,默认不覆盖。"
                  f"如需覆盖请显式传 --overwrite:{output_path}"
              )
      
      
      def _check_pandoc_available(pandoc: str) -> None:
          try:
              subprocess.run(
                  [pandoc, "--version"],
                  stdout=subprocess.DEVNULL,
                  stderr=subprocess.DEVNULL,
                  check=True,
              )
          except FileNotFoundError:
              _die(
                  "未找到 pandoc。请先安装 Pandoc,并确保 `pandoc` 在 PATH 中。"
              )
          except subprocess.CalledProcessError:
              _die("pandoc 可执行但运行失败:请检查 pandoc 安装状态。")
      
      
      def _resource_path_for(input_md: Path) -> str:
          """
          构建资源路径列表,确保 Pandoc 能找到相对路径的图片
      
          优先级:
          1. Markdown 文件所在目录
          2. Markdown 文件的父目录(支持 raw/ 子目录)
          3. 当前工作目录
          """
          md_dir = input_md.parent.resolve()
          md_parent = md_dir.parent.resolve()
          cwd = Path.cwd().resolve()
      
          # 构建资源路径列表
          paths = [str(md_dir)]
      
          # 如果父目录存在且不同于 md_dir,也加入
          if md_parent != md_dir:
              paths.append(str(md_parent))
      
          # 添加当前工作目录
          if cwd != md_dir and cwd != md_parent:
              paths.append(str(cwd))
      
          # Pandoc 使用系统的路径分隔符(POSIX ':',Windows ';')
          return os.pathsep.join(paths)
      
      
      def convert_one(
          input_md: Path,
          output_docx: Path,
          ref_doc: Path | None,
          opts: Options,
          cfg: EffectiveConfig,
      ) -> list[str]:
          """
          构建单个文件的转换命令
          """
          cmd: list[str] = [
              opts.pandoc,
              "--from",
              cfg.pandoc_from,
              "--to",
              cfg.pandoc_to,
              "--wrap",
              cfg.pandoc_wrap,
              "--markdown-headings=atx",  # 使用 ATX 标题样式,提高兼容性
          ]
          if cfg.pandoc_standalone:
              cmd.append("--standalone")
      
          # 关键:设置资源路径,确保能找到相对路径的图片
          cmd.extend([
              "--resource-path",
              _resource_path_for(input_md),
          ])
      
          # 输出文件
          cmd.extend([
              "--output",
              str(output_docx),
          ])
      
          # 输入文件
          cmd.append(str(input_md))
      
          # 参考文档
          if ref_doc is not None:
              cmd.extend(["--reference-doc", str(ref_doc)])
      
          # 目录
          if opts.toc:
              cmd.append("--toc")
              cmd.extend(["--toc-depth", str(opts.toc_depth)])
      
          return cmd
      
      
      def _parse_args(argv: list[str]) -> tuple[Options, list[Path]]:
          parser = argparse.ArgumentParser(
              description="Convert Markdown file(s) to Word (.docx) via Pandoc, with safe defaults.",
          )
          parser.add_argument("md_files", nargs="*", help="Markdown 文件路径(一个或多个)")
          parser.add_argument(
              "--list-templates",
              action="store_true",
              help="列出内置模板并退出",
          )
          parser.add_argument(
              "--pandoc",
              default="pandoc",
              help="Pandoc 可执行文件名或路径(默认:pandoc)",
          )
          parser.add_argument(
              "--config",
              default=None,
              help="可选:读取自定义 config.yaml(用于 max_inputs / allowed extensions / pandoc 默认参数)",
          )
          parser.add_argument(
              "--template",
              default=None,
              help="内置模板名(默认读取 config.yaml 的 pandoc.default_template;缺失则为 default)",
          )
          parser.add_argument(
              "--reference-doc",
              dest="reference_doc",
              default=None,
              help="自定义 reference.docx 路径(优先级高于 --template)",
          )
          parser.add_argument(
              "--output-dir",
              default=None,
              help="输出目录(默认:与输入文件同目录)",
          )
          parser.add_argument(
              "--output",
              default=None,
              help="显式指定输出 .docx 路径(仅允许单输入;与 --output-dir/--output-suffix 互斥)",
          )
          parser.add_argument(
              "--output-suffix",
              default="",
              help="输出文件名后缀(默认空)。如后缀以 '-' 开头,请用等号形式:--output-suffix=-cn-modern",
          )
          parser.add_argument(
              "--overwrite",
              action="store_true",
              help="允许覆盖已存在的输出 docx(默认禁用)",
          )
          parser.add_argument(
              "--dry-run",
              action="store_true",
              help="只打印将要执行的 pandoc 命令,不真正转换",
          )
          parser.add_argument(
              "--allow-any-extension",
              action="store_true",
              help="允许输入文件不是 .md/.markdown(默认只接受 Markdown 扩展名)",
          )
          parser.add_argument("--toc", action="store_true", help="在 Word 中生成目录(TOC)")
          parser.add_argument("--toc-depth", type=int, default=3, help="TOC 深度(默认:3)")
          parser.add_argument(
              "--fix-images",
              action="store_true",
              help="自动转换 RGBA PNG 图片为 RGB 模式,解决 Word 兼容性问题(需要 Pillow)",
          )
          parser.add_argument(
              "--clean",
              action="store_true",
              help="转换完成后清理 .bensz-api/skills/md-to-word/<timestamp>/ 工作目录(默认保留以便增量转换)",
          )
          parser.add_argument(
              "--keep-temp-files",
              action="store_true",
              help="[调试用] 保留临时文件(已废弃,使用 --clean 控制清理)",
          )
      
          args = parser.parse_args(argv)
      
          if args.list_templates:
              opts = Options(
                  pandoc=args.pandoc,
                  template=args.template,
                  reference_doc=None,
                  output_dir=None,
                  output=None,
                  output_suffix="",
                  overwrite=False,
                  dry_run=True,
                  toc=False,
                  toc_depth=args.toc_depth,
                  allow_any_extension=True,
                  list_templates=True,
                  config_path=None,
                  extract_media=None,
                  fix_images=False,
                  keep_temp_files=False,
                  clean=False,
              )
              return opts, []
      
          if any(sep and sep in args.output_suffix for sep in [os.sep, os.altsep]):
              _die("--output-suffix 只能是文件名后缀,不能包含路径分隔符。")
      
          md_files = [Path(p) for p in args.md_files]
          output_dir = Path(args.output_dir).expanduser().resolve() if args.output_dir else None
          output = Path(args.output).expanduser().resolve() if args.output else None
          reference_doc = Path(args.reference_doc).expanduser() if args.reference_doc else None
      
          if args.toc_depth < 1 or args.toc_depth > 6:
              _die("--toc-depth 必须在 1~6 之间。")
      
          if output is not None:
              if output_dir is not None or args.output_suffix:
                  _die("--output 与 --output-dir/--output-suffix 互斥。")
              if output.suffix.lower() != ".docx":
                  _die("--output 必须以 .docx 结尾。")
      
          opts = Options(
              pandoc=args.pandoc,
              template=args.template,
              reference_doc=reference_doc,
              output_dir=output_dir,
              output=output,
              output_suffix=args.output_suffix,
              overwrite=args.overwrite,
              dry_run=args.dry_run,
              toc=args.toc,
              toc_depth=args.toc_depth,
              allow_any_extension=args.allow_any_extension,
              list_templates=False,
              config_path=Path(args.config).expanduser().resolve() if args.config else None,
              extract_media=None,
              fix_images=args.fix_images,
              keep_temp_files=args.keep_temp_files,
              clean=args.clean,
          )
          return opts, md_files
      
      
      def _load_effective_config(default_config: Path, override: Path | None) -> EffectiveConfig:
          config_path = override if override is not None else default_config
          if not config_path.exists():
              return EffectiveConfig()
      
          try:
              import yaml  # type: ignore
          except Exception:
              if override is not None:
                  _die("缺少 PyYAML(yaml)依赖,无法读取 --config。请安装 PyYAML 或不传 --config。")
              return EffectiveConfig()
      
          raw: Any = yaml.safe_load(config_path.read_text(encoding="utf-8"))
          if not isinstance(raw, dict):
              return EffectiveConfig()
      
          limits = raw.get("limits") if isinstance(raw.get("limits"), dict) else {}
          io = raw.get("io") if isinstance(raw.get("io"), dict) else {}
          pandoc = raw.get("pandoc") if isinstance(raw.get("pandoc"), dict) else {}
          templates_raw = raw.get("templates") if isinstance(raw.get("templates"), dict) else {}
      
          max_inputs = limits.get("max_inputs", 200)
          if not isinstance(max_inputs, int) or max_inputs < 1:
              max_inputs = 200
      
          exts = io.get("allowed_input_extensions", [".md", ".markdown"])
          if isinstance(exts, list):
              allowed = {str(x).lower() for x in exts if str(x).startswith(".")}
          else:
              allowed = {".md", ".markdown"}
          if not allowed:
              allowed = {".md", ".markdown"}
      
          pandoc_from = pandoc.get("from", "markdown+smart")
          pandoc_to = pandoc.get("to", "docx")
          pandoc_wrap = pandoc.get("wrap", "preserve")
          pandoc_standalone = pandoc.get("standalone", True)
          default_template = pandoc.get("default_template", "default")
      
          if not isinstance(pandoc_from, str) or not pandoc_from:
              pandoc_from = "markdown+smart"
          if not isinstance(pandoc_to, str) or not pandoc_to:
              pandoc_to = "docx"
          if not isinstance(pandoc_wrap, str) or not pandoc_wrap:
              pandoc_wrap = "preserve"
          if not isinstance(pandoc_standalone, bool):
              pandoc_standalone = True
          if not isinstance(default_template, str) or not default_template:
              default_template = "default"
      
          templates: dict[str, Path] = dict(FALLBACK_TEMPLATES)
          for k, v in templates_raw.items():
              if not isinstance(k, str) or not k:
                  continue
              if not isinstance(v, str) or not v:
                  continue
              templates[k] = Path(v)
      
          return EffectiveConfig(
              max_inputs=max_inputs,
              allowed_input_extensions=frozenset(allowed),
              pandoc_from=pandoc_from,
              pandoc_to=pandoc_to,
              pandoc_wrap=pandoc_wrap,
              pandoc_standalone=pandoc_standalone,
              default_template=default_template,
              templates=templates,
          )
      
      
      def main(argv: list[str]) -> int:
          opts, md_files = _parse_args(argv)
      
          skill_root = Path(__file__).resolve().parents[1]
          cfg = _load_effective_config(skill_root / "config.yaml", opts.config_path)
      
          if opts.list_templates:
              for name in sorted(cfg.templates.keys()):
                  print(f"{name}\t{(skill_root / cfg.templates[name]).as_posix()}")
              return 0
      
          if not md_files:
              _die("缺少输入文件。请提供一个或多个 Markdown 文件路径。")
      
          if len(md_files) > cfg.max_inputs:
              _die(f"输入文件过多(>{cfg.max_inputs})。请分批处理,或在 config.yaml 调整 limits。")
      
          # 图片修复功能需要 Pillow
          if opts.fix_images and not _check_pillow_available():
              print("⚠️  警告:--fix-images 需要 Pillow (PIL),但未安装。将跳过图片修复。")
              print("   安装方法:pip install Pillow")
              opts.fix_images = False
      
          for p in md_files:
              if not p.exists() or not p.is_file():
                  _die(f"Markdown 文件不存在或不是文件:{p}")
              if not opts.allow_any_extension and p.suffix.lower() not in cfg.allowed_input_extensions:
                  _die(
                      "默认只接受 "
                      f"{', '.join(sorted(cfg.allowed_input_extensions))}:{p};"
                      "如确需转换,请显式传 --allow-any-extension"
                  )
      
          ref_doc = _resolve_reference_doc(opts, skill_root, cfg)
          if not opts.dry_run:
              _check_pandoc_available(opts.pandoc)
              if opts.output is not None:
                  opts.output.parent.mkdir(parents=True, exist_ok=True)
              elif opts.output_dir is not None:
                  opts.output_dir.mkdir(parents=True, exist_ok=True)
      
          # 跟踪工作目录(用于 --clean 清理)
          work_dirs_to_cleanup: set[Path] = set()
      
          try:
              if opts.output is not None:
                  if len(md_files) != 1:
                      _die("--output 仅允许在单输入文件时使用。")
                  input_md = md_files[0].expanduser().resolve()
                  out_docx = opts.output
                  _ensure_no_overwrite(out_docx, opts.overwrite)
      
                  # 图片处理(使用 .bensz-api/skills/md-to-word/<timestamp>/ 工作目录)
                  actual_input_md = input_md
                  if opts.fix_images:
                      md_copy, fixed_images = _prepare_md_with_rgb_images(input_md, not opts.clean)
                      if md_copy is not None:
                          image_count = len(fixed_images) if fixed_images else 0
                          rgb_dir = md_copy.parent / "output" / "images-rgb"
                          total_count = len(list(rgb_dir.glob("*"))) if rgb_dir.exists() else 0
                          if image_count > 0:
                              print(f"🔧 已转换 {image_count}/{total_count} 张图片为 RGB 模式")
                          actual_input_md = md_copy
                          if opts.clean:
                              work_dirs_to_cleanup.add(md_copy.parent)
      
                  cmd = convert_one(actual_input_md, out_docx, ref_doc, opts, cfg)
                  if opts.dry_run:
                      print(shlex.join(cmd))
                      return 0
                  subprocess.run(cmd, check=True)
                  return 0
      
              for input_md in md_files:
                  input_md_abs = input_md.expanduser().resolve()
                  out_docx = _compute_output_path(input_md_abs, opts.output_dir, opts.output_suffix)
                  _ensure_no_overwrite(out_docx, opts.overwrite)
      
                  # 图片处理(使用 .bensz-api/skills/md-to-word/<timestamp>/ 工作目录)
                  actual_input_md = input_md_abs
                  if opts.fix_images:
                      md_copy, fixed_images = _prepare_md_with_rgb_images(input_md_abs, not opts.clean)
                      if md_copy is not None:
                          image_count = len(fixed_images) if fixed_images else 0
                          rgb_dir = md_copy.parent / "output" / "images-rgb"
                          total_count = len(list(rgb_dir.glob("*"))) if rgb_dir.exists() else 0
                          if image_count > 0:
                              print(f"🔧 已转换 {image_count}/{total_count} 张图片为 RGB 模式")
                          actual_input_md = md_copy
                          if opts.clean:
                              work_dirs_to_cleanup.add(md_copy.parent)
      
                  cmd = convert_one(actual_input_md, out_docx, ref_doc, opts, cfg)
                  if opts.dry_run:
                      print(shlex.join(cmd))
                      continue
      
                  subprocess.run(cmd, check=True)
      
              return 0
      
          finally:
              # 清理工作目录
              if opts.clean:
                  for work_dir in work_dirs_to_cleanup:
                      try:
                          if work_dir.is_dir():
                              # 递归删除
                              import shutil
                              shutil.rmtree(work_dir)
                      except Exception:
                          # 清理失败不中断主流程
                          pass
      
      
      if __name__ == "__main__":
          raise SystemExit(main(sys.argv[1:]))
      
  • CHANGELOG.md 3 KB
    # md-to-word - 变更日志
    
    本文档记录 `md-to-word` 技能的重要变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。
    
    ## [Unreleased]
    
    ### Changed(变更)
    - 规范化 `SKILL.md` 正文骨架,补齐输入、输出、校验、失败恢复和公共约束摘要;md-to-word 的既有功能语义保持不变。
    
    ## [0.4.0] - 2026-01-13
    
    ### Added(新增)
    
    - 新增 `scripts/fix_template_namespace.py` 工具脚本,用于检测和修复自定义 Word 模板的命名空间问题
    - 在 `scripts/md_to_word.py` 中自动添加 `--markdown-headings=atx` 参数,提高 Pandoc 兼容性
    - 更新 SKILL.md 文档,新增"Word 兼容性问题与解决方案"章节,详细说明两个主要问题和解决方案
    
    ### Fixed(修复)
    
    - **修复 Word 警告问题**:彻底解决 Word 打开文档时提示"无法读取的内容"的问题
      - 根本原因:内置模板(cn-modern、compact)使用非标准的 `ns0:` 命名空间前缀,而不是标准的 `w:` 前缀
      - 解决方案:使用 Pandoc 重新生成所有内置模板,确保使用标准 OOXML 命名空间
      - 验证结果:所有模板和生成的 Word 文件 `ns0:` 使用次数为 0,`w:` 使用次数正常
    
    ### Changed(变更)
    
    - 更新版本号至 0.4.0,同步更新 config.yaml 和 SKILL.md 的 metadata.version
    - 更新 description 描述,强调内置模板已修复命名空间兼容性问题
    
    ---
    
    ## [0.3.0] - 2026-01-12
    
    ### Added(新增)
    
    - 重构图片处理工作流,使用 `.md-to-word/` 隐藏目录统一管理中间产物
    - 支持所有图片格式(PNG/JPG/GIF/BMP/WebP)智能转换
    - 智能转换非 RGB 模式图片(RGBA/P/PA/LA → RGB)
    - 默认保留工作目录便于增量转换
    - 新增 `--clean` 选项主动清理工作目录
    
    ---
    
    ## [0.2.0] - 2026-01-12
    
    ### Fixed(修复)
    
    - 修复图片嵌入问题:优化 `_resource_path_for()` 函数,确保 Pandoc 能正确找到相对路径的本地图片
    - 资源路径现包含:Markdown 文件所在目录、父目录、当前工作目录,支持 `raw/` 等子目录结构
    
    ### Changed(变更)
    
    - 简化代码架构:删除不必要的远程图片处理模块(image_downloader.py、md_preprocessor.py、docx_validator.py)
    - 专注于 Pandoc 原生能力,通过正确配置 `--resource-path` 解决图片嵌入问题
    
    ---
    
    ## [0.1.0] - 2026-01-12
    
    ### Added(新增)
    
    - 初始化 `md-to-word` 技能:基于 Pandoc 将 Markdown 批量转换为 Word(.docx)。
    - 内置 3 个 reference.docx 模板(`default` / `cn-modern` / `compact`),并支持 `--reference-doc` 自定义模板。
    - 提供确定性脚本 `scripts/md_to_word.py`:默认不覆写输出、支持输出目录与 dry-run。
    - 脚本增强:`--list-templates`、`--output-suffix`(多模板输出不覆盖)、`--output`(单文件显式输出路径)。
    - 脚本支持 `--config` 读取 `config.yaml` 的关键约束与默认参数(max_inputs、扩展名白名单、pandoc 默认参数、templates)。
    
  • config.yaml 856 B
    # 技能基本信息(版本号 Single Source of Truth)
    skill_info:
      name: md-to-word
      version: 0.4.0
      description: 将一个或多个 Markdown 文档转换为格式优美的 Word(.docx),基于 Pandoc + 内置 reference.docx 模板(可选自定义模板),并确保不修改任何原始 Markdown 文件。内置模板已修复命名空间兼容性问题,支持 RGBA 图片自动转换。
      author: "Bensz Conan"
      category: 文档转换
    
    pandoc:
      executable: pandoc
      from: markdown+smart
      to: docx
      standalone: true
      wrap: preserve
      default_template: default
    
    templates:
      default: assets/reference-default.docx
      cn-modern: assets/reference-cn-modern.docx
      compact: assets/reference-compact.docx
    
    io:
      overwrite: false
      default_output_dir: null
      allowed_input_extensions:
        - .md
        - .markdown
    
    limits:
      max_inputs: 200
    
    
  • README.md 3.5 KB
    # md-to-word
    
    这个 skill 用来把一个或多个 Markdown 文档转换成可交付的 Word `.docx` 文件,强调模板、美观排版、安全输出和兼容性;它不会修改原始 Markdown,也不会默认覆盖已有输出。
    
    ## 用法
    
    ### 最推荐用法
    
    ```text
    请使用 md-to-word skill: `xx.md`
    输入:一个或多个 Markdown 文件;可选模板 `default` / `cn-modern` / `compact`
    输出:对应的 `.docx` 文件,默认不覆盖已有输出,也不修改原始 Markdown
    ```
    
    ### 进阶用法
    
    ```text
    请使用 md-to-word skill: `report.md`
    输入:`report.md`
    输出:`report.docx`
    另外,还有下列参数约束:
    - 模板:`cn-modern`
    - 自动修复 RGBA 图片:是
    - 输出目录:`./docx-out`
    ```
    
    ## 能做什么
    
    - 把 Markdown 转成交付级 `.docx`,而不是只调用一次最小 Pandoc 命令。
    - 提供内置模板,适合通用、中文友好和紧凑排版三种常见风格。
    - 处理 Word 兼容性问题,尤其是图片模式和模板命名空间问题。
    - 默认保持输入文件不变,也默认不覆盖已有 docx。
    - 不适合编辑现有 Word 文档,也不是通用排版设计器。
    
    ## 使用示例
    
    ### 示例 1:转换单个 Markdown
    
    ```text
    请使用 md-to-word skill: `proposal.md`
    输入:`proposal.md`
    输出:`proposal.docx`
    ```
    
    ### 示例 2:批量转换并指定模板
    
    ```text
    请使用 md-to-word skill 批量转换这些 Markdown。
    输入:`a.md`、`b.md`
    输出:对应的 `.docx` 文件
    另外,还有下列参数约束:
    - 模板:`compact`
    - 输出目录:`./out`
    ```
    
    ### 示例 3:强调图片兼容性
    
    ```text
    请使用 md-to-word skill: `report.md`
    输入:`report.md`
    输出:`.docx`
    另外,还有下列参数约束:
    - 自动修复 RGBA 图片:是
    - 如果输出已存在:不要覆盖
    ```
    
    ## 输出
    
    - 默认输出为输入文件同目录下的同名 `.docx`。
    - 默认不覆盖已有输出。
    - 默认不修改、重命名或删除原始 Markdown。
    - 开启图片修复时,会在工作目录下创建 `.bensz-api/task-{yyyymmdd-hhmm}-{简短描述}/md-to-word/{yyyy-mm-dd-hh-mm}/` 作为中间工作区。
    
    ## 配置
    
    - 配置文件:`md-to-word/config.yaml`
    - 内置模板:
      - `default`
      - `cn-modern`
      - `compact`
    - 默认模板:`default`
    - 允许输入扩展名:`.md`、`.markdown`
    - 最大输入数量:`200`
    
    ## 备选用法(脚本/硬编码)
    
    如果你明确知道输入文件和模板,脚本方式通常是最稳、最高效的做法。
    
    ### 查看模板
    
    ```bash
    python3 md-to-word/scripts/md_to_word.py --list-templates
    ```
    
    ### 基础转换
    
    ```bash
    python3 md-to-word/scripts/md_to_word.py proposal.md
    ```
    
    ### 指定模板和输出目录
    
    ```bash
    python3 md-to-word/scripts/md_to_word.py \
      --template cn-modern \
      --output-dir ./out \
      proposal.md
    ```
    
    ### 修复图片并批量转换
    
    ```bash
    python3 md-to-word/scripts/md_to_word.py \
      --template compact \
      --fix-images \
      a.md b.md
    ```
    
    ## 常见问题
    
    ### Q:它会修改我的 Markdown 吗?
    
    A:不会。这个 skill 的安全边界之一就是“只读取输入 Markdown,输出新的 `.docx`”。
    
    ### Q:如果同名 `.docx` 已经存在怎么办?
    
    A:默认会报错并停止,除非你显式使用 `--overwrite`。
    
    ### Q:什么时候该用 `--fix-images`?
    
    A:当你的 Markdown 里有 RGBA PNG、透明图或 Word 打开后提示图片兼容性问题时,优先开启它。
    
    ### Q:内置模板和自定义 `reference.docx` 怎么选?
    
    A:大多数场景先用内置模板即可;只有你已经有成熟的 Word 样式体系时,再显式传入自定义 `reference.docx`。
    
  • SKILL.md 10.5 KB
    ---
    name: md-to-word
    description: 将一个或多个 Markdown 文档转换为格式优美的 Word(.docx),基于 Pandoc + 内置 reference.docx 模板(可选自定义模板),并确保不修改任何原始 Markdown 文件。内置模板已修复命名空间兼容性问题,支持 RGBA 图片自动转换。
    metadata:
      author: Bensz Conan
      short-description: Markdown → Word(Pandoc + 多模板 + 安全不覆写)
      keywords:
        - md-to-word
        - markdown
        - docx
        - word
        - pandoc
        - convert
        - template
        - reference.docx
        - RGBA PNG fix
        - 命名空间修复
    ---
    
    # md-to-word(Markdown 转 Word)
    
    ## 目标
    
    将一个或多个 Markdown 文档转换为格式优美的 Word(.docx),基于 Pandoc + 内置 reference.docx 模板(可选自定义模板),并确保不修改任何原始 Markdown 文件。内置模板已修复命名空间兼容性问题,支持 RGBA 图片自动转换。
    
    ## 流程
    
    ### 输入
    
    输入为一个或多个 Markdown 文件;可选输入包括内置或自定义 `reference.docx`、输出目录、Pandoc 参数和图片处理选项。转换不得修改原始 Markdown,输出路径须在用户授权范围内。
    
    ### 执行步骤
    
    #### 你要解决的问题
    
    用户给你一个或多个标准 Markdown 文档,希望把它们转换成**排版美观、可审查、可交付**的 Word(`.docx`),并且能在不同项目里复用同一套转换流程与样式模板。
    
    **常见问题解决方案**:
    - **RGBA PNG 导致 Word 警告**:使用 `--fix-images` 自动转换为 RGB 模式
    - **图片路径问题**:脚本自动处理相对路径资源引用
    - **中文排版问题**:使用 `--template cn-modern` 获得更好的中文样式
    
    #### 内置模板(Pandoc reference.docx)
    
    内置模板文件位于 `assets/`:
    - `default`:`assets/reference-default.docx`
    - `cn-modern`:`assets/reference-cn-modern.docx`(中文更友好字体/样式)
    - `compact`:`assets/reference-compact.docx`(更紧凑段落间距)
    
    #### 推荐执行方式
    
    优先运行确定性脚本 `scripts/md_to_word.py`,避免 AI 手写 Pandoc 命令导致参数缺失或误覆盖。
    
    示例:
    
    ```bash
    python3 md-to-word/scripts/md_to_word.py \
      --template cn-modern \
      --output-dir /path/to/out \
      /path/to/a.md /path/to/b.md
    ```
    
    如用户需要自定义样式,允许:
    - 使用 `--reference-doc /path/to/reference.docx` 覆盖内置模板(用户自带)。
    - 需要用同一份 Markdown 生成多套风格时,使用 `--output-suffix` 避免覆盖(默认不覆盖)。
    - 用户不确定模板可选项时,先运行 `python3 md-to-word/scripts/md_to_word.py --list-templates`。
    
    #### 核心工作流
    
    ##### 步骤 0:预检查(不写任何输出前)
    
    1. 校验 `md_files` 均存在且为文件。
       - 默认仅接受 `.md/.markdown`;如用户确实给了其他扩展名,必须显式使用 `--allow-any-extension`。
    2. 确认 Pandoc 可用(默认执行 `pandoc --version`);不可用时给出明确安装提示,并停止。
    3. 选择模板:
       - 优先 `--reference-doc`(用户显式指定);
       - 否则使用 `--template`(默认 `default`)。
    4. 计算输出路径:
       - 默认:`{input_dir}/{basename}.docx`
       - 单输入且用户想指定输出文件名:使用 `--output /path/to/out.docx`
       - 指定 `--output-dir`:`{output_dir}/{basename}.docx`
       - 若输出已存在:默认报错并停止(除非用户明确要求 `--overwrite`)。
    
    ##### 步骤 1:逐文件转换(必须覆盖全部输入)
    
    对每个 Markdown 文件:
    - **图片处理**(可选,`--fix-images`):
      - 在 MD 所在目录创建 `.bensz-api/task-{yyyymmdd-hhmm}-{简短描述}/md-to-word/{yyyy-mm-dd-hh-mm}/` 隐藏工作目录
      - 创建 `.bensz-api/task-{yyyymmdd-hhmm}-{简短描述}/md-to-word/{yyyy-mm-dd-hh-mm}/output/images-rgb/` 存放转换后的图片
      - 创建 MD 副本,更新所有图片链接指向 RGB 版本
      - 仅转换非 RGB 模式的图片(RGBA/P/L 等),RGB 图片直接复制
    - 以**非 shell**方式调用 Pandoc(防止命令注入)。
    - 自动设置 `--resource-path`,包含 `.bensz-api/task-{yyyymmdd-hhmm}-{简短描述}/md-to-word/{yyyy-mm-dd-hh-mm}/` 目录。
    - 生成 `.docx` 到目标输出路径。
    - 可选:使用 `--clean` 转换后清理 `.bensz-api/task-{yyyymmdd-hhmm}-{简短描述}/md-to-word/{yyyy-mm-dd-hh-mm}/` 工作目录(默认保留便于增量转换)
    
    ##### 步骤 2:轻量自检(输出后必须做)
    
    - [ ] 输入 Markdown 文件的内容未被修改(可选:对关键输入做 hash 前后对比)
    - [ ] 输出 `.docx` 均成功生成且路径符合预期
    - [ ] 未发生意外覆盖(除非用户明确要求)
    - [ ] 如存在图片/链接,Word 中渲染正常(无法验证时说明原因与建议)
    
    ### 输出
    
    #### 输入输出
    
    **输入**
    - `md_files`:一个或多个 Markdown 文件路径(建议 `.md` / `.markdown`)
    - 可选:`template`(内置模板名)或 `reference_doc`(自定义 reference.docx 路径)
    - 可选:`output_dir`(输出目录)
    
    **输出**
    - 对每个输入 Markdown,生成一个同名 `.docx`(默认输出到输入文件同目录;也可输出到 `output_dir`)
    
    ### 输出管理
    
    #### BenszAPI 任务工作区
    
    
    ### 校验
    
    转换前检查输入扩展名、文件存在性、Pandoc/Pillow 可用性和模板;转换后核对每个 `.docx` 存在、可打开、图片/链接渲染正常(无法验证时明确说明),且源 Markdown 未被覆盖。
    
    ### 失败与恢复
    
    #### Word 兼容性问题与解决方案
    
    ##### 问题 1:Word 打开时提示"发现无法读取的内容"(模板命名空间问题)
    
    **原因**:自定义 Word 模板使用了非标准的 XML 命名空间前缀(`ns0:`),与 Pandoc 的 `--reference-doc` 参数结合时可能导致 Word 兼容性问题。
    
    **解决方案**:
    1. **内置模板已修复**:所有内置模板(`cn-modern`、`compact`、`default`)已更新为使用标准命名空间
    2. **自动兼容性参数**:脚本自动添加 `--markdown-headings=atx` 参数提高兼容性
    3. **自定义模板修复**:使用 `scripts/fix_template_namespace.py` 修复自定义模板
    
    ```bash
    # 修复自定义模板
    python3 md-to-word/scripts/fix_template_namespace.py \
      --input /path/to/custom-template.docx \
      --output /path/to/custom-template-fixed.docx \
      --verify
    ```
    
    ##### 问题 2:RGBA PNG 图片导致 Word 警告
    
    **原因**:Markdown 中引用的 PNG 图片使用 RGBA 模式(带透明通道),这种格式在嵌入 Word 文档时可能导致兼容性问题。
    
    **解决方案**:使用 `--fix-images` 参数自动转换
    
    ```bash
    python3 md-to-word/scripts/md_to_word.py \
      --fix-images \
      --template cn-modern \
      your-document.md
    ```
    
    **工作原理**:
    1. 在 MD 所在目录创建 `.bensz-api/task-{yyyymmdd-hhmm}-{简短描述}/md-to-word/{yyyy-mm-dd-hh-mm}/` 隐藏工作目录
    2. 创建 `.bensz-api/task-{yyyymmdd-hhmm}-{简短描述}/md-to-word/{yyyy-mm-dd-hh-mm}/output/images-rgb/` 存放转换后的图片
    3. 扫描 Markdown 中引用的所有图片(支持 PNG/JPG/GIF/BMP/WebP)
    4. 检测图片模式,仅转换非 RGB 模式的图片
       - RGBA → RGB(白色背景)
       - P/PA/LA 等 → RGB
       - RGB/L → 直接复制
    5. 创建 MD 副本,更新图片链接指向 `.bensz-api/task-{yyyymmdd-hhmm}-{简短描述}/md-to-word/{yyyy-mm-dd-hh-mm}/output/images-rgb/`
    6. 使用 MD 副本执行 Pandoc 转换
    7. 默认保留 `.bensz-api/task-{yyyymmdd-hhmm}-{简短描述}/md-to-word/{yyyy-mm-dd-hh-mm}/` 便于增量转换,使用 `--clean` 清理
    
    **工作目录结构**:
    ```
    your-doc.md
    your-doc.docx
    .bensz-api/task-{yyyymmdd-hhmm}-{简短描述}/md-to-word/{yyyy-mm-dd-hh-mm}/              # 隐藏工作目录(默认保留)
    ├── your-doc.md          # MD 副本(图片链接已更新)
    └── output/
        └── images-rgb/      # RGB 模式图片
            ├── figure1.png  # 转换后(RGBA→RGB)
            └── photo.jpg    # 直接复制(已是 RGB)
    ```
    
    **依赖**:
    - 需要 Pillow 库:`pip install Pillow`
    - 如未安装,脚本会跳过图片修复并给出提示
    
    **清理选项**:
    - 默认保留 `.bensz-api/task-{yyyymmdd-hhmm}-{简短描述}/md-to-word/{yyyy-mm-dd-hh-mm}/` 工作目录,便于后续增量转换
    - 使用 `--clean` 转换后自动清理工作目录
    - 手动清理:`rm -rf /path/to/md/.bensz-api/task-{yyyymmdd-hhmm}-{简短描述}/md-to-word`
    
    
    ## 约束
    
    <!-- BEGIN COMMON CONSTRAINTS -->
    <!-- Source-Hash: sha256:15120201e9e0c7569517261d57ecefb63ac279c26ed13876f8e95b6dc35854d3 -->
    <!-- Template-ID: skill-common-constraints; Template-Version: 1; Sync-Policy: exact-block -->
    
    ### 公共硬约束
    
    本块由 `docs/templates/skill-common-constraints.md` 统一维护;每个 `SKILL.md` 的 `## 约束` 必须逐字同步本块,不得在副本中改写公共规则。
    
    - 任务需要落盘时,使用唯一的 `./.bensz-api/task-{yyyymmdd-hhmm}-{简短描述}/` 根目录;共享材料放入 `shared/`,Skill 专属材料放入该 Skill 的 `input/`、`output/`、`log/`。
    - 正式交付物、源代码和正式计划按项目约定保存,不写入任务工作区;未经授权不覆盖、删除、迁移或远程写入。
    - 项目维护变更检查 BAC 可用性并记录需求、AI 产出、工具结果、文件改动和验证摘要;BAC 只做过程审计,不替代署名、责任或合规判断。
    - 不记录 API Key、访问令牌、密码、Cookie、环境/凭据文件、私有 Prompt、身份信息、本地用户名、主机名或不必要的大体积原始数据。
    - 文件路径必须规范化并限制在授权项目范围内;外部 URL、子进程和网络访问遵循最小权限,防止路径遍历、SSRF 和命令注入。
    - Skill 版本唯一记录在自身 `config.yaml:skill_info.version`;公开 API、协议、目录或配置变更同步文档与 `CHANGELOG.md`。
    - `bensz-collect-bugs` 是一个 Agent Skill;仅将 Bensz Agent Skill 或 Bensz 基础设施本身的设计缺陷交给它。先脱敏写入 `~/.bensz-skills/bugs/`,当前任务不中断,只有用户明确要求才公开上报,禁止直接修改用户已安装的 Skill 源码。
    
    <!-- End of canonical common constraints. -->
    <!-- END COMMON CONSTRAINTS -->
    
    ### Skill 专属约束
    
    #### 安全约束
    
    - 你**只能读取**用户提供的 Markdown 文件及其引用资源(如图片)。
    - 你**绝不能修改/覆盖/重命名/删除**任何输入 Markdown 文件或其同目录已有文件。
    - 默认**不覆盖**任何已存在的输出 `.docx`;除非用户明确要求覆盖,才可使用 `--overwrite`。
    - 输出文件只能是**新生成的** `.docx`(以及测试目录中的中间产物)。
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related