Skill: bloom-u/doccraft (version 1.0.0)

--- name: doccraft description: Create complete, source-grounded professional documents from existing materials, outlines, or templates, then generate, edit, review, or redline the final .docx files. Use when Codex needs to turn PDFs, DOCX, TXT, Markdown, or mixed project sources into proposals, technical方案, implementation plans, construction schemes, reports,申报材料, and other formal long-form deliverables with structured drafting, Word formatting, and review-ready document workflows. --- # DocCraft ## Overview Use this skill in two layers: 1. Build grounded text from source materials and a target framework. 2. If needed, create or revise the final `.docx` deliverable. Keep Markdown or plain text as the working source of truth until the wording and structure are stable. Move into DOCX creation, formatting, tracked changes, or comments only after the content is sufficiently mature. This skill vendors the DOCX capability locally. Do not assume a separate `$docx` skill is installed. Some upload targets reject bundled OOXML schema files such as `.xsd`. When preparing a publishable package, use the packaged upload-safe variant from this skill. That variant keeps document generation and editing workflows, but omits the strict schema-validation bundle. When the final deliverable is a Word document, always determine the format authority before finalization: 1. User-provided template or existing target document. 2. Explicit written formatting requirements from the user. 3. An accepted sample deliverable in the same domain. 4. The default format profile in this skill. ## Workflow Decision Tree ### A. Build a new document from existing materials Use the source-grounded text workflow: 1. Normalize the task inputs. 2. Build a source manifest. 3. Create section briefs from the target outline. 4. Draft section files. 5. Run document-level consistency review. 6. Assemble the final Markdown or text output. ### B. Read, create, or edit a Word document Use the DOCX workflows in this skill: 1. Read existing `.docx` content with `pandoc` when text extraction is enough. 2. Create a new `.docx` from scratch only after fully reading [docx-js.md](docx-js.md). 3. Edit or redline existing `.docx` files only after fully reading [ooxml.md](ooxml.md). 4. Default to tracked changes or comments for government, legal, academic, commercial, or third-party documents. ### C. Do both Split the work into phases: 1. Draft and stabilize text first. 2. Freeze the source-backed wording. 3. Move into `.docx` generation or `.docx` editing. Do not mix heavy content generation and low-level OOXML editing in the same pass unless the task is trivial. ## Source-Grounded Text Workflow ### 1. Normalize the inputs Confirm these four inputs before drafting: - Source corpus: directories and files that contain the facts. - Target structure: required outline, template, or chapter list. - Writing rules: tone, exclusions, terminology, formatting, review constraints. - Output contract: section files, merged Markdown, DOCX, review memo, or tracked-change file. If any input is missing, infer the minimum safe default and state the assumption in the working notes, not in the final deliverable. If the output contract includes `.docx`, also determine the format authority early. Read [references/word-format-profile.md](references/word-format-profile.md) and use [scripts/init_format_profile.py](scripts/init_format_profile.py) when a format profile needs to be captured or confirmed. If the task is to produce a formal, complete Word deliverable, capture a delivery brief before drafting. Read [references/word-delivery-brief.md](references/word-delivery-brief.md) and use [scripts/init_delivery_brief.py](scripts/init_delivery_brief.py) to record what the user must specify and what still needs confirmation. ### 2. Build a source manifest Before writing, create a manifest of the available materials. Read [references/source-manifest.md](references/source-manifest.md) when the corpus has more than a few files. Use [scripts/build_manifest.py](scripts/build_manifest.py) to generate a first-pass inventory. Then enrich or trim it manually as needed. The manifest should separate: - Authoritative source files. - Derivative files such as extracted text, drafts, or previous outputs. - Template or outline files. - Files that are only useful for lookup, not for quoting. ### 3. Create section briefs Do not draft directly from a large corpus into the final chapters. First create a section brief for each target section. Read [references/section-briefs.md](references/section-briefs.md) when building the planning layer. Use [scripts/plan_sections.py](scripts/plan_sections.py) to bootstrap the outline into brief stubs. Each section brief should contain at least: - Section id and title. - Purpose of the section. - Must-cover points. - Allowed generic material. - Primary and secondary sources. - Expected figures, tables, and checklists. - Exclusions and collision boundaries with nearby sections. - Open questions or unresolved source gaps. For large projects, section briefs are the shared contract between multiple agents. ### 4. Draft sections Read [references/drafting-rules.md](references/drafting-rules.md) before drafting. Draft against the section brief, not against the whole corpus. This keeps the writing grounded and reduces repetition. Rules: - Keep verifiable facts tied to source material. - Allow generic boilerplate only for management, quality, safety, process, or industry-standard measures that do not conflict with the source corpus. - Prefer concrete implementation language over slogan-style abstractions. - Integrate figures and tables into nearby prose. Do not create headings that only say "figure" or "table". - If the final deliverable must not show source notes, keep any evidence notes in working files only. ### 5. Run consistency review Read [references/consistency-review.md](references/consistency-review.md) before merging the full document. Run a full-pass review for: - Terminology consistency. - Number, quantity, scope, and interface consistency. - Duplicate or contradictory paragraphs. - Section boundary collisions. - Heading numbering and naming consistency. - Hidden source labels that should not appear in final copy. ### 6. Assemble the output Use [scripts/assemble_markdown.py](scripts/assemble_markdown.py) to merge ordered chapter files into a single Markdown draft when needed. Keep a clear distinction between: - Working drafts with evidence or TODO notes. - Clean deliverables prepared for DOCX conversion. ## Default Execution Pattern ### Single-agent mode Default to a single agent when: - The outline is short. - The source corpus is small. - Cross-section dependencies are high. - The user mainly wants a coherent draft, not parallel throughput. ### Multi-agent mode Use multiple agents only when: - The source corpus is large. - Sections are sufficiently independent. - A shared glossary, writing rules, and section-brief set can be produced first. In multi-agent mode, create these shared artifacts before splitting work: 1. Source manifest. 2. Section brief pack. 3. Glossary or terminology list if naming is fragile. 4. Shared writing rules. Do not let agents improvise different naming systems, evidence rules, or section boundaries. ## DOCX Workflows ### Read or analyze `.docx` If only the text is needed, convert the file with: ```bash pandoc --track-changes=all path-to-file.docx -o output.md ``` If structure, comments, media, or tracked changes matter, unpack the OOXML package and inspect the XML. ### Create a new `.docx` Before writing any code, fully read [docx-js.md](docx-js.md) without truncation. Use the bundled `docx` JavaScript workflow when: - Creating a new Word document from stable text. - Rebuilding a formatted deliverable from Markdown or structured content. - Generating tables, headings, page settings, headers, or footers programmatically. Before final DOCX generation, lock the format profile. If no authoritative template exists, use the default profile in [references/word-format-profile.md](references/word-format-profile.md). Before final DOCX generation, also resolve the delivery brief items that affect document completeness, review mode, and output packaging. Before final DOCX generation, run [scripts/resolve_word_job.py](scripts/resolve_word_job.py) or follow [references/word-assembly-plan.md](references/word-assembly-plan.md) to determine whether the Word job is actually ready for delivery, which package components will be generated, and which blockers remain. When the job is ready, generate the file with [scripts/generate_docx_from_markdown.cjs](scripts/generate_docx_from_markdown.cjs). Feed it the merged Markdown body, the delivery brief JSON, and the format profile JSON. ### Edit an existing `.docx` Before editing, fully read [ooxml.md](ooxml.md) without truncation. Use the bundled OOXML workflow when: - Editing an existing Word file. - Preserving existing formatting. - Adding comments. - Inserting tracked changes. - Performing safe structural edits in a professional document. ### Redline and review mode Default to tracked changes or comments when editing: - Government documents. - Legal, commercial, or academic documents. - Another author's file. - Any document where reviewability matters as much as correctness. Use the bundled local resources in this skill: - [scripts/document.py](scripts/document.py) - [scripts/utilities.py](scripts/utilities.py) - [ooxml/scripts/unpack.py](ooxml/scripts/unpack.py) - [ooxml/scripts/pack.py](ooxml/scripts/pack.py) - [ooxml/scripts/validate.py](ooxml/scripts/validate.py) If the upload-safe package excluded the OOXML `.xsd` schema set, treat strict schema validation as unavailable and continue with unpack, edit, and repack workflows only. ### Format confirmation rule Do not ask the user to confirm formatting too early if the task is still in the text-drafting phase. Draft the content first unless formatting decisions would change the structure materially. Ask the user to confirm the format before final Word delivery when any of these are true: - The document is an external or formal deliverable. - No authoritative template or accepted sample document exists. - The user gave conflicting formatting signals. - The output must match an institutional standard closely. Do not ask for confirmation when: - The user already provided a template or a target `.docx` to match. - The task is still a Markdown or text-only draft. - The user explicitly accepts the skill default. When confirmation is needed, present the resolved format profile, not an open-ended question. Default to the profile in [references/word-format-profile.md](references/word-format-profile.md) if the user does not override it. ### Delivery-brief rule For a complete Word deliverable, separate user inputs into two buckets. The user must specify these before drafting: - Document title - Source corpus or authoritative source set - Target outline, template, or existing document to match - Document purpose and intended audience - Output mode: new document, rewrite, or edit existing `.docx` The user must confirm these before final Word delivery: - Delivery stage is final, not just draft - Completeness scope such as cover, table of contents, appendices, glossary, lists, and attachments - Review mode: clean copy, tracked changes, or comments - Format profile or template choice - File naming or versioning if the delivery package is formal - Whether working notes, source markers, or traceability hints must be removed from the final copy If these items are unclear, proceed with text drafting only when safe defaults will not invalidate the content. Do not finalize the Word deliverable until the unresolved delivery-brief items are closed. ### Word assembly rule Treat final Word generation as a gated assembly step, not just a file export. Before assembly, resolve: - readiness for draft or final stage - required package components such as cover, table of contents, appendices, glossary, lists, and attachment manifest - review artifact mode such as clean copy, tracked changes, or comments - cleanup behavior for working notes and source traces If the job is not ready, stop at Markdown or internal DOCX draft output and surface the blockers explicitly. ## Resource Map - [references/source-manifest.md](references/source-manifest.md): how to inventory and classify the source corpus. - [references/section-briefs.md](references/section-briefs.md): how to convert an outline into executable chapter briefs. - [references/drafting-rules.md](references/drafting-rules.md): drafting standards for grounded long-form writing. - [references/consistency-review.md](references/consistency-review.md): document-level review checklist. - [references/word-format-profile.md](references/word-format-profile.md): format authority rules and the default Word style profile. - [references/word-delivery-brief.md](references/word-delivery-brief.md): what the user must specify and confirm for a complete Word deliverable. - [references/word-assembly-plan.md](references/word-assembly-plan.md): how to decide readiness and package composition before final DOCX delivery. - [references/publishing-installation.md](references/publishing-installation.md): how to package, publish, and install the skill. - [scripts/generate_docx_from_markdown.cjs](scripts/generate_docx_from_markdown.cjs): generate a `.docx` from Markdown, the delivery brief, and the format profile. - [docx-js.md](docx-js.md): full reference for creating new `.docx` files. - [ooxml.md](ooxml.md): full reference for editing existing OOXML-based Word files. ## Non-Negotiable Rules - Do not invent facts that should come from the source corpus. - Do not treat draft text as a primary source when an authoritative source exists. - Do not move to OOXML-level editing before the content is stable unless the task is purely editorial. - Do not assume a separate DOCX skill is available. Use the bundled resources in this skill. - Do not finalize Word output without resolving the format authority. - Do not finalize a formal Word deliverable without resolving the delivery brief.

path: ooxml.md
content: # Office Open XML 技术参考

**重要：开始之前请阅读完整文档。** 本文档涵盖：
- [技术指南](#技术指南) - 架构合规规则和验证要求
- [文档内容模式](#文档内容模式) - 标题、列表、表格、格式等的 XML 模式
- [Document 库 (Python)](#document-库-python) - 推荐的 OOXML 操作方法，带自动基础设施设置
- [修订追踪（红线批注）](#修订追踪红线批注) - 实现修订追踪的 XML 模式

## 技术指南

### 架构合规
- **`<w:pPr>` 中的元素顺序**：`<w:pStyle>`、`<w:numPr>`、`<w:spacing>`、`<w:ind>`、`<w:jc>`
- **空白字符**：为包含前导/尾随空格的 `<w:t>` 元素添加 `xml:space='preserve'`
- **Unicode**：在 ASCII 内容中转义字符：`"` 变为 `&#8220;`
  - **字符编码参考**：弯引号 `""` 变为 `&#8220;&#8221;`，撇号 `'` 变为 `&#8217;`，破折号 `—` 变为 `&#8212;`
- **修订追踪**：在 `<w:r>` 元素外部使用带有 `w:author="Claude"` 的 `<w:del>` 和 `<w:ins>` 标签
  - **关键**：`<w:ins>` 以 `</w:ins>` 结束，`<w:del>` 以 `</w:del>` 结束 - 永远不要混淆
  - **RSID 必须是 8 位十六进制**：使用如 `00AB1234` 的值（仅限 0-9、A-F 字符）
  - **trackRevisions 位置**：在 settings.xml 中 `<w:proofState>` 之后添加 `<w:trackRevisions/>`
- **图片**：添加到 `word/media/`，在 `document.xml` 中引用，设置尺寸以防止溢出

## 文档内容模式

### 基本结构
```xml
<w:p>
  <w:r><w:t>文本内容</w:t></w:r>
</w:p>
```

### 标题和样式
```xml
<w:p>
  <w:pPr>
    <w:pStyle w:val="Title"/>
    <w:jc w:val="center"/>
  </w:pPr>
  <w:r><w:t>文档标题</w:t></w:r>
</w:p>

<w:p>
  <w:pPr><w:pStyle w:val="Heading2"/></w:pPr>
  <w:r><w:t>章节标题</w:t></w:r>
</w:p>
```

### 文本格式
```xml
<!-- 粗体 -->
<w:r><w:rPr><w:b/><w:bCs/></w:rPr><w:t>粗体</w:t></w:r>
<!-- 斜体 -->
<w:r><w:rPr><w:i/><w:iCs/></w:rPr><w:t>斜体</w:t></w:r>
<!-- 下划线 -->
<w:r><w:rPr><w:u w:val="single"/></w:rPr><w:t>下划线</w:t></w:r>
<!-- 高亮 -->
<w:r><w:rPr><w:highlight w:val="yellow"/></w:rPr><w:t>高亮</w:t></w:r>
```

### 列表
```xml
<!-- 编号列表 -->
<w:p>
  <w:pPr>
    <w:pStyle w:val="ListParagraph"/>
    <w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr>
    <w:spacing w:before="240"/>
  </w:pPr>
  <w:r><w:t>第一项</w:t></w:r>
</w:p>

<!-- 从 1 重新开始编号列表 - 使用不同的 numId -->
<w:p>
  <w:pPr>
    <w:pStyle w:val="ListParagraph"/>
    <w:numPr><w:ilvl w:val="0"/><w:numId w:val="2"/></w:numPr>
    <w:spacing w:before="240"/>
  </w:pPr>
  <w:r><w:t>新列表第 1 项</w:t></w:r>
</w:p>

<!-- 项目符号列表（级别 2） -->
<w:p>
  <w:pPr>
    <w:pStyle w:val="ListParagraph"/>
    <w:numPr><w:ilvl w:val="1"/><w:numId w:val="1"/></w:numPr>
    <w:spacing w:before="240"/>
    <w:ind w:left="900"/>
  </w:pPr>
  <w:r><w:t>项目符号项</w:t></w:r>
</w:p>
```

### 表格
```xml
<w:tbl>
  <w:tblPr>
    <w:tblStyle w:val="TableGrid"/>
    <w:tblW w:w="0" w:type="auto"/>
  </w:tblPr>
  <w:tblGrid>
    <w:gridCol w:w="4675"/><w:gridCol w:w="4675"/>
  </w:tblGrid>
  <w:tr>
    <w:tc>
      <w:tcPr><w:tcW w:w="4675" w:type="dxa"/></w:tcPr>
      <w:p><w:r><w:t>单元格 1</w:t></w:r></w:p>
    </w:tc>
    <w:tc>
      <w:tcPr><w:tcW w:w="4675" w:type="dxa"/></w:tcPr>
      <w:p><w:r><w:t>单元格 2</w:t></w:r></w:p>
    </w:tc>
  </w:tr>
</w:tbl>
```

### 布局
```xml
<!-- 新章节前分页（常见模式） -->
<w:p>
  <w:r>
    <w:br w:type="page"/>
  </w:r>
</w:p>
<w:p>
  <w:pPr>
    <w:pStyle w:val="Heading1"/>
  </w:pPr>
  <w:r>
    <w:t>新章节标题</w:t>
  </w:r>
</w:p>

<!-- 居中段落 -->
<w:p>
  <w:pPr>
    <w:spacing w:before="240" w:after="0"/>
    <w:jc w:val="center"/>
  </w:pPr>
  <w:r><w:t>居中文本</w:t></w:r>
</w:p>

<!-- 字体更改 - 段落级别（应用于所有 run） -->
<w:p>
  <w:pPr>
    <w:rPr><w:rFonts w:ascii="Courier New" w:hAnsi="Courier New"/></w:rPr>
  </w:pPr>
  <w:r><w:t>等宽文本</w:t></w:r>
</w:p>

<!-- 字体更改 - run 级别（仅适用于此文本） -->
<w:p>
  <w:r>
    <w:rPr><w:rFonts w:ascii="Courier New" w:hAnsi="Courier New"/></w:rPr>
    <w:t>这段文本是 Courier New</w:t>
  </w:r>
  <w:r><w:t> 而这段文本使用默认字体</w:t></w:r>
</w:p>
```

## 文件更新

添加内容时，更新以下文件：

**`word/_rels/document.xml.rels`：**
```xml
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering" Target="numbering.xml"/>
<Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/>
```

**`[Content_Types].xml`：**
```xml
<Default Extension="png" ContentType="image/png"/>
<Override PartName="/word/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/>
```

### 图片
**关键**：计算尺寸以防止页面溢出并保持纵横比。

```xml
<!-- 最小必需结构 -->
<w:p>
  <w:r>
    <w:drawing>
      <wp:inline>
        <wp:extent cx="2743200" cy="1828800"/>
        <wp:docPr id="1" name="Picture 1"/>
        <a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
          <a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">
            <pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
              <pic:nvPicPr>
                <pic:cNvPr id="0" name="image1.png"/>
                <pic:cNvPicPr/>
              </pic:nvPicPr>
              <pic:blipFill>
                <a:blip r:embed="rId5"/>
                <!-- 添加以保持纵横比的拉伸填充 -->
                <a:stretch>
                  <a:fillRect/>
                </a:stretch>
              </pic:blipFill>
              <pic:spPr>
                <a:xfrm>
                  <a:ext cx="2743200" cy="1828800"/>
                </a:xfrm>
                <a:prstGeom prst="rect"/>
              </pic:spPr>
            </pic:pic>
          </a:graphicData>
        </a:graphic>
      </wp:inline>
    </w:drawing>
  </w:r>
</w:p>
```

### 链接（超链接）

**重要**：所有超链接（内部和外部）都需要在 styles.xml 中定义 Hyperlink 样式。没有此样式，链接将看起来像普通文本而不是蓝色下划线可点击链接。

**外部链接：**
```xml
<!-- 在 document.xml 中 -->
<w:hyperlink r:id="rId5">
  <w:r>
    <w:rPr><w:rStyle w:val="Hyperlink"/></w:rPr>
    <w:t>链接文本</w:t>
  </w:r>
</w:hyperlink>

<!-- 在 word/_rels/document.xml.rels 中 -->
<Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
              Target="https://www.example.com/" TargetMode="External"/>
```

**内部链接：**

```xml
<!-- 链接到书签 -->
<w:hyperlink w:anchor="myBookmark">
  <w:r>
    <w:rPr><w:rStyle w:val="Hyperlink"/></w:rPr>
    <w:t>链接文本</w:t>
  </w:r>
</w:hyperlink>

<!-- 书签目标 -->
<w:bookmarkStart w:id="0" w:name="myBookmark"/>
<w:r><w:t>目标内容</w:t></w:r>
<w:bookmarkEnd w:id="0"/>
```

**Hyperlink 样式（styles.xml 中必需）：**
```xml
<w:style w:type="character" w:styleId="Hyperlink">
  <w:name w:val="Hyperlink"/>
  <w:basedOn w:val="DefaultParagraphFont"/>
  <w:uiPriority w:val="99"/>
  <w:unhideWhenUsed/>
  <w:rPr>
    <w:color w:val="467886" w:themeColor="hyperlink"/>
    <w:u w:val="single"/>
  </w:rPr>
</w:style>
```

## Document 库 (Python)

使用 `scripts/document.py` 中的 Document 类处理所有修订追踪和批注。它自动处理基础设施设置（people.xml、RSID、settings.xml、批注文件、关系、内容类型）。仅对库不支持的复杂场景使用直接 XML 操作。

**处理 Unicode 和实体：**
- **搜索**：实体表示法和 Unicode 字符都可用 - `contains="&#8220;Company"` 和 `contains="\u201cCompany"` 查找相同的文本
- **替换**：使用实体（`&#8220;`）或 Unicode（`\u201c`）都可以 - 两者都会根据文件编码（ascii → 实体，utf-8 → Unicode）适当转换

### 初始化

**找到 docx 技能根目录**（包含 `scripts/` 和 `ooxml/` 的目录）：
```bash
# 搜索 document.py 以定位技能根目录
# 注意：/mnt/skills 仅作示例；请检查您的上下文以获取实际位置
find /mnt/skills -name "document.py" -path "*/docx/scripts/*" 2>/dev/null | head -1
# 示例输出：/mnt/skills/docx/scripts/document.py
# 技能根目录是：/mnt/skills/docx
```

**运行脚本时设置 PYTHONPATH** 为 docx 技能根目录：
```bash
PYTHONPATH=/mnt/skills/docx python your_script.py
```

**在脚本中**，从技能根目录导入：
```python
from scripts.document import Document, DocxXMLEditor

# 基本初始化（自动创建临时副本并设置基础设施）
doc = Document('unpacked')

# 自定义作者和首字母缩写
doc = Document('unpacked', author="John Doe", initials="JD")

# 启用修订追踪模式
doc = Document('unpacked', track_revisions=True)

# 指定自定义 RSID（如果未提供则自动生成）
doc = Document('unpacked', rsid="07DC5ECB")
```

### 创建修订追踪

**关键**：只标记实际更改的文本。将所有未更改的文本保留在 `<w:del>`/`<w:ins>` 标签外部。标记未更改的文本会使编辑不专业且更难审阅。

**属性处理**：Document 类会自动向新元素注入属性（w:id、w:date、w:rsidR、w:rsidDel、w16du:dateUtc、xml:space）。保留原始文档中未更改的文本时，复制带有现有属性的原始 `<w:r>` 元素以保持文档完整性。

**方法选择指南**：
- **向普通文本添加您自己的更改**：使用带有 `<w:del>`/`<w:ins>` 标签的 `replace_node()`，或使用 `suggest_deletion()` 删除整个 `<w:r>` 或 `<w:p>` 元素
- **部分修改其他作者的修订追踪**：使用 `replace_node()` 将您的更改嵌套在他们的 `<w:ins>`/`<w:del>` 中
- **完全拒绝其他作者的插入**：对 `<w:ins>` 元素使用 `revert_insertion()`（而不是 `suggest_deletion()`）
- **完全拒绝其他作者的删除**：对 `<w:del>` 元素使用 `revert_deletion()` 以使用修订追踪恢复删除的内容

```python
# 最小编辑 - 更改一个词："The report is monthly" → "The report is quarterly"
# 原始：<w:r w:rsidR="00AB12CD"><w:rPr><w:rFonts w:ascii="Calibri"/></w:rPr><w:t>The report is monthly</w:t></w:r>
node = doc["word/document.xml"].get_node(tag="w:r", contains="The report is monthly")
rpr = tags[0].toxml() if (tags := node.getElementsByTagName("w:rPr")) else ""
replacement = f'<w:r w:rsidR="00AB12CD">{rpr}<w:t>The report is </w:t></w:r><w:del><w:r>{rpr}<w:delText>monthly</w:delText></w:r></w:del><w:ins><w:r>{rpr}<w:t>quarterly</w:t></w:r></w:ins>'
doc["word/document.xml"].replace_node(node, replacement)

# 最小编辑 - 更改数字："within 30 days" → "within 45 days"
# 原始：<w:r w:rsidR="00XYZ789"><w:rPr><w:rFonts w:ascii="Calibri"/></w:rPr><w:t>within 30 days</w:t></w:r>
node = doc["word/document.xml"].get_node(tag="w:r", contains="within 30 days")
rpr = tags[0].toxml() if (tags := node.getElementsByTagName("w:rPr")) else ""
replacement = f'<w:r w:rsidR="00XYZ789">{rpr}<w:t>within </w:t></w:r><w:del><w:r>{rpr}<w:delText>30</w:delText></w:r></w:del><w:ins><w:r>{rpr}<w:t>45</w:t></w:r></w:ins><w:r w:rsidR="00XYZ789">{rpr}<w:t> days</w:t></w:r>'
doc["word/document.xml"].replace_node(node, replacement)

# 完全替换 - 即使替换所有文本也要保留格式
node = doc["word/document.xml"].get_node(tag="w:r", contains="apple")
rpr = tags[0].toxml() if (tags := node.getElementsByTagName("w:rPr")) else ""
replacement = f'<w:del><w:r>{rpr}<w:delText>apple</w:delText></w:r></w:del><w:ins><w:r>{rpr}<w:t>banana orange</w:t></w:r></w:ins>'
doc["word/document.xml"].replace_node(node, replacement)

# 插入新内容（无需属性 - 自动注入）
node = doc["word/document.xml"].get_node(tag="w:r", contains="existing text")
doc["word/document.xml"].insert_after(node, '<w:ins><w:r><w:t>new text</w:t></w:r></w:ins>')

# 部分删除其他作者的插入
# 原始：<w:ins w:author="Jane Smith" w:date="..."><w:r><w:t>quarterly financial report</w:t></w:r></w:ins>
# 目标：只删除"financial"使其变为"quarterly report"
node = doc["word/document.xml"].get_node(tag="w:ins", attrs={"w:id": "5"})
# 重要：保留外层 <w:ins> 上的 w:author="Jane Smith" 以维护作者身份
replacement = '''<w:ins w:author="Jane Smith" w:date="2025-01-15T10:00:00Z">
  <w:r><w:t>quarterly </w:t></w:r>
  <w:del><w:r><w:delText>financial </w:delText></w:r></w:del>
  <w:r><w:t>report</w:t></w:r>
</w:ins>'''
doc["word/document.xml"].replace_node(node, replacement)

# 更改其他作者插入的一部分
# 原始：<w:ins w:author="Jane Smith"><w:r><w:t>in silence, safe and sound</w:t></w:r></w:ins>
# 目标：将"safe and sound"改为"soft and unbound"
node = doc["word/document.xml"].get_node(tag="w:ins", attrs={"w:id": "8"})
replacement = f'''<w:ins w:author="Jane Smith" w:date="2025-01-15T10:00:00Z">
  <w:r><w:t>in silence, </w:t></w:r>
</w:ins>
<w:ins>
  <w:r><w:t>soft and unbound</w:t></w:r>
</w:ins>
<w:ins w:author="Jane Smith" w:date="2025-01-15T10:00:00Z">
  <w:del><w:r><w:delText>safe and sound</w:delText></w:r></w:del>
</w:ins>'''
doc["word/document.xml"].replace_node(node, replacement)

# 删除整个 run（仅在删除所有内容时使用；部分删除请使用 replace_node）
node = doc["word/document.xml"].get_node(tag="w:r", contains="text to delete")
doc["word/document.xml"].suggest_deletion(node)

# 删除整个段落（就地处理，同时处理普通段落和编号列表段落）
para = doc["word/document.xml"].get_node(tag="w:p", contains="paragraph to delete")
doc["word/document.xml"].suggest_deletion(para)

# 添加新编号列表项
target_para = doc["word/document.xml"].get_node(tag="w:p", contains="existing list item")
pPr = tags[0].toxml() if (tags := target_para.getElementsByTagName("w:pPr")) else ""
new_item = f'<w:p>{pPr}<w:r><w:t>New item</w:t></w:r></w:p>'
tracked_para = DocxXMLEditor.suggest_paragraph(new_item)
doc["word/document.xml"].insert_after(target_para, tracked_para)
# 可选：在内容前添加间距段落以获得更好的视觉分隔
# spacing = DocxXMLEditor.suggest_paragraph('<w:p><w:pPr><w:pStyle w:val="ListParagraph"/></w:pPr></w:p>')
# doc["word/document.xml"].insert_after(target_para, spacing + tracked_para)
```

### 添加批注

```python
# 添加跨越两个现有修订追踪的批注
# 注意：w:id 是自动生成的。仅当您从 XML 检查中知道 w:id 时才按 w:id 搜索
start_node = doc["word/document.xml"].get_node(tag="w:del", attrs={"w:id": "1"})
end_node = doc["word/document.xml"].get_node(tag="w:ins", attrs={"w:id": "2"})
doc.add_comment(start=start_node, end=end_node, text="此更改的解释")

# 在段落上添加批注
para = doc["word/document.xml"].get_node(tag="w:p", contains="paragraph text")
doc.add_comment(start=para, end=para, text="关于此段落的批注")

# 在新创建的修订追踪上添加批注
# 首先创建修订追踪
node = doc["word/document.xml"].get_node(tag="w:r", contains="old")
new_nodes = doc["word/document.xml"].replace_node(
    node,
    '<w:del><w:r><w:delText>old</w:delText></w:r></w:del><w:ins><w:r><w:t>new</w:t></w:r></w:ins>'
)
# 然后在新创建的元素上添加批注
# new_nodes[0] 是 <w:del>，new_nodes[1] 是 <w:ins>
doc.add_comment(start=new_nodes[0], end=new_nodes[1], text="根据要求将 old 改为 new")

# 回复现有批注
doc.reply_to_comment(parent_comment_id=0, text="我同意这个更改")
```

### 拒绝修订追踪

**重要**：使用 `revert_insertion()` 拒绝插入，使用 `revert_deletion()` 使用修订追踪恢复删除。仅对普通未标记内容使用 `suggest_deletion()`。

```python
# 拒绝插入（将其包装在删除中）
# 当其他作者插入了您想删除的文本时使用此方法
ins = doc["word/document.xml"].get_node(tag="w:ins", attrs={"w:id": "5"})
nodes = doc["word/document.xml"].revert_insertion(ins)  # 返回 [ins]

# 拒绝删除（创建插入以恢复删除的内容）
# 当其他作者删除了您想恢复的文本时使用此方法
del_elem = doc["word/document.xml"].get_node(tag="w:del", attrs={"w:id": "3"})
nodes = doc["word/document.xml"].revert_deletion(del_elem)  # 返回 [del_elem, new_ins]

# 拒绝段落中的所有插入
para = doc["word/document.xml"].get_node(tag="w:p", contains="paragraph text")
nodes = doc["word/document.xml"].revert_insertion(para)  # 返回 [para]

# 拒绝段落中的所有删除
para = doc["word/document.xml"].get_node(tag="w:p", contains="paragraph text")
nodes = doc["word/document.xml"].revert_deletion(para)  # 返回 [para]
```

### 插入图片

**关键**：Document 类使用位于 `doc.unpacked_path` 的临时副本。始终将图片复制到此临时目录，而不是原始解包文件夹。

```python
from PIL import Image
import shutil, os

# 首先初始化文档
doc = Document('unpacked')

# 复制图片并计算保持纵横比的全宽尺寸
media_dir = os.path.join(doc.unpacked_path, 'word/media')
os.makedirs(media_dir, exist_ok=True)
shutil.copy('image.png', os.path.join(media_dir, 'image1.png'))
img = Image.open(os.path.join(media_dir, 'image1.png'))
width_emus = int(6.5 * 914400)  # 6.5 英寸可用宽度，914400 EMU/英寸
height_emus = int(width_emus * img.size[1] / img.size[0])

# 添加关系和内容类型
rels_editor = doc['word/_rels/document.xml.rels']
next_rid = rels_editor.get_next_rid()
rels_editor.append_to(rels_editor.dom.documentElement,
    f'<Relationship Id="{next_rid}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/>')
doc['[Content_Types].xml'].append_to(doc['[Content_Types].xml'].dom.documentElement,
    '<Default Extension="png" ContentType="image/png"/>')

# 插入图片
node = doc["word/document.xml"].get_node(tag="w:p", line_number=100)
doc["word/document.xml"].insert_after(node, f'''<w:p>
  <w:r>
    <w:drawing>
      <wp:inline distT="0" distB="0" distL="0" distR="0">
        <wp:extent cx="{width_emus}" cy="{height_emus}"/>
        <wp:docPr id="1" name="Picture 1"/>
        <a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
          <a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">
            <pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
              <pic:nvPicPr><pic:cNvPr id="1" name="image1.png"/><pic:cNvPicPr/></pic:nvPicPr>
              <pic:blipFill><a:blip r:embed="{next_rid}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill>
              <pic:spPr><a:xfrm><a:ext cx="{width_emus}" cy="{height_emus}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr>
            </pic:pic>
          </a:graphicData>
        </a:graphic>
      </wp:inline>
    </w:drawing>
  </w:r>
</w:p>''')
```

### 获取节点

```python
# 按文本内容
node = doc["word/document.xml"].get_node(tag="w:p", contains="specific text")

# 按行号范围
para = doc["word/document.xml"].get_node(tag="w:p", line_number=range(100, 150))

# 按属性
node = doc["word/document.xml"].get_node(tag="w:del", attrs={"w:id": "1"})

# 按精确行号（必须是标签打开的行号）
para = doc["word/document.xml"].get_node(tag="w:p", line_number=42)

# 组合过滤器
node = doc["word/document.xml"].get_node(tag="w:r", line_number=range(40, 60), contains="text")

# 当文本多次出现时消歧 - 添加 line_number 范围
node = doc["word/document.xml"].get_node(tag="w:r", contains="Section", line_number=range(2400, 2500))
```

### 保存

```python
# 自动验证保存（复制回原始目录）
doc.save()  # 默认验证，验证失败时抛出错误

# 保存到不同位置
doc.save('modified-unpacked')

# 跳过验证（仅用于调试 - 生产中需要这个表明存在 XML 问题）
doc.save(validate=False)
```

### 直接 DOM 操作

对于库未涵盖的复杂场景：

```python
# 访问任何 XML 文件
editor = doc["word/document.xml"]
editor = doc["word/comments.xml"]

# 直接 DOM 访问（defusedxml.minidom.Document）
node = doc["word/document.xml"].get_node(tag="w:p", line_number=5)
parent = node.parentNode
parent.removeChild(node)
parent.appendChild(node)  # 移动到末尾

# 通用文档操作（不带修订追踪）
old_node = doc["word/document.xml"].get_node(tag="w:p", contains="original text")
doc["word/document.xml"].replace_node(old_node, "<w:p><w:r><w:t>replacement text</w:t></w:r></w:p>")

# 多次插入 - 使用返回值保持顺序
node = doc["word/document.xml"].get_node(tag="w:r", line_number=100)
nodes = doc["word/document.xml"].insert_after(node, "<w:r><w:t>A</w:t></w:r>")
nodes = doc["word/document.xml"].insert_after(nodes[-1], "<w:r><w:t>B</w:t></w:r>")
nodes = doc["word/document.xml"].insert_after(nodes[-1], "<w:r><w:t>C</w:t></w:r>")
# 结果：original_node, A, B, C
```

## 修订追踪（红线批注）

**对所有修订追踪使用上面的 Document 类。** 以下模式仅供构建替换 XML 字符串时参考。

### 验证规则
验证器检查撤销 Claude 更改后文档文本是否与原始文档匹配。这意味着：
- **永远不要修改其他作者 `<w:ins>` 或 `<w:del>` 标签内的文本**
- **始终使用嵌套删除**来删除其他作者的插入
- **每个编辑都必须正确追踪**使用 `<w:ins>` 或 `<w:del>` 标签

### 修订追踪模式

**关键规则**：
1. 永远不要修改其他作者修订追踪内的内容。始终使用嵌套删除。
2. **XML 结构**：始终将 `<w:del>` 和 `<w:ins>` 放在包含完整 `<w:r>` 元素的段落级别。永远不要嵌套在 `<w:r>` 元素内部 - 这会创建破坏文档处理的无效 XML。

**文本插入：**
```xml
<w:ins w:id="1" w:author="Claude" w:date="2025-07-30T23:05:00Z" w16du:dateUtc="2025-07-31T06:05:00Z">
  <w:r w:rsidR="00792858">
    <w:t>插入的文本</w:t>
  </w:r>
</w:ins>
```

**文本删除：**
```xml
<w:del w:id="2" w:author="Claude" w:date="2025-07-30T23:05:00Z" w16du:dateUtc="2025-07-31T06:05:00Z">
  <w:r w:rsidDel="00792858">
    <w:delText>删除的文本</w:delText>
  </w:r>
</w:del>
```

**删除其他作者的插入（必须使用嵌套结构）：**
```xml
<!-- 将删除嵌套在原始插入中 -->
<w:ins w:author="Jane Smith" w:id="16">
  <w:del w:author="Claude" w:id="40">
    <w:r><w:delText>monthly</w:delText></w:r>
  </w:del>
</w:ins>
<w:ins w:author="Claude" w:id="41">
  <w:r><w:t>weekly</w:t></w:r>
</w:ins>
```

**恢复其他作者的删除：**
```xml
<!-- 保留他们的删除不变，在其后添加新插入 -->
<w:del w:author="Jane Smith" w:id="50">
  <w:r><w:delText>within 30 days</w:delText></w:r>
</w:del>
<w:ins w:author="Claude" w:id="51">
  <w:r><w:t>within 30 days</w:t></w:r>
</w:ins>
```


path: docx-js.md
content: # DOCX 库教程

使用 JavaScript/TypeScript 生成 .docx 文件。

**重要：开始之前请阅读完整文档。** 关键的格式规则和常见陷阱贯穿全文 - 跳过章节可能导致文件损坏或渲染问题。

## 设置
假设 docx 已全局安装
如果未安装：`npm install -g docx`

```javascript
const { Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell, ImageRun, Media,
        Header, Footer, AlignmentType, PageOrientation, LevelFormat, ExternalHyperlink,
        InternalHyperlink, TableOfContents, HeadingLevel, BorderStyle, WidthType, TabStopType,
        TabStopPosition, UnderlineType, ShadingType, VerticalAlign, SymbolRun, PageNumber,
        FootnoteReferenceRun, Footnote, PageBreak } = require('docx');

// 创建和保存
const doc = new Document({ sections: [{ children: [/* 内容 */] }] });
Packer.toBuffer(doc).then(buffer => fs.writeFileSync("doc.docx", buffer)); // Node.js
Packer.toBlob(doc).then(blob => { /* 下载逻辑 */ }); // 浏览器
```

## 文本和格式
```javascript
// 重要：永远不要使用 \n 换行 - 始终使用单独的 Paragraph 元素
// ❌ 错误：new TextRun("第一行\n第二行")
// ✅ 正确：new Paragraph({ children: [new TextRun("第一行")] }), new Paragraph({ children: [new TextRun("第二行")] })

// 包含所有格式选项的基本文本
new Paragraph({
  alignment: AlignmentType.CENTER,
  spacing: { before: 200, after: 200 },
  indent: { left: 720, right: 720 },
  children: [
    new TextRun({ text: "粗体", bold: true }),
    new TextRun({ text: "斜体", italics: true }),
    new TextRun({ text: "下划线", underline: { type: UnderlineType.DOUBLE, color: "FF0000" } }),
    new TextRun({ text: "彩色", color: "FF0000", size: 28, font: "Arial" }), // Arial 默认
    new TextRun({ text: "高亮", highlight: "yellow" }),
    new TextRun({ text: "删除线", strike: true }),
    new TextRun({ text: "x2", superScript: true }),
    new TextRun({ text: "H2O", subScript: true }),
    new TextRun({ text: "小型大写", smallCaps: true }),
    new SymbolRun({ char: "2022", font: "Symbol" }), // 项目符号 •
    new SymbolRun({ char: "00A9", font: "Arial" })   // 版权符号 © - 使用 Arial 字体
  ]
})
```

## 样式和专业格式

```javascript
const doc = new Document({
  styles: {
    default: { document: { run: { font: "Arial", size: 24 } } }, // 12pt 默认
    paragraphStyles: [
      // 文档标题样式 - 覆盖内置 Title 样式
      { id: "Title", name: "Title", basedOn: "Normal",
        run: { size: 56, bold: true, color: "000000", font: "Arial" },
        paragraph: { spacing: { before: 240, after: 120 }, alignment: AlignmentType.CENTER } },
      // 重要：通过使用精确的 ID 覆盖内置标题样式
      { id: "Heading1", name: "Heading 1", basedOn: "Normal", next: "Normal", quickFormat: true,
        run: { size: 32, bold: true, color: "000000", font: "Arial" }, // 16pt
        paragraph: { spacing: { before: 240, after: 240 }, outlineLevel: 0 } }, // TOC 必需
      { id: "Heading2", name: "Heading 2", basedOn: "Normal", next: "Normal", quickFormat: true,
        run: { size: 28, bold: true, color: "000000", font: "Arial" }, // 14pt
        paragraph: { spacing: { before: 180, after: 180 }, outlineLevel: 1 } },
      // 自定义样式使用你自己的 ID
      { id: "myStyle", name: "My Style", basedOn: "Normal",
        run: { size: 28, bold: true, color: "000000" },
        paragraph: { spacing: { after: 120 }, alignment: AlignmentType.CENTER } }
    ],
    characterStyles: [{ id: "myCharStyle", name: "My Char Style",
      run: { color: "FF0000", bold: true, underline: { type: UnderlineType.SINGLE } } }]
  },
  sections: [{
    properties: { page: { margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 } } },
    children: [
      new Paragraph({ heading: HeadingLevel.TITLE, children: [new TextRun("文档标题")] }), // 使用覆盖的 Title 样式
      new Paragraph({ heading: HeadingLevel.HEADING_1, children: [new TextRun("标题 1")] }), // 使用覆盖的 Heading1 样式
      new Paragraph({ style: "myStyle", children: [new TextRun("自定义段落样式")] }),
      new Paragraph({ children: [
        new TextRun("普通文本带有"),
        new TextRun({ text: "自定义字符样式", style: "myCharStyle" })
      ]})
    ]
  }]
});
```

**专业字体组合：**
- **Arial（标题）+ Arial（正文）** - 最广泛支持，简洁专业
- **Times New Roman（标题）+ Arial（正文）** - 经典衬线标题配现代无衬线正文
- **Georgia（标题）+ Verdana（正文）** - 屏幕阅读优化，优雅对比

**关键样式原则：**
- **覆盖内置样式**：使用精确的 ID 如 "Heading1"、"Heading2"、"Heading3" 来覆盖 Word 的内置标题样式
- **HeadingLevel 常量**：`HeadingLevel.HEADING_1` 使用 "Heading1" 样式，`HeadingLevel.HEADING_2` 使用 "Heading2" 样式，等等
- **包含 outlineLevel**：H1 设置 `outlineLevel: 0`，H2 设置 `outlineLevel: 1`，等等，以确保目录正常工作
- **使用自定义样式**而非内联格式以保持一致性
- **设置默认字体**使用 `styles.default.document.run.font` - Arial 是通用支持的
- **建立视觉层次**使用不同字号（标题 > 子标题 > 正文）
- **添加适当间距**使用 `before` 和 `after` 段落间距
- **谨慎使用颜色**：标题默认使用黑色（000000）和灰色阴影（标题1、标题2等）
- **设置一致的边距**（1440 = 1 英寸是标准）


## 列表（始终使用正确的列表 - 永远不要使用 Unicode 项目符号）
```javascript
// 项目符号 - 始终使用 numbering 配置，而不是 unicode 符号
// 关键：使用 LevelFormat.BULLET 常量，而不是字符串 "bullet"
const doc = new Document({
  numbering: {
    config: [
      { reference: "bullet-list",
        levels: [{ level: 0, format: LevelFormat.BULLET, text: "•", alignment: AlignmentType.LEFT,
          style: { paragraph: { indent: { left: 720, hanging: 360 } } } }] },
      { reference: "first-numbered-list",
        levels: [{ level: 0, format: LevelFormat.DECIMAL, text: "%1.", alignment: AlignmentType.LEFT,
          style: { paragraph: { indent: { left: 720, hanging: 360 } } } }] },
      { reference: "second-numbered-list", // 不同的 reference = 从 1 重新开始
        levels: [{ level: 0, format: LevelFormat.DECIMAL, text: "%1.", alignment: AlignmentType.LEFT,
          style: { paragraph: { indent: { left: 720, hanging: 360 } } } }] }
    ]
  },
  sections: [{
    children: [
      // 项目符号列表项
      new Paragraph({ numbering: { reference: "bullet-list", level: 0 },
        children: [new TextRun("第一个项目符号")] }),
      new Paragraph({ numbering: { reference: "bullet-list", level: 0 },
        children: [new TextRun("第二个项目符号")] }),
      // 编号列表项
      new Paragraph({ numbering: { reference: "first-numbered-list", level: 0 },
        children: [new TextRun("第一个编号项")] }),
      new Paragraph({ numbering: { reference: "first-numbered-list", level: 0 },
        children: [new TextRun("第二个编号项")] }),
      // ⚠️ 关键：不同的 reference = 独立列表，从 1 重新开始
      // 相同的 reference = 继续之前的编号
      new Paragraph({ numbering: { reference: "second-numbered-list", level: 0 },
        children: [new TextRun("从 1 重新开始（因为使用了不同的 reference）")] })
    ]
  }]
});

// ⚠️ 关键编号规则：每个 reference 创建一个独立的编号列表
// - 相同 reference = 继续编号（1, 2, 3... 然后 4, 5, 6...）
// - 不同 reference = 从 1 重新开始（1, 2, 3... 然后 1, 2, 3...）
// 为每个单独的编号部分使用唯一的 reference 名称！

// ⚠️ 关键：永远不要使用 unicode 项目符号 - 它们创建的假列表无法正常工作
// new TextRun("• 项目")           // 错误
// new SymbolRun({ char: "2022" }) // 错误
// ✅ 始终使用带有 LevelFormat.BULLET 的 numbering 配置来创建真正的 Word 列表
```

## 表格
```javascript
// 包含边距、边框、表头和项目符号的完整表格
const tableBorder = { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" };
const cellBorders = { top: tableBorder, bottom: tableBorder, left: tableBorder, right: tableBorder };

new Table({
  columnWidths: [4680, 4680], // ⚠️ 关键：在表格级别设置列宽 - 值使用 DXA（点的二十分之一）
  margins: { top: 100, bottom: 100, left: 180, right: 180 }, // 为所有单元格统一设置
  rows: [
    new TableRow({
      tableHeader: true,
      children: [
        new TableCell({
          borders: cellBorders,
          width: { size: 4680, type: WidthType.DXA }, // 也在每个单元格上设置宽度
          // ⚠️ 关键：始终使用 ShadingType.CLEAR 以防止 Word 中出现黑色背景
          shading: { fill: "D5E8F0", type: ShadingType.CLEAR },
          verticalAlign: VerticalAlign.CENTER,
          children: [new Paragraph({
            alignment: AlignmentType.CENTER,
            children: [new TextRun({ text: "表头", bold: true, size: 22 })]
          })]
        }),
        new TableCell({
          borders: cellBorders,
          width: { size: 4680, type: WidthType.DXA }, // 也在每个单元格上设置宽度
          shading: { fill: "D5E8F0", type: ShadingType.CLEAR },
          children: [new Paragraph({
            alignment: AlignmentType.CENTER,
            children: [new TextRun({ text: "项目符号", bold: true, size: 22 })]
          })]
        })
      ]
    }),
    new TableRow({
      children: [
        new TableCell({
          borders: cellBorders,
          width: { size: 4680, type: WidthType.DXA }, // 也在每个单元格上设置宽度
          children: [new Paragraph({ children: [new TextRun("常规数据")] })]
        }),
        new TableCell({
          borders: cellBorders,
          width: { size: 4680, type: WidthType.DXA }, // 也在每个单元格上设置宽度
          children: [
            new Paragraph({
              numbering: { reference: "bullet-list", level: 0 },
              children: [new TextRun("第一个项目符号")]
            }),
            new Paragraph({
              numbering: { reference: "bullet-list", level: 0 },
              children: [new TextRun("第二个项目符号")]
            })
          ]
        })
      ]
    })
  ]
})
```

**重要：表格宽度和边框**
- 同时使用 `columnWidths: [width1, width2, ...]` 数组和每个单元格上的 `width: { size: X, type: WidthType.DXA }`
- 值使用 DXA（点的二十分之一）：1440 = 1 英寸，Letter 纸张可用宽度 = 9360 DXA（1 英寸边距）
- 将边框应用于单独的 `TableCell` 元素，而不是 `Table` 本身

**预计算的列宽（Letter 尺寸，1 英寸边距 = 总共 9360 DXA）：**
- **2 列：** `columnWidths: [4680, 4680]`（等宽）
- **3 列：** `columnWidths: [3120, 3120, 3120]`（等宽）

## 链接和导航
```javascript
// 目录（需要标题）- 关键：只使用 HeadingLevel，不要使用自定义样式
// ❌ 错误：new Paragraph({ heading: HeadingLevel.HEADING_1, style: "customHeader", children: [new TextRun("标题")] })
// ✅ 正确：new Paragraph({ heading: HeadingLevel.HEADING_1, children: [new TextRun("标题")] })
new TableOfContents("目录", { hyperlink: true, headingStyleRange: "1-3" }),

// 外部链接
new Paragraph({
  children: [new ExternalHyperlink({
    children: [new TextRun({ text: "Google", style: "Hyperlink" })],
    link: "https://www.google.com"
  })]
}),

// 内部链接和书签
new Paragraph({
  children: [new InternalHyperlink({
    children: [new TextRun({ text: "跳转到章节", style: "Hyperlink" })],
    anchor: "section1"
  })]
}),
new Paragraph({
  children: [new TextRun("章节内容")],
  bookmark: { id: "section1", name: "section1" }
}),
```

## 图片和媒体
```javascript
// 带有尺寸和定位的基本图片
// 关键：始终指定 'type' 参数 - ImageRun 必需
new Paragraph({
  alignment: AlignmentType.CENTER,
  children: [new ImageRun({
    type: "png", // 新要求：必须指定图片类型（png, jpg, jpeg, gif, bmp, svg）
    data: fs.readFileSync("image.png"),
    transformation: { width: 200, height: 150, rotation: 0 }, // rotation 以度为单位
    altText: { title: "Logo", description: "公司标志", name: "Name" } // 重要：三个字段都是必需的
  })]
})
```

## 分页符
```javascript
// 手动分页符
new Paragraph({ children: [new PageBreak()] }),

// 段落前分页
new Paragraph({
  pageBreakBefore: true,
  children: [new TextRun("这将在新页面开始")]
})

// ⚠️ 关键：永远不要单独使用 PageBreak - 它会创建无效的 XML，Word 无法打开
// ❌ 错误：new PageBreak()
// ✅ 正确：new Paragraph({ children: [new PageBreak()] })
```

## 页眉/页脚和页面设置
```javascript
const doc = new Document({
  sections: [{
    properties: {
      page: {
        margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 }, // 1440 = 1 英寸
        size: { orientation: PageOrientation.LANDSCAPE },
        pageNumbers: { start: 1, formatType: "decimal" } // "upperRoman", "lowerRoman", "upperLetter", "lowerLetter"
      }
    },
    headers: {
      default: new Header({ children: [new Paragraph({
        alignment: AlignmentType.RIGHT,
        children: [new TextRun("页眉文本")]
      })] })
    },
    footers: {
      default: new Footer({ children: [new Paragraph({
        alignment: AlignmentType.CENTER,
        children: [new TextRun("第 "), new TextRun({ children: [PageNumber.CURRENT] }), new TextRun(" 页，共 "), new TextRun({ children: [PageNumber.TOTAL_PAGES] }), new TextRun(" 页")]
      })] })
    },
    children: [/* 内容 */]
  }]
});
```

## 制表符
```javascript
new Paragraph({
  tabStops: [
    { type: TabStopType.LEFT, position: TabStopPosition.MAX / 4 },
    { type: TabStopType.CENTER, position: TabStopPosition.MAX / 2 },
    { type: TabStopType.RIGHT, position: TabStopPosition.MAX * 3 / 4 }
  ],
  children: [new TextRun("左对齐\t居中\t右对齐")]
})
```

## 常量和快速参考
- **下划线：** `SINGLE`、`DOUBLE`、`WAVY`、`DASH`
- **边框：** `SINGLE`、`DOUBLE`、`DASHED`、`DOTTED`
- **编号：** `DECIMAL`（1,2,3）、`UPPER_ROMAN`（I,II,III）、`LOWER_LETTER`（a,b,c）
- **制表符：** `LEFT`、`CENTER`、`RIGHT`、`DECIMAL`
- **符号：** `"2022"`（•）、`"00A9"`（©）、`"00AE"`（®）、`"2122"`（™）、`"00B0"`（°）、`"F070"`（✓）、`"F0FC"`（✗）

## 关键问题和常见错误
- **关键：PageBreak 必须始终在 Paragraph 内部** - 单独的 PageBreak 会创建无效的 XML，Word 无法打开
- **始终对表格单元格着色使用 ShadingType.CLEAR** - 永远不要使用 ShadingType.SOLID（会导致黑色背景）
- 度量单位使用 DXA（1440 = 1 英寸）| 每个表格单元格需要 ≥1 个 Paragraph | 目录只需要 HeadingLevel 样式
- **始终使用自定义样式**配合 Arial 字体以获得专业外观和正确的视觉层次
- **始终设置默认字体**使用 `styles.default.document.run.font` - 推荐 Arial
- **始终对表格使用 columnWidths 数组** + 单独的单元格宽度以确保兼容性
- **永远不要使用 unicode 符号作为项目符号** - 始终使用带有 `LevelFormat.BULLET` 常量的正确 numbering 配置（不是字符串 "bullet"）
- **永远不要在任何地方使用 \n 换行** - 始终使用单独的 Paragraph 元素作为每一行
- **始终在 Paragraph 的 children 中使用 TextRun 对象** - 永远不要直接在 Paragraph 上使用 text 属性
- **图片关键：** ImageRun 必须有 `type` 参数 - 始终指定 "png"、"jpg"、"jpeg"、"gif"、"bmp" 或 "svg"
- **项目符号关键：** 必须使用 `LevelFormat.BULLET` 常量，而不是字符串 "bullet"，并包含 `text: "•"` 作为项目符号字符
- **编号关键：** 每个 numbering reference 创建一个独立的列表。相同 reference = 继续编号（1,2,3 然后 4,5,6）。不同 reference = 从 1 重新开始（1,2,3 然后 1,2,3）。为每个单独的编号部分使用唯一的 reference 名称！
- **目录关键：** 使用 TableOfContents 时，标题必须只使用 HeadingLevel - 不要向标题段落添加自定义样式，否则目录将无法正常工作
- **表格：** 设置 `columnWidths` 数组 + 单独的单元格宽度，将边框应用于单元格而不是表格
- **在表格级别设置表格边距**以获得一致的单元格内边距（避免每个单元格重复设置）


path: LICENSE.txt
content: © 2025 Anthropic, PBC. All rights reserved.

LICENSE: Use of these materials (including all code, prompts, assets, files,
and other components of this Skill) is governed by your agreement with
Anthropic regarding use of Anthropic's services. If no separate agreement
exists, use is governed by Anthropic's Consumer Terms of Service or
Commercial Terms of Service, as applicable:
https://www.anthropic.com/legal/consumer-terms
https://www.anthropic.com/legal/commercial-terms
Your applicable agreement is referred to as the "Agreement." "Services" are
as defined in the Agreement.

ADDITIONAL RESTRICTIONS: Notwithstanding anything in the Agreement to the
contrary, users may not:

- Extract these materials from the Services or retain copies of these
  materials outside the Services
- Reproduce or copy these materials, except for temporary copies created
  automatically during authorized use of the Services
- Create derivative works based on these materials
- Distribute, sublicense, or transfer these materials to any third party
- Make, offer to sell, sell, or import any inventions embodied in these
  materials
- Reverse engineer, decompile, or disassemble these materials

The receipt, viewing, or possession of these materials does not convey or
imply any license or right beyond those expressly granted above.

Anthropic retains all right, title, and interest in these materials,
including all copyrights, patents, and other intellectual property rights.


path: scripts/build_manifest.py
content: #!/usr/bin/env python3

from __future__ import annotations

import argparse
import json
import os
from dataclasses import asdict, dataclass
from pathlib import Path

IGNORED_NAMES = {".DS_Store"}
IGNORED_SUFFIXES = {".uploading.cfg"}
TEXT_EXTS = {".txt", ".md", ".markdown"}
DOC_EXTS = {".pdf", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx"}


@dataclass
class ManifestItem:
    id: str
    path: str
    kind: str
    format: str
    title: str
    authority: str
    notes: str
    risk_flags: list[str]


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Build a first-pass manifest from source files and directories."
    )
    parser.add_argument("inputs", nargs="+", help="Files or directories to scan")
    parser.add_argument("--out", help="Optional output file path")
    parser.add_argument(
        "--format",
        choices=("json", "md"),
        default="md",
        help="Manifest output format",
    )
    return parser.parse_args()


def should_skip(path: Path) -> bool:
    if path.name in IGNORED_NAMES:
        return True
    return any(path.name.endswith(suffix) for suffix in IGNORED_SUFFIXES)


def walk_inputs(inputs: list[str]) -> list[Path]:
    files: list[Path] = []
    for raw in inputs:
        path = Path(raw).expanduser()
        if not path.exists():
            continue
        if path.is_file():
            if not should_skip(path):
                files.append(path)
            continue
        for root, dirnames, filenames in os.walk(path):
            dirnames[:] = sorted(d for d in dirnames if not d.startswith("."))
            for name in sorted(filenames):
                candidate = Path(root) / name
                if not should_skip(candidate):
                    files.append(candidate)
    return sorted(files, key=lambda item: str(item).lower())


def detect_kind(path: Path) -> str:
    name = path.name.lower()
    full = str(path).lower()
    if any(token in name for token in ("框架", "模板", "目录", "outline", "template")):
        return "outline"
    if any(token in full for token in ("/draft/", "/chapters/")):
        return "draft"
    if any(token in name for token in ("合并", "final", "output", "输出")):
        return "output"
    return "source"


def detect_authority(kind: str, path: Path) -> str:
    if path.suffix.lower() in TEXT_EXTS and "_pdftext" in str(path).lower():
        return "derived"
    if kind == "source":
        return "authoritative"
    return "working"


def detect_notes(path: Path, kind: str) -> str:
    if kind == "outline":
        return "Target structure or template candidate."
    if path.suffix.lower() == ".pdf":
        return "Likely authoritative narrative or technical source."
    if path.suffix.lower() == ".docx":
        return "Word source or extracted working document."
    if path.suffix.lower() in TEXT_EXTS:
        return "Text source or working draft for quick scanning."
    return "General project material."


def detect_risk_flags(path: Path) -> list[str]:
    flags: list[str] = []
    lowered = path.name.lower()
    if any(token in lowered for token in ("实施", "方案", "建设", "设计", "技术")):
        flags.append("scope")
    if any(token in lowered for token in ("标准", "规范", "接口")):
        flags.append("standards")
    if any(token in lowered for token in ("进度", "计划", "里程碑")):
        flags.append("schedule")
    if path.suffix.lower() in DOC_EXTS:
        flags.append("facts")
    return flags


def slugify(path: Path, index: int) -> str:
    stem = path.stem.strip() or f"file-{index}"
    cleaned = []
    for char in stem:
        if char.isalnum():
            cleaned.append(char.lower())
        elif char in {"-", "_"}:
            cleaned.append("-")
    slug = "".join(cleaned).strip("-")
    if not slug:
        slug = f"file-{index}"
    return f"{index:03d}-{slug[:40]}"


def build_items(paths: list[Path]) -> list[ManifestItem]:
    items: list[ManifestItem] = []
    for index, path in enumerate(paths, start=1):
        kind = detect_kind(path)
        items.append(
            ManifestItem(
                id=slugify(path, index),
                path=str(path),
                kind=kind,
                format=path.suffix.lower().lstrip(".") or "unknown",
                title=path.stem,
                authority=detect_authority(kind, path),
                notes=detect_notes(path, kind),
                risk_flags=detect_risk_flags(path),
            )
        )
    return items


def render_markdown(items: list[ManifestItem]) -> str:
    lines = [
        "| id | kind | authority | format | title | path | risk_flags |",
        "| --- | --- | --- | --- | --- | --- | --- |",
    ]
    for item in items:
        lines.append(
            "| {id} | {kind} | {authority} | {format} | {title} | {path} | {risk_flags} |".format(
                id=item.id,
                kind=item.kind,
                authority=item.authority,
                format=item.format,
                title=item.title.replace("|", "/"),
                path=item.path.replace("|", "/"),
                risk_flags=", ".join(item.risk_flags) or "-",
            )
        )
    return "\n".join(lines) + "\n"


def emit(text: str, out: str | None) -> None:
    if out:
        Path(out).write_text(text, encoding="utf-8")
    else:
        print(text, end="")


def main() -> int:
    args = parse_args()
    paths = walk_inputs(args.inputs)
    items = build_items(paths)
    if args.format == "json":
        payload = json.dumps([asdict(item) for item in items], ensure_ascii=False, indent=2)
        emit(payload + "\n", args.out)
    else:
        emit(render_markdown(items), args.out)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/plan_sections.py
content: #!/usr/bin/env python3

from __future__ import annotations

import argparse
import json
import re
from dataclasses import asdict, dataclass
from pathlib import Path

NUMBERED_RE = re.compile(r"^\s*(\d+(?:\.\d+)*)[\.\、\)]?\s*(.+?)\s*$")
HEADING_RE = re.compile(r"^\s*(#{1,6})\s+(.+?)\s*$")


@dataclass
class SectionBrief:
    section_id: str
    section_title: str
    purpose: str
    must_cover: list[str]
    primary_sources: list[str]
    secondary_sources: list[str]
    allowed_generic_material: str
    exclusions: list[str]
    expected_tables_or_figures: list[str]
    target_length: str
    open_questions: list[str]


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Bootstrap section briefs from a plain-text outline."
    )
    parser.add_argument("--outline", required=True, help="Outline text or markdown file")
    parser.add_argument("--out", help="Optional output file")
    parser.add_argument(
        "--format",
        choices=("md", "json"),
        default="md",
        help="Output format",
    )
    return parser.parse_args()


def parse_outline(path: Path) -> list[tuple[str, str]]:
    sections: list[tuple[str, str]] = []
    heading_counts: dict[int, int] = {}
    for raw_line in path.read_text(encoding="utf-8").splitlines():
        line = raw_line.strip()
        if not line:
            continue
        numbered = NUMBERED_RE.match(line)
        if numbered:
            sections.append((numbered.group(1), numbered.group(2)))
            continue
        heading = HEADING_RE.match(line)
        if heading:
            level = len(heading.group(1))
            heading_counts[level] = heading_counts.get(level, 0) + 1
            section_id = f"h{level}-{heading_counts[level]}"
            sections.append((section_id, heading.group(2)))
    return sections


def build_briefs(sections: list[tuple[str, str]]) -> list[SectionBrief]:
    briefs: list[SectionBrief] = []
    for section_id, title in sections:
        briefs.append(
            SectionBrief(
                section_id=section_id,
                section_title=title,
                purpose="",
                must_cover=[],
                primary_sources=[],
                secondary_sources=[],
                allowed_generic_material="",
                exclusions=[],
                expected_tables_or_figures=[],
                target_length="",
                open_questions=[],
            )
        )
    return briefs


def render_markdown(briefs: list[SectionBrief]) -> str:
    blocks: list[str] = []
    for brief in briefs:
        blocks.append(
            "\n".join(
                [
                    f"## {brief.section_id} {brief.section_title}",
                    "",
                    "- Purpose:",
                    "- Must cover:",
                    "- Primary sources:",
                    "- Secondary sources:",
                    "- Allowed generic material:",
                    "- Exclusions:",
                    "- Expected tables or figures:",
                    "- Target length:",
                    "- Open questions:",
                    "",
                ]
            )
        )
    return "\n".join(blocks).rstrip() + "\n"


def emit(text: str, out: str | None) -> None:
    if out:
        Path(out).write_text(text, encoding="utf-8")
    else:
        print(text, end="")


def main() -> int:
    args = parse_args()
    outline_path = Path(args.outline)
    briefs = build_briefs(parse_outline(outline_path))
    if args.format == "json":
        text = json.dumps([asdict(item) for item in briefs], ensure_ascii=False, indent=2) + "\n"
    else:
        text = render_markdown(briefs)
    emit(text, args.out)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/package_skill.py
content: #!/usr/bin/env python3

from __future__ import annotations

import argparse
import zipfile
from pathlib import Path


EXCLUDE_DIRS = {"__pycache__"}
EXCLUDE_SUFFIXES = {".pyc", ".pyo", ".DS_Store", ".uploading.cfg", ".xsd"}


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Create a clean distributable zip for a skill folder."
    )
    parser.add_argument(
        "--skill-dir",
        default=str(Path(__file__).resolve().parents[1]),
        help="Path to the skill directory",
    )
    parser.add_argument("--out", required=True, help="Output zip file path")
    return parser.parse_args()


def should_skip(path: Path) -> bool:
    if any(part in EXCLUDE_DIRS for part in path.parts):
        return True
    return any(str(path).endswith(suffix) for suffix in EXCLUDE_SUFFIXES)


def main() -> int:
    args = parse_args()
    skill_dir = Path(args.skill_dir).resolve()
    out = Path(args.out).resolve()
    out.parent.mkdir(parents=True, exist_ok=True)

    with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zf:
        for file_path in sorted(skill_dir.rglob("*")):
            if not file_path.is_file():
                continue
            if should_skip(file_path):
                continue
            zf.write(file_path, file_path.relative_to(skill_dir.parent))

    print(out)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/assemble_markdown.py
content: #!/usr/bin/env python3

from __future__ import annotations

import argparse
from pathlib import Path


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Merge markdown chapter files into one ordered document."
    )
    parser.add_argument("inputs", nargs="+", help="Markdown files or directories")
    parser.add_argument("--out", help="Optional output markdown path")
    parser.add_argument("--title", help="Optional document title")
    return parser.parse_args()


def collect_files(inputs: list[str]) -> list[Path]:
    files: list[Path] = []
    for raw in inputs:
        path = Path(raw)
        if path.is_file() and path.suffix.lower() == ".md":
            files.append(path)
            continue
        if path.is_dir():
            files.extend(sorted(path.glob("*.md"), key=lambda item: item.name.lower()))
    return sorted(files, key=lambda item: item.name.lower())


def read_blocks(files: list[Path]) -> list[str]:
    blocks: list[str] = []
    for path in files:
        text = path.read_text(encoding="utf-8").strip()
        if text:
            blocks.append(text)
    return blocks


def main() -> int:
    args = parse_args()
    files = collect_files(args.inputs)
    blocks = read_blocks(files)
    parts: list[str] = []
    if args.title:
        parts.append(f"# {args.title}")
    parts.extend(blocks)
    merged = "\n\n".join(parts).rstrip() + "\n"
    if args.out:
        Path(args.out).write_text(merged, encoding="utf-8")
    else:
        print(merged, end="")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/generate_docx_from_markdown.cjs
content: #!/usr/bin/env node

const fs = require("node:fs");
const { createRequire } = require("node:module");
const path = require("node:path");

function requireDocx() {
  const localRequire = createRequire(path.resolve(process.cwd(), "sgdb-docx-loader.cjs"));
  const candidates = [];
  if (process.env.SGDB_DOCX_MODULE) {
    candidates.push(process.env.SGDB_DOCX_MODULE);
  }
  candidates.push("docx");
  for (const candidate of candidates) {
    try {
      return localRequire(candidate);
    } catch (error) {
      // Try next candidate.
    }
    try {
      return require(candidate);
    } catch (error) {
      // Try next candidate.
    }
  }
  throw new Error(
    "Cannot load the 'docx' module. Install it in the current workspace or set SGDB_DOCX_MODULE to a resolvable module path.",
  );
}

const {
  AlignmentType,
  BorderStyle,
  Document,
  Footer,
  LineRuleType,
  PageBreak,
  PageNumber,
  Packer,
  Paragraph,
  Table,
  TableCell,
  TableOfContents,
  TableRow,
  TextRun,
  WidthType,
} = requireDocx();

function parseArgs(argv) {
  const args = {};
  for (let i = 0; i < argv.length; i += 1) {
    const key = argv[i];
    if (!key.startsWith("--")) continue;
    const value = argv[i + 1];
    args[key.slice(2)] = value;
    i += 1;
  }
  const required = ["markdown", "delivery-brief", "format-profile", "out"];
  for (const key of required) {
    if (!args[key]) {
      throw new Error(`Missing required argument --${key}`);
    }
  }
  args.stage = args.stage || "final";
  return args;
}

function loadJson(filePath) {
  return JSON.parse(fs.readFileSync(filePath, "utf8"));
}

function readOptional(filePath) {
  if (!filePath) return "";
  return fs.readFileSync(filePath, "utf8");
}

function cmToTwip(cm) {
  return Math.round((Number(cm) / 2.54) * 1440);
}

function ptToHalfPoint(pt) {
  return Math.round(Number(pt) * 2);
}

function lineSpecToTwip(spec, fallbackPt) {
  if (!spec) return fallbackPt * 20;
  const match = String(spec).match(/(\d+(?:\.\d+)?)/);
  return match ? Math.round(Number(match[1]) * 20) : fallbackPt * 20;
}

function firstLineIndent(chars) {
  return Number(chars || 0) * 320;
}

function splitFontSpec(spec, fallbackFont, fallbackSizePt) {
  if (!spec) {
    return { font: fallbackFont, sizePt: fallbackSizePt };
  }
  const match = String(spec).match(/^(.+?)\s+(\d+(?:\.\d+)?)\s*pt/i);
  if (!match) {
    return { font: fallbackFont, sizePt: fallbackSizePt };
  }
  return { font: match[1].trim(), sizePt: Number(match[2]) };
}

function missingFields(payload, fields) {
  const missing = [];
  for (const field of fields) {
    const value = payload[field];
    if (value === undefined || value === null) {
      missing.push(field);
      continue;
    }
    if (typeof value === "string" && value.trim() === "") {
      missing.push(field);
    }
  }
  return missing;
}

function deriveComponents(scope, stage) {
  const normalized = String(scope || "").toLowerCase();
  const components = ["body"];
  if (stage === "draft" && normalized.includes("body-only draft")) {
    return components;
  }
  if (normalized.includes("full-deliverable") || stage === "final") {
    for (const item of ["cover", "table-of-contents"]) {
      if (!components.includes(item)) {
        components.push(item);
      }
    }
  }
  const keywordMap = {
    append: "appendices",
    glossary: "glossary",
    "figure list": "figure-list",
    "table list": "table-list",
    attachment: "attachments-manifest",
  };
  for (const [needle, component] of Object.entries(keywordMap)) {
    if (normalized.includes(needle) && !components.includes(component)) {
      components.push(component);
    }
  }
  return components;
}

function resolveReadiness(brief, profile, stage) {
  const draftMissing = missingFields(brief, [
    "document_title",
    "source_authority",
    "target_structure",
    "document_purpose",
    "audience",
    "output_mode",
  ]);
  const blockers = draftMissing.map((field) => `missing draft field: ${field}`);
  if (stage === "final") {
    const finalMissing = missingFields(brief, [
      "delivery_stage",
      "completeness_scope",
      "review_mode",
      "format_authority",
      "file_naming_and_version",
    ]);
    blockers.push(...finalMissing.map((field) => `missing final field: ${field}`));
    if (String(brief.delivery_stage || "").toLowerCase() !== "final") {
      blockers.push("delivery_stage is not set to final");
    }
    if (profile.needs_confirmation === true) {
      blockers.push("format profile still requires confirmation");
    }
  }
  return {
    blockers,
    components: deriveComponents(brief.completeness_scope || "body-only draft", stage),
  };
}

function makeFooterRuns(style) {
  if (String(style || "").includes("第")) {
    return [new TextRun("第 "), new TextRun({ children: [PageNumber.CURRENT] }), new TextRun(" 页")];
  }
  return [new TextRun("- "), new TextRun({ children: [PageNumber.CURRENT] }), new TextRun(" -")];
}

function buildStyles(profile) {
  const body = profile.fonts.body;
  const h1 = profile.fonts.heading_1;
  const h2 = profile.fonts.heading_2;
  const h3 = profile.fonts.heading_3;
  const h4 = profile.fonts.heading_4;
  const tableCaption = splitFontSpec(profile.tables.caption_font, "SimHei", 12);
  const tableHeader = splitFontSpec(profile.tables.header_font, "SimHei", 12);
  const tableBody = splitFontSpec(profile.tables.body_font, "SimSun", 12);
  const figureCaption = splitFontSpec(profile.figures.caption_font, body.font, 12);
  return {
    default: {
      document: {
        run: {
          font: body.font,
          size: ptToHalfPoint(body.size_pt),
          color: "000000",
        },
      },
    },
    paragraphStyles: [
      {
        id: "SGDB_Body",
        name: "SGDB Body",
        run: { font: body.font, size: ptToHalfPoint(body.size_pt), color: "000000" },
        paragraph: {
          spacing: {
            line: lineSpecToTwip(profile.paragraph.line_spacing, 28),
            lineRule: LineRuleType.EXACT,
            before: Number(profile.paragraph.space_before_pt || 0) * 20,
            after: Number(profile.paragraph.space_after_pt || 0) * 20,
          },
          indent: { firstLine: firstLineIndent(profile.paragraph.first_line_indent_chars || 2) },
          alignment: AlignmentType.JUSTIFIED,
        },
      },
      {
        id: "SGDB_H1",
        name: "SGDB Heading 1",
        basedOn: "SGDB_Body",
        run: { font: h1.font, size: ptToHalfPoint(h1.size_pt), bold: !!h1.bold, color: "000000" },
        paragraph: {
          spacing: { line: lineSpecToTwip(profile.paragraph.line_spacing, 28), lineRule: LineRuleType.EXACT, before: 240, after: 120 },
          indent: { firstLine: 0 },
          outlineLevel: 0,
        },
      },
      {
        id: "SGDB_H2",
        name: "SGDB Heading 2",
        basedOn: "SGDB_Body",
        run: { font: h2.font, size: ptToHalfPoint(h2.size_pt), bold: !!h2.bold, color: "000000" },
        paragraph: {
          spacing: { line: lineSpecToTwip(profile.paragraph.line_spacing, 28), lineRule: LineRuleType.EXACT, before: 200, after: 100 },
          indent: { firstLine: 0 },
          outlineLevel: 1,
        },
      },
      {
        id: "SGDB_H3",
        name: "SGDB Heading 3",
        basedOn: "SGDB_Body",
        run: { font: h3.font, size: ptToHalfPoint(h3.size_pt), bold: !!h3.bold, color: "000000" },
        paragraph: {
          spacing: { line: lineSpecToTwip(profile.paragraph.line_spacing, 28), lineRule: LineRuleType.EXACT, before: 160, after: 80 },
          indent: { firstLine: 0 },
          outlineLevel: 2,
        },
      },
      {
        id: "SGDB_H4",
        name: "SGDB Heading 4",
        basedOn: "SGDB_Body",
        run: { font: h4.font, size: ptToHalfPoint(h4.size_pt), bold: !!h4.bold, color: "000000" },
        paragraph: {
          spacing: {
            line: lineSpecToTwip(h4.line_spacing, 30),
            lineRule: LineRuleType.EXACT,
            before: Number(h4.space_before_pt || 6) * 20,
            after: Number(h4.space_after_pt || 3) * 20,
          },
          indent: { firstLine: 0 },
          outlineLevel: 3,
        },
      },
      {
        id: "SGDB_TableCaption",
        name: "SGDB Table Caption",
        basedOn: "SGDB_Body",
        run: { font: tableCaption.font, size: ptToHalfPoint(tableCaption.sizePt), bold: true, color: "000000" },
        paragraph: { spacing: { line: 360, lineRule: LineRuleType.EXACT, before: 120, after: 80 }, alignment: AlignmentType.CENTER },
      },
      {
        id: "SGDB_TableHeader",
        name: "SGDB Table Header",
        basedOn: "SGDB_Body",
        run: { font: tableHeader.font, size: ptToHalfPoint(tableHeader.sizePt), bold: true, color: "000000" },
        paragraph: { spacing: { line: 360, lineRule: LineRuleType.EXACT, before: 0, after: 0 }, alignment: AlignmentType.CENTER },
      },
      {
        id: "SGDB_TableText",
        name: "SGDB Table Text",
        basedOn: "SGDB_Body",
        run: { font: tableBody.font, size: ptToHalfPoint(tableBody.sizePt), color: "000000" },
        paragraph: { spacing: { line: 360, lineRule: LineRuleType.EXACT, before: 0, after: 0 }, alignment: AlignmentType.LEFT },
      },
      {
        id: "SGDB_FigureCaption",
        name: "SGDB Figure Caption",
        basedOn: "SGDB_Body",
        run: { font: figureCaption.font, size: ptToHalfPoint(figureCaption.sizePt), color: "000000" },
        paragraph: { spacing: { line: 360, lineRule: LineRuleType.EXACT, before: 80, after: 80 }, alignment: AlignmentType.CENTER },
      },
      {
        id: "SGDB_CoverTitle",
        name: "SGDB Cover Title",
        basedOn: "SGDB_Body",
        run: { font: h1.font, size: Math.max(ptToHalfPoint(h1.size_pt + 6), 44), bold: true, color: "000000" },
        paragraph: { spacing: { before: 800, after: 240 }, alignment: AlignmentType.CENTER },
      },
      {
        id: "SGDB_CoverMeta",
        name: "SGDB Cover Meta",
        basedOn: "SGDB_Body",
        run: { font: body.font, size: ptToHalfPoint(body.size_pt), color: "000000" },
        paragraph: { spacing: { before: 80, after: 80 }, alignment: AlignmentType.CENTER },
      },
    ],
  };
}

function paragraph(style, text, extra = {}) {
  return new Paragraph({
    style,
    ...extra,
    children: [new TextRun(text)],
  });
}

function pageBreakParagraph() {
  return new Paragraph({ children: [new PageBreak()] });
}

function makeTableCell(text, widthPct, style) {
  return new TableCell({
    width: { size: widthPct, type: WidthType.PERCENTAGE },
    children: [new Paragraph({ style, children: [new TextRun(text)] })],
  });
}

function makeMarkdownTable(rows) {
  const headers = rows[0];
  const bodyRows = rows.slice(1);
  const cellWidth = Math.floor(100 / headers.length);
  return new Table({
    width: { size: 100, type: WidthType.PERCENTAGE },
    borders: {
      top: { style: BorderStyle.SINGLE, size: 4, color: "000000" },
      bottom: { style: BorderStyle.SINGLE, size: 4, color: "000000" },
      insideHorizontal: { style: BorderStyle.SINGLE, size: 2, color: "000000" },
      left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
      right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
      insideVertical: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
    },
    rows: [
      new TableRow({
        children: headers.map((cell) => makeTableCell(cell, cellWidth, "SGDB_TableHeader")),
      }),
      ...bodyRows.map(
        (row) =>
          new TableRow({
            children: row.map((cell) => makeTableCell(cell, cellWidth, "SGDB_TableText")),
          }),
      ),
    ],
  });
}

function normalizeTableLine(line) {
  return line
    .trim()
    .replace(/^\|/, "")
    .replace(/\|$/, "")
    .split("|")
    .map((cell) => cell.trim());
}

function flushParagraph(elements, buffer) {
  const text = buffer.join(" ").trim();
  if (!text) return;
  if (/^表[\d一二三四五六七八九十\-]/.test(text)) {
    elements.push(paragraph("SGDB_TableCaption", text));
  } else if (/^图[\d一二三四五六七八九十\-]/.test(text)) {
    elements.push(paragraph("SGDB_FigureCaption", text));
  } else {
    elements.push(paragraph("SGDB_Body", text));
  }
  buffer.length = 0;
}

function markdownToElements(markdown) {
  const elements = [];
  const lines = markdown.replace(/\r\n/g, "\n").split("\n");
  const paragraphBuffer = [];
  const tableBuffer = [];

  function flushTable() {
    if (!tableBuffer.length) return;
    const rows = tableBuffer
      .map(normalizeTableLine)
      .filter((row) => row.length > 0 && !row.every((cell) => /^:?-{2,}:?$/.test(cell)));
    if (rows.length >= 2) {
      elements.push(makeMarkdownTable(rows));
    }
    tableBuffer.length = 0;
  }

  for (const rawLine of lines) {
    const line = rawLine.trimEnd();
    const trimmed = line.trim();
    if (!trimmed) {
      flushParagraph(elements, paragraphBuffer);
      flushTable();
      continue;
    }
    if (/^\|.+\|$/.test(trimmed)) {
      flushParagraph(elements, paragraphBuffer);
      tableBuffer.push(trimmed);
      continue;
    }
    flushTable();
    const heading = trimmed.match(/^(#{1,4})\s+(.+)$/);
    if (heading) {
      flushParagraph(elements, paragraphBuffer);
      const level = heading[1].length;
      elements.push(paragraph(`SGDB_H${level}`, heading[2]));
      continue;
    }
    const bullet = trimmed.match(/^[-*]\s+(.+)$/);
    if (bullet) {
      flushParagraph(elements, paragraphBuffer);
      elements.push(
        new Paragraph({
          style: "SGDB_Body",
          bullet: { level: 0 },
          children: [new TextRun(bullet[1])],
        }),
      );
      continue;
    }
    const ordered = trimmed.match(/^\d+[.)]\s+(.+)$/);
    if (ordered) {
      flushParagraph(elements, paragraphBuffer);
      elements.push(paragraph("SGDB_Body", ordered[1]));
      continue;
    }
    const image = trimmed.match(/^!\[(.*?)\]\((.+?)\)$/);
    if (image) {
      flushParagraph(elements, paragraphBuffer);
      elements.push(paragraph("SGDB_FigureCaption", image[1] || image[2]));
      continue;
    }
    paragraphBuffer.push(trimmed);
  }
  flushParagraph(elements, paragraphBuffer);
  flushTable();
  return elements;
}

function buildCover(brief) {
  return [
    paragraph("SGDB_CoverTitle", brief.document_title),
    paragraph("SGDB_CoverMeta", brief.document_purpose || ""),
    paragraph("SGDB_CoverMeta", `适用对象：${brief.audience}`),
    pageBreakParagraph(),
  ];
}

function buildToc() {
  return [
    new TableOfContents("目录", { headingStyleRange: "1-4", hyperlink: true }),
    pageBreakParagraph(),
  ];
}

function buildOptionalSection(title, markdown, styleId) {
  const elements = [paragraph(styleId, title)];
  elements.push(...markdownToElements(markdown));
  return elements;
}

async function main() {
  const args = parseArgs(process.argv.slice(2));
  const brief = loadJson(args["delivery-brief"]);
  const profile = loadJson(args["format-profile"]);
  const { blockers, components } = resolveReadiness(brief, profile, args.stage);
  if (blockers.length) {
    throw new Error(`Word job is not ready:\n- ${blockers.join("\n- ")}`);
  }

  const markdown = fs.readFileSync(args.markdown, "utf8");
  const appendixMarkdown = readOptional(args["appendices-markdown"]);
  const glossaryMarkdown = readOptional(args["glossary-markdown"]);
  const attachmentsMarkdown = readOptional(args["attachments-markdown"]);

  if (args.stage === "final") {
    if (components.includes("appendices") && !appendixMarkdown) {
      throw new Error("Appendices are required by completeness_scope but --appendices-markdown was not provided.");
    }
    if (components.includes("glossary") && !glossaryMarkdown) {
      throw new Error("Glossary is required by completeness_scope but --glossary-markdown was not provided.");
    }
    if (components.includes("attachments-manifest") && !attachmentsMarkdown) {
      throw new Error("Attachments manifest is required by completeness_scope but --attachments-markdown was not provided.");
    }
    if (components.includes("figure-list") || components.includes("table-list")) {
      throw new Error("figure-list/table-list output is not implemented yet. Remove them from completeness_scope or extend the generator.");
    }
  }

  const children = [];
  if (components.includes("cover")) {
    children.push(...buildCover(brief));
  }
  if (components.includes("table-of-contents")) {
    children.push(...buildToc());
  }
  children.push(...markdownToElements(markdown));

  if (components.includes("glossary")) {
    children.push(pageBreakParagraph(), ...buildOptionalSection("术语表", glossaryMarkdown || "待补充。", "SGDB_H1"));
  }
  if (components.includes("appendices")) {
    children.push(pageBreakParagraph(), ...buildOptionalSection("附录", appendixMarkdown || "待补充。", "SGDB_H1"));
  }
  if (components.includes("attachments-manifest")) {
    children.push(
      pageBreakParagraph(),
      ...buildOptionalSection("附件清单", attachmentsMarkdown || "待补充。", "SGDB_H1"),
    );
  }

  const footerRuns = makeFooterRuns(profile.footer.page_number_style);
  const doc = new Document({
    styles: buildStyles(profile),
    sections: [
      {
        properties: {
          page: {
            margin: {
              top: cmToTwip(profile.margins_cm.top),
              bottom: cmToTwip(profile.margins_cm.bottom),
              left: cmToTwip(profile.margins_cm.left),
              right: cmToTwip(profile.margins_cm.right),
            },
            pageNumbers: { start: 1, formatType: "decimal" },
          },
        },
        footers: {
          default:
            profile.header === "none"
              ? new Footer({
                  children: [
                    new Paragraph({
                      alignment: AlignmentType.CENTER,
                      children: footerRuns,
                    }),
                  ],
                })
              : undefined,
        },
        children,
      },
    ],
  });

  const buffer = await Packer.toBuffer(doc);
  fs.writeFileSync(args.out, buffer);
  console.log(args.out);
}

main().catch((error) => {
  console.error(error.message);
  process.exit(1);
});


path: scripts/__init__.py
content: # 将 scripts 目录设为包，以便在测试中使用相对导入


path: scripts/init_format_profile.py
content: #!/usr/bin/env python3

from __future__ import annotations

import argparse
import json
from pathlib import Path


DEFAULT_PROFILE = {
    "format_authority": "default-profile",
    "needs_confirmation": True,
    "paper": "A4 portrait",
    "margins_cm": {"top": 2.5, "bottom": 2.5, "left": 3.0, "right": 2.5},
    "paragraph": {
        "alignment": "justified",
        "first_line_indent_chars": 2,
        "space_before_pt": 0,
        "space_after_pt": 0,
        "line_spacing": "exact 28 pt",
    },
    "fonts": {
        "body": {"font": "FangSong_GB2312", "size_pt": 16, "bold": False},
        "heading_1": {"font": "SimHei", "size_pt": 16, "bold": True},
        "heading_2": {"font": "KaiTi_GB2312", "size_pt": 16, "bold": True},
        "heading_3": {"font": "FangSong_GB2312", "size_pt": 16, "bold": True},
        "heading_4": {
            "font": "FangSong_GB2312",
            "size_pt": 16,
            "bold": False,
            "line_spacing": "exact 30 pt",
            "space_before_pt": 6,
            "space_after_pt": 3,
        },
    },
    "tables": {
        "caption_font": "SimHei 12 pt bold centered",
        "header_font": "SimHei 12 pt bold centered",
        "body_font": "SimSun 12 pt",
        "style": "three-line table",
        "row_spacing": "exact 18-20 pt",
    },
    "figures": {
        "caption_font": "FangSong_GB2312 12 pt centered",
    },
    "header": "none",
    "footer": {
        "page_number_font": "SimSun 9 pt",
        "alignment": "centered",
        "page_number_style": "- 1 -",
    },
    "exceptions": [],
}


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Create a first-pass Word format profile for confirmation or record keeping."
    )
    parser.add_argument("--out", help="Optional output file")
    parser.add_argument(
        "--format",
        choices=("md", "json"),
        default="md",
        help="Output format",
    )
    parser.add_argument(
        "--authority",
        default=DEFAULT_PROFILE["format_authority"],
        help="Resolved format authority label",
    )
    parser.add_argument(
        "--needs-confirmation",
        choices=("yes", "no"),
        default="yes",
        help="Whether the format still needs user confirmation",
    )
    return parser.parse_args()


def build_profile(authority: str, needs_confirmation: str) -> dict:
    profile = json.loads(json.dumps(DEFAULT_PROFILE))
    profile["format_authority"] = authority
    profile["needs_confirmation"] = needs_confirmation == "yes"
    return profile


def render_markdown(profile: dict) -> str:
    margins = profile["margins_cm"]
    paragraph = profile["paragraph"]
    fonts = profile["fonts"]
    tables = profile["tables"]
    footer = profile["footer"]
    lines = [
        "# Word Format Profile",
        "",
        f"- Format authority: {profile['format_authority']}",
        f"- Needs confirmation: {'yes' if profile['needs_confirmation'] else 'no'}",
        "",
        "## Page setup",
        "",
        f"- Paper: {profile['paper']}",
        f"- Margins: top {margins['top']} cm, bottom {margins['bottom']} cm, left {margins['left']} cm, right {margins['right']} cm",
        f"- Paragraph alignment: {paragraph['alignment']}",
        f"- First-line indent: {paragraph['first_line_indent_chars']} characters",
        f"- Paragraph spacing: before {paragraph['space_before_pt']} pt, after {paragraph['space_after_pt']} pt",
        f"- Line spacing: {paragraph['line_spacing']}",
        "",
        "## Fonts",
        "",
        f"- Body: {fonts['body']['font']} {fonts['body']['size_pt']} pt",
        f"- Heading 1: {fonts['heading_1']['font']} {fonts['heading_1']['size_pt']} pt bold",
        f"- Heading 2: {fonts['heading_2']['font']} {fonts['heading_2']['size_pt']} pt bold",
        f"- Heading 3: {fonts['heading_3']['font']} {fonts['heading_3']['size_pt']} pt bold",
        f"- Heading 4: {fonts['heading_4']['font']} {fonts['heading_4']['size_pt']} pt regular, {fonts['heading_4']['line_spacing']}",
        "",
        "## Tables and figures",
        "",
        f"- Table caption: {tables['caption_font']}",
        f"- Table header: {tables['header_font']}",
        f"- Table body: {tables['body_font']}",
        f"- Table style: {tables['style']}",
        f"- Table row spacing: {tables['row_spacing']}",
        f"- Figure caption: {profile['figures']['caption_font']}",
        "",
        "## Header and footer",
        "",
        f"- Header: {profile['header']}",
        f"- Footer page number: {footer['page_number_font']}, {footer['alignment']}, style {footer['page_number_style']}",
        "",
        "## Exceptions",
        "",
        "- None yet",
        "",
    ]
    return "\n".join(lines)


def emit(text: str, out: str | None) -> None:
    if out:
        Path(out).write_text(text, encoding="utf-8")
    else:
        print(text, end="")


def main() -> int:
    args = parse_args()
    profile = build_profile(args.authority, args.needs_confirmation)
    if args.format == "json":
        text = json.dumps(profile, ensure_ascii=False, indent=2) + "\n"
    else:
        text = render_markdown(profile)
    emit(text, args.out)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/document.py
content: #!/usr/bin/env python3
"""
用于处理 Word 文档的库：批注、修订追踪和编辑。

用法:
    from skills.docx.scripts.document import Document

    # 初始化
    doc = Document('workspace/unpacked')
    doc = Document('workspace/unpacked', author="John Doe", initials="JD")

    # 查找节点
    node = doc["word/document.xml"].get_node(tag="w:del", attrs={"w:id": "1"})
    node = doc["word/document.xml"].get_node(tag="w:p", line_number=10)

    # 添加批注
    doc.add_comment(start=node, end=node, text="批注文本")
    doc.reply_to_comment(parent_comment_id=0, text="回复文本")

    # 建议修订追踪
    doc["word/document.xml"].suggest_deletion(node)  # 删除内容
    doc["word/document.xml"].revert_insertion(ins_node)  # 拒绝插入
    doc["word/document.xml"].revert_deletion(del_node)  # 拒绝删除

    # 保存
    doc.save()
"""

import html
import random
import shutil
import tempfile
from datetime import datetime, timezone
from pathlib import Path

from defusedxml import minidom
from ooxml.scripts.pack import pack_document
from ooxml.scripts.validation.docx import DOCXSchemaValidator
from ooxml.scripts.validation.redlining import RedliningValidator

from .utilities import XMLEditor

# 模板文件路径
TEMPLATE_DIR = Path(__file__).parent / "templates"


class DocxXMLEditor(XMLEditor):
    """自动将 RSID、作者和日期应用到新元素的 XMLEditor。

    在插入新内容时自动为支持这些属性的元素添加属性：
    - w:rsidR、w:rsidRDefault、w:rsidP（用于 w:p 和 w:r 元素）
    - w:author 和 w:date（用于 w:ins、w:del、w:comment 元素）
    - w:id（用于 w:ins 和 w:del 元素）

    属性:
        dom (defusedxml.minidom.Document): 用于直接操作的 DOM 文档
    """

    def __init__(
        self, xml_path, rsid: str, author: str = "Claude", initials: str = "C"
    ):
        """使用必需的 RSID 和可选的作者初始化。

        参数:
            xml_path: 要编辑的 XML 文件路径
            rsid: 自动应用到新元素的 RSID
            author: 修订追踪和批注的作者名称（默认："Claude"）
            initials: 作者首字母缩写（默认："C"）
        """
        super().__init__(xml_path)
        self.rsid = rsid
        self.author = author
        self.initials = initials

    def _get_next_change_id(self):
        """通过检查所有修订追踪元素获取下一个可用的修改 ID。"""
        max_id = -1
        for tag in ("w:ins", "w:del"):
            elements = self.dom.getElementsByTagName(tag)
            for elem in elements:
                change_id = elem.getAttribute("w:id")
                if change_id:
                    try:
                        max_id = max(max_id, int(change_id))
                    except ValueError:
                        pass
        return max_id + 1

    def _ensure_w16du_namespace(self):
        """确保根元素声明了 w16du 命名空间。"""
        root = self.dom.documentElement
        if not root.hasAttribute("xmlns:w16du"):  # type: ignore
            root.setAttribute(  # type: ignore
                "xmlns:w16du",
                "http://schemas.microsoft.com/office/word/2023/wordml/word16du",
            )

    def _ensure_w16cex_namespace(self):
        """确保根元素声明了 w16cex 命名空间。"""
        root = self.dom.documentElement
        if not root.hasAttribute("xmlns:w16cex"):  # type: ignore
            root.setAttribute(  # type: ignore
                "xmlns:w16cex",
                "http://schemas.microsoft.com/office/word/2018/wordml/cex",
            )

    def _ensure_w14_namespace(self):
        """确保根元素声明了 w14 命名空间。"""
        root = self.dom.documentElement
        if not root.hasAttribute("xmlns:w14"):  # type: ignore
            root.setAttribute(  # type: ignore
                "xmlns:w14",
                "http://schemas.microsoft.com/office/word/2010/wordml",
            )

    def _inject_attributes_to_nodes(self, nodes):
        """在适用的 DOM 节点中注入 RSID、作者和日期属性。

        为支持这些属性的元素添加属性：
        - w:r: 获取 w:rsidR（如果在 w:del 内则获取 w:rsidDel）
        - w:p: 获取 w:rsidR、w:rsidRDefault、w:rsidP、w14:paraId、w14:textId
        - w:t: 如果文本有前导/尾随空格则获取 xml:space="preserve"
        - w:ins、w:del: 获取 w:id、w:author、w:date、w16du:dateUtc
        - w:comment: 获取 w:author、w:date、w:initials
        - w16cex:commentExtensible: 获取 w16cex:dateUtc

        参数:
            nodes: 要处理的 DOM 节点列表
        """
        from datetime import datetime, timezone

        timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")

        def is_inside_deletion(elem):
            """检查元素是否在 w:del 元素内部。"""
            parent = elem.parentNode
            while parent:
                if parent.nodeType == parent.ELEMENT_NODE and parent.tagName == "w:del":
                    return True
                parent = parent.parentNode
            return False

        def add_rsid_to_p(elem):
            if not elem.hasAttribute("w:rsidR"):
                elem.setAttribute("w:rsidR", self.rsid)
            if not elem.hasAttribute("w:rsidRDefault"):
                elem.setAttribute("w:rsidRDefault", self.rsid)
            if not elem.hasAttribute("w:rsidP"):
                elem.setAttribute("w:rsidP", self.rsid)
            # 如果不存在则添加 w14:paraId 和 w14:textId
            if not elem.hasAttribute("w14:paraId"):
                self._ensure_w14_namespace()
                elem.setAttribute("w14:paraId", _generate_hex_id())
            if not elem.hasAttribute("w14:textId"):
                self._ensure_w14_namespace()
                elem.setAttribute("w14:textId", _generate_hex_id())

        def add_rsid_to_r(elem):
            # 对于 <w:del> 内的 <w:r> 使用 w:rsidDel，否则使用 w:rsidR
            if is_inside_deletion(elem):
                if not elem.hasAttribute("w:rsidDel"):
                    elem.setAttribute("w:rsidDel", self.rsid)
            else:
                if not elem.hasAttribute("w:rsidR"):
                    elem.setAttribute("w:rsidR", self.rsid)

        def add_tracked_change_attrs(elem):
            # 如果不存在则自动分配 w:id
            if not elem.hasAttribute("w:id"):
                elem.setAttribute("w:id", str(self._get_next_change_id()))
            if not elem.hasAttribute("w:author"):
                elem.setAttribute("w:author", self.author)
            if not elem.hasAttribute("w:date"):
                elem.setAttribute("w:date", timestamp)
            # 为修订追踪添加 w16du:dateUtc（与 w:date 相同，因为我们生成 UTC 时间戳）
            if elem.tagName in ("w:ins", "w:del") and not elem.hasAttribute(
                "w16du:dateUtc"
            ):
                self._ensure_w16du_namespace()
                elem.setAttribute("w16du:dateUtc", timestamp)

        def add_comment_attrs(elem):
            if not elem.hasAttribute("w:author"):
                elem.setAttribute("w:author", self.author)
            if not elem.hasAttribute("w:date"):
                elem.setAttribute("w:date", timestamp)
            if not elem.hasAttribute("w:initials"):
                elem.setAttribute("w:initials", self.initials)

        def add_comment_extensible_date(elem):
            # 为批注可扩展元素添加 w16cex:dateUtc
            if not elem.hasAttribute("w16cex:dateUtc"):
                self._ensure_w16cex_namespace()
                elem.setAttribute("w16cex:dateUtc", timestamp)

        def add_xml_space_to_t(elem):
            # 如果文本有前导/尾随空格，则为 w:t 添加 xml:space="preserve"
            if (
                elem.firstChild
                and elem.firstChild.nodeType == elem.firstChild.TEXT_NODE
            ):
                text = elem.firstChild.data
                if text and (text[0].isspace() or text[-1].isspace()):
                    if not elem.hasAttribute("xml:space"):
                        elem.setAttribute("xml:space", "preserve")

        for node in nodes:
            if node.nodeType != node.ELEMENT_NODE:
                continue

            # 处理节点本身
            if node.tagName == "w:p":
                add_rsid_to_p(node)
            elif node.tagName == "w:r":
                add_rsid_to_r(node)
            elif node.tagName == "w:t":
                add_xml_space_to_t(node)
            elif node.tagName in ("w:ins", "w:del"):
                add_tracked_change_attrs(node)
            elif node.tagName == "w:comment":
                add_comment_attrs(node)
            elif node.tagName == "w16cex:commentExtensible":
                add_comment_extensible_date(node)

            # 处理后代元素（getElementsByTagName 不返回元素本身）
            for elem in node.getElementsByTagName("w:p"):
                add_rsid_to_p(elem)
            for elem in node.getElementsByTagName("w:r"):
                add_rsid_to_r(elem)
            for elem in node.getElementsByTagName("w:t"):
                add_xml_space_to_t(elem)
            for tag in ("w:ins", "w:del"):
                for elem in node.getElementsByTagName(tag):
                    add_tracked_change_attrs(elem)
            for elem in node.getElementsByTagName("w:comment"):
                add_comment_attrs(elem)
            for elem in node.getElementsByTagName("w16cex:commentExtensible"):
                add_comment_extensible_date(elem)

    def replace_node(self, elem, new_content):
        """替换节点并自动注入属性。"""
        nodes = super().replace_node(elem, new_content)
        self._inject_attributes_to_nodes(nodes)
        return nodes

    def insert_after(self, elem, xml_content):
        """在节点后插入并自动注入属性。"""
        nodes = super().insert_after(elem, xml_content)
        self._inject_attributes_to_nodes(nodes)
        return nodes

    def insert_before(self, elem, xml_content):
        """在节点前插入并自动注入属性。"""
        nodes = super().insert_before(elem, xml_content)
        self._inject_attributes_to_nodes(nodes)
        return nodes

    def append_to(self, elem, xml_content):
        """追加到节点并自动注入属性。"""
        nodes = super().append_to(elem, xml_content)
        self._inject_attributes_to_nodes(nodes)
        return nodes

    def revert_insertion(self, elem):
        """通过将内容包装在删除标记中来拒绝插入。

        将 w:ins 中的所有 run 包装在 w:del 中，将 w:t 转换为 w:delText。
        可以处理单个 w:ins 元素或包含多个 w:ins 的容器元素。

        参数:
            elem: 要处理的元素（w:ins、w:p、w:body 等）

        返回:
            list: 包含已处理元素的列表

        异常:
            ValueError: 如果元素不包含 w:ins 元素

        示例:
            # 拒绝单个插入
            ins = doc["word/document.xml"].get_node(tag="w:ins", attrs={"w:id": "5"})
            doc["word/document.xml"].revert_insertion(ins)

            # 拒绝段落中的所有插入
            para = doc["word/document.xml"].get_node(tag="w:p", line_number=42)
            doc["word/document.xml"].revert_insertion(para)
        """
        # 收集插入
        ins_elements = []
        if elem.tagName == "w:ins":
            ins_elements.append(elem)
        else:
            ins_elements.extend(elem.getElementsByTagName("w:ins"))

        # 验证是否有要拒绝的插入
        if not ins_elements:
            raise ValueError(
                f"revert_insertion 需要 w:ins 元素。"
                f"提供的元素 <{elem.tagName}> 不包含插入。"
            )

        # 处理所有插入 - 将所有子元素包装在 w:del 中
        for ins_elem in ins_elements:
            runs = list(ins_elem.getElementsByTagName("w:r"))
            if not runs:
                continue

            # 创建删除包装器
            del_wrapper = self.dom.createElement("w:del")

            # 处理每个 run
            for run in runs:
                # 将 w:t → w:delText 和 w:rsidR → w:rsidDel
                if run.hasAttribute("w:rsidR"):
                    run.setAttribute("w:rsidDel", run.getAttribute("w:rsidR"))
                    run.removeAttribute("w:rsidR")
                elif not run.hasAttribute("w:rsidDel"):
                    run.setAttribute("w:rsidDel", self.rsid)

                for t_elem in list(run.getElementsByTagName("w:t")):
                    del_text = self.dom.createElement("w:delText")
                    # 复制所有子节点（不仅是 firstChild）以处理实体
                    while t_elem.firstChild:
                        del_text.appendChild(t_elem.firstChild)
                    for i in range(t_elem.attributes.length):
                        attr = t_elem.attributes.item(i)
                        del_text.setAttribute(attr.name, attr.value)
                    t_elem.parentNode.replaceChild(del_text, t_elem)

            # 将所有子元素从 ins 移动到 del 包装器
            while ins_elem.firstChild:
                del_wrapper.appendChild(ins_elem.firstChild)

            # 将 del 包装器添加回 ins
            ins_elem.appendChild(del_wrapper)

            # 为删除包装器注入属性
            self._inject_attributes_to_nodes([del_wrapper])

        return [elem]

    def revert_deletion(self, elem):
        """通过重新插入已删除的内容来拒绝删除。

        在每个 w:del 之后创建 w:ins 元素，复制已删除的内容并
        将 w:delText 转换回 w:t。
        可以处理单个 w:del 元素或包含多个 w:del 的容器元素。

        参数:
            elem: 要处理的元素（w:del、w:p、w:body 等）

        返回:
            list: 如果 elem 是 w:del，返回 [elem, new_ins]。否则返回 [elem]。

        异常:
            ValueError: 如果元素不包含 w:del 元素

        示例:
            # 拒绝单个删除 - 返回 [w:del, w:ins]
            del_elem = doc["word/document.xml"].get_node(tag="w:del", attrs={"w:id": "3"})
            nodes = doc["word/document.xml"].revert_deletion(del_elem)

            # 拒绝段落中的所有删除 - 返回 [para]
            para = doc["word/document.xml"].get_node(tag="w:p", line_number=42)
            nodes = doc["word/document.xml"].revert_deletion(para)
        """
        # 首先收集删除 - 在修改 DOM 之前
        del_elements = []
        is_single_del = elem.tagName == "w:del"

        if is_single_del:
            del_elements.append(elem)
        else:
            del_elements.extend(elem.getElementsByTagName("w:del"))

        # 验证是否有要拒绝的删除
        if not del_elements:
            raise ValueError(
                f"revert_deletion 需要 w:del 元素。"
                f"提供的元素 <{elem.tagName}> 不包含删除。"
            )

        # 跟踪创建的插入（仅当 elem 是单个 w:del 时相关）
        created_insertion = None

        # 处理所有删除 - 创建复制已删除内容的插入
        for del_elem in del_elements:
            # 克隆已删除的 run 并将其转换为插入
            runs = list(del_elem.getElementsByTagName("w:r"))
            if not runs:
                continue

            # 创建插入包装器
            ins_elem = self.dom.createElement("w:ins")

            for run in runs:
                # 克隆 run
                new_run = run.cloneNode(True)

                # 将 w:delText → w:t
                for del_text in list(new_run.getElementsByTagName("w:delText")):
                    t_elem = self.dom.createElement("w:t")
                    # 复制所有子节点（不仅是 firstChild）以处理实体
                    while del_text.firstChild:
                        t_elem.appendChild(del_text.firstChild)
                    for i in range(del_text.attributes.length):
                        attr = del_text.attributes.item(i)
                        t_elem.setAttribute(attr.name, attr.value)
                    del_text.parentNode.replaceChild(t_elem, del_text)

                # 更新 run 属性：w:rsidDel → w:rsidR
                if new_run.hasAttribute("w:rsidDel"):
                    new_run.setAttribute("w:rsidR", new_run.getAttribute("w:rsidDel"))
                    new_run.removeAttribute("w:rsidDel")
                elif not new_run.hasAttribute("w:rsidR"):
                    new_run.setAttribute("w:rsidR", self.rsid)

                ins_elem.appendChild(new_run)

            # 在删除之后插入新的插入
            nodes = self.insert_after(del_elem, ins_elem.toxml())

            # 如果处理单个 w:del，跟踪创建的插入
            if is_single_del and nodes:
                created_insertion = nodes[0]

        # 根据输入类型返回
        if is_single_del and created_insertion:
            return [elem, created_insertion]
        else:
            return [elem]

    @staticmethod
    def suggest_paragraph(xml_content: str) -> str:
        """转换段落 XML 以添加用于插入的修订追踪包装。

        将 run 包装在 <w:ins> 中，并在 w:pPr 的 w:rPr 中添加 <w:ins/> 用于编号列表。

        参数:
            xml_content: 包含 <w:p> 元素的 XML 字符串

        返回:
            str: 带有修订追踪包装的转换后的 XML
        """
        wrapper = f'<root xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">{xml_content}</root>'
        doc = minidom.parseString(wrapper)
        para = doc.getElementsByTagName("w:p")[0]

        # 确保 w:pPr 存在
        pPr_list = para.getElementsByTagName("w:pPr")
        if not pPr_list:
            pPr = doc.createElement("w:pPr")
            para.insertBefore(
                pPr, para.firstChild
            ) if para.firstChild else para.appendChild(pPr)
        else:
            pPr = pPr_list[0]

        # 确保 w:pPr 中的 w:rPr 存在
        rPr_list = pPr.getElementsByTagName("w:rPr")
        if not rPr_list:
            rPr = doc.createElement("w:rPr")
            pPr.appendChild(rPr)
        else:
            rPr = rPr_list[0]

        # 将 <w:ins/> 添加到 w:rPr
        ins_marker = doc.createElement("w:ins")
        rPr.insertBefore(
            ins_marker, rPr.firstChild
        ) if rPr.firstChild else rPr.appendChild(ins_marker)

        # 将所有非 pPr 子元素包装在 <w:ins> 中
        ins_wrapper = doc.createElement("w:ins")
        for child in [c for c in para.childNodes if c.nodeName != "w:pPr"]:
            para.removeChild(child)
            ins_wrapper.appendChild(child)
        para.appendChild(ins_wrapper)

        return para.toxml()

    def suggest_deletion(self, elem):
        """使用修订追踪将 w:r 或 w:p 元素标记为已删除（就地 DOM 操作）。

        对于 w:r：包装在 <w:del> 中，将 <w:t> 转换为 <w:delText>，保留 w:rPr
        对于 w:p（普通）：将内容包装在 <w:del> 中，将 <w:t> 转换为 <w:delText>
        对于 w:p（编号列表）：在 w:pPr 的 w:rPr 中添加 <w:del/>，将内容包装在 <w:del> 中

        参数:
            elem: 没有现有修订追踪的 w:r 或 w:p DOM 元素

        返回:
            Element: 修改后的元素

        异常:
            ValueError: 如果元素有现有的修订追踪或结构无效
        """
        if elem.nodeName == "w:r":
            # 检查是否存在 w:delText
            if elem.getElementsByTagName("w:delText"):
                raise ValueError("w:r 元素已包含 w:delText")

            # 将 w:t → w:delText
            for t_elem in list(elem.getElementsByTagName("w:t")):
                del_text = self.dom.createElement("w:delText")
                # 复制所有子节点（不仅是 firstChild）以处理实体
                while t_elem.firstChild:
                    del_text.appendChild(t_elem.firstChild)
                # 保留 xml:space 等属性
                for i in range(t_elem.attributes.length):
                    attr = t_elem.attributes.item(i)
                    del_text.setAttribute(attr.name, attr.value)
                t_elem.parentNode.replaceChild(del_text, t_elem)

            # 更新 run 属性：w:rsidR → w:rsidDel
            if elem.hasAttribute("w:rsidR"):
                elem.setAttribute("w:rsidDel", elem.getAttribute("w:rsidR"))
                elem.removeAttribute("w:rsidR")
            elif not elem.hasAttribute("w:rsidDel"):
                elem.setAttribute("w:rsidDel", self.rsid)

            # 包装在 w:del 中
            del_wrapper = self.dom.createElement("w:del")
            parent = elem.parentNode
            parent.insertBefore(del_wrapper, elem)
            parent.removeChild(elem)
            del_wrapper.appendChild(elem)

            # 为删除包装器注入属性
            self._inject_attributes_to_nodes([del_wrapper])

            return del_wrapper

        elif elem.nodeName == "w:p":
            # 检查是否存在现有的修订追踪
            if elem.getElementsByTagName("w:ins") or elem.getElementsByTagName("w:del"):
                raise ValueError("w:p 元素已包含修订追踪")

            # 检查是否为编号列表项
            pPr_list = elem.getElementsByTagName("w:pPr")
            is_numbered = pPr_list and pPr_list[0].getElementsByTagName("w:numPr")

            if is_numbered:
                # 在 w:pPr 的 w:rPr 中添加 <w:del/>
                pPr = pPr_list[0]
                rPr_list = pPr.getElementsByTagName("w:rPr")

                if not rPr_list:
                    rPr = self.dom.createElement("w:rPr")
                    pPr.appendChild(rPr)
                else:
                    rPr = rPr_list[0]

                # 添加 <w:del/> 标记
                del_marker = self.dom.createElement("w:del")
                rPr.insertBefore(
                    del_marker, rPr.firstChild
                ) if rPr.firstChild else rPr.appendChild(del_marker)

            # 在所有 run 中将 w:t → w:delText
            for t_elem in list(elem.getElementsByTagName("w:t")):
                del_text = self.dom.createElement("w:delText")
                # 复制所有子节点（不仅是 firstChild）以处理实体
                while t_elem.firstChild:
                    del_text.appendChild(t_elem.firstChild)
                # 保留 xml:space 等属性
                for i in range(t_elem.attributes.length):
                    attr = t_elem.attributes.item(i)
                    del_text.setAttribute(attr.name, attr.value)
                t_elem.parentNode.replaceChild(del_text, t_elem)

            # 更新 run 属性：w:rsidR → w:rsidDel
            for run in elem.getElementsByTagName("w:r"):
                if run.hasAttribute("w:rsidR"):
                    run.setAttribute("w:rsidDel", run.getAttribute("w:rsidR"))
                    run.removeAttribute("w:rsidR")
                elif not run.hasAttribute("w:rsidDel"):
                    run.setAttribute("w:rsidDel", self.rsid)

            # 将所有非 pPr 子元素包装在 <w:del> 中
            del_wrapper = self.dom.createElement("w:del")
            for child in [c for c in elem.childNodes if c.nodeName != "w:pPr"]:
                elem.removeChild(child)
                del_wrapper.appendChild(child)
            elem.appendChild(del_wrapper)

            # 为删除包装器注入属性
            self._inject_attributes_to_nodes([del_wrapper])

            return elem

        else:
            raise ValueError(f"元素必须是 w:r 或 w:p，得到的是 {elem.nodeName}")


def _generate_hex_id() -> str:
    """为段落/持久 ID 生成随机的 8 字符十六进制 ID。

    根据 OOXML 规范，值必须小于 0x7FFFFFFF：
    - paraId 必须 < 0x80000000
    - durableId 必须 < 0x7FFFFFFF
    我们对两者都使用更严格的约束（0x7FFFFFFF）。
    """
    return f"{random.randint(1, 0x7FFFFFFE):08X}"


def _generate_rsid() -> str:
    """生成随机的 8 字符十六进制 RSID。"""
    return "".join(random.choices("0123456789ABCDEF", k=8))


class Document:
    """管理解压后的 Word 文档中的批注。"""

    def __init__(
        self,
        unpacked_dir,
        rsid=None,
        track_revisions=False,
        author="Claude",
        initials="C",
    ):
        """
        使用解压后的 Word 文档目录路径初始化。
        自动设置批注基础设施（people.xml、RSID）。

        参数:
            unpacked_dir: 解压后的 DOCX 目录路径（必须包含 word/ 子目录）
            rsid: 用于所有批注元素的可选 RSID。如果未提供，将自动生成。
            track_revisions: 如果为 True，在 settings.xml 中启用修订追踪（默认：False）
            author: 批注的默认作者名称（默认："Claude"）
            initials: 批注的默认作者首字母缩写（默认："C"）
        """
        self.original_path = Path(unpacked_dir)

        if not self.original_path.exists() or not self.original_path.is_dir():
            raise ValueError(f"目录未找到: {unpacked_dir}")

        # 创建临时目录，包含解压内容和基线的子目录
        self.temp_dir = tempfile.mkdtemp(prefix="docx_")
        self.unpacked_path = Path(self.temp_dir) / "unpacked"
        shutil.copytree(self.original_path, self.unpacked_path)

        # 将原始目录打包为临时 .docx 用于验证基线（在解压目录外）
        self.original_docx = Path(self.temp_dir) / "original.docx"
        pack_document(self.original_path, self.original_docx, validate=False)

        self.word_path = self.unpacked_path / "word"

        # 如果未提供则生成 RSID
        self.rsid = rsid if rsid else _generate_rsid()
        print(f"使用 RSID: {self.rsid}")

        # 设置默认作者和首字母缩写
        self.author = author
        self.initials = initials

        # 延迟加载编辑器的缓存
        self._editors = {}

        # 批注文件路径
        self.comments_path = self.word_path / "comments.xml"
        self.comments_extended_path = self.word_path / "commentsExtended.xml"
        self.comments_ids_path = self.word_path / "commentsIds.xml"
        self.comments_extensible_path = self.word_path / "commentsExtensible.xml"

        # 加载现有批注并确定下一个 ID（在设置修改文件之前）
        self.existing_comments = self._load_existing_comments()
        self.next_comment_id = self._get_next_comment_id()

        # 方便访问 document.xml 编辑器（半私有）
        self._document = self["word/document.xml"]

        # 设置修订追踪基础设施
        self._setup_tracking(track_revisions=track_revisions)

        # 将作者添加到 people.xml
        self._add_author_to_people(author)

    def __getitem__(self, xml_path: str) -> DocxXMLEditor:
        """
        获取或创建指定 XML 文件的 DocxXMLEditor。

        使用方括号表示法启用延迟加载的编辑器：
            node = doc["word/document.xml"].get_node(tag="w:p", line_number=42)

        参数:
            xml_path: XML 文件的相对路径（例如 "word/document.xml"、"word/comments.xml"）

        返回:
            指定文件的 DocxXMLEditor 实例

        异常:
            ValueError: 如果文件不存在

        示例:
            # 从 document.xml 获取节点
            node = doc["word/document.xml"].get_node(tag="w:del", attrs={"w:id": "1"})

            # 从 comments.xml 获取节点
            comment = doc["word/comments.xml"].get_node(tag="w:comment", attrs={"w:id": "0"})
        """
        if xml_path not in self._editors:
            file_path = self.unpacked_path / xml_path
            if not file_path.exists():
                raise ValueError(f"XML 文件未找到: {xml_path}")
            # 对所有编辑器使用带有 RSID、作者和首字母缩写的 DocxXMLEditor
            self._editors[xml_path] = DocxXMLEditor(
                file_path, rsid=self.rsid, author=self.author, initials=self.initials
            )
        return self._editors[xml_path]

    def add_comment(self, start, end, text: str) -> int:
        """
        添加从一个元素跨越到另一个元素的批注。

        参数:
            start: 起始点的 DOM 元素
            end: 结束点的 DOM 元素
            text: 批注内容

        返回:
            创建的批注 ID

        示例:
            start_node = cm.get_document_node(tag="w:del", id="1")
            end_node = cm.get_document_node(tag="w:ins", id="2")
            cm.add_comment(start=start_node, end=end_node, text="说明")
        """
        comment_id = self.next_comment_id
        para_id = _generate_hex_id()
        durable_id = _generate_hex_id()
        timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")

        # 立即将批注范围添加到 document.xml
        self._document.insert_before(start, self._comment_range_start_xml(comment_id))

        # 如果结束节点是段落，在其内部追加批注标记
        # 否则在其后插入（用于 run 级别的锚点）
        if end.tagName == "w:p":
            self._document.append_to(end, self._comment_range_end_xml(comment_id))
        else:
            self._document.insert_after(end, self._comment_range_end_xml(comment_id))

        # 立即添加到 comments.xml
        self._add_to_comments_xml(
            comment_id, para_id, text, self.author, self.initials, timestamp
        )

        # 立即添加到 commentsExtended.xml
        self._add_to_comments_extended_xml(para_id, parent_para_id=None)

        # 立即添加到 commentsIds.xml
        self._add_to_comments_ids_xml(para_id, durable_id)

        # 立即添加到 commentsExtensible.xml
        self._add_to_comments_extensible_xml(durable_id)

        # 更新 existing_comments 以便回复功能正常工作
        self.existing_comments[comment_id] = {"para_id": para_id}

        self.next_comment_id += 1
        return comment_id

    def reply_to_comment(
        self,
        parent_comment_id: int,
        text: str,
    ) -> int:
        """
        为现有批注添加回复。

        参数:
            parent_comment_id: 要回复的父批注的 w:id
            text: 回复文本

        返回:
            为回复创建的批注 ID

        示例:
            cm.reply_to_comment(parent_comment_id=0, text="我同意这个更改")
        """
        if parent_comment_id not in self.existing_comments:
            raise ValueError(f"未找到 id={parent_comment_id} 的父批注")

        parent_info = self.existing_comments[parent_comment_id]
        comment_id = self.next_comment_id
        para_id = _generate_hex_id()
        durable_id = _generate_hex_id()
        timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")

        # 立即将批注范围添加到 document.xml
        parent_start_elem = self._document.get_node(
            tag="w:commentRangeStart", attrs={"w:id": str(parent_comment_id)}
        )
        parent_ref_elem = self._document.get_node(
            tag="w:commentReference", attrs={"w:id": str(parent_comment_id)}
        )

        self._document.insert_after(
            parent_start_elem, self._comment_range_start_xml(comment_id)
        )
        parent_ref_run = parent_ref_elem.parentNode
        self._document.insert_after(
            parent_ref_run, f'<w:commentRangeEnd w:id="{comment_id}"/>'
        )
        self._document.insert_after(
            parent_ref_run, self._comment_ref_run_xml(comment_id)
        )

        # 立即添加到 comments.xml
        self._add_to_comments_xml(
            comment_id, para_id, text, self.author, self.initials, timestamp
        )

        # 立即添加到 commentsExtended.xml（带有父级）
        self._add_to_comments_extended_xml(
            para_id, parent_para_id=parent_info["para_id"]
        )

        # 立即添加到 commentsIds.xml
        self._add_to_comments_ids_xml(para_id, durable_id)

        # 立即添加到 commentsExtensible.xml
        self._add_to_comments_extensible_xml(durable_id)

        # 更新 existing_comments 以便回复功能正常工作
        self.existing_comments[comment_id] = {"para_id": para_id}

        self.next_comment_id += 1
        return comment_id

    def __del__(self):
        """删除时清理临时目录。"""
        if hasattr(self, "temp_dir") and Path(self.temp_dir).exists():
            shutil.rmtree(self.temp_dir)

    def validate(self) -> None:
        """
        根据 XSD 架构和红线规则验证文档。

        异常:
            ValueError: 如果验证失败。
        """
        # 使用当前状态创建验证器
        schema_validator = DOCXSchemaValidator(
            self.unpacked_path, self.original_docx, verbose=False
        )
        redlining_validator = RedliningValidator(
            self.unpacked_path, self.original_docx, verbose=False
        )

        # 运行验证
        if not schema_validator.validate():
            raise ValueError("架构验证失败")
        if not redlining_validator.validate():
            raise ValueError("红线验证失败")

    def save(self, destination=None, validate=True) -> None:
        """
        将所有修改的 XML 文件保存到磁盘并复制到目标目录。

        这会持久化通过 add_comment() 和 reply_to_comment() 所做的所有更改。

        参数:
            destination: 可选的保存路径。如果为 None，则保存回原始目录。
            validate: 如果为 True，在保存前验证文档（默认：True）。
        """
        # 仅当批注文件存在时才确保批注关系和内容类型
        if self.comments_path.exists():
            self._ensure_comment_relationships()
            self._ensure_comment_content_types()

        # 在临时目录中保存所有修改的 XML 文件
        for editor in self._editors.values():
            editor.save()

        # 默认进行验证
        if validate:
            self.validate()

        # 将内容从临时目录复制到目标（或原始目录）
        target_path = Path(destination) if destination else self.original_path
        shutil.copytree(self.unpacked_path, target_path, dirs_exist_ok=True)

    # ==================== 私有方法: 初始化 ====================

    def _get_next_comment_id(self):
        """获取下一个可用的批注 ID。"""
        if not self.comments_path.exists():
            return 0

        editor = self["word/comments.xml"]
        max_id = -1
        for comment_elem in editor.dom.getElementsByTagName("w:comment"):
            comment_id = comment_elem.getAttribute("w:id")
            if comment_id:
                try:
                    max_id = max(max_id, int(comment_id))
                except ValueError:
                    pass
        return max_id + 1

    def _load_existing_comments(self):
        """从文件加载现有批注以启用回复功能。"""
        if not self.comments_path.exists():
            return {}

        editor = self["word/comments.xml"]
        existing = {}

        for comment_elem in editor.dom.getElementsByTagName("w:comment"):
            comment_id = comment_elem.getAttribute("w:id")
            if not comment_id:
                continue

            # 从批注内的 w:p 元素中查找 para_id
            para_id = None
            for p_elem in comment_elem.getElementsByTagName("w:p"):
                para_id = p_elem.getAttribute("w14:paraId")
                if para_id:
                    break

            if not para_id:
                continue

            existing[int(comment_id)] = {"para_id": para_id}

        return existing

    # ==================== 私有方法: 设置方法 ====================

    def _setup_tracking(self, track_revisions=False):
        """在解压目录中设置批注基础设施。

        参数:
            track_revisions: 如果为 True，在 settings.xml 中启用修订追踪
        """
        # 创建或更新 word/people.xml
        people_file = self.word_path / "people.xml"
        self._update_people_xml(people_file)

        # 更新 XML 文件
        self._add_content_type_for_people(self.unpacked_path / "[Content_Types].xml")
        self._add_relationship_for_people(
            self.word_path / "_rels" / "document.xml.rels"
        )

        # 始终将 RSID 添加到 settings.xml，可选启用 trackRevisions
        self._update_settings(
            self.word_path / "settings.xml", track_revisions=track_revisions
        )

    def _update_people_xml(self, path):
        """如果不存在则创建 people.xml。"""
        if not path.exists():
            # 从模板复制
            shutil.copy(TEMPLATE_DIR / "people.xml", path)

    def _add_content_type_for_people(self, path):
        """如果尚不存在，将 people.xml 内容类型添加到 [Content_Types].xml。"""
        editor = self["[Content_Types].xml"]

        if self._has_override(editor, "/word/people.xml"):
            return

        # 添加 Override 元素
        root = editor.dom.documentElement
        override_xml = '<Override PartName="/word/people.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.people+xml"/>'
        editor.append_to(root, override_xml)

    def _add_relationship_for_people(self, path):
        """如果尚不存在，将 people.xml 关系添加到 document.xml.rels。"""
        editor = self["word/_rels/document.xml.rels"]

        if self._has_relationship(editor, "people.xml"):
            return

        root = editor.dom.documentElement
        root_tag = root.tagName  # type: ignore
        prefix = root_tag.split(":")[0] + ":" if ":" in root_tag else ""
        next_rid = editor.get_next_rid()

        # 创建关系条目
        rel_xml = f'<{prefix}Relationship Id="{next_rid}" Type="http://schemas.microsoft.com/office/2011/relationships/people" Target="people.xml"/>'
        editor.append_to(root, rel_xml)

    def _update_settings(self, path, track_revisions=False):
        """将 RSID 添加到 settings.xml 并可选启用修订追踪。

        参数:
            path: settings.xml 的路径
            track_revisions: 如果为 True，添加 trackRevisions 元素

        按照 OOXML 架构顺序放置元素：
        - trackRevisions: 靠前（在 defaultTabStop 之前）
        - rsids: 靠后（在 compat 之后）
        """
        editor = self["word/settings.xml"]
        root = editor.get_node(tag="w:settings")
        prefix = root.tagName.split(":")[0] if ":" in root.tagName else "w"

        # 如果请求则有条件地添加 trackRevisions
        if track_revisions:
            track_revisions_exists = any(
                elem.tagName == f"{prefix}:trackRevisions"
                for elem in editor.dom.getElementsByTagName(f"{prefix}:trackRevisions")
            )

            if not track_revisions_exists:
                track_rev_xml = f"<{prefix}:trackRevisions/>"
                # 尝试在 documentProtection、defaultTabStop 之前插入，或在开头插入
                inserted = False
                for tag in [f"{prefix}:documentProtection", f"{prefix}:defaultTabStop"]:
                    elements = editor.dom.getElementsByTagName(tag)
                    if elements:
                        editor.insert_before(elements[0], track_rev_xml)
                        inserted = True
                        break
                if not inserted:
                    # 作为 settings 的第一个子元素插入
                    if root.firstChild:
                        editor.insert_before(root.firstChild, track_rev_xml)
                    else:
                        editor.append_to(root, track_rev_xml)

        # 始终检查 rsids 部分是否存在
        rsids_elements = editor.dom.getElementsByTagName(f"{prefix}:rsids")

        if not rsids_elements:
            # 添加新的 rsids 部分
            rsids_xml = f'''<{prefix}:rsids>
  <{prefix}:rsidRoot {prefix}:val="{self.rsid}"/>
  <{prefix}:rsid {prefix}:val="{self.rsid}"/>
</{prefix}:rsids>'''

            # 尝试在 compat 之后、clrSchemeMapping 之前插入，或在结束标签之前插入
            inserted = False
            compat_elements = editor.dom.getElementsByTagName(f"{prefix}:compat")
            if compat_elements:
                editor.insert_after(compat_elements[0], rsids_xml)
                inserted = True

            if not inserted:
                clr_elements = editor.dom.getElementsByTagName(
                    f"{prefix}:clrSchemeMapping"
                )
                if clr_elements:
                    editor.insert_before(clr_elements[0], rsids_xml)
                    inserted = True

            if not inserted:
                editor.append_to(root, rsids_xml)
        else:
            # 检查此 rsid 是否已存在
            rsids_elem = rsids_elements[0]
            rsid_exists = any(
                elem.getAttribute(f"{prefix}:val") == self.rsid
                for elem in rsids_elem.getElementsByTagName(f"{prefix}:rsid")
            )

            if not rsid_exists:
                rsid_xml = f'<{prefix}:rsid {prefix}:val="{self.rsid}"/>'
                editor.append_to(rsids_elem, rsid_xml)

    # ==================== 私有方法: XML 文件创建 ====================

    def _add_to_comments_xml(
        self, comment_id, para_id, text, author, initials, timestamp
    ):
        """将单个批注添加到 comments.xml。"""
        if not self.comments_path.exists():
            shutil.copy(TEMPLATE_DIR / "comments.xml", self.comments_path)

        editor = self["word/comments.xml"]
        root = editor.get_node(tag="w:comments")

        escaped_text = (
            text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
        )
        # 注意：w:p 上的 w:rsidR、w:rsidRDefault、w:rsidP，w:r 上的 w:rsidR，
        # 以及 w:comment 上的 w:author、w:date、w:initials 由 DocxXMLEditor 自动添加
        comment_xml = f'''<w:comment w:id="{comment_id}">
  <w:p w14:paraId="{para_id}" w14:textId="77777777">
    <w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:annotationRef/></w:r>
    <w:r><w:rPr><w:color w:val="000000"/><w:sz w:val="20"/><w:szCs w:val="20"/></w:rPr><w:t>{escaped_text}</w:t></w:r>
  </w:p>
</w:comment>'''
        editor.append_to(root, comment_xml)

    def _add_to_comments_extended_xml(self, para_id, parent_para_id):
        """将单个批注添加到 commentsExtended.xml。"""
        if not self.comments_extended_path.exists():
            shutil.copy(
                TEMPLATE_DIR / "commentsExtended.xml", self.comments_extended_path
            )

        editor = self["word/commentsExtended.xml"]
        root = editor.get_node(tag="w15:commentsEx")

        if parent_para_id:
            xml = f'<w15:commentEx w15:paraId="{para_id}" w15:paraIdParent="{parent_para_id}" w15:done="0"/>'
        else:
            xml = f'<w15:commentEx w15:paraId="{para_id}" w15:done="0"/>'
        editor.append_to(root, xml)

    def _add_to_comments_ids_xml(self, para_id, durable_id):
        """将单个批注添加到 commentsIds.xml。"""
        if not self.comments_ids_path.exists():
            shutil.copy(TEMPLATE_DIR / "commentsIds.xml", self.comments_ids_path)

        editor = self["word/commentsIds.xml"]
        root = editor.get_node(tag="w16cid:commentsIds")

        xml = f'<w16cid:commentId w16cid:paraId="{para_id}" w16cid:durableId="{durable_id}"/>'
        editor.append_to(root, xml)

    def _add_to_comments_extensible_xml(self, durable_id):
        """将单个批注添加到 commentsExtensible.xml。"""
        if not self.comments_extensible_path.exists():
            shutil.copy(
                TEMPLATE_DIR / "commentsExtensible.xml", self.comments_extensible_path
            )

        editor = self["word/commentsExtensible.xml"]
        root = editor.get_node(tag="w16cex:commentsExtensible")

        xml = f'<w16cex:commentExtensible w16cex:durableId="{durable_id}"/>'
        editor.append_to(root, xml)

    # ==================== 私有方法: XML 片段 ====================

    def _comment_range_start_xml(self, comment_id):
        """生成批注范围开始的 XML。"""
        return f'<w:commentRangeStart w:id="{comment_id}"/>'

    def _comment_range_end_xml(self, comment_id):
        """生成带有引用 run 的批注范围结束的 XML。

        注意：w:rsidR 由 DocxXMLEditor 自动添加。
        """
        return f'''<w:commentRangeEnd w:id="{comment_id}"/>
<w:r>
  <w:rPr><w:rStyle w:val="CommentReference"/></w:rPr>
  <w:commentReference w:id="{comment_id}"/>
</w:r>'''

    def _comment_ref_run_xml(self, comment_id):
        """生成批注引用 run 的 XML。

        注意：w:rsidR 由 DocxXMLEditor 自动添加。
        """
        return f'''<w:r>
  <w:rPr><w:rStyle w:val="CommentReference"/></w:rPr>
  <w:commentReference w:id="{comment_id}"/>
</w:r>'''

    # ==================== 私有方法: 元数据更新 ====================

    def _has_relationship(self, editor, target):
        """检查是否存在具有给定目标的关系。"""
        for rel_elem in editor.dom.getElementsByTagName("Relationship"):
            if rel_elem.getAttribute("Target") == target:
                return True
        return False

    def _has_override(self, editor, part_name):
        """检查是否存在具有给定部件名称的覆盖。"""
        for override_elem in editor.dom.getElementsByTagName("Override"):
            if override_elem.getAttribute("PartName") == part_name:
                return True
        return False

    def _has_author(self, editor, author):
        """检查 people.xml 中是否已存在作者。"""
        for person_elem in editor.dom.getElementsByTagName("w15:person"):
            if person_elem.getAttribute("w15:author") == author:
                return True
        return False

    def _add_author_to_people(self, author):
        """将作者添加到 people.xml（在初始化期间调用）。"""
        people_path = self.word_path / "people.xml"

        # people.xml 应该在 _setup_tracking 之后已经存在
        if not people_path.exists():
            raise ValueError("people.xml 应该在 _setup_tracking 之后存在")

        editor = self["word/people.xml"]
        root = editor.get_node(tag="w15:people")

        # 检查作者是否已存在
        if self._has_author(editor, author):
            return

        # 使用正确的 XML 转义添加作者以防止注入
        escaped_author = html.escape(author, quote=True)
        person_xml = f'''<w15:person w15:author="{escaped_author}">
  <w15:presenceInfo w15:providerId="None" w15:userId="{escaped_author}"/>
</w15:person>'''
        editor.append_to(root, person_xml)

    def _ensure_comment_relationships(self):
        """确保 word/_rels/document.xml.rels 具有批注关系。"""
        editor = self["word/_rels/document.xml.rels"]

        if self._has_relationship(editor, "comments.xml"):
            return

        root = editor.dom.documentElement
        root_tag = root.tagName  # type: ignore
        prefix = root_tag.split(":")[0] + ":" if ":" in root_tag else ""
        next_rid_num = int(editor.get_next_rid()[3:])

        # 添加关系元素
        rels = [
            (
                next_rid_num,
                "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments",
                "comments.xml",
            ),
            (
                next_rid_num + 1,
                "http://schemas.microsoft.com/office/2011/relationships/commentsExtended",
                "commentsExtended.xml",
            ),
            (
                next_rid_num + 2,
                "http://schemas.microsoft.com/office/2016/09/relationships/commentsIds",
                "commentsIds.xml",
            ),
            (
                next_rid_num + 3,
                "http://schemas.microsoft.com/office/2018/08/relationships/commentsExtensible",
                "commentsExtensible.xml",
            ),
        ]

        for rel_id, rel_type, target in rels:
            rel_xml = f'<{prefix}Relationship Id="rId{rel_id}" Type="{rel_type}" Target="{target}"/>'
            editor.append_to(root, rel_xml)

    def _ensure_comment_content_types(self):
        """确保 [Content_Types].xml 具有批注内容类型。"""
        editor = self["[Content_Types].xml"]

        if self._has_override(editor, "/word/comments.xml"):
            return

        root = editor.dom.documentElement

        # 添加 Override 元素
        overrides = [
            (
                "/word/comments.xml",
                "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml",
            ),
            (
                "/word/commentsExtended.xml",
                "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml",
            ),
            (
                "/word/commentsIds.xml",
                "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsIds+xml",
            ),
            (
                "/word/commentsExtensible.xml",
                "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtensible+xml",
            ),
        ]

        for part_name, content_type in overrides:
            override_xml = (
                f'<Override PartName="{part_name}" ContentType="{content_type}"/>'
            )
            editor.append_to(root, override_xml)


path: scripts/init_delivery_brief.py
content: #!/usr/bin/env python3

from __future__ import annotations

import argparse
import json
from pathlib import Path


DEFAULT_BRIEF = {
    "document_title": "",
    "source_authority": "",
    "target_structure": "",
    "document_purpose": "",
    "audience": "",
    "output_mode": "new-docx",
    "delivery_stage": "draft",
    "completeness_scope": "body-only draft",
    "review_mode": "clean-copy",
    "format_authority": "default-profile",
    "file_naming_and_version": "",
    "cleanup_rule": "remove working notes from final body",
    "open_questions": [],
}


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Create a first-pass delivery brief for a complete Word deliverable."
    )
    parser.add_argument("--out", help="Optional output file")
    parser.add_argument(
        "--format",
        choices=("md", "json"),
        default="md",
        help="Output format",
    )
    parser.add_argument("--output-mode", default=DEFAULT_BRIEF["output_mode"])
    parser.add_argument("--delivery-stage", default=DEFAULT_BRIEF["delivery_stage"])
    parser.add_argument("--review-mode", default=DEFAULT_BRIEF["review_mode"])
    parser.add_argument("--format-authority", default=DEFAULT_BRIEF["format_authority"])
    return parser.parse_args()


def build_brief(args: argparse.Namespace) -> dict:
    brief = json.loads(json.dumps(DEFAULT_BRIEF))
    brief["output_mode"] = args.output_mode
    brief["delivery_stage"] = args.delivery_stage
    brief["review_mode"] = args.review_mode
    brief["format_authority"] = args.format_authority
    return brief


def render_markdown(brief: dict) -> str:
    lines = [
        "# Word Delivery Brief",
        "",
        f"- Document title: {brief['document_title'] or '[to confirm]'}",
        f"- Source authority: {brief['source_authority'] or '[to confirm]'}",
        f"- Target structure: {brief['target_structure'] or '[to confirm]'}",
        f"- Document purpose: {brief['document_purpose'] or '[to confirm]'}",
        f"- Audience: {brief['audience'] or '[to confirm]'}",
        f"- Output mode: {brief['output_mode']}",
        f"- Delivery stage: {brief['delivery_stage']}",
        f"- Completeness scope: {brief['completeness_scope']}",
        f"- Review mode: {brief['review_mode']}",
        f"- Format authority: {brief['format_authority']}",
        f"- File naming and version: {brief['file_naming_and_version'] or '[to confirm]'}",
        f"- Cleanup rule: {brief['cleanup_rule']}",
        "",
        "## Open questions",
        "",
        "- None yet",
        "",
    ]
    return "\n".join(lines)


def emit(text: str, out: str | None) -> None:
    if out:
        Path(out).write_text(text, encoding="utf-8")
    else:
        print(text, end="")


def main() -> int:
    args = parse_args()
    brief = build_brief(args)
    if args.format == "json":
        text = json.dumps(brief, ensure_ascii=False, indent=2) + "\n"
    else:
        text = render_markdown(brief)
    emit(text, args.out)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/utilities.py
content: #!/usr/bin/env python3
"""
用于编辑 OOXML 文档的工具。

本模块提供 XMLEditor，一个用于操作 XML 文件的工具，支持
基于行号的节点查找和 DOM 操作。在解析过程中，每个元素都会
自动标注其原始行号和列位置。

使用示例:
    editor = XMLEditor("document.xml")

    # 按行号或范围查找节点
    elem = editor.get_node(tag="w:r", line_number=519)
    elem = editor.get_node(tag="w:p", line_number=range(100, 200))

    # 按文本内容查找节点
    elem = editor.get_node(tag="w:p", contains="特定文本")

    # 按属性查找节点
    elem = editor.get_node(tag="w:r", attrs={"w:id": "target"})

    # 组合过滤器
    elem = editor.get_node(tag="w:p", line_number=range(1, 50), contains="文本")

    # 替换、插入或操作
    new_elem = editor.replace_node(elem, "<w:r><w:t>新文本</w:t></w:r>")
    editor.insert_after(new_elem, "<w:r><w:t>更多</w:t></w:r>")

    # 保存更改
    editor.save()
"""

import html
from pathlib import Path
from typing import Optional, Union

import defusedxml.minidom
import defusedxml.sax


class XMLEditor:
    """
    用于操作 OOXML XML 文件的编辑器，支持基于行号的节点查找。

    该类解析 XML 文件并跟踪每个元素的原始行号和列位置。
    这使得可以通过原始文件中的行号查找节点，
    这在处理 Read 工具输出时非常有用。

    属性:
        xml_path: 正在编辑的 XML 文件路径
        encoding: 检测到的 XML 文件编码（'ascii' 或 'utf-8'）
        dom: 解析后的 DOM 树，元素上带有 parse_position 属性
    """

    def __init__(self, xml_path):
        """
        使用 XML 文件路径初始化并进行行号跟踪解析。

        参数:
            xml_path: 要编辑的 XML 文件路径（str 或 Path）

        异常:
            ValueError: 如果 XML 文件不存在
        """
        self.xml_path = Path(xml_path)
        if not self.xml_path.exists():
            raise ValueError(f"XML 文件未找到: {xml_path}")

        with open(self.xml_path, "rb") as f:
            header = f.read(200).decode("utf-8", errors="ignore")
        self.encoding = "ascii" if 'encoding="ascii"' in header else "utf-8"

        parser = _create_line_tracking_parser()
        self.dom = defusedxml.minidom.parse(str(self.xml_path), parser)

    def get_node(
        self,
        tag: str,
        attrs: Optional[dict[str, str]] = None,
        line_number: Optional[Union[int, range]] = None,
        contains: Optional[str] = None,
    ):
        """
        通过标签和标识符获取 DOM 元素。

        通过原始文件中的行号或匹配属性值查找元素。必须恰好找到一个匹配。

        参数:
            tag: XML 标签名（例如 "w:del"、"w:ins"、"w:r"）
            attrs: 要匹配的属性名-值对字典（例如 {"w:id": "1"}）
            line_number: 原始 XML 文件中的行号（int）或行范围（range）（1-索引）
            contains: 必须出现在元素内任何文本节点中的文本字符串。
                      支持实体表示法（&#8220;）和 Unicode 字符（\u201c）。

        返回:
            defusedxml.minidom.Element: 匹配的 DOM 元素

        异常:
            ValueError: 如果未找到节点或找到多个匹配

        示例:
            elem = editor.get_node(tag="w:r", line_number=519)
            elem = editor.get_node(tag="w:r", line_number=range(100, 200))
            elem = editor.get_node(tag="w:del", attrs={"w:id": "1"})
            elem = editor.get_node(tag="w:p", attrs={"w14:paraId": "12345678"})
            elem = editor.get_node(tag="w:commentRangeStart", attrs={"w:id": "0"})
            elem = editor.get_node(tag="w:p", contains="特定文本")
            elem = editor.get_node(tag="w:t", contains="&#8220;协议")  # 实体表示法
            elem = editor.get_node(tag="w:t", contains="\\u201c协议")   # Unicode 字符
        """
        matches = []
        for elem in self.dom.getElementsByTagName(tag):
            # 检查 line_number 过滤器
            if line_number is not None:
                parse_pos = getattr(elem, "parse_position", (None,))
                elem_line = parse_pos[0]

                # 处理单个行号和范围
                if isinstance(line_number, range):
                    if elem_line not in line_number:
                        continue
                else:
                    if elem_line != line_number:
                        continue

            # 检查 attrs 过滤器
            if attrs is not None:
                if not all(
                    elem.getAttribute(attr_name) == attr_value
                    for attr_name, attr_value in attrs.items()
                ):
                    continue

            # 检查 contains 过滤器
            if contains is not None:
                elem_text = self._get_element_text(elem)
                # 规范化搜索字符串：将 HTML 实体转换为 Unicode 字符
                # 这允许搜索 "&#8220;Rowan" 和 ""Rowan"
                normalized_contains = html.unescape(contains)
                if normalized_contains not in elem_text:
                    continue

            # 如果所有适用的过滤器都通过，这是一个匹配
            matches.append(elem)

        if not matches:
            # 构建描述性错误消息
            filters = []
            if line_number is not None:
                line_str = (
                    f"第 {line_number.start}-{line_number.stop - 1} 行"
                    if isinstance(line_number, range)
                    else f"第 {line_number} 行"
                )
                filters.append(f"在 {line_str}")
            if attrs is not None:
                filters.append(f"属性为 {attrs}")
            if contains is not None:
                filters.append(f"包含 '{contains}'")

            filter_desc = " ".join(filters) if filters else ""
            base_msg = f"未找到节点: <{tag}> {filter_desc}".strip()

            # 根据使用的过滤器添加有用提示
            if contains:
                hint = "文本可能分布在多个元素中或使用了不同的措辞。"
            elif line_number:
                hint = "如果文档被修改，行号可能已更改。"
            elif attrs:
                hint = "请验证属性值是否正确。"
            else:
                hint = "尝试添加过滤器（attrs、line_number 或 contains）。"

            raise ValueError(f"{base_msg}。{hint}")
        if len(matches) > 1:
            raise ValueError(
                f"找到多个节点: <{tag}>。"
                f"添加更多过滤器（attrs、line_number 或 contains）以缩小搜索范围。"
            )
        return matches[0]

    def _get_element_text(self, elem):
        """
        递归提取元素中的所有文本内容。

        跳过只包含空白字符（空格、制表符、换行符）的文本节点，
        这些通常表示 XML 格式而非文档内容。

        参数:
            elem: 要从中提取文本的 defusedxml.minidom.Element

        返回:
            str: 元素内所有非空白文本节点的连接文本
        """
        text_parts = []
        for node in elem.childNodes:
            if node.nodeType == node.TEXT_NODE:
                # 跳过只有空白字符的文本节点（XML 格式）
                if node.data.strip():
                    text_parts.append(node.data)
            elif node.nodeType == node.ELEMENT_NODE:
                text_parts.append(self._get_element_text(node))
        return "".join(text_parts)

    def replace_node(self, elem, new_content):
        """
        用新的 XML 内容替换 DOM 元素。

        参数:
            elem: 要替换的 defusedxml.minidom.Element
            new_content: 包含要替换节点的 XML 字符串

        返回:
            List[defusedxml.minidom.Node]: 所有插入的节点

        示例:
            new_nodes = editor.replace_node(old_elem, "<w:r><w:t>文本</w:t></w:r>")
        """
        parent = elem.parentNode
        nodes = self._parse_fragment(new_content)
        for node in nodes:
            parent.insertBefore(node, elem)
        parent.removeChild(elem)
        return nodes

    def insert_after(self, elem, xml_content):
        """
        在 DOM 元素之后插入 XML 内容。

        参数:
            elem: 要在其后插入的 defusedxml.minidom.Element
            xml_content: 包含要插入的 XML 字符串

        返回:
            List[defusedxml.minidom.Node]: 所有插入的节点

        示例:
            new_nodes = editor.insert_after(elem, "<w:r><w:t>文本</w:t></w:r>")
        """
        parent = elem.parentNode
        next_sibling = elem.nextSibling
        nodes = self._parse_fragment(xml_content)
        for node in nodes:
            if next_sibling:
                parent.insertBefore(node, next_sibling)
            else:
                parent.appendChild(node)
        return nodes

    def insert_before(self, elem, xml_content):
        """
        在 DOM 元素之前插入 XML 内容。

        参数:
            elem: 要在其前插入的 defusedxml.minidom.Element
            xml_content: 包含要插入的 XML 字符串

        返回:
            List[defusedxml.minidom.Node]: 所有插入的节点

        示例:
            new_nodes = editor.insert_before(elem, "<w:r><w:t>文本</w:t></w:r>")
        """
        parent = elem.parentNode
        nodes = self._parse_fragment(xml_content)
        for node in nodes:
            parent.insertBefore(node, elem)
        return nodes

    def append_to(self, elem, xml_content):
        """
        将 XML 内容作为 DOM 元素的子元素追加。

        参数:
            elem: 要追加到的 defusedxml.minidom.Element
            xml_content: 包含要追加的 XML 字符串

        返回:
            List[defusedxml.minidom.Node]: 所有插入的节点

        示例:
            new_nodes = editor.append_to(elem, "<w:r><w:t>文本</w:t></w:r>")
        """
        nodes = self._parse_fragment(xml_content)
        for node in nodes:
            elem.appendChild(node)
        return nodes

    def get_next_rid(self):
        """获取关系文件的下一个可用 rId。"""
        max_id = 0
        for rel_elem in self.dom.getElementsByTagName("Relationship"):
            rel_id = rel_elem.getAttribute("Id")
            if rel_id.startswith("rId"):
                try:
                    max_id = max(max_id, int(rel_id[3:]))
                except ValueError:
                    pass
        return f"rId{max_id + 1}"

    def save(self):
        """
        将编辑后的 XML 保存回文件。

        序列化 DOM 树并写回原始文件路径，
        保留原始编码（ascii 或 utf-8）。
        """
        content = self.dom.toxml(encoding=self.encoding)
        self.xml_path.write_bytes(content)

    def _parse_fragment(self, xml_content):
        """
        解析 XML 片段并返回导入的节点列表。

        参数:
            xml_content: 包含 XML 片段的字符串

        返回:
            导入到此文档的 defusedxml.minidom.Node 对象列表

        异常:
            AssertionError: 如果片段不包含元素节点
        """
        # 从根文档元素提取命名空间声明
        root_elem = self.dom.documentElement
        namespaces = []
        if root_elem and root_elem.attributes:
            for i in range(root_elem.attributes.length):
                attr = root_elem.attributes.item(i)
                if attr.name.startswith("xmlns"):  # type: ignore
                    namespaces.append(f'{attr.name}="{attr.value}"')  # type: ignore

        ns_decl = " ".join(namespaces)
        wrapper = f"<root {ns_decl}>{xml_content}</root>"
        fragment_doc = defusedxml.minidom.parseString(wrapper)
        nodes = [
            self.dom.importNode(child, deep=True)
            for child in fragment_doc.documentElement.childNodes  # type: ignore
        ]
        elements = [n for n in nodes if n.nodeType == n.ELEMENT_NODE]
        assert elements, "片段必须包含至少一个元素"
        return nodes


def _create_line_tracking_parser():
    """
    创建一个跟踪每个元素行号和列号的 SAX 解析器。

    对 SAX 内容处理器进行猴子补丁，将底层 expat 解析器的
    当前行号和列位置存储到每个元素的 parse_position
    属性（行号, 列号）元组中。

    返回:
        defusedxml.sax.xmlreader.XMLReader: 配置好的 SAX 解析器
    """

    def set_content_handler(dom_handler):
        def startElementNS(name, tagName, attrs):
            orig_start_cb(name, tagName, attrs)
            cur_elem = dom_handler.elementStack[-1]
            cur_elem.parse_position = (
                parser._parser.CurrentLineNumber,  # type: ignore
                parser._parser.CurrentColumnNumber,  # type: ignore
            )

        orig_start_cb = dom_handler.startElementNS
        dom_handler.startElementNS = startElementNS
        orig_set_content_handler(dom_handler)

    parser = defusedxml.sax.make_parser()
    orig_set_content_handler = parser.setContentHandler
    parser.setContentHandler = set_content_handler  # type: ignore
    return parser


path: scripts/resolve_word_job.py
content: #!/usr/bin/env python3

from __future__ import annotations

import argparse
import json
from pathlib import Path


REQUIRED_DRAFT_FIELDS = [
    "document_title",
    "source_authority",
    "target_structure",
    "document_purpose",
    "audience",
    "output_mode",
]

REQUIRED_FINAL_FIELDS = [
    "delivery_stage",
    "completeness_scope",
    "review_mode",
    "format_authority",
    "file_naming_and_version",
]


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Resolve Word-job readiness and package composition from a delivery brief and format profile."
    )
    parser.add_argument("--delivery-brief", required=True, help="Path to delivery brief JSON")
    parser.add_argument("--format-profile", required=True, help="Path to format profile JSON")
    parser.add_argument(
        "--stage",
        choices=("draft", "final"),
        default="final",
        help="Target assembly stage",
    )
    parser.add_argument("--out", help="Optional output file")
    parser.add_argument(
        "--format",
        choices=("md", "json"),
        default="md",
        help="Output format",
    )
    return parser.parse_args()


def load_json(path: str) -> dict:
    return json.loads(Path(path).read_text(encoding="utf-8"))


def missing_fields(payload: dict, fields: list[str]) -> list[str]:
    missing: list[str] = []
    for field in fields:
        value = payload.get(field)
        if value is None:
            missing.append(field)
            continue
        if isinstance(value, str) and not value.strip():
            missing.append(field)
    return missing


def derive_components(scope: str, stage: str) -> list[str]:
    normalized = scope.lower()
    components = ["body"]
    if stage == "draft" and "body-only draft" in normalized:
        return components
    if "full-deliverable" in normalized or stage == "final":
        for item in ("cover", "table-of-contents"):
            if item not in components:
                components.append(item)
    keyword_map = {
        "append": "appendices",
        "glossary": "glossary",
        "figure list": "figure-list",
        "table list": "table-list",
        "attachment": "attachments-manifest",
    }
    for needle, component in keyword_map.items():
        if needle in normalized and component not in components:
            components.append(component)
    return components


def resolve_job(brief: dict, profile: dict, stage: str) -> dict:
    blockers: list[str] = []
    warnings: list[str] = []
    assumptions: list[str] = []

    draft_missing = missing_fields(brief, REQUIRED_DRAFT_FIELDS)
    if draft_missing:
        blockers.extend(f"missing draft field: {field}" for field in draft_missing)

    if stage == "final":
        final_missing = missing_fields(brief, REQUIRED_FINAL_FIELDS)
        if final_missing:
            blockers.extend(f"missing final field: {field}" for field in final_missing)
        if brief.get("delivery_stage", "").strip().lower() != "final":
            blockers.append("delivery_stage is not set to final")
        if profile.get("needs_confirmation") is True:
            blockers.append("format profile still requires confirmation")

    scope = brief.get("completeness_scope", "body-only draft")
    components = derive_components(scope, stage)

    review_mode = brief.get("review_mode", "clean-copy")
    if review_mode == "clean-copy" and brief.get("output_mode") == "edit-existing-docx":
        warnings.append("editing an existing docx as clean-copy may hide review history")

    if scope == "body-only draft" and stage == "final":
        warnings.append("final stage with body-only draft scope will generate a minimal package")

    if not brief.get("cleanup_rule"):
        assumptions.append("default cleanup rule should remove working notes from final body")

    return {
        "target_stage": stage,
        "ready": not blockers,
        "blockers": blockers,
        "warnings": warnings,
        "assumptions": assumptions,
        "package_components": components,
        "review_artifact_mode": review_mode,
        "cleanup_rule": brief.get("cleanup_rule", ""),
        "format_authority": brief.get("format_authority", profile.get("format_authority", "")),
        "file_naming_and_version": brief.get("file_naming_and_version", ""),
    }


def render_markdown(report: dict) -> str:
    lines = [
        "# Word Job Resolution",
        "",
        f"- Target stage: {report['target_stage']}",
        f"- Ready: {'yes' if report['ready'] else 'no'}",
        f"- Review artifact mode: {report['review_artifact_mode']}",
        f"- Format authority: {report['format_authority'] or '[unknown]'}",
        f"- File naming and version: {report['file_naming_and_version'] or '[to confirm]'}",
        f"- Cleanup rule: {report['cleanup_rule'] or '[to confirm]'}",
        "",
        "## Package components",
        "",
    ]
    for component in report["package_components"]:
        lines.append(f"- {component}")
    lines.extend(["", "## Blockers", ""])
    if report["blockers"]:
        for blocker in report["blockers"]:
            lines.append(f"- {blocker}")
    else:
        lines.append("- None")
    lines.extend(["", "## Warnings", ""])
    if report["warnings"]:
        for warning in report["warnings"]:
            lines.append(f"- {warning}")
    else:
        lines.append("- None")
    lines.extend(["", "## Assumptions", ""])
    if report["assumptions"]:
        for assumption in report["assumptions"]:
            lines.append(f"- {assumption}")
    else:
        lines.append("- None")
    lines.append("")
    return "\n".join(lines)


def emit(text: str, out: str | None) -> None:
    if out:
        Path(out).write_text(text, encoding="utf-8")
    else:
        print(text, end="")


def main() -> int:
    args = parse_args()
    brief = load_json(args.delivery_brief)
    profile = load_json(args.format_profile)
    report = resolve_job(brief, profile, args.stage)
    if args.format == "json":
        text = json.dumps(report, ensure_ascii=False, indent=2) + "\n"
    else:
        text = render_markdown(report)
    emit(text, args.out)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/templates/comments.xml
content: <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:comments xmlns:wpc="http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas" xmlns:cx="http://schemas.microsoft.com/office/drawing/2014/chartex" xmlns:cx1="http://schemas.microsoft.com/office/drawing/2015/9/8/chartex" xmlns:cx2="http://schemas.microsoft.com/office/drawing/2015/10/21/chartex" xmlns:cx3="http://schemas.microsoft.com/office/drawing/2016/5/9/chartex" xmlns:cx4="http://schemas.microsoft.com/office/drawing/2016/5/10/chartex" xmlns:cx5="http://schemas.microsoft.com/office/drawing/2016/5/11/chartex" xmlns:cx6="http://schemas.microsoft.com/office/drawing/2016/5/12/chartex" xmlns:cx7="http://schemas.microsoft.com/office/drawing/2016/5/13/chartex" xmlns:cx8="http://schemas.microsoft.com/office/drawing/2016/5/14/chartex" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:aink="http://schemas.microsoft.com/office/drawing/2016/ink" xmlns:am3d="http://schemas.microsoft.com/office/drawing/2017/model3d" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:oel="http://schemas.microsoft.com/office/2019/extlst" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:wp14="http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:w10="urn:schemas-microsoft-com:office:word" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml" xmlns:w16cex="http://schemas.microsoft.com/office/word/2018/wordml/cex" xmlns:w16cid="http://schemas.microsoft.com/office/word/2016/wordml/cid" xmlns:w16="http://schemas.microsoft.com/office/word/2018/wordml" xmlns:w16du="http://schemas.microsoft.com/office/word/2023/wordml/word16du" xmlns:w16sdtdh="http://schemas.microsoft.com/office/word/2020/wordml/sdtdatahash" xmlns:w16sdtfl="http://schemas.microsoft.com/office/word/2024/wordml/sdtformatlock" xmlns:w16se="http://schemas.microsoft.com/office/word/2015/wordml/symex" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup" xmlns:wpi="http://schemas.microsoft.com/office/word/2010/wordprocessingInk" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" mc:Ignorable="w14 w15 w16se w16cid w16 w16cex w16sdtdh w16sdtfl w16du wp14">
</w:comments>

path: scripts/templates/commentsExtensible.xml
content: <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w16cex:commentsExtensible xmlns:wpc="http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas" xmlns:cx="http://schemas.microsoft.com/office/drawing/2014/chartex" xmlns:cx1="http://schemas.microsoft.com/office/drawing/2015/9/8/chartex" xmlns:cx2="http://schemas.microsoft.com/office/drawing/2015/10/21/chartex" xmlns:cx3="http://schemas.microsoft.com/office/drawing/2016/5/9/chartex" xmlns:cx4="http://schemas.microsoft.com/office/drawing/2016/5/10/chartex" xmlns:cx5="http://schemas.microsoft.com/office/drawing/2016/5/11/chartex" xmlns:cx6="http://schemas.microsoft.com/office/drawing/2016/5/12/chartex" xmlns:cx7="http://schemas.microsoft.com/office/drawing/2016/5/13/chartex" xmlns:cx8="http://schemas.microsoft.com/office/drawing/2016/5/14/chartex" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:aink="http://schemas.microsoft.com/office/drawing/2016/ink" xmlns:am3d="http://schemas.microsoft.com/office/drawing/2017/model3d" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:oel="http://schemas.microsoft.com/office/2019/extlst" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:wp14="http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:w10="urn:schemas-microsoft-com:office:word" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml" xmlns:w16cex="http://schemas.microsoft.com/office/word/2018/wordml/cex" xmlns:w16cid="http://schemas.microsoft.com/office/word/2016/wordml/cid" xmlns:w16="http://schemas.microsoft.com/office/word/2018/wordml" xmlns:w16du="http://schemas.microsoft.com/office/word/2023/wordml/word16du" xmlns:w16sdtdh="http://schemas.microsoft.com/office/word/2020/wordml/sdtdatahash" xmlns:w16sdtfl="http://schemas.microsoft.com/office/word/2024/wordml/sdtformatlock" xmlns:w16se="http://schemas.microsoft.com/office/word/2015/wordml/symex" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup" xmlns:wpi="http://schemas.microsoft.com/office/word/2010/wordprocessingInk" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:cr="http://schemas.microsoft.com/office/comments/2020/reactions" mc:Ignorable="w14 w15 w16se w16cid w16 w16cex w16sdtdh w16sdtfl cr w16du wp14">
</w16cex:commentsExtensible>

path: scripts/templates/commentsExtended.xml
content: <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w15:commentsEx xmlns:wpc="http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas" xmlns:cx="http://schemas.microsoft.com/office/drawing/2014/chartex" xmlns:cx1="http://schemas.microsoft.com/office/drawing/2015/9/8/chartex" xmlns:cx2="http://schemas.microsoft.com/office/drawing/2015/10/21/chartex" xmlns:cx3="http://schemas.microsoft.com/office/drawing/2016/5/9/chartex" xmlns:cx4="http://schemas.microsoft.com/office/drawing/2016/5/10/chartex" xmlns:cx5="http://schemas.microsoft.com/office/drawing/2016/5/11/chartex" xmlns:cx6="http://schemas.microsoft.com/office/drawing/2016/5/12/chartex" xmlns:cx7="http://schemas.microsoft.com/office/drawing/2016/5/13/chartex" xmlns:cx8="http://schemas.microsoft.com/office/drawing/2016/5/14/chartex" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:aink="http://schemas.microsoft.com/office/drawing/2016/ink" xmlns:am3d="http://schemas.microsoft.com/office/drawing/2017/model3d" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:oel="http://schemas.microsoft.com/office/2019/extlst" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:wp14="http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:w10="urn:schemas-microsoft-com:office:word" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml" xmlns:w16cex="http://schemas.microsoft.com/office/word/2018/wordml/cex" xmlns:w16cid="http://schemas.microsoft.com/office/word/2016/wordml/cid" xmlns:w16="http://schemas.microsoft.com/office/word/2018/wordml" xmlns:w16du="http://schemas.microsoft.com/office/word/2023/wordml/word16du" xmlns:w16sdtdh="http://schemas.microsoft.com/office/word/2020/wordml/sdtdatahash" xmlns:w16sdtfl="http://schemas.microsoft.com/office/word/2024/wordml/sdtformatlock" xmlns:w16se="http://schemas.microsoft.com/office/word/2015/wordml/symex" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup" xmlns:wpi="http://schemas.microsoft.com/office/word/2010/wordprocessingInk" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" mc:Ignorable="w14 w15 w16se w16cid w16 w16cex w16sdtdh w16sdtfl w16du wp14">
</w15:commentsEx>

path: scripts/templates/commentsIds.xml
content: <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w16cid:commentsIds xmlns:wpc="http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas" xmlns:cx="http://schemas.microsoft.com/office/drawing/2014/chartex" xmlns:cx1="http://schemas.microsoft.com/office/drawing/2015/9/8/chartex" xmlns:cx2="http://schemas.microsoft.com/office/drawing/2015/10/21/chartex" xmlns:cx3="http://schemas.microsoft.com/office/drawing/2016/5/9/chartex" xmlns:cx4="http://schemas.microsoft.com/office/drawing/2016/5/10/chartex" xmlns:cx5="http://schemas.microsoft.com/office/drawing/2016/5/11/chartex" xmlns:cx6="http://schemas.microsoft.com/office/drawing/2016/5/12/chartex" xmlns:cx7="http://schemas.microsoft.com/office/drawing/2016/5/13/chartex" xmlns:cx8="http://schemas.microsoft.com/office/drawing/2016/5/14/chartex" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:aink="http://schemas.microsoft.com/office/drawing/2016/ink" xmlns:am3d="http://schemas.microsoft.com/office/drawing/2017/model3d" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:oel="http://schemas.microsoft.com/office/2019/extlst" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:wp14="http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:w10="urn:schemas-microsoft-com:office:word" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml" xmlns:w16cex="http://schemas.microsoft.com/office/word/2018/wordml/cex" xmlns:w16cid="http://schemas.microsoft.com/office/word/2016/wordml/cid" xmlns:w16="http://schemas.microsoft.com/office/word/2018/wordml" xmlns:w16du="http://schemas.microsoft.com/office/word/2023/wordml/word16du" xmlns:w16sdtdh="http://schemas.microsoft.com/office/word/2020/wordml/sdtdatahash" xmlns:w16sdtfl="http://schemas.microsoft.com/office/word/2024/wordml/sdtformatlock" xmlns:w16se="http://schemas.microsoft.com/office/word/2015/wordml/symex" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup" xmlns:wpi="http://schemas.microsoft.com/office/word/2010/wordprocessingInk" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" mc:Ignorable="w14 w15 w16se w16cid w16 w16cex w16sdtdh w16sdtfl w16du wp14">
</w16cid:commentsIds>

path: scripts/templates/people.xml
content: <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w15:people xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml">
</w15:people>

path: ooxml/scripts/pack.py
content: #!/usr/bin/env python3
"""
将目录打包为 .docx、.pptx 或 .xlsx 文件的工具，同时移除 XML 格式化。

使用示例：
    python pack.py <input_directory> <office_file> [--force]
"""

import argparse
import shutil
import subprocess
import sys
import tempfile
import defusedxml.minidom
import zipfile
from pathlib import Path


def main():
    parser = argparse.ArgumentParser(description="将目录打包为 Office 文件")
    parser.add_argument("input_directory", help="解压后的 Office 文档目录")
    parser.add_argument("output_file", help="输出的 Office 文件（.docx/.pptx/.xlsx）")
    parser.add_argument("--force", action="store_true", help="跳过验证")
    args = parser.parse_args()

    try:
        success = pack_document(
            args.input_directory, args.output_file, validate=not args.force
        )

        # 如果跳过验证则显示警告
        if args.force:
            print("警告：已跳过验证，文件可能损坏", file=sys.stderr)
        # 如果验证失败则以错误退出
        elif not success:
            print("内容将产生损坏的文件。", file=sys.stderr)
            print("请在重新打包前验证 XML。", file=sys.stderr)
            print("使用 --force 跳过验证并强制打包。", file=sys.stderr)
            sys.exit(1)

    except ValueError as e:
        sys.exit(f"错误：{e}")


def pack_document(input_dir, output_file, validate=False):
    """将目录打包为 Office 文件（.docx/.pptx/.xlsx）。

    参数：
        input_dir: 解压后的 Office 文档目录路径
        output_file: 输出的 Office 文件路径
        validate: 如果为 True，使用 soffice 进行验证（默认：False）

    返回：
        bool: 成功返回 True，验证失败返回 False
    """
    input_dir = Path(input_dir)
    output_file = Path(output_file)

    if not input_dir.is_dir():
        raise ValueError(f"{input_dir} 不是目录")
    if output_file.suffix.lower() not in {".docx", ".pptx", ".xlsx"}:
        raise ValueError(f"{output_file} 必须是 .docx、.pptx 或 .xlsx 文件")

    # 在临时目录中工作，避免修改原始文件
    with tempfile.TemporaryDirectory() as temp_dir:
        temp_content_dir = Path(temp_dir) / "content"
        shutil.copytree(input_dir, temp_content_dir)

        # 处理 XML 文件以移除格式化空白
        for pattern in ["*.xml", "*.rels"]:
            for xml_file in temp_content_dir.rglob(pattern):
                condense_xml(xml_file)

        # 创建最终的 Office 文件作为 zip 压缩包
        output_file.parent.mkdir(parents=True, exist_ok=True)
        with zipfile.ZipFile(output_file, "w", zipfile.ZIP_DEFLATED) as zf:
            for f in temp_content_dir.rglob("*"):
                if f.is_file():
                    zf.write(f, f.relative_to(temp_content_dir))

        # 如果请求则进行验证
        if validate:
            if not validate_document(output_file):
                output_file.unlink()  # 删除损坏的文件
                return False

    return True


def validate_document(doc_path):
    """使用 soffice 转换为 HTML 来验证文档。"""
    # 根据文件扩展名确定正确的过滤器
    match doc_path.suffix.lower():
        case ".docx":
            filter_name = "html:HTML"
        case ".pptx":
            filter_name = "html:impress_html_Export"
        case ".xlsx":
            filter_name = "html:HTML (StarCalc)"

    with tempfile.TemporaryDirectory() as temp_dir:
        try:
            result = subprocess.run(
                [
                    "soffice",
                    "--headless",
                    "--convert-to",
                    filter_name,
                    "--outdir",
                    temp_dir,
                    str(doc_path),
                ],
                capture_output=True,
                timeout=10,
                text=True,
            )
            if not (Path(temp_dir) / f"{doc_path.stem}.html").exists():
                error_msg = result.stderr.strip() or "文档验证失败"
                print(f"验证错误：{error_msg}", file=sys.stderr)
                return False
            return True
        except FileNotFoundError:
            print("警告：未找到 soffice。跳过验证。", file=sys.stderr)
            return True
        except subprocess.TimeoutExpired:
            print("验证错误：转换超时", file=sys.stderr)
            return False
        except Exception as e:
            print(f"验证错误：{e}", file=sys.stderr)
            return False


def condense_xml(xml_file):
    """移除不必要的空白和注释。"""
    with open(xml_file, "r", encoding="utf-8") as f:
        dom = defusedxml.minidom.parse(f)

    # 处理每个元素以移除空白和注释
    for element in dom.getElementsByTagName("*"):
        # 跳过 w:t 元素及其处理
        if element.tagName.endswith(":t"):
            continue

        # 移除仅包含空白的文本节点和注释节点
        for child in list(element.childNodes):
            if (
                child.nodeType == child.TEXT_NODE
                and child.nodeValue
                and child.nodeValue.strip() == ""
            ) or child.nodeType == child.COMMENT_NODE:
                element.removeChild(child)

    # 写回压缩后的 XML
    with open(xml_file, "wb") as f:
        f.write(dom.toxml(encoding="UTF-8"))


if __name__ == "__main__":
    main()


path: ooxml/scripts/validate.py
content: #!/usr/bin/env python3
"""
用于验证 Office 文档 XML 文件是否符合 XSD 架构和修订追踪规则的命令行工具。

用法：
    python validate.py <dir> --original <original_file>
"""

import argparse
import sys
from pathlib import Path

from validation import DOCXSchemaValidator, PPTXSchemaValidator, RedliningValidator


def main():
    parser = argparse.ArgumentParser(description="验证 Office 文档 XML 文件")
    parser.add_argument(
        "unpacked_dir",
        help="解压后的 Office 文档目录路径",
    )
    parser.add_argument(
        "--original",
        required=True,
        help="原始文件路径（.docx/.pptx/.xlsx）",
    )
    parser.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="启用详细输出",
    )
    args = parser.parse_args()

    # 验证路径
    unpacked_dir = Path(args.unpacked_dir)
    original_file = Path(args.original)
    file_extension = original_file.suffix.lower()
    assert unpacked_dir.is_dir(), f"错误：{unpacked_dir} 不是目录"
    assert original_file.is_file(), f"错误：{original_file} 不是文件"
    assert file_extension in [".docx", ".pptx", ".xlsx"], (
        f"错误：{original_file} 必须是 .docx、.pptx 或 .xlsx 文件"
    )

    # 运行验证
    match file_extension:
        case ".docx":
            validators = [DOCXSchemaValidator, RedliningValidator]
        case ".pptx":
            validators = [PPTXSchemaValidator]
        case _:
            print(f"错误：不支持文件类型 {file_extension} 的验证")
            sys.exit(1)

    # 运行验证器
    success = True
    for V in validators:
        validator = V(unpacked_dir, original_file, verbose=args.verbose)
        if not validator.validate():
            success = False

    if success:
        print("所有验证通过！")

    sys.exit(0 if success else 1)


if __name__ == "__main__":
    main()


path: ooxml/scripts/unpack.py
content: #!/usr/bin/env python3
"""解压并格式化 Office 文件（.docx、.pptx、.xlsx）的 XML 内容"""

import random
import sys
import defusedxml.minidom
import zipfile
from pathlib import Path

# 获取命令行参数
assert len(sys.argv) == 3, "用法：python unpack.py <office_file> <output_dir>"
input_file, output_dir = sys.argv[1], sys.argv[2]

# 解压并格式化
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
zipfile.ZipFile(input_file).extractall(output_path)

# 格式化打印所有 XML 文件
xml_files = list(output_path.rglob("*.xml")) + list(output_path.rglob("*.rels"))
for xml_file in xml_files:
    content = xml_file.read_text(encoding="utf-8")
    dom = defusedxml.minidom.parseString(content)
    xml_file.write_bytes(dom.toprettyxml(indent="  ", encoding="ascii"))

# 对于 .docx 文件，建议一个用于修订追踪的 RSID
if input_file.endswith(".docx"):
    suggested_rsid = "".join(random.choices("0123456789ABCDEF", k=8))
    print(f"建议用于编辑会话的 RSID：{suggested_rsid}")


path: ooxml/scripts/validation/docx.py
content: """
用于根据 XSD 架构验证 Word 文档 XML 文件的验证器。
"""

import re
import tempfile
import zipfile

import lxml.etree

from .base import BaseSchemaValidator


class DOCXSchemaValidator(BaseSchemaValidator):
    """用于根据 XSD 架构验证 Word 文档 XML 文件的验证器。"""

    # Word 特定命名空间
    WORD_2006_NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"

    # Word 特定元素到关系类型的映射
    # 从空映射开始 - 在发现具体情况时添加
    ELEMENT_RELATIONSHIP_TYPES = {}

    def validate(self):
        """运行所有验证检查，如果全部通过则返回 True。"""
        # 测试 0：XML 格式正确性
        if not self.validate_xml():
            return False

        # 测试 1：命名空间声明
        all_valid = True
        if not self.validate_namespaces():
            all_valid = False

        # 测试 2：唯一 ID
        if not self.validate_unique_ids():
            all_valid = False

        # 测试 3：关系和文件引用验证
        if not self.validate_file_references():
            all_valid = False

        # 测试 4：内容类型声明
        if not self.validate_content_types():
            all_valid = False

        # 测试 5：XSD 架构验证
        if not self.validate_against_xsd():
            all_valid = False

        # 测试 6：空白保留
        if not self.validate_whitespace_preservation():
            all_valid = False

        # 测试 7：删除验证
        if not self.validate_deletions():
            all_valid = False

        # 测试 8：插入验证
        if not self.validate_insertions():
            all_valid = False

        # 测试 9：关系 ID 引用验证
        if not self.validate_all_relationship_ids():
            all_valid = False

        # 计算和比较段落数
        self.compare_paragraph_counts()

        return all_valid

    def validate_whitespace_preservation(self):
        """
        验证包含空白的 w:t 元素是否有 xml:space='preserve' 属性。
        """
        errors = []

        for xml_file in self.xml_files:
            # 只检查 document.xml 文件
            if xml_file.name != "document.xml":
                continue

            try:
                root = lxml.etree.parse(str(xml_file)).getroot()

                # 查找所有 w:t 元素
                for elem in root.iter(f"{{{self.WORD_2006_NAMESPACE}}}t"):
                    if elem.text:
                        text = elem.text
                        # 检查文本是否以空白开头或结尾
                        if re.match(r"^\s.*", text) or re.match(r".*\s$", text):
                            # 检查是否存在 xml:space="preserve" 属性
                            xml_space_attr = f"{{{self.XML_NAMESPACE}}}space"
                            if (
                                xml_space_attr not in elem.attrib
                                or elem.attrib[xml_space_attr] != "preserve"
                            ):
                                # 显示文本预览
                                text_preview = (
                                    repr(text)[:50] + "..."
                                    if len(repr(text)) > 50
                                    else repr(text)
                                )
                                errors.append(
                                    f"  {xml_file.relative_to(self.unpacked_dir)}: "
                                    f"第 {elem.sourceline} 行: 包含空白的 w:t 元素缺少 xml:space='preserve': {text_preview}"
                                )

            except (lxml.etree.XMLSyntaxError, Exception) as e:
                errors.append(
                    f"  {xml_file.relative_to(self.unpacked_dir)}: 错误: {e}"
                )

        if errors:
            print(f"失败 - 发现 {len(errors)} 个空白保留违规:")
            for error in errors:
                print(error)
            return False
        else:
            if self.verbose:
                print("通过 - 所有空白都已正确保留")
            return True

    def validate_deletions(self):
        """
        验证 w:t 元素不在 w:del 元素内。
        由于某种原因，XSD 验证无法捕获此问题，因此我们手动检查。
        """
        errors = []

        for xml_file in self.xml_files:
            # 只检查 document.xml 文件
            if xml_file.name != "document.xml":
                continue

            try:
                root = lxml.etree.parse(str(xml_file)).getroot()

                # 查找所有作为 w:del 元素后代的 w:t 元素
                namespaces = {"w": self.WORD_2006_NAMESPACE}
                xpath_expression = ".//w:del//w:t"
                problematic_t_elements = root.xpath(
                    xpath_expression, namespaces=namespaces
                )
                for t_elem in problematic_t_elements:
                    if t_elem.text:
                        # 显示文本预览
                        text_preview = (
                            repr(t_elem.text)[:50] + "..."
                            if len(repr(t_elem.text)) > 50
                            else repr(t_elem.text)
                        )
                        errors.append(
                            f"  {xml_file.relative_to(self.unpacked_dir)}: "
                            f"第 {t_elem.sourceline} 行: 在 <w:del> 内发现 <w:t>: {text_preview}"
                        )

            except (lxml.etree.XMLSyntaxError, Exception) as e:
                errors.append(
                    f"  {xml_file.relative_to(self.unpacked_dir)}: 错误: {e}"
                )

        if errors:
            print(f"失败 - 发现 {len(errors)} 个删除验证违规:")
            for error in errors:
                print(error)
            return False
        else:
            if self.verbose:
                print("通过 - 在 w:del 元素内未发现 w:t 元素")
            return True

    def count_paragraphs_in_unpacked(self):
        """计算解压文档中的段落数。"""
        count = 0

        for xml_file in self.xml_files:
            # 只检查 document.xml 文件
            if xml_file.name != "document.xml":
                continue

            try:
                root = lxml.etree.parse(str(xml_file)).getroot()
                # 计算所有 w:p 元素
                paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p")
                count = len(paragraphs)
            except Exception as e:
                print(f"计算解压文档中段落数时出错: {e}")

        return count

    def count_paragraphs_in_original(self):
        """计算原始 docx 文件中的段落数。"""
        count = 0

        try:
            # 创建临时目录以解压原始文件
            with tempfile.TemporaryDirectory() as temp_dir:
                # 解压原始 docx
                with zipfile.ZipFile(self.original_file, "r") as zip_ref:
                    zip_ref.extractall(temp_dir)

                # 解析 document.xml
                doc_xml_path = temp_dir + "/word/document.xml"
                root = lxml.etree.parse(doc_xml_path).getroot()

                # 计算所有 w:p 元素
                paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p")
                count = len(paragraphs)

        except Exception as e:
            print(f"计算原始文档中段落数时出错: {e}")

        return count

    def validate_insertions(self):
        """
        验证 w:delText 元素不在 w:ins 元素内。
        w:delText 只有在嵌套于 w:del 内时才允许出现在 w:ins 中。
        """
        errors = []

        for xml_file in self.xml_files:
            if xml_file.name != "document.xml":
                continue

            try:
                root = lxml.etree.parse(str(xml_file)).getroot()
                namespaces = {"w": self.WORD_2006_NAMESPACE}

                # 查找 w:ins 中不在 w:del 内的 w:delText
                invalid_elements = root.xpath(
                    ".//w:ins//w:delText[not(ancestor::w:del)]",
                    namespaces=namespaces
                )

                for elem in invalid_elements:
                    text_preview = (
                        repr(elem.text or "")[:50] + "..."
                        if len(repr(elem.text or "")) > 50
                        else repr(elem.text or "")
                    )
                    errors.append(
                        f"  {xml_file.relative_to(self.unpacked_dir)}: "
                        f"第 {elem.sourceline} 行: <w:ins> 内的 <w:delText>: {text_preview}"
                    )

            except (lxml.etree.XMLSyntaxError, Exception) as e:
                errors.append(
                    f"  {xml_file.relative_to(self.unpacked_dir)}: 错误: {e}"
                )

        if errors:
            print(f"失败 - 发现 {len(errors)} 个插入验证违规:")
            for error in errors:
                print(error)
            return False
        else:
            if self.verbose:
                print("通过 - 在 w:ins 元素内未发现 w:delText 元素")
            return True

    def compare_paragraph_counts(self):
        """比较原始文档和新文档之间的段落数。"""
        original_count = self.count_paragraphs_in_original()
        new_count = self.count_paragraphs_in_unpacked()

        diff = new_count - original_count
        diff_str = f"+{diff}" if diff > 0 else str(diff)
        print(f"\n段落数: {original_count} → {new_count} ({diff_str})")


if __name__ == "__main__":
    raise RuntimeError("此模块不应直接运行。")


path: ooxml/scripts/validation/__init__.py
content: """
用于 Word 文档处理的验证模块。
"""

from .base import BaseSchemaValidator
from .docx import DOCXSchemaValidator
from .pptx import PPTXSchemaValidator
from .redlining import RedliningValidator

__all__ = [
    "BaseSchemaValidator",
    "DOCXSchemaValidator",
    "PPTXSchemaValidator",
    "RedliningValidator",
]


path: ooxml/scripts/validation/redlining.py
content: """
用于验证 Word 文档中修订追踪的验证器。
"""

import subprocess
import tempfile
import zipfile
from pathlib import Path


class RedliningValidator:
    """用于验证 Word 文档中修订追踪的验证器。"""

    def __init__(self, unpacked_dir, original_docx, verbose=False):
        self.unpacked_dir = Path(unpacked_dir)
        self.original_docx = Path(original_docx)
        self.verbose = verbose
        self.namespaces = {
            "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
        }

    def validate(self):
        """主验证方法，有效返回 True，否则返回 False。"""
        # 验证解压目录是否存在且结构正确
        modified_file = self.unpacked_dir / "word" / "document.xml"
        if not modified_file.exists():
            print(f"失败 - 未找到修改后的 document.xml: {modified_file}")
            return False

        # 首先，检查是否有需要验证的 Claude 修订
        try:
            import xml.etree.ElementTree as ET

            tree = ET.parse(modified_file)
            root = tree.getroot()

            # 检查 Claude 作者的 w:del 或 w:ins 标签
            del_elements = root.findall(".//w:del", self.namespaces)
            ins_elements = root.findall(".//w:ins", self.namespaces)

            # 仅过滤包含 Claude 的修改
            claude_del_elements = [
                elem
                for elem in del_elements
                if elem.get(f"{{{self.namespaces['w']}}}author") == "Claude"
            ]
            claude_ins_elements = [
                elem
                for elem in ins_elements
                if elem.get(f"{{{self.namespaces['w']}}}author") == "Claude"
            ]

            # 仅当使用了 Claude 的修订追踪时才需要进行修订验证
            if not claude_del_elements and not claude_ins_elements:
                if self.verbose:
                    print("通过 - 未发现 Claude 的修订追踪。")
                return True

        except Exception:
            # 如果无法解析 XML，继续完整验证
            pass

        # 创建临时目录以解压原始 docx
        with tempfile.TemporaryDirectory() as temp_dir:
            temp_path = Path(temp_dir)

            # 解压原始 docx
            try:
                with zipfile.ZipFile(self.original_docx, "r") as zip_ref:
                    zip_ref.extractall(temp_path)
            except Exception as e:
                print(f"失败 - 解压原始 docx 时出错: {e}")
                return False

            original_file = temp_path / "word" / "document.xml"
            if not original_file.exists():
                print(
                    f"失败 - 在 {self.original_docx} 中未找到原始 document.xml"
                )
                return False

            # 使用 xml.etree.ElementTree 解析两个 XML 文件进行修订验证
            try:
                import xml.etree.ElementTree as ET

                modified_tree = ET.parse(modified_file)
                modified_root = modified_tree.getroot()
                original_tree = ET.parse(original_file)
                original_root = original_tree.getroot()
            except ET.ParseError as e:
                print(f"失败 - 解析 XML 文件时出错: {e}")
                return False

            # 从两个文档中移除 Claude 的修订追踪
            self._remove_claude_tracked_changes(original_root)
            self._remove_claude_tracked_changes(modified_root)

            # 提取并比较文本内容
            modified_text = self._extract_text_content(modified_root)
            original_text = self._extract_text_content(original_root)

            if modified_text != original_text:
                # 显示每个段落的详细字符级差异
                error_message = self._generate_detailed_diff(
                    original_text, modified_text
                )
                print(error_message)
                return False

            if self.verbose:
                print("通过 - Claude 的所有更改都已正确追踪")
            return True

    def _generate_detailed_diff(self, original_text, modified_text):
        """使用 git word diff 生成详细的单词级差异。"""
        error_parts = [
            "失败 - 移除 Claude 的修订追踪后文档文本不匹配",
            "",
            "可能的原因:",
            "  1. 修改了其他作者 <w:ins> 或 <w:del> 标签内的文本",
            "  2. 未使用正确的修订追踪进行编辑",
            "  3. 删除他人的插入时未将 <w:del> 嵌套在 <w:ins> 内",
            "",
            "对于预先标记的文档，使用正确的模式:",
            "  - 要拒绝他人的插入: 将 <w:del> 嵌套在其 <w:ins> 内",
            "  - 要恢复他人的删除: 在其 <w:del> 之后添加新的 <w:ins>",
            "",
        ]

        # 显示 git word diff
        git_diff = self._get_git_word_diff(original_text, modified_text)
        if git_diff:
            error_parts.extend(["差异:", "============", git_diff])
        else:
            error_parts.append("无法生成单词差异（git 不可用）")

        return "\n".join(error_parts)

    def _get_git_word_diff(self, original_text, modified_text):
        """使用 git 生成字符级精确的单词差异。"""
        try:
            with tempfile.TemporaryDirectory() as temp_dir:
                temp_path = Path(temp_dir)

                # 创建两个文件
                original_file = temp_path / "original.txt"
                modified_file = temp_path / "modified.txt"

                original_file.write_text(original_text, encoding="utf-8")
                modified_file.write_text(modified_text, encoding="utf-8")

                # 首先尝试字符级差异以获得精确的差异
                result = subprocess.run(
                    [
                        "git",
                        "diff",
                        "--word-diff=plain",
                        "--word-diff-regex=.",  # 逐字符差异
                        "-U0",  # 零行上下文 - 只显示更改的行
                        "--no-index",
                        str(original_file),
                        str(modified_file),
                    ],
                    capture_output=True,
                    text=True,
                )

                if result.stdout.strip():
                    # 清理输出 - 移除 git diff 头行
                    lines = result.stdout.split("\n")
                    # 跳过头行（diff --git、index、+++、---、@@）
                    content_lines = []
                    in_content = False
                    for line in lines:
                        if line.startswith("@@"):
                            in_content = True
                            continue
                        if in_content and line.strip():
                            content_lines.append(line)

                    if content_lines:
                        return "\n".join(content_lines)

                # 如果字符级差异太冗长则回退到单词级差异
                result = subprocess.run(
                    [
                        "git",
                        "diff",
                        "--word-diff=plain",
                        "-U0",  # 零行上下文
                        "--no-index",
                        str(original_file),
                        str(modified_file),
                    ],
                    capture_output=True,
                    text=True,
                )

                if result.stdout.strip():
                    lines = result.stdout.split("\n")
                    content_lines = []
                    in_content = False
                    for line in lines:
                        if line.startswith("@@"):
                            in_content = True
                            continue
                        if in_content and line.strip():
                            content_lines.append(line)
                    return "\n".join(content_lines)

        except (subprocess.CalledProcessError, FileNotFoundError, Exception):
            # Git 不可用或其他错误，返回 None 使用回退方案
            pass

        return None

    def _remove_claude_tracked_changes(self, root):
        """从 XML 根元素中移除 Claude 作者的修订追踪。"""
        ins_tag = f"{{{self.namespaces['w']}}}ins"
        del_tag = f"{{{self.namespaces['w']}}}del"
        author_attr = f"{{{self.namespaces['w']}}}author"

        # 移除 w:ins 元素
        for parent in root.iter():
            to_remove = []
            for child in parent:
                if child.tag == ins_tag and child.get(author_attr) == "Claude":
                    to_remove.append(child)
            for elem in to_remove:
                parent.remove(elem)

        # 展开 author 为 "Claude" 的 w:del 元素中的内容
        deltext_tag = f"{{{self.namespaces['w']}}}delText"
        t_tag = f"{{{self.namespaces['w']}}}t"

        for parent in root.iter():
            to_process = []
            for child in parent:
                if child.tag == del_tag and child.get(author_attr) == "Claude":
                    to_process.append((child, list(parent).index(child)))

            # 逆序处理以维护索引
            for del_elem, del_index in reversed(to_process):
                # 移动前将 w:delText 转换为 w:t
                for elem in del_elem.iter():
                    if elem.tag == deltext_tag:
                        elem.tag = t_tag

                # 将 w:del 的所有子元素移动到其父元素，然后移除 w:del
                for child in reversed(list(del_elem)):
                    parent.insert(del_index, child)
                parent.remove(del_elem)

    def _extract_text_content(self, root):
        """从 Word XML 中提取文本内容，保留段落结构。

        跳过空段落以避免当修订追踪的插入仅添加结构元素而无文本内容时出现误报。
        """
        p_tag = f"{{{self.namespaces['w']}}}p"
        t_tag = f"{{{self.namespaces['w']}}}t"

        paragraphs = []
        for p_elem in root.findall(f".//{p_tag}"):
            # 获取此段落内的所有文本元素
            text_parts = []
            for t_elem in p_elem.findall(f".//{t_tag}"):
                if t_elem.text:
                    text_parts.append(t_elem.text)
            paragraph_text = "".join(text_parts)
            # 跳过空段落 - 它们不影响内容验证
            if paragraph_text:
                paragraphs.append(paragraph_text)

        return "\n".join(paragraphs)


if __name__ == "__main__":
    raise RuntimeError("此模块不应直接运行。")


path: ooxml/scripts/validation/pptx.py
content: """
用于根据 XSD 架构验证 PowerPoint 演示文稿 XML 文件的验证器。
"""

import re

from .base import BaseSchemaValidator


class PPTXSchemaValidator(BaseSchemaValidator):
    """用于根据 XSD 架构验证 PowerPoint 演示文稿 XML 文件的验证器。"""

    # PowerPoint 演示文稿命名空间
    PRESENTATIONML_NAMESPACE = (
        "http://schemas.openxmlformats.org/presentationml/2006/main"
    )

    # PowerPoint 特定元素到关系类型的映射
    ELEMENT_RELATIONSHIP_TYPES = {
        "sldid": "slide",
        "sldmasterid": "slidemaster",
        "notesmasterid": "notesmaster",
        "sldlayoutid": "slidelayout",
        "themeid": "theme",
        "tablestyleid": "tablestyles",
    }

    def validate(self):
        """运行所有验证检查，如果全部通过则返回 True。"""
        # 测试 0：XML 格式正确性
        if not self.validate_xml():
            return False

        # 测试 1：命名空间声明
        all_valid = True
        if not self.validate_namespaces():
            all_valid = False

        # 测试 2：唯一 ID
        if not self.validate_unique_ids():
            all_valid = False

        # 测试 3：UUID ID 验证
        if not self.validate_uuid_ids():
            all_valid = False

        # 测试 4：关系和文件引用验证
        if not self.validate_file_references():
            all_valid = False

        # 测试 5：幻灯片版式 ID 验证
        if not self.validate_slide_layout_ids():
            all_valid = False

        # 测试 6：内容类型声明
        if not self.validate_content_types():
            all_valid = False

        # 测试 7：XSD 架构验证
        if not self.validate_against_xsd():
            all_valid = False

        # 测试 8：备注幻灯片引用验证
        if not self.validate_notes_slide_references():
            all_valid = False

        # 测试 9：关系 ID 引用验证
        if not self.validate_all_relationship_ids():
            all_valid = False

        # 测试 10：重复幻灯片版式引用验证
        if not self.validate_no_duplicate_slide_layouts():
            all_valid = False

        return all_valid

    def validate_uuid_ids(self):
        """验证看起来像 UUID 的 ID 属性只包含十六进制值。"""
        import lxml.etree

        errors = []
        # UUID 模式：8-4-4-4-12 十六进制数字，可选花括号/连字符
        uuid_pattern = re.compile(
            r"^[\{\(]?[0-9A-Fa-f]{8}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{12}[\}\)]?$"
        )

        for xml_file in self.xml_files:
            try:
                root = lxml.etree.parse(str(xml_file)).getroot()

                # 检查所有元素的 ID 属性
                for elem in root.iter():
                    for attr, value in elem.attrib.items():
                        # 检查是否为 ID 属性
                        attr_name = attr.split("}")[-1].lower()
                        if attr_name == "id" or attr_name.endswith("id"):
                            # 检查值是否看起来像 UUID（具有正确的长度和模式结构）
                            if self._looks_like_uuid(value):
                                # 验证它在正确位置只包含十六进制字符
                                if not uuid_pattern.match(value):
                                    errors.append(
                                        f"  {xml_file.relative_to(self.unpacked_dir)}: "
                                        f"第 {elem.sourceline} 行: ID '{value}' 看起来像 UUID 但包含无效的十六进制字符"
                                    )

            except (lxml.etree.XMLSyntaxError, Exception) as e:
                errors.append(
                    f"  {xml_file.relative_to(self.unpacked_dir)}: 错误: {e}"
                )

        if errors:
            print(f"失败 - 发现 {len(errors)} 个 UUID ID 验证错误:")
            for error in errors:
                print(error)
            return False
        else:
            if self.verbose:
                print("通过 - 所有类 UUID 的 ID 都包含有效的十六进制值")
            return True

    def _looks_like_uuid(self, value):
        """检查值是否具有 UUID 的一般结构。"""
        # 移除常见的 UUID 分隔符
        clean_value = value.strip("{}()").replace("-", "")
        # 检查是否为 32 个类十六进制字符（可能包含无效的十六进制字符）
        return len(clean_value) == 32 and all(c.isalnum() for c in clean_value)

    def validate_slide_layout_ids(self):
        """验证幻灯片母版中的 sldLayoutId 元素引用的是有效的幻灯片版式。"""
        import lxml.etree

        errors = []

        # 查找所有幻灯片母版文件
        slide_masters = list(self.unpacked_dir.glob("ppt/slideMasters/*.xml"))

        if not slide_masters:
            if self.verbose:
                print("通过 - 未找到幻灯片母版")
            return True

        for slide_master in slide_masters:
            try:
                # 解析幻灯片母版文件
                root = lxml.etree.parse(str(slide_master)).getroot()

                # 查找此幻灯片母版对应的 _rels 文件
                rels_file = slide_master.parent / "_rels" / f"{slide_master.name}.rels"

                if not rels_file.exists():
                    errors.append(
                        f"  {slide_master.relative_to(self.unpacked_dir)}: "
                        f"缺少关系文件: {rels_file.relative_to(self.unpacked_dir)}"
                    )
                    continue

                # 解析关系文件
                rels_root = lxml.etree.parse(str(rels_file)).getroot()

                # 构建指向幻灯片版式的有效关系 ID 集合
                valid_layout_rids = set()
                for rel in rels_root.findall(
                    f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship"
                ):
                    rel_type = rel.get("Type", "")
                    if "slideLayout" in rel_type:
                        valid_layout_rids.add(rel.get("Id"))

                # 查找幻灯片母版中的所有 sldLayoutId 元素
                for sld_layout_id in root.findall(
                    f".//{{{self.PRESENTATIONML_NAMESPACE}}}sldLayoutId"
                ):
                    r_id = sld_layout_id.get(
                        f"{{{self.OFFICE_RELATIONSHIPS_NAMESPACE}}}id"
                    )
                    layout_id = sld_layout_id.get("id")

                    if r_id and r_id not in valid_layout_rids:
                        errors.append(
                            f"  {slide_master.relative_to(self.unpacked_dir)}: "
                            f"第 {sld_layout_id.sourceline} 行: id='{layout_id}' 的 sldLayoutId "
                            f"引用的 r:id='{r_id}' 在幻灯片版式关系中未找到"
                        )

            except (lxml.etree.XMLSyntaxError, Exception) as e:
                errors.append(
                    f"  {slide_master.relative_to(self.unpacked_dir)}: 错误: {e}"
                )

        if errors:
            print(f"失败 - 发现 {len(errors)} 个幻灯片版式 ID 验证错误:")
            for error in errors:
                print(error)
            print(
                "请移除无效引用或将缺失的幻灯片版式添加到关系文件中。"
            )
            return False
        else:
            if self.verbose:
                print("通过 - 所有幻灯片版式 ID 都引用有效的幻灯片版式")
            return True

    def validate_no_duplicate_slide_layouts(self):
        """验证每个幻灯片只有一个 slideLayout 引用。"""
        import lxml.etree

        errors = []
        slide_rels_files = list(self.unpacked_dir.glob("ppt/slides/_rels/*.xml.rels"))

        for rels_file in slide_rels_files:
            try:
                root = lxml.etree.parse(str(rels_file)).getroot()

                # 查找所有 slideLayout 关系
                layout_rels = [
                    rel
                    for rel in root.findall(
                        f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship"
                    )
                    if "slideLayout" in rel.get("Type", "")
                ]

                if len(layout_rels) > 1:
                    errors.append(
                        f"  {rels_file.relative_to(self.unpacked_dir)}: 有 {len(layout_rels)} 个 slideLayout 引用"
                    )

            except Exception as e:
                errors.append(
                    f"  {rels_file.relative_to(self.unpacked_dir)}: 错误: {e}"
                )

        if errors:
            print("失败 - 发现包含重复 slideLayout 引用的幻灯片:")
            for error in errors:
                print(error)
            return False
        else:
            if self.verbose:
                print("通过 - 所有幻灯片都只有一个 slideLayout 引用")
            return True

    def validate_notes_slide_references(self):
        """验证每个 notesSlide 文件只被一个幻灯片引用。"""
        import lxml.etree

        errors = []
        notes_slide_references = {}  # 跟踪哪些幻灯片引用每个 notesSlide

        # 查找所有幻灯片关系文件
        slide_rels_files = list(self.unpacked_dir.glob("ppt/slides/_rels/*.xml.rels"))

        if not slide_rels_files:
            if self.verbose:
                print("通过 - 未找到幻灯片关系文件")
            return True

        for rels_file in slide_rels_files:
            try:
                # 解析关系文件
                root = lxml.etree.parse(str(rels_file)).getroot()

                # 查找所有 notesSlide 关系
                for rel in root.findall(
                    f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship"
                ):
                    rel_type = rel.get("Type", "")
                    if "notesSlide" in rel_type:
                        target = rel.get("Target", "")
                        if target:
                            # 规范化目标路径以处理相对路径
                            normalized_target = target.replace("../", "")

                            # 跟踪哪个幻灯片引用此 notesSlide
                            slide_name = rels_file.stem.replace(
                                ".xml", ""
                            )  # 例如 "slide1"

                            if normalized_target not in notes_slide_references:
                                notes_slide_references[normalized_target] = []
                            notes_slide_references[normalized_target].append(
                                (slide_name, rels_file)
                            )

            except (lxml.etree.XMLSyntaxError, Exception) as e:
                errors.append(
                    f"  {rels_file.relative_to(self.unpacked_dir)}: 错误: {e}"
                )

        # 检查重复引用
        for target, references in notes_slide_references.items():
            if len(references) > 1:
                slide_names = [ref[0] for ref in references]
                errors.append(
                    f"  备注幻灯片 '{target}' 被多个幻灯片引用: {', '.join(slide_names)}"
                )
                for slide_name, rels_file in references:
                    errors.append(f"    - {rels_file.relative_to(self.unpacked_dir)}")

        if errors:
            print(
                f"失败 - 发现 {len([e for e in errors if not e.startswith('    ')])} 个备注幻灯片引用验证错误:"
            )
            for error in errors:
                print(error)
            print("每个幻灯片可以选择性地拥有自己的备注幻灯片文件。")
            return False
        else:
            if self.verbose:
                print("通过 - 所有备注幻灯片引用都是唯一的")
            return True


if __name__ == "__main__":
    raise RuntimeError("此模块不应直接运行。")


path: ooxml/scripts/validation/base.py
content: """
文档文件通用验证逻辑的基础验证器。
"""

import re
from pathlib import Path

import lxml.etree


class BaseSchemaValidator:
    """文档文件通用验证逻辑的基础验证器。"""

    # 'id' 属性必须在其文件内唯一的元素
    # 格式：element_name -> (attribute_name, scope)
    # scope 可以是 'file'（文件内唯一）或 'global'（跨所有文件唯一）
    UNIQUE_ID_REQUIREMENTS = {
        # Word 元素
        "comment": ("id", "file"),  # comments.xml 中的批注 ID
        "commentrangestart": ("id", "file"),  # 必须与批注 ID 匹配
        "commentrangeend": ("id", "file"),  # 必须与批注 ID 匹配
        "bookmarkstart": ("id", "file"),  # 书签起始 ID
        "bookmarkend": ("id", "file"),  # 书签结束 ID
        # 注意：ins 和 del（修订追踪）在同一修订的一部分时可以共享 ID
        # PowerPoint 元素
        "sldid": ("id", "file"),  # presentation.xml 中的幻灯片 ID
        "sldmasterid": ("id", "global"),  # 幻灯片母版 ID 必须全局唯一
        "sldlayoutid": ("id", "global"),  # 幻灯片版式 ID 必须全局唯一
        "cm": ("authorid", "file"),  # 批注作者 ID
        # Excel 元素
        "sheet": ("sheetid", "file"),  # workbook.xml 中的工作表 ID
        "definedname": ("id", "file"),  # 命名区域 ID
        # 绘图/形状元素（所有格式）
        "cxnsp": ("id", "file"),  # 连接形状 ID
        "sp": ("id", "file"),  # 形状 ID
        "pic": ("id", "file"),  # 图片 ID
        "grpsp": ("id", "file"),  # 组合形状 ID
    }

    # 元素名称到预期关系类型的映射
    # 子类应使用特定于格式的映射覆盖此项
    ELEMENT_RELATIONSHIP_TYPES = {}

    # 所有 Office 文档类型的统一架构映射
    SCHEMA_MAPPINGS = {
        # 文档类型特定架构
        "word": "ISO-IEC29500-4_2016/wml.xsd",  # Word 文档
        "ppt": "ISO-IEC29500-4_2016/pml.xsd",  # PowerPoint 演示文稿
        "xl": "ISO-IEC29500-4_2016/sml.xsd",  # Excel 电子表格
        # 通用文件类型
        "[Content_Types].xml": "ecma/fouth-edition/opc-contentTypes.xsd",
        "app.xml": "ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd",
        "core.xml": "ecma/fouth-edition/opc-coreProperties.xsd",
        "custom.xml": "ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd",
        ".rels": "ecma/fouth-edition/opc-relationships.xsd",
        # Word 特定文件
        "people.xml": "microsoft/wml-2012.xsd",
        "commentsIds.xml": "microsoft/wml-cid-2016.xsd",
        "commentsExtensible.xml": "microsoft/wml-cex-2018.xsd",
        "commentsExtended.xml": "microsoft/wml-2012.xsd",
        # 图表文件（跨文档类型通用）
        "chart": "ISO-IEC29500-4_2016/dml-chart.xsd",
        # 主题文件（跨文档类型通用）
        "theme": "ISO-IEC29500-4_2016/dml-main.xsd",
        # 绘图和媒体文件
        "drawing": "ISO-IEC29500-4_2016/dml-main.xsd",
    }

    # 统一命名空间常量
    MC_NAMESPACE = "http://schemas.openxmlformats.org/markup-compatibility/2006"
    XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace"

    # 验证器中使用的通用 OOXML 命名空间
    PACKAGE_RELATIONSHIPS_NAMESPACE = (
        "http://schemas.openxmlformats.org/package/2006/relationships"
    )
    OFFICE_RELATIONSHIPS_NAMESPACE = (
        "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
    )
    CONTENT_TYPES_NAMESPACE = (
        "http://schemas.openxmlformats.org/package/2006/content-types"
    )

    # 我们应该清理可忽略命名空间的文件夹
    MAIN_CONTENT_FOLDERS = {"word", "ppt", "xl"}

    # 所有允许的 OOXML 命名空间（所有文档类型的超集）
    OOXML_NAMESPACES = {
        "http://schemas.openxmlformats.org/officeDocument/2006/math",
        "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
        "http://schemas.openxmlformats.org/schemaLibrary/2006/main",
        "http://schemas.openxmlformats.org/drawingml/2006/main",
        "http://schemas.openxmlformats.org/drawingml/2006/chart",
        "http://schemas.openxmlformats.org/drawingml/2006/chartDrawing",
        "http://schemas.openxmlformats.org/drawingml/2006/diagram",
        "http://schemas.openxmlformats.org/drawingml/2006/picture",
        "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing",
        "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing",
        "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
        "http://schemas.openxmlformats.org/presentationml/2006/main",
        "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
        "http://schemas.openxmlformats.org/officeDocument/2006/sharedTypes",
        "http://www.w3.org/XML/1998/namespace",
    }

    def __init__(self, unpacked_dir, original_file, verbose=False):
        self.unpacked_dir = Path(unpacked_dir).resolve()
        self.original_file = Path(original_file)
        self.verbose = verbose

        # 设置架构目录
        self.schemas_dir = Path(__file__).parent.parent.parent / "schemas"

        # 获取所有 XML 和 .rels 文件
        patterns = ["*.xml", "*.rels"]
        self.xml_files = [
            f for pattern in patterns for f in self.unpacked_dir.rglob(pattern)
        ]

        if not self.xml_files:
            print(f"警告：在 {self.unpacked_dir} 中未找到 XML 文件")

    def validate(self):
        """运行所有验证检查，如果全部通过则返回 True。"""
        raise NotImplementedError("子类必须实现 validate 方法")

    def validate_xml(self):
        """验证所有 XML 文件格式是否正确。"""
        errors = []

        for xml_file in self.xml_files:
            try:
                # 尝试解析 XML 文件
                lxml.etree.parse(str(xml_file))
            except lxml.etree.XMLSyntaxError as e:
                errors.append(
                    f"  {xml_file.relative_to(self.unpacked_dir)}: "
                    f"第 {e.lineno} 行: {e.msg}"
                )
            except Exception as e:
                errors.append(
                    f"  {xml_file.relative_to(self.unpacked_dir)}: "
                    f"意外错误: {str(e)}"
                )

        if errors:
            print(f"失败 - 发现 {len(errors)} 个 XML 违规:")
            for error in errors:
                print(error)
            return False
        else:
            if self.verbose:
                print("通过 - 所有 XML 文件格式正确")
            return True

    def validate_namespaces(self):
        """验证 Ignorable 属性中的命名空间前缀是否已声明。"""
        errors = []

        for xml_file in self.xml_files:
            try:
                root = lxml.etree.parse(str(xml_file)).getroot()
                declared = set(root.nsmap.keys()) - {None}  # 排除默认命名空间

                for attr_val in [
                    v for k, v in root.attrib.items() if k.endswith("Ignorable")
                ]:
                    undeclared = set(attr_val.split()) - declared
                    errors.extend(
                        f"  {xml_file.relative_to(self.unpacked_dir)}: "
                        f"命名空间 '{ns}' 在 Ignorable 中但未声明"
                        for ns in undeclared
                    )
            except lxml.etree.XMLSyntaxError:
                continue

        if errors:
            print(f"失败 - {len(errors)} 个命名空间问题:")
            for error in errors:
                print(error)
            return False
        if self.verbose:
            print("通过 - 所有命名空间前缀已正确声明")
        return True

    def validate_unique_ids(self):
        """验证特定 ID 是否根据 OOXML 要求唯一。"""
        errors = []
        global_ids = {}  # 跟踪跨所有文件的全局唯一 ID

        for xml_file in self.xml_files:
            try:
                root = lxml.etree.parse(str(xml_file)).getroot()
                file_ids = {}  # 跟踪必须在此文件内唯一的 ID

                # 从树中移除所有 mc:AlternateContent 元素
                mc_elements = root.xpath(
                    ".//mc:AlternateContent", namespaces={"mc": self.MC_NAMESPACE}
                )
                for elem in mc_elements:
                    elem.getparent().remove(elem)

                # 现在检查清理后的树中的 ID
                for elem in root.iter():
                    # 获取不带命名空间的元素名称
                    tag = (
                        elem.tag.split("}")[-1].lower()
                        if "}" in elem.tag
                        else elem.tag.lower()
                    )

                    # 检查此元素类型是否有 ID 唯一性要求
                    if tag in self.UNIQUE_ID_REQUIREMENTS:
                        attr_name, scope = self.UNIQUE_ID_REQUIREMENTS[tag]

                        # 查找指定的属性
                        id_value = None
                        for attr, value in elem.attrib.items():
                            attr_local = (
                                attr.split("}")[-1].lower()
                                if "}" in attr
                                else attr.lower()
                            )
                            if attr_local == attr_name:
                                id_value = value
                                break

                        if id_value is not None:
                            if scope == "global":
                                # 检查全局唯一性
                                if id_value in global_ids:
                                    prev_file, prev_line, prev_tag = global_ids[
                                        id_value
                                    ]
                                    errors.append(
                                        f"  {xml_file.relative_to(self.unpacked_dir)}: "
                                        f"第 {elem.sourceline} 行: <{tag}> 中的全局 ID '{id_value}' "
                                        f"已在 {prev_file} 的第 {prev_line} 行 <{prev_tag}> 中使用"
                                    )
                                else:
                                    global_ids[id_value] = (
                                        xml_file.relative_to(self.unpacked_dir),
                                        elem.sourceline,
                                        tag,
                                    )
                            elif scope == "file":
                                # 检查文件级唯一性
                                key = (tag, attr_name)
                                if key not in file_ids:
                                    file_ids[key] = {}

                                if id_value in file_ids[key]:
                                    prev_line = file_ids[key][id_value]
                                    errors.append(
                                        f"  {xml_file.relative_to(self.unpacked_dir)}: "
                                        f"第 {elem.sourceline} 行: <{tag}> 中重复的 {attr_name}='{id_value}' "
                                        f"（首次出现在第 {prev_line} 行）"
                                    )
                                else:
                                    file_ids[key][id_value] = elem.sourceline

            except (lxml.etree.XMLSyntaxError, Exception) as e:
                errors.append(
                    f"  {xml_file.relative_to(self.unpacked_dir)}: 错误: {e}"
                )

        if errors:
            print(f"失败 - 发现 {len(errors)} 个 ID 唯一性违规:")
            for error in errors:
                print(error)
            return False
        else:
            if self.verbose:
                print("通过 - 所有必需的 ID 都是唯一的")
            return True

    def validate_file_references(self):
        """
        验证所有 .rels 文件正确引用文件，并且所有文件都被引用。
        """
        errors = []

        # 查找所有 .rels 文件
        rels_files = list(self.unpacked_dir.rglob("*.rels"))

        if not rels_files:
            if self.verbose:
                print("通过 - 未找到 .rels 文件")
            return True

        # 获取解压目录中的所有文件（排除引用文件）
        all_files = []
        for file_path in self.unpacked_dir.rglob("*"):
            if (
                file_path.is_file()
                and file_path.name != "[Content_Types].xml"
                and not file_path.name.endswith(".rels")
            ):  # 此文件不被 .rels 引用
                all_files.append(file_path.resolve())

        # 跟踪被任何 .rels 文件引用的所有文件
        all_referenced_files = set()

        if self.verbose:
            print(
                f"找到 {len(rels_files)} 个 .rels 文件和 {len(all_files)} 个目标文件"
            )

        # 检查每个 .rels 文件
        for rels_file in rels_files:
            try:
                # 解析关系文件
                rels_root = lxml.etree.parse(str(rels_file)).getroot()

                # 获取此 .rels 文件所在的目录
                rels_dir = rels_file.parent

                # 查找所有关系及其目标
                referenced_files = set()
                broken_refs = []

                for rel in rels_root.findall(
                    ".//ns:Relationship",
                    namespaces={"ns": self.PACKAGE_RELATIONSHIPS_NAMESPACE},
                ):
                    target = rel.get("Target")
                    if target and not target.startswith(
                        ("http", "mailto:")
                    ):  # 跳过外部 URL
                        # 相对于 .rels 文件位置解析目标路径
                        if rels_file.name == ".rels":
                            # 根 .rels 文件 - 目标相对于 unpacked_dir
                            target_path = self.unpacked_dir / target
                        else:
                            # 其他 .rels 文件 - 目标相对于其父目录的父目录
                            # 例如，word/_rels/document.xml.rels -> 目标相对于 word/
                            base_dir = rels_dir.parent
                            target_path = base_dir / target

                        # 规范化路径并检查是否存在
                        try:
                            target_path = target_path.resolve()
                            if target_path.exists() and target_path.is_file():
                                referenced_files.add(target_path)
                                all_referenced_files.add(target_path)
                            else:
                                broken_refs.append((target, rel.sourceline))
                        except (OSError, ValueError):
                            broken_refs.append((target, rel.sourceline))

                # 报告损坏的引用
                if broken_refs:
                    rel_path = rels_file.relative_to(self.unpacked_dir)
                    for broken_ref, line_num in broken_refs:
                        errors.append(
                            f"  {rel_path}: 第 {line_num} 行: 对 {broken_ref} 的损坏引用"
                        )

            except Exception as e:
                rel_path = rels_file.relative_to(self.unpacked_dir)
                errors.append(f"  解析 {rel_path} 时出错: {e}")

        # 检查未引用的文件（存在但未在任何地方引用的文件）
        unreferenced_files = set(all_files) - all_referenced_files

        if unreferenced_files:
            for unref_file in sorted(unreferenced_files):
                unref_rel_path = unref_file.relative_to(self.unpacked_dir)
                errors.append(f"  未引用的文件: {unref_rel_path}")

        if errors:
            print(f"失败 - 发现 {len(errors)} 个关系验证错误:")
            for error in errors:
                print(error)
            print(
                "严重：这些错误将导致文档显示为损坏。"
                + "必须修复损坏的引用，"
                + "未引用的文件必须被引用或删除。"
            )
            return False
        else:
            if self.verbose:
                print(
                    "通过 - 所有引用都有效，所有文件都已正确引用"
                )
            return True

    def validate_all_relationship_ids(self):
        """
        验证 XML 文件中的所有 r:id 属性引用了对应 .rels 文件中存在的 ID，
        并可选验证关系类型。
        """
        import lxml.etree

        errors = []

        # 处理每个可能包含 r:id 引用的 XML 文件
        for xml_file in self.xml_files:
            # 跳过 .rels 文件本身
            if xml_file.suffix == ".rels":
                continue

            # 确定对应的 .rels 文件
            # 对于 dir/file.xml，它是 dir/_rels/file.xml.rels
            rels_dir = xml_file.parent / "_rels"
            rels_file = rels_dir / f"{xml_file.name}.rels"

            # 如果没有对应的 .rels 文件则跳过（这是可以的）
            if not rels_file.exists():
                continue

            try:
                # 解析 .rels 文件以获取有效的关系 ID 及其类型
                rels_root = lxml.etree.parse(str(rels_file)).getroot()
                rid_to_type = {}

                for rel in rels_root.findall(
                    f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship"
                ):
                    rid = rel.get("Id")
                    rel_type = rel.get("Type", "")
                    if rid:
                        # 检查重复的 rId
                        if rid in rid_to_type:
                            rels_rel_path = rels_file.relative_to(self.unpacked_dir)
                            errors.append(
                                f"  {rels_rel_path}: 第 {rel.sourceline} 行: "
                                f"重复的关系 ID '{rid}'（ID 必须唯一）"
                            )
                        # 从完整 URL 中仅提取类型名称
                        type_name = (
                            rel_type.split("/")[-1] if "/" in rel_type else rel_type
                        )
                        rid_to_type[rid] = type_name

                # 解析 XML 文件以查找所有 r:id 引用
                xml_root = lxml.etree.parse(str(xml_file)).getroot()

                # 查找所有具有 r:id 属性的元素
                for elem in xml_root.iter():
                    # 检查 r:id 属性（关系 ID）
                    rid_attr = elem.get(f"{{{self.OFFICE_RELATIONSHIPS_NAMESPACE}}}id")
                    if rid_attr:
                        xml_rel_path = xml_file.relative_to(self.unpacked_dir)
                        elem_name = (
                            elem.tag.split("}")[-1] if "}" in elem.tag else elem.tag
                        )

                        # 检查 ID 是否存在
                        if rid_attr not in rid_to_type:
                            errors.append(
                                f"  {xml_rel_path}: 第 {elem.sourceline} 行: "
                                f"<{elem_name}> 引用了不存在的关系 '{rid_attr}' "
                                f"（有效 ID: {', '.join(sorted(rid_to_type.keys())[:5])}{'...' if len(rid_to_type) > 5 else ''})"
                            )
                        # 检查此元素是否有类型期望
                        elif self.ELEMENT_RELATIONSHIP_TYPES:
                            expected_type = self._get_expected_relationship_type(
                                elem_name
                            )
                            if expected_type:
                                actual_type = rid_to_type[rid_attr]
                                # 检查实际类型是否匹配或包含预期类型
                                if expected_type not in actual_type.lower():
                                    errors.append(
                                        f"  {xml_rel_path}: 第 {elem.sourceline} 行: "
                                        f"<{elem_name}> 引用的 '{rid_attr}' 指向 '{actual_type}' "
                                        f"但应该指向 '{expected_type}' 关系"
                                    )

            except Exception as e:
                xml_rel_path = xml_file.relative_to(self.unpacked_dir)
                errors.append(f"  处理 {xml_rel_path} 时出错: {e}")

        if errors:
            print(f"失败 - 发现 {len(errors)} 个关系 ID 引用错误:")
            for error in errors:
                print(error)
            print("\n这些 ID 不匹配将导致文档显示为损坏！")
            return False
        else:
            if self.verbose:
                print("通过 - 所有关系 ID 引用都有效")
            return True

    def _get_expected_relationship_type(self, element_name):
        """
        获取元素的预期关系类型。
        首先检查显式映射，然后尝试模式检测。
        """
        # 将元素名称规范化为小写
        elem_lower = element_name.lower()

        # 首先检查显式映射
        if elem_lower in self.ELEMENT_RELATIONSHIP_TYPES:
            return self.ELEMENT_RELATIONSHIP_TYPES[elem_lower]

        # 尝试常见模式的模式检测
        # 模式 1：以 "Id" 结尾的元素通常期望前缀类型的关系
        if elem_lower.endswith("id") and len(elem_lower) > 2:
            # 例如，"sldId" -> "sld"，"sldMasterId" -> "sldMaster"
            prefix = elem_lower[:-2]  # 移除 "id"
            # 检查是否是像 "sldMasterId" 这样的复合词
            if prefix.endswith("master"):
                return prefix.lower()
            elif prefix.endswith("layout"):
                return prefix.lower()
            else:
                # 简单情况如 "sldId" -> "slide"
                # 常见转换
                if prefix == "sld":
                    return "slide"
                return prefix.lower()

        # 模式 2：以 "Reference" 结尾的元素期望前缀类型的关系
        if elem_lower.endswith("reference") and len(elem_lower) > 9:
            prefix = elem_lower[:-9]  # 移除 "reference"
            return prefix.lower()

        return None

    def validate_content_types(self):
        """验证所有内容文件是否在 [Content_Types].xml 中正确声明。"""
        errors = []

        # 查找 [Content_Types].xml 文件
        content_types_file = self.unpacked_dir / "[Content_Types].xml"
        if not content_types_file.exists():
            print("失败 - 未找到 [Content_Types].xml 文件")
            return False

        try:
            # 解析并获取所有声明的部件和扩展名
            root = lxml.etree.parse(str(content_types_file)).getroot()
            declared_parts = set()
            declared_extensions = set()

            # 获取 Override 声明（特定文件）
            for override in root.findall(
                f".//{{{self.CONTENT_TYPES_NAMESPACE}}}Override"
            ):
                part_name = override.get("PartName")
                if part_name is not None:
                    declared_parts.add(part_name.lstrip("/"))

            # 获取 Default 声明（按扩展名）
            for default in root.findall(
                f".//{{{self.CONTENT_TYPES_NAMESPACE}}}Default"
            ):
                extension = default.get("Extension")
                if extension is not None:
                    declared_extensions.add(extension.lower())

            # 需要内容类型声明的根元素
            declarable_roots = {
                "sld",
                "sldLayout",
                "sldMaster",
                "presentation",  # PowerPoint
                "document",  # Word
                "workbook",
                "worksheet",  # Excel
                "theme",  # 通用
            }

            # 应该声明的常见媒体文件扩展名
            media_extensions = {
                "png": "image/png",
                "jpg": "image/jpeg",
                "jpeg": "image/jpeg",
                "gif": "image/gif",
                "bmp": "image/bmp",
                "tiff": "image/tiff",
                "wmf": "image/x-wmf",
                "emf": "image/x-emf",
            }

            # 获取解压目录中的所有文件
            all_files = list(self.unpacked_dir.rglob("*"))
            all_files = [f for f in all_files if f.is_file()]

            # 检查所有 XML 文件的 Override 声明
            for xml_file in self.xml_files:
                path_str = str(xml_file.relative_to(self.unpacked_dir)).replace(
                    "\\", "/"
                )

                # 跳过非内容文件
                if any(
                    skip in path_str
                    for skip in [".rels", "[Content_Types]", "docProps/", "_rels/"]
                ):
                    continue

                try:
                    root_tag = lxml.etree.parse(str(xml_file)).getroot().tag
                    root_name = root_tag.split("}")[-1] if "}" in root_tag else root_tag

                    if root_name in declarable_roots and path_str not in declared_parts:
                        errors.append(
                            f"  {path_str}: 具有 <{root_name}> 根元素的文件未在 [Content_Types].xml 中声明"
                        )

                except Exception:
                    continue  # 跳过无法解析的文件

            # 检查所有非 XML 文件的 Default 扩展名声明
            for file_path in all_files:
                # 跳过 XML 文件和元数据文件（上面已检查）
                if file_path.suffix.lower() in {".xml", ".rels"}:
                    continue
                if file_path.name == "[Content_Types].xml":
                    continue
                if "_rels" in file_path.parts or "docProps" in file_path.parts:
                    continue

                extension = file_path.suffix.lstrip(".").lower()
                if extension and extension not in declared_extensions:
                    # 检查是否是应该声明的已知媒体扩展名
                    if extension in media_extensions:
                        relative_path = file_path.relative_to(self.unpacked_dir)
                        errors.append(
                            f'  {relative_path}: 扩展名为 \'{extension}\' 的文件未在 [Content_Types].xml 中声明 - 应添加: <Default Extension="{extension}" ContentType="{media_extensions[extension]}"/>'
                        )

        except Exception as e:
            errors.append(f"  解析 [Content_Types].xml 时出错: {e}")

        if errors:
            print(f"失败 - 发现 {len(errors)} 个内容类型声明错误:")
            for error in errors:
                print(error)
            return False
        else:
            if self.verbose:
                print(
                    "通过 - 所有内容文件都已在 [Content_Types].xml 中正确声明"
                )
            return True

    def validate_file_against_xsd(self, xml_file, verbose=False):
        """根据 XSD 架构验证单个 XML 文件，与原始文件比较。

        参数:
            xml_file: 要验证的 XML 文件路径
            verbose: 启用详细输出

        返回:
            tuple: (is_valid, new_errors_set) 其中 is_valid 为 True/False/None（跳过）
        """
        # 解析两个路径以处理符号链接
        xml_file = Path(xml_file).resolve()
        unpacked_dir = self.unpacked_dir.resolve()

        # 验证当前文件
        is_valid, current_errors = self._validate_single_file_xsd(
            xml_file, unpacked_dir
        )

        if is_valid is None:
            return None, set()  # 跳过
        elif is_valid:
            return True, set()  # 有效，无错误

        # 获取此特定文件的原始文件错误
        original_errors = self._get_original_file_errors(xml_file)

        # 与原始文件比较（两者都保证是集合）
        assert current_errors is not None
        new_errors = current_errors - original_errors

        if new_errors:
            if verbose:
                relative_path = xml_file.relative_to(unpacked_dir)
                print(f"失败 - {relative_path}: {len(new_errors)} 个新错误")
                for error in list(new_errors)[:3]:
                    truncated = error[:250] + "..." if len(error) > 250 else error
                    print(f"  - {truncated}")
            return False, new_errors
        else:
            # 所有错误在原始文件中已存在
            if verbose:
                print(
                    f"通过 - 无新错误（原始文件有 {len(current_errors)} 个错误）"
                )
            return True, set()

    def validate_against_xsd(self):
        """根据 XSD 架构验证 XML 文件，仅显示与原始文件相比的新错误。"""
        new_errors = []
        original_error_count = 0
        valid_count = 0
        skipped_count = 0

        for xml_file in self.xml_files:
            relative_path = str(xml_file.relative_to(self.unpacked_dir))
            is_valid, new_file_errors = self.validate_file_against_xsd(
                xml_file, verbose=False
            )

            if is_valid is None:
                skipped_count += 1
                continue
            elif is_valid and not new_file_errors:
                valid_count += 1
                continue
            elif is_valid:
                # 有错误但全部在原始文件中存在
                original_error_count += 1
                valid_count += 1
                continue

            # 有新错误
            new_errors.append(f"  {relative_path}: {len(new_file_errors)} 个新错误")
            for error in list(new_file_errors)[:3]:  # 显示前 3 个错误
                new_errors.append(
                    f"    - {error[:250]}..." if len(error) > 250 else f"    - {error}"
                )

        # 打印摘要
        if self.verbose:
            print(f"已验证 {len(self.xml_files)} 个文件:")
            print(f"  - 有效: {valid_count}")
            print(f"  - 跳过（无架构）: {skipped_count}")
            if original_error_count:
                print(f"  - 有原始错误（已忽略）: {original_error_count}")
            print(
                f"  - 有新错误: {len(new_errors) > 0 and len([e for e in new_errors if not e.startswith('    ')]) or 0}"
            )

        if new_errors:
            print("\n失败 - 发现新的验证错误:")
            for error in new_errors:
                print(error)
            return False
        else:
            if self.verbose:
                print("\n通过 - 未引入新的 XSD 验证错误")
            return True

    def _get_schema_path(self, xml_file):
        """确定 XML 文件的适当架构路径。"""
        # 检查精确文件名匹配
        if xml_file.name in self.SCHEMA_MAPPINGS:
            return self.schemas_dir / self.SCHEMA_MAPPINGS[xml_file.name]

        # 检查 .rels 文件
        if xml_file.suffix == ".rels":
            return self.schemas_dir / self.SCHEMA_MAPPINGS[".rels"]

        # 检查图表文件
        if "charts/" in str(xml_file) and xml_file.name.startswith("chart"):
            return self.schemas_dir / self.SCHEMA_MAPPINGS["chart"]

        # 检查主题文件
        if "theme/" in str(xml_file) and xml_file.name.startswith("theme"):
            return self.schemas_dir / self.SCHEMA_MAPPINGS["theme"]

        # 检查文件是否在主内容文件夹中并使用适当的架构
        if xml_file.parent.name in self.MAIN_CONTENT_FOLDERS:
            return self.schemas_dir / self.SCHEMA_MAPPINGS[xml_file.parent.name]

        return None

    def _clean_ignorable_namespaces(self, xml_doc):
        """移除不在允许命名空间中的属性和元素。"""
        # 创建干净的副本
        xml_string = lxml.etree.tostring(xml_doc, encoding="unicode")
        xml_copy = lxml.etree.fromstring(xml_string)

        # 移除不在允许命名空间中的属性
        for elem in xml_copy.iter():
            attrs_to_remove = []

            for attr in elem.attrib:
                # 检查属性是否来自允许的命名空间之外的命名空间
                if "{" in attr:
                    ns = attr.split("}")[0][1:]
                    if ns not in self.OOXML_NAMESPACES:
                        attrs_to_remove.append(attr)

            # 移除收集的属性
            for attr in attrs_to_remove:
                del elem.attrib[attr]

        # 移除不在允许命名空间中的元素
        self._remove_ignorable_elements(xml_copy)

        return lxml.etree.ElementTree(xml_copy)

    def _remove_ignorable_elements(self, root):
        """递归移除所有不在允许命名空间中的元素。"""
        elements_to_remove = []

        # 查找要移除的元素
        for elem in list(root):
            # 跳过非元素节点（注释、处理指令等）
            if not hasattr(elem, "tag") or callable(elem.tag):
                continue

            tag_str = str(elem.tag)
            if tag_str.startswith("{"):
                ns = tag_str.split("}")[0][1:]
                if ns not in self.OOXML_NAMESPACES:
                    elements_to_remove.append(elem)
                    continue

            # 递归清理子元素
            self._remove_ignorable_elements(elem)

        # 移除收集的元素
        for elem in elements_to_remove:
            root.remove(elem)

    def _preprocess_for_mc_ignorable(self, xml_doc):
        """预处理 XML 以正确处理 mc:Ignorable 属性。"""
        # 验证前从根元素移除 mc:Ignorable 属性
        root = xml_doc.getroot()

        # 从根元素移除 mc:Ignorable 属性
        if f"{{{self.MC_NAMESPACE}}}Ignorable" in root.attrib:
            del root.attrib[f"{{{self.MC_NAMESPACE}}}Ignorable"]

        return xml_doc

    def _validate_single_file_xsd(self, xml_file, base_path):
        """根据 XSD 架构验证单个 XML 文件。返回 (is_valid, errors_set)。"""
        schema_path = self._get_schema_path(xml_file)
        if not schema_path:
            return None, None  # 跳过文件

        try:
            # 加载架构
            with open(schema_path, "rb") as xsd_file:
                parser = lxml.etree.XMLParser()
                xsd_doc = lxml.etree.parse(
                    xsd_file, parser=parser, base_url=str(schema_path)
                )
                schema = lxml.etree.XMLSchema(xsd_doc)

            # 加载和预处理 XML
            with open(xml_file, "r") as f:
                xml_doc = lxml.etree.parse(f)

            xml_doc, _ = self._remove_template_tags_from_text_nodes(xml_doc)
            xml_doc = self._preprocess_for_mc_ignorable(xml_doc)

            # 如果需要则清理可忽略的命名空间
            relative_path = xml_file.relative_to(base_path)
            if (
                relative_path.parts
                and relative_path.parts[0] in self.MAIN_CONTENT_FOLDERS
            ):
                xml_doc = self._clean_ignorable_namespaces(xml_doc)

            # 验证
            if schema.validate(xml_doc):
                return True, set()
            else:
                errors = set()
                for error in schema.error_log:
                    # 存储规范化的错误消息（不带行号以便比较）
                    errors.add(error.message)
                return False, errors

        except Exception as e:
            return False, {str(e)}

    def _get_original_file_errors(self, xml_file):
        """获取原始文档中单个文件的 XSD 验证错误。

        参数:
            xml_file: unpacked_dir 中要检查的 XML 文件路径

        返回:
            set: 原始文件的错误消息集合
        """
        import tempfile
        import zipfile

        # 解析两个路径以处理符号链接（例如，macOS 上的 /var vs /private/var）
        xml_file = Path(xml_file).resolve()
        unpacked_dir = self.unpacked_dir.resolve()
        relative_path = xml_file.relative_to(unpacked_dir)

        with tempfile.TemporaryDirectory() as temp_dir:
            temp_path = Path(temp_dir)

            # 解压原始文件
            with zipfile.ZipFile(self.original_file, "r") as zip_ref:
                zip_ref.extractall(temp_path)

            # 在原始文件中查找对应文件
            original_xml_file = temp_path / relative_path

            if not original_xml_file.exists():
                # 文件在原始文件中不存在，所以没有原始错误
                return set()

            # 验证原始文件中的特定文件
            is_valid, errors = self._validate_single_file_xsd(
                original_xml_file, temp_path
            )
            return errors if errors else set()

    def _remove_template_tags_from_text_nodes(self, xml_doc):
        """从 XML 文本节点中移除模板标签并收集警告。

        模板标签遵循 {{ ... }} 模式，用作内容替换的占位符。
        在 XSD 验证之前应从文本内容中移除它们，同时保留 XML 结构。

        返回:
            tuple: (cleaned_xml_doc, warnings_list)
        """
        warnings = []
        template_pattern = re.compile(r"\{\{[^}]*\}\}")

        # 创建文档副本以避免修改原始文档
        xml_string = lxml.etree.tostring(xml_doc, encoding="unicode")
        xml_copy = lxml.etree.fromstring(xml_string)

        def process_text_content(text, content_type):
            if not text:
                return text
            matches = list(template_pattern.finditer(text))
            if matches:
                for match in matches:
                    warnings.append(
                        f"在 {content_type} 中发现模板标签: {match.group()}"
                    )
                return template_pattern.sub("", text)
            return text

        # 处理文档中的所有文本节点
        for elem in xml_copy.iter():
            # 如果是 w:t 元素则跳过处理
            if not hasattr(elem, "tag") or callable(elem.tag):
                continue
            tag_str = str(elem.tag)
            if tag_str.endswith("}t") or tag_str == "t":
                continue

            elem.text = process_text_content(elem.text, "文本内容")
            elem.tail = process_text_content(elem.tail, "尾部内容")

        return lxml.etree.ElementTree(xml_copy), warnings


if __name__ == "__main__":
    raise RuntimeError("此模块不应直接运行。")


path: agents/openai.yaml
content: interface:
  display_name: "DocCraft"
  short_description: "Build formal docs from source files"
  default_prompt: "Use $doccraft to turn source materials and an outline into a complete, review-ready Word document."


path: references/drafting-rules.md
content: # Drafting Rules

Apply these rules when converting section briefs into chapter drafts.

## Grounding

- Treat the source corpus as the factual boundary.
- Keep numbers, scope, interfaces, standards, and named systems grounded in source files.
- Use generic industry practices only where they fill process or management structure and do not conflict with the sources.

## Writing stance

- Write toward the target template, not toward a generic summary.
- Prefer concrete implementation language over slogans.
- Explain how the work will be designed, built, integrated, tested, accepted, or operated.
- Keep the prose reviewable. If the user would need to verify a claim, make the claim specific enough to check.

## Structure

- Follow the target outline even when the source corpus uses a different structure.
- Use headings for content layers, not as placeholders for standalone figures or tables.
- Integrate diagrams and tables into nearby explanation.
- Keep section openings aligned with the section purpose, not with generic background filler.

## Working notes versus final copy

- Keep evidence notes, source reminders, and TODO markers in working drafts only.
- Remove file names, extraction artifacts, and planning annotations from final deliverables unless the user explicitly wants them.
- If a section contains assumptions, keep them in a review note or working memo unless the deliverable requires explicit assumptions.

## Style guardrails

- Avoid repetitive AI-style padding.
- Avoid abstract claims that cannot be checked.
- Avoid overusing parentheses and symbol-driven sentence structure when normal prose is clearer.
- Avoid copying source phrasing mechanically when a cleaner project-facing sentence is needed.

## Escalation points

Stop and clarify if:

- Two authoritative sources conflict on a critical fact.
- The target template requires details that the corpus does not support.
- A requested conclusion would require invented facts.


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)