Zurück zu Skills
wecomteam/wecom-cliVor der Ausführung prüfen

SKILL DETAIL

wecomcli-doc

wecomteam/wecom-cli/wecomcli-doc

wecomcli-doc is a content operation skill for WeCom (WeChat Work) doc documents, covering creation of online documents, importing local files, reading document content, appending text, and full overwrite. It triggers only when the user explicitly specifies 'doc', 'docx', 'word', 'online document', 'office document', or provides a https://doc.weixin.qq.com/doc/xxx link. For generic 'document' requests without a specified type, the default routing goes to wecomcli-smartpage (smart document), and this skill does not preempt. If the request involves structured data semantics such as fields, records, filtering, sorting, statistics, or grouping, smart documents or smart sheets should be used instead of static markdown tables in doc. This skill operates via the wecom-cli command-line tool. Creating a new document follows a two-step process: generate a .docx file, then import it. Import supports .doc, .docx, and .txt formats. When reading content, you can specify the return format as text, markdown, or ooxml; long content is automatically saved to a local file. The append vs. overwrite semantics default to append unless the user explicitly uses strong words like 'overwrite', 'rewrite', or 'replace'. For public management operations (e.g., search, permission changes), use wecomcli-doc-manage; for online sheets, use wecomcli-sheet; for smart sheets, use wecomcli-smartsheet.

Installationen · 176Quelle ansehen

Installation

npx skills add https://github.com/wecomteam/wecom-cli --skill wecomcli-doc

Skill-Dateien

SKILL.md

Zuletzt synchronisiert · 27.08.2026

references/doc-contents-append.md
# 追加内容到在线文档 — `wecom-cli doc contents append`

在**doc文档**的末尾追加新的文本内容,不影响已有内容。

## 命令

```bash
wecom-cli doc contents append --json '<JSON 参数>'
```

## 参数

| 字段 | 类型 | 必填 | 默认值 | 语义                        |
|---|---|---|---|---------------------------|
| `docid` | string | 是 | — | doc文档 ID                  |
| `content` | string | 是 | — | 要追加的文本内容;支持格式:`text`(纯文本) |

## 返回

追加成功返回空对象。
references/doc-contents-overwrite.md
# 覆盖在线文档内容 — `wecom-cli doc contents overwrite`

全量覆盖**doc文档**中的所有内容,原有内容将被完全替换。

## 命令

```bash
wecom-cli doc contents overwrite --json '<JSON 参数>'
```

## 参数

| 字段 | 类型 | 必填 | 默认值 | 语义 |
|---|---|---|---|---|
| `docid` | string | 是 | — | 在线文档 ID|
| `content_type` | string | 否 |`markdown` | 内容格式枚举:`text` 纯文本 / `markdown`;通常传 `text` |
| `content` | string | 否 | — | 覆盖写入的完整文本;与 `file_path` 二选一。|
| `file_path` | string | 否 | — | 本地文件路径;与 `content` 二选一,支持通过文件路径覆盖 |

## 返回

覆盖成功返回空对象。

## 使用规则

- **`content` / `file_path` 二选一**:可以直接传入内容,也可以传入文件路径;两者不可同时省略。
- **清空文档不能传空值**:`content` 若传 `null`、空字符串或不传都会被拒;要清空请传 `" "`(一个空格)。
references/doc-create.md
# 创建 docx 文件

模型只需写一份 JSONL 描述文件(**不需要写 Python 脚本**),由分发器 `build_docx.py` 把每条命令派发到对应函数完成 `.docx` 生成

# 整体工作流

| 步骤         | 命令                                                                               | 说明 |
|------------|----------------------------------------------------------------------------------|---|
| 1. 写 jsonl | `Write` 工具                                                                       | 输出一个 `*.jsonl` 文件 |
| 2. 生成 docx | `python build_docx.py <*.jsonl>` | 自动应用默认样式 → 按 `action` 派发 → 输出 `.docx` |

> **`build_docx.py` 位置**:(即与 当前 `references/` 同级的 `scripts/` 目录下)

# JSONL 书写规范

## 格式

- 文件后缀:`.jsonl`
- 每行一个 JSON 对象,结构固定为:
  ```json
  {"action": "<函数名>", "params": {<入参对象>}}
  ```
- 每个 JSON 对象必须压缩到单行
- 整个 `.jsonl` 文件中不得出现空行(行与行之间直接相连)

## 所有 action 一览

| Action | 用途                               |
|---|----------------------------------|
| `add_paragraph` | 段落(纯文本 / 列表样式 / Subtitle / 多 run 混排格式) |
| `add_heading` | **所有标题**:封面主标题(level=0,即 Title) + 章节标题(level=1~4) |
| `add_table` | 固定布局表格                           |
| `add_page_break` | 分页                               |

> **硬性规则**:任何"标题"性质的文本(封面主标题、章节/小节标题)一律使用 `add_heading`,**禁止**用 `add_paragraph` + `style: "Title"` 的写法。仅当确实需要"副标题段落"时才使用 `add_paragraph` + `style: "Subtitle"`。

## action详解

### `add_paragraph` — 段落

可写纯文本、套用列表样式、套用 Subtitle、或多 run 混排格式。

> 注意:本 action **不**用于生成"标题"。封面主标题、章节标题一律使用 `add_heading`。

| 参数 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `text` | string | — | 单 run 纯文本(与 `runs` 二选一;同时传以 `runs` 为准) |
| `runs` | array | — | 多 run 混排,元素见下表 |
| `style` | string | — | 内置样式名:`List Bullet` / `List Number` / `Subtitle` |
| `alignment` | string | — | 段落级对齐:`left` / `center` / `right` / `justify` |

`runs` 元素字段(仅字符级格式,无段落级字段):

| 字段 | 类型 | 说明 |
|---|---|---|
| `text` | string | 文本 |
| `bold` / `italic` / `underline` | bool | 粗体 / 斜体 / 下划线 |
| `color_hex` | string | 字色(6 位 hex,无 `#`) |
| `size_pt` | number | 字号 |
| `font` | string | 西文字体 |
| `east_asia_font` | string | 中文字体 |

纯文本:
```jsonl
{"action": "add_paragraph", "params": {"text": "这是一段正文。"}}
```

Title / Subtitle(封面级):
- **Title 必须用 `add_heading` + `level: 0`**,**禁止**写成 `add_paragraph` + `style: "Title"`
- Subtitle 才使用 `add_paragraph` + `style: "Subtitle"`

```jsonl
{"action": "add_heading", "params": {"text": "项目周报", "level": 0}}
{"action": "add_paragraph", "params": {"text": "2026 年第 22 周", "style": "Subtitle"}}
```

列表(必须用内置样式,绝不手写 `•` 或 `1.`):

| 级别 | Bullet 样式 | Number 样式 |
|---|---|---|
| 0 | `List Bullet` | `List Number` |
| 1 | `List Bullet 2` | `List Number 2` |
| 2 | `List Bullet 3` | `List Number 3` |

> 内置最深 3 级;如需更深嵌套,应重组内容结构而非手写 `List Bullet 4`(不存在该样式,运行会报错)

```jsonl
{"action": "add_paragraph", "params": {"text": "一级要点", "style": "List Bullet"}}
{"action": "add_paragraph", "params": {"text": "二级要点", "style": "List Bullet 2"}}
{"action": "add_paragraph", "params": {"text": "编号 1",   "style": "List Number"}}
```

混排格式(同样要压缩到单行):
```jsonl
{"action": "add_paragraph", "params": {"runs": [{"text": "重要:"}, {"text": "请按时提交", "bold": true, "color_hex": "C00000"}, {"text": ",谢谢配合。"}]}}
```

### `add_heading` — 标题(封面主标题 + 章节标题)

**所有标题统一使用本 action**。封面主标题用 `level: 0`(对应 Word 的 Title 样式),章节标题用 `level: 1~4`。

| 参数 | 类型 | 默认 | 说明        |
|---|---|---|-----------|
| `text` | string | `""` | 标题文本      |
| `level` | int | 1 | 标题级别:`0` = 封面主标题(Title),`1~4` = 一~四级章节标题 |

```jsonl
{"action": "add_heading", "params": {"text": "项目周报", "level": 0}}
{"action": "add_heading", "params": {"text": "第一章 引言", "level": 1}}
{"action": "add_heading", "params": {"text": "1.1 背景", "level": 2}}
```

### `add_table` — 固定布局表格

| 参数 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `data` | array<array> | — | **必填**:二维数组,每个元素是一个 cell |

**Cell 的两种合法形态**(**不支持 `runs` 多 run 混排**):

| 形态 | 示例 | 说明 |
|---|---|---|
| 字符串 | `"docid"` | 纯文本 cell |
| 单 run 对象 | `{"text": "字段", "bold": true, "color_hex": "FF0000"}` | 整个 cell 共享一组字符格式 |

Cell 对象支持的字段与 `add_paragraph.runs` 的元素字段完全一致:
`text` / `bold` / `italic` / `underline` / `color_hex` / `size_pt` / `font` / `east_asia_font`。

> **重要**:单元格内**无法做"段内局部高亮"**(即一句话里只标红其中几个字)。如有此类需求,请把高亮文本拆出表格,作为表格上方/下方的独立 `add_paragraph + runs` 段落。

写 `data`(二维数组),只描述每行内容,首行加粗即可。**整条 JSON 必须压缩到单行**:

```jsonl
{"action": "add_table", "params": {"data": [[{"text": "字段", "bold": true}, {"text": "类型", "bold": true}, {"text": "说明", "bold": true}], ["docid", "string", "文档 ID"], ["url", "string", "访问链接"]]}}
```

### `add_page_break` — 分页

无任何参数,传空对象即可。

```jsonl
{"action": "add_page_break", "params": {}}
```

## 五、完整示例

> 注意:示例展示的是**最终 jsonl 文件的真实形态**——每行一条 action,表格压缩为单行,行间无空行。

```jsonl
{"action": "add_heading", "params": {"text": "项目周报", "level": 0}}
{"action": "add_paragraph", "params": {"text": "2026 年第 22 周", "style": "Subtitle"}}
{"action": "add_heading", "params": {"text": "一、本周进展", "level": 1}}
{"action": "add_paragraph", "params": {"text": "完成核心模块开发,进入联调阶段。"}}
{"action": "add_paragraph", "params": {"text": "完成 API 设计评审", "style": "List Bullet"}}
{"action": "add_paragraph", "params": {"text": "完成 60% 核心代码", "style": "List Bullet"}}
{"action": "add_paragraph", "params": {"text": "联调开始", "style": "List Bullet 2"}}
{"action": "add_heading", "params": {"text": "二、风险提示", "level": 1}}
{"action": "add_paragraph", "params": {"runs": [{"text": "需重点关注:"}, {"text": "依赖方接口延期", "bold": true, "color_hex": "C00000"}, {"text": ",预计影响排期 2 天。"}]}}
{"action": "add_heading", "params": {"text": "三、下周计划", "level": 1}}
{"action": "add_table", "params": {"data": [[{"text": "任务", "bold": true}, {"text": "负责人", "bold": true}, {"text": "DDL", "bold": true}], ["完成联调", "张三", "周三"], ["性能压测", "李四", "周四"], ["发版评审", "王五", "周五"]]}}
```
scripts/build_docx.py
"""build_docx.py — Generate a .docx file from a JSONL spec.

Each line of the input file is a single command::

    {"action": "<function_name>", "params": {...}}

Workflow::

    1. 读 JSONL → 一次性按 ``references/doc-create.md`` 做参数校验
       (action 取值 / params 字段名 / 类型 / 取值范围)。
       任何偏差立即抛 ``TypeError``("类型错误,无法执行")。
    2. 校验通过后,再创建 docx 并按 action 派发到 ``DocxBuilder``。
    3. 通过本地文件系统写出 ``.docx``,输出路径自动选取于
       ``WECOMAGENT_WRITABLE_DIRS`` 的第一个目录;同名文件会追加
       ``_1`` / ``_2`` … 后缀避免覆盖。

Usage::

    python build_docx.py <spec.jsonl>
"""

from __future__ import annotations

import argparse
import base64
import functools
import io
import json
import os
import re
import sys
import time
from pathlib import Path
from typing import Any, Iterator, NamedTuple

from docx import Document
from docx.enum.table import WD_TABLE_ALIGNMENT
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml import OxmlElement, parse_xml
from docx.oxml.ns import nsdecls, qn
from docx.shared import Cm, Emu, Pt, RGBColor

# ===========================================================================
# Constants & lookups
# ===========================================================================

_PARAGRAPH_ALIGN = {
    "left": WD_ALIGN_PARAGRAPH.LEFT,
    "center": WD_ALIGN_PARAGRAPH.CENTER,
    "right": WD_ALIGN_PARAGRAPH.RIGHT,
    "justify": WD_ALIGN_PARAGRAPH.JUSTIFY,
}

_TABLE_ALIGN = {
    "left": WD_TABLE_ALIGNMENT.LEFT,
    "center": WD_TABLE_ALIGNMENT.CENTER,
    "right": WD_TABLE_ALIGNMENT.RIGHT,
}

EMU_PER_DXA = 635                  # 1 dxa = 1/20 pt = 635 EMU
DEFAULT_TABLE_TOTAL_DXA = 9072     # ~6.30 in, A4 content-area width
DEFAULT_LINE_DXA_NORMAL = 312
DEFAULT_LINE_DXA_HEADING = 408

# Table-level frame uses a thin theme-default line; per-cell borders
# use a soft gray, applied to every cell so the grid stays consistent
# on renderers that ignore table-level borders.
DEFAULT_TABLE_BORDER_COLOR_HEX = "auto"
DEFAULT_TABLE_BORDER_SIZE = 4
DEFAULT_CELL_BORDER_COLOR_HEX = "CBCDD1"
DEFAULT_CELL_BORDER_SIZE = 6

# --- XSD ordering anchors --------------------------------------------------
# Each tuple lists the children that the new element must appear *before*,
# per the OOXML schema. ``_set_unique_child`` inserts the new element ahead
# of the first sibling found.


def _anchors_after(tag: str, order: tuple) -> tuple:
    """Return the slice of ``order`` strictly after ``tag``."""
    return order[order.index(tag) + 1:]


# CT_PPrBase: shared by snapToGrid (pos 21) and spacing (pos 22) —
# every sibling listed comes after both.
_PPR_SPACING_ANCHORS = (
    qn("w:contextualSpacing"),
    qn("w:jc"),
    qn("w:outlineLvl"),
)

# CT_TblPr order (relevant prefix).
_TBL_PR_ORDER = (
    qn("w:tblW"),
    qn("w:jc"),
    qn("w:tblCellSpacing"),
    qn("w:tblInd"),
    qn("w:tblBorders"),
    qn("w:shd"),
    qn("w:tblLayout"),
    qn("w:tblLook"),
)
_TBL_PR_TBLW_ANCHORS = _anchors_after(qn("w:tblW"), _TBL_PR_ORDER)
_TBL_PR_BORDERS_ANCHORS = _anchors_after(qn("w:tblBorders"), _TBL_PR_ORDER)
_TBL_PR_LAYOUT_ANCHORS = _anchors_after(qn("w:tblLayout"), _TBL_PR_ORDER)

# CT_TcPrInner order (relevant prefix).
_TC_PR_ORDER = (
    qn("w:tcBorders"),
    qn("w:shd"),
    qn("w:noWrap"),
    qn("w:tcMar"),
    qn("w:textDirection"),
    qn("w:tcFitText"),
    qn("w:vAlign"),
    qn("w:hideMark"),
    qn("w:headers"),
)
_TC_PR_BORDERS_ANCHORS = _anchors_after(qn("w:tcBorders"), _TC_PR_ORDER)
_TC_PR_SHD_ANCHORS = _anchors_after(qn("w:shd"), _TC_PR_ORDER)
_TC_PR_MAR_ANCHORS = _anchors_after(qn("w:tcMar"), _TC_PR_ORDER)
_TC_PR_VALIGN_ANCHORS = _anchors_after(qn("w:vAlign"), _TC_PR_ORDER)


class _HeadingPreset(NamedTuple):
    style_name: str
    size_pt: int
    color_hex: str
    alignment: str | None


# Title 24pt → H1 18pt → H2 16pt → H3 14pt → H4 12pt → H5/H6 11pt.
_DEFAULT_HEADINGS: tuple[_HeadingPreset, ...] = (
    _HeadingPreset("Title",     24, "1A1A1A", "center"),
    _HeadingPreset("Subtitle",  18, "5C5C5C", "center"),
    _HeadingPreset("Heading 1", 18, "1A1A1A", None),
    _HeadingPreset("Heading 2", 16, "1A1A1A", None),
    _HeadingPreset("Heading 3", 14, "1A1A1A", None),
    _HeadingPreset("Heading 4", 12, "1A1A1A", None),
    _HeadingPreset("Heading 5", 11, "1A1A1A", None),
    _HeadingPreset("Heading 6", 11, "1A1A1A", None),
)

# 6 位十六进制颜色字符串,可选前缀 '#'。供 _hex_to_rgb / 上游校验复用。
_HEX_COLOR_RE = re.compile(r"^#?[0-9A-Fa-f]{6}$")


# ===========================================================================
# Exceptions
# ===========================================================================


class SpecTypeError(TypeError):
    """JSONL 参数校验失败抛出,等同 ``TypeError``,附带行号上下文。"""


# Backwards-compatible alias for any existing caller that imports SpecError.
SpecError = SpecTypeError


# ===========================================================================
# JSONL spec validation — 上游一次性校验(基于 references/doc-create.md)
# ===========================================================================
#
# 校验范围严格对齐 doc-create.md 中描述的 4 个 action 及其 params。
# 任何偏差均抛出 ``SpecTypeError``(继承自 ``TypeError``),由 main()
# 统一捕获并转成 "类型错误,无法执行" 提示。

# 段落 style 仅支持以下内置样式(doc-create.md:列表样式 + Subtitle)。
ALLOWED_PARAGRAPH_STYLES: frozenset[str] = frozenset({
    "List Bullet", "List Bullet 2", "List Bullet 3",
    "List Number", "List Number 2", "List Number 3",
    "Subtitle",
})

# 段落级对齐枚举。
ALLOWED_ALIGNMENTS: frozenset[str] = frozenset({
    "left", "center", "right", "justify",
})

# run / cell 对象支持的字段及类型(与 doc-create.md 中表格一致)。
# (int, float) 元组用于 "数字" 类(运行时显式排除 bool)。
RUN_FIELD_TYPES: dict[str, Any] = {
    "text": str,
    "bold": bool,
    "italic": bool,
    "underline": bool,
    "color_hex": str,
    "size_pt": (int, float),
    "font": str,
    "east_asia_font": str,
}

# add_heading.level 取值范围(doc-create.md:0=Title,1~4=章节标题)。
HEADING_LEVEL_MIN: int = 0
HEADING_LEVEL_MAX: int = 4

# 结构化输入上限:在校验阶段尽早拒绝异常输入,避免下游构建 / 序列化
MAX_COMMANDS: int = 5000              # 单份 JSONL 的命令条数上限
MAX_TEXT_CHARS: int = 20000           # 段落 text / run.text 单字段长度上限
MAX_RUNS_PER_PARAGRAPH: int = 200     # 单段 runs 数组长度上限
MAX_TABLE_ROWS: int = 10000            # 单表行数上限
MAX_TABLE_COLS: int = 50              # 单行列数上限
MAX_CELL_TEXT_CHARS: int = 5000       # 表格 cell(字符串或 run.text)长度上限


def _spec_raise(ctx: str, msg: str) -> None:
    raise SpecTypeError(f"{ctx}: {msg}")


def _spec_type_name(expected: Any) -> str:
    if isinstance(expected, type):
        return expected.__name__
    if isinstance(expected, tuple):
        return " | ".join(t.__name__ for t in expected if isinstance(t, type))
    return str(expected)


def _spec_check_type(value: Any, expected: Any, ctx: str, name: str) -> None:
    """对值做基础类型检查;显式拒绝 bool 充当 int/float。"""
    if expected is int:
        if isinstance(value, bool) or not isinstance(value, int):
            _spec_raise(ctx, f"参数 '{name}' 类型错误,期望 int,实际 {type(value).__name__}")
        return
    if expected is bool:
        if not isinstance(value, bool):
            _spec_raise(ctx, f"参数 '{name}' 类型错误,期望 bool,实际 {type(value).__name__}")
        return
    if isinstance(expected, tuple) and int in expected and float in expected:
        if isinstance(value, bool) or not isinstance(value, (int, float)):
            _spec_raise(ctx, f"参数 '{name}' 类型错误,期望 number,实际 {type(value).__name__}")
        return
    if not isinstance(value, expected):
        _spec_raise(
            ctx,
            f"参数 '{name}' 类型错误,期望 {_spec_type_name(expected)},"
            f"实际 {type(value).__name__}",
        )


def _spec_check_hex_color(value: Any, ctx: str, name: str) -> None:
    if not (isinstance(value, str) and _HEX_COLOR_RE.match(value)):
        _spec_raise(
            ctx,
            f"参数 '{name}' 必须是 6 位十六进制颜色字符串"
            f"(如 'FF0000' 或 '#FF0000'),实际为 {value!r}",
        )


def _spec_validate_run_object(
    obj: Any,
    ctx: str,
    max_text_chars: int = MAX_TEXT_CHARS,
) -> None:
    """校验一个 run / table-cell 对象(字段集合相同)。

    ``max_text_chars`` 控制 ``text`` 字段的长度上限:段落里的 run 沿用
    ``MAX_TEXT_CHARS``;表格 cell 上下文则收窄到 ``MAX_CELL_TEXT_CHARS``。
    """
    if not isinstance(obj, dict):
        _spec_raise(ctx, f"必须是 JSON 对象(dict),实际 {type(obj).__name__}")

    unknown = set(obj) - set(RUN_FIELD_TYPES)
    if unknown:
        _spec_raise(
            ctx,
            f"包含未知字段 {sorted(unknown)};允许字段: {sorted(RUN_FIELD_TYPES)}",
        )

    for fname, expected in RUN_FIELD_TYPES.items():
        if fname not in obj:
            continue
        _spec_check_type(obj[fname], expected, ctx, fname)

    if "text" in obj and len(obj["text"]) > max_text_chars:
        _spec_raise(
            ctx,
            f"参数 'text' 长度 {len(obj['text'])} 超过上限 {max_text_chars} 字符",
        )

    if "color_hex" in obj:
        _spec_check_hex_color(obj["color_hex"], ctx, "color_hex")

    if "size_pt" in obj:
        size = obj["size_pt"]
        if size < 1 or size > 819:
            _spec_raise(ctx, f"参数 'size_pt' 必须在 [1, 819] 范围内,实际为 {size}")


def _spec_validate_add_paragraph(params: dict, ctx: str) -> None:
    allowed = {"text", "runs", "style", "alignment"}
    unknown = set(params) - allowed
    if unknown:
        _spec_raise(
            ctx,
            f"add_paragraph 含未知参数 {sorted(unknown)};允许参数: {sorted(allowed)}",
        )

    if "text" in params:
        _spec_check_type(params["text"], str, ctx, "text")
        if len(params["text"]) > MAX_TEXT_CHARS:
            _spec_raise(
                ctx,
                f"参数 'text' 长度 {len(params['text'])} 超过上限 "
                f"{MAX_TEXT_CHARS} 字符",
            )

    if "runs" in params:
        runs = params["runs"]
        if not isinstance(runs, list):
            _spec_raise(ctx, f"参数 'runs' 必须是数组,实际 {type(runs).__name__}")
        if len(runs) > MAX_RUNS_PER_PARAGRAPH:
            _spec_raise(
                ctx,
                f"参数 'runs' 数量 {len(runs)} 超过上限 "
                f"{MAX_RUNS_PER_PARAGRAPH}",
            )
        for i, r in enumerate(runs):
            _spec_validate_run_object(r, f"{ctx}.runs[{i}]")

    if "style" in params:
        style = params["style"]
        if not isinstance(style, str) or style not in ALLOWED_PARAGRAPH_STYLES:
            _spec_raise(
                ctx,
                f"参数 'style' 必须是 {sorted(ALLOWED_PARAGRAPH_STYLES)} 之一,"
                f"实际为 {style!r}",
            )

    if "alignment" in params:
        align = params["alignment"]
        if not isinstance(align, str) or align not in ALLOWED_ALIGNMENTS:
            _spec_raise(
                ctx,
                f"参数 'alignment' 必须是 {sorted(ALLOWED_ALIGNMENTS)} 之一,"
                f"实际为 {align!r}",
            )


def _spec_validate_add_heading(params: dict, ctx: str) -> None:
    allowed = {"text", "level"}
    unknown = set(params) - allowed
    if unknown:
        _spec_raise(
            ctx,
            f"add_heading 含未知参数 {sorted(unknown)};允许参数: {sorted(allowed)}",
        )

    if "text" in params:
        _spec_check_type(params["text"], str, ctx, "text")

    if "level" in params:
        level = params["level"]
        if isinstance(level, bool) or not isinstance(level, int):
            _spec_raise(ctx, f"参数 'level' 必须是整数,实际为 {type(level).__name__}")
        if not (HEADING_LEVEL_MIN <= level <= HEADING_LEVEL_MAX):
            _spec_raise(
                ctx,
                f"参数 'level' 必须在 [{HEADING_LEVEL_MIN}, {HEADING_LEVEL_MAX}] 之间"
                f"(0=封面主标题 Title,1~4=一~四级章节标题),实际为 {level}",
            )


def _spec_validate_add_table(params: dict, ctx: str) -> None:
    allowed = {"data"}
    unknown = set(params) - allowed
    if unknown:
        _spec_raise(
            ctx,
            f"add_table 含未知参数 {sorted(unknown)};仅支持参数: {sorted(allowed)}",
        )

    if "data" not in params:
        _spec_raise(ctx, "add_table 缺少必填参数 'data'")

    data = params["data"]
    if not isinstance(data, list):
        _spec_raise(ctx, f"参数 'data' 必须是二维数组,实际 {type(data).__name__}")
    if not data:
        _spec_raise(ctx, "参数 'data' 不能为空数组")
    if len(data) > MAX_TABLE_ROWS:
        _spec_raise(
            ctx,
            f"参数 'data' 行数 {len(data)} 超过上限 {MAX_TABLE_ROWS}",
        )

    for ri, row in enumerate(data):
        if not isinstance(row, list):
            _spec_raise(
                ctx,
                f"参数 'data[{ri}]' 必须是数组(一行 cells),实际 {type(row).__name__}",
            )
        if len(row) > MAX_TABLE_COLS:
            _spec_raise(
                ctx,
                f"参数 'data[{ri}]' 列数 {len(row)} 超过上限 {MAX_TABLE_COLS}",
            )
        for ci, cell in enumerate(row):
            cell_ctx = f"{ctx}.data[{ri}][{ci}]"
            if isinstance(cell, str):
                if len(cell) > MAX_CELL_TEXT_CHARS:
                    _spec_raise(
                        cell_ctx,
                        f"cell 文本长度 {len(cell)} 超过上限 "
                        f"{MAX_CELL_TEXT_CHARS} 字符",
                    )
                continue
            if isinstance(cell, dict):
                _spec_validate_run_object(
                    cell, cell_ctx, max_text_chars=MAX_CELL_TEXT_CHARS,
                )
                continue
            _spec_raise(
                cell_ctx,
                f"cell 必须是字符串或 dict(单 run 对象),实际 {type(cell).__name__}",
            )


def _spec_validate_add_page_break(params: dict, ctx: str) -> None:
    if params:
        _spec_raise(ctx, f"add_page_break 不接受任何参数,实际为 {params!r}")


# action 名称 → 校验函数;同时充当 "合法 action 集合"。
_SPEC_ACTION_VALIDATORS: dict[str, Any] = {
    "add_paragraph":  _spec_validate_add_paragraph,
    "add_heading":    _spec_validate_add_heading,
    "add_table":      _spec_validate_add_table,
    "add_page_break": _spec_validate_add_page_break,
}


def _spec_validate_command(cmd: Any, line_no: int) -> tuple[str, dict]:
    """校验单条 JSONL 命令,返回 ``(action, params)`` 便于派发器复用。"""
    ctx = f"Line {line_no}"

    if not isinstance(cmd, dict):
        _spec_raise(ctx, f"每行必须是 JSON 对象,实际 {type(cmd).__name__}")

    extra = set(cmd) - {"action", "params"}
    if extra:
        _spec_raise(ctx, f"命令仅允许 'action' / 'params' 字段,多余字段: {sorted(extra)}")

    if "action" not in cmd:
        _spec_raise(ctx, "缺少必填字段 'action'")

    action = cmd["action"]
    if not isinstance(action, str):
        _spec_raise(ctx, f"'action' 必须是字符串,实际 {type(action).__name__}")
    if action not in _SPEC_ACTION_VALIDATORS:
        _spec_raise(
            ctx,
            f"未知的 action {action!r},允许的 action: {sorted(_SPEC_ACTION_VALIDATORS)}",
        )

    params = cmd.get("params", {})
    if not isinstance(params, dict):
        _spec_raise(ctx, f"'params' 必须是 JSON 对象,实际 {type(params).__name__}")

    _SPEC_ACTION_VALIDATORS[action](params, f"{ctx} action='{action}'")
    return action, params


# ===========================================================================
# Sandboxed local IO helpers
# ===========================================================================
#
# 所有 fs 读写都限制在
# ``WECOMAGENT_READABLE_DIRS`` / ``WECOMAGENT_WRITABLE_DIRS`` 限定
# (JSON 数组:``[{"path": "/abs/dir", "label": "..."}]``)。

ENV_READABLE = "WECOMAGENT_READABLE_DIRS"
ENV_WRITABLE = "WECOMAGENT_WRITABLE_DIRS"

# 读入 / 写出文件的大小硬上限:30 MiB。
# - 读入:避免一次性把巨型 JSONL 拉进内存撑爆进程;
# - 写出:避免生成过大的 .docx 写入磁盘(base64 后体积更大)。
MAX_FILE_SIZE_BYTES = 30 * 1024 * 1024


@functools.lru_cache(maxsize=None)
def _parse_roots(env_name: str) -> tuple[str, ...]:
    """Parse a JSON-array env var into a tuple of realpath roots (cached)."""
    raw = os.environ.get(env_name, "")
    if not raw:
        raise RuntimeError(f"环境变量 {env_name} 未设置或为空")
    parsed = json.loads(raw)
    if not isinstance(parsed, list):
        raise RuntimeError(
            f"{env_name} 必须是 JSON 数组,实际为 {type(parsed).__name__}"
        )
    roots: list[str] = []
    for it in parsed:
        if isinstance(it, str):
            it = json.loads(it)
        if not isinstance(it, dict):
            raise RuntimeError(
                f"{env_name} 元素必须是 dict 或 dict 的 JSON 字符串,"
                f"实际为 {type(it).__name__}"
            )
        p = it.get("path")
        if not isinstance(p, str) or not p.strip():
            raise RuntimeError(f"{env_name} 元素缺少有效的 path 字段: {it!r}")
        roots.append(os.path.realpath(p.strip()))
    if not roots:
        raise RuntimeError(f"环境变量 {env_name} 解析后为空")
    return tuple(roots)


def _reject_relative_segments(path: str) -> None:
    """Reject path strings that include ``.`` or ``..`` segments such as
    ``./foo``, ``../bar`` or ``/abs/path/../x``.

    Although ``os.path.realpath`` would silently normalize these away,
    accepting them would bypass the contract that callers must hand in
    explicit, fully-qualified paths inside the sandboxed roots — and
    could be abused to escape the intended directory in edge cases where
    symlinks are present.
    """
    if not path:
        return
    for seg in path.replace("\\", "/").split("/"):
        if seg in (".", ".."):
            raise ValueError(
                f"路径不允许包含 './' 或 '../' 这类相对路径片段: {path!r}"
            )


def _ensure_within(path: str, env_name: str) -> str:
    """Realpath ``path`` and ensure it lies within one of ``env_name``'s roots."""
    if not path:
        raise ValueError("path 不能为空")
    _reject_relative_segments(path)
    real = os.path.realpath(path)
    roots = _parse_roots(env_name)
    for root in roots:
        try:
            common = os.path.commonpath([real, root])
        except ValueError:
            continue
        if common == root:
            return real
    raise PermissionError(
        f"路径越权: {real} 不在 {env_name} 范围 {roots} 之内"
    )


def _read_text(path: str) -> str:
    """Read a UTF-8 text file from an allowed readable directory.

    最多读取 ``MAX_FILE_SIZE_BYTES + 1`` 字节,以便在不把超大文件
    整体载入内存的前提下判断是否超限。
    """
    real = _ensure_within(path, ENV_READABLE)
    with open(real, "rb") as f:
        data = f.read(MAX_FILE_SIZE_BYTES + 1)
    if len(data) > MAX_FILE_SIZE_BYTES:
        raise ValueError(
            f"输入文件过大:{path!r} "
            f"超过上限 {MAX_FILE_SIZE_BYTES} 字节(30 MiB)"
        )
    return data.decode("utf-8")


def _write_b64(path: str, data_b64: str, overwrite: bool = False) -> None:
    """Write a base64-encoded binary blob to an allowed writable directory.

    这里在写入前对路径做 ``os.path.islink`` 检查并显式拒绝:
      - 检查 ``path``(原始入参):拦截 "目标位置本身就是软链" 的常见情况;
      - 检查 ``real``(realpath 结果):作为防御纵深,覆盖悬挂软链 /
        竞态等 realpath 仍可能返回软链的边缘情况。
    """
    real = _ensure_within(path, ENV_WRITABLE)
    if os.path.islink(path) or os.path.islink(real):
        raise PermissionError(
            f"拒绝写入符号链接以避免跨目录覆盖: {path!r}"
        )
    data = base64.b64decode(data_b64, validate=True)
    if len(data) > MAX_FILE_SIZE_BYTES:
        raise ValueError(
            f"输出文件过大:解码后 {len(data)} 字节,"
            f"超过上限 {MAX_FILE_SIZE_BYTES} 字节(30 MiB)"
        )

    parent = os.path.dirname(real)
    os.makedirs(parent, exist_ok=True)

    # 创建父目录后再次解析路径,防止目录在检查和写入之间变为软链。
    real = _ensure_within(path, ENV_WRITABLE)
    if os.path.islink(path) or os.path.islink(real):
        raise PermissionError(
            f"拒绝写入符号链接以避免跨目录覆盖: {path!r}"
        )

    mode = "wb" if overwrite else "xb"
    with open(real, mode) as f:
        f.write(data)


# ===========================================================================
# Generic OOXML helpers
# ===========================================================================
#
# 注:颜色 / 数值 / 取值合法性已在上游 _spec_validate_command 阶段校验,
# 此处不再重复检查;下游 helpers 仅负责生成 OOXML 元素。


def _hex_to_rgb(color_hex: str) -> RGBColor:
    s = color_hex.lstrip("#")
    return RGBColor(int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16))


def _set_unique_child(parent, tag, new_el, insert_before=()) -> None:
    """Replace any existing ``tag`` children of ``parent`` with ``new_el``,
    inserting ahead of the first sibling listed in ``insert_before`` (the
    XSD ordering constraint). Falls back to append."""
    for existing in parent.findall(tag):
        parent.remove(existing)
    for sibling_tag in insert_before:
        sibling = parent.find(sibling_tag)
        if sibling is not None:
            sibling.addprevious(new_el)
            return
    parent.append(new_el)


def _set_east_asia_font(rPr, font_name: str) -> None:
    """Set ``w:eastAsia`` on the rFonts child of ``rPr`` (creating it if needed)."""
    rFonts = rPr.find(qn("w:rFonts"))
    if rFonts is None:
        rFonts = OxmlElement("w:rFonts")
        rPr.insert(0, rFonts)
    rFonts.set(qn("w:eastAsia"), font_name)


def _strip_theme_color(element) -> None:
    """Remove ``themeColor``/``themeTint``/``themeShade`` from every
    ``w:color`` under ``element``.

    Built-in heading styles ship with theme-tinted colors that many
    renderers prefer over the explicit ``w:val``, leaking the accent
    color (typically blue) instead of the requested RGB.
    """
    color_tag = qn("w:color")
    color_attrs = (qn("w:themeColor"), qn("w:themeTint"), qn("w:themeShade"))
    for color_el in element.iter(color_tag):
        for key in color_attrs:
            if key in color_el.attrib:
                del color_el.attrib[key]


def _force_color_on_rpr(rPr, color_hex: str) -> None:
    """Replace any ``<w:color>`` under ``rPr`` with a plain ``w:val`` one
    (no theme attributes)."""
    for existing in rPr.findall(qn("w:color")):
        rPr.remove(existing)
    color_el = OxmlElement("w:color")
    color_el.set(qn("w:val"), color_hex.lstrip("#").upper())
    rFonts = rPr.find(qn("w:rFonts"))
    if rFonts is not None:
        rFonts.addnext(color_el)
    else:
        rPr.insert(0, color_el)


def _apply_run_format(run, spec: dict) -> None:
    """Apply formatting from a run-spec dict to a python-docx Run."""
    if spec.get("bold"):
        run.bold = True
    if spec.get("italic"):
        run.italic = True
    if spec.get("underline"):
        run.underline = True
    if "color_hex" in spec:
        run.font.color.rgb = _hex_to_rgb(spec["color_hex"])
    if "size_pt" in spec:
        run.font.size = Pt(spec["size_pt"])
    if "font" in spec:
        run.font.name = spec["font"]
    if "east_asia_font" in spec:
        _set_east_asia_font(run._element.get_or_add_rPr(), spec["east_asia_font"])


def _make_borders_el(wrapper_tag: str, sides: tuple, color_hex: str, size: int):
    """Build a ``<w:tblBorders>`` / ``<w:tcBorders>`` element with all
    sides sharing the same single-line style, size and color."""
    inner = "".join(
        f'<w:{s} w:val="single" w:sz="{size}" w:color="{color_hex}"/>'
        for s in sides
    )
    return parse_xml(f'<w:{wrapper_tag} {nsdecls("w")}>{inner}</w:{wrapper_tag}>')


# ===========================================================================
# DocxBuilder — every public method (no leading underscore) is a JSONL action
# ===========================================================================


class DocxBuilder:
    """Each public method (no leading underscore) is callable as an `action`.

    所有方法不再做内部参数校验,调用方(``_dispatch``)保证传入的参数
    已通过上游 ``_spec_validate_command`` 检查。
    """

    def __init__(self) -> None:
        self.doc: Any = None  # python-docx Document

    # -- 0. Default initialization -----------------------------------------

    def _init_defaults(self) -> None:
        """Run document + page + Normal + heading defaults. Called once by
        ``run_jsonl`` before any user action; spec-level setup_* overrides win."""
        self._create_document()
        self.setup_page()
        self.setup_normal_style()
        for preset in _DEFAULT_HEADINGS:
            self.setup_heading_style(
                style_name=preset.style_name,
                size_pt=preset.size_pt,
                color_hex=preset.color_hex,
                alignment=preset.alignment,
            )

    # -- 1. Document lifecycle --------------------------------------------

    def _create_document(self) -> None:
        # Underscore-prefixed: not exposed as a JSONL action — calling it
        # twice would replace ``self.doc`` and drop everything written so far.
        self.doc = Document()
        settings = self.doc.settings.element
        zoom = settings.find(qn("w:zoom"))
        if zoom is not None and zoom.get(qn("w:percent")) is None:
            zoom.set(qn("w:percent"), "100")

    def save(self, path: str) -> None:
        """Persist the document to the local filesystem.

        ``overwrite=False`` enforces the "never clobber an existing .docx"
        guarantee that ``_pick_output_path`` makes when picking the filename.

        在编码 / 写入之前校验序列化后的 docx 体积不得超过
        ``MAX_FILE_SIZE_BYTES``;超限直接抛 ``ValueError`` 中止保存。
        """
        buf = io.BytesIO()
        self.doc.save(buf)
        size = buf.tell()
        if size > MAX_FILE_SIZE_BYTES:
            raise ValueError(
                f"输出文件过大:序列化后 {size} 字节,"
                f"超过上限 {MAX_FILE_SIZE_BYTES} 字节(30 MiB)"
            )
        data_b64 = base64.b64encode(buf.getvalue()).decode("ascii")
        _write_b64(path, data_b64, overwrite=False)

    # -- 2. Page setup ----------------------------------------------------

    def setup_page(
        self,
        width_cm: float = 21,
        height_cm: float = 29.7,
        top_cm: float = 2.4,
        bottom_cm: float = 2.4,
        left_cm: float = 2.5,
        right_cm: float = 2.5,
        header_cm: float = 1.27,
        footer_cm: float = 1.27,
    ) -> None:
        section = self.doc.sections[0]
        section.page_width = Cm(width_cm)
        section.page_height = Cm(height_cm)
        section.top_margin = Cm(top_cm)
        section.bottom_margin = Cm(bottom_cm)
        section.left_margin = Cm(left_cm)
        section.right_margin = Cm(right_cm)
        section.header_distance = Cm(header_cm)
        section.footer_distance = Cm(footer_cm)

    # -- 3. Style setup ---------------------------------------------------

    def setup_normal_style(
        self,
        font: str = "Arial",
        east_asia_font: str = "微软雅黑",
        size_pt: float = 11,
        color_hex: str = "333333",
        before_dxa: int = 60,
        after_dxa: int = 60,
        line_dxa: int = DEFAULT_LINE_DXA_NORMAL,
        widow_control: bool = False,
        snap_to_grid: bool = False,
    ) -> None:
        style = self.doc.styles["Normal"]
        style.font.name = font
        style.font.size = Pt(size_pt)
        style.font.color.rgb = _hex_to_rgb(color_hex)

        rPr = style.element.get_or_add_rPr()
        _set_east_asia_font(rPr, east_asia_font)

        style.paragraph_format.widow_control = widow_control

        pPr = style.element.get_or_add_pPr()

        snap = OxmlElement("w:snapToGrid")
        snap.set(qn("w:val"), "1" if snap_to_grid else "0")
        _set_unique_child(pPr, qn("w:snapToGrid"), snap, _PPR_SPACING_ANCHORS)

        sp = self._make_spacing_el(before_dxa, after_dxa, line_dxa)
        _set_unique_child(pPr, qn("w:spacing"), sp, _PPR_SPACING_ANCHORS)

    def setup_heading_style(
        self,
        style_name: str,
        size_pt: float,
        color_hex: str = "1A1A1A",
        bold: bool = True,
        font: str = "Arial",
        line_dxa: int = DEFAULT_LINE_DXA_HEADING,
        before_dxa: int = 0,
        after_dxa: int = 0,
        alignment: str | None = None,
        keep_with_next: bool = True,
        keep_together: bool = True,
    ) -> None:
        """Configure Title / Subtitle / Heading 1..N styles.

        仅由 ``_init_defaults`` 内部调用,传入的参数来自 ``_DEFAULT_HEADINGS``
        预置常量,已知合法;不再做单独校验。
        """
        style = self.doc.styles[style_name]
        style.font.name = font
        style.font.size = Pt(size_pt)
        style.font.bold = bold
        style.font.color.rgb = _hex_to_rgb(color_hex)
        style.paragraph_format.space_before = Pt(0)
        style.paragraph_format.space_after = Pt(0)
        style.paragraph_format.keep_with_next = keep_with_next
        style.paragraph_format.keep_together = keep_together
        if alignment is not None:
            style.paragraph_format.alignment = _PARAGRAPH_ALIGN[alignment]

        pPr = style.element.get_or_add_pPr()
        sp = self._make_spacing_el(before_dxa, after_dxa, line_dxa)
        _set_unique_child(pPr, qn("w:spacing"), sp, _PPR_SPACING_ANCHORS)

        # Drop the decorative theme-accent bottom border that built-in
        # Title (and a few headings) ship with.
        for pBdr in pPr.findall(qn("w:pBdr")):
            pPr.remove(pBdr)

        # Strip themeColor/Tint/Shade everywhere in the style element so
        # the requested RGB actually wins on renderers that prefer theme.
        _strip_theme_color(style.element)

        # Mirror the color onto the paired *character* style (Title↔TitleChar,
        # Heading 1↔Heading1Char, …): some renderers (WPS, Word for Mac /
        # Online, the WeCom doc preview) apply the linked char style's rPr
        # to runs in preference to the paragraph style's rPr.
        self._sync_linked_char_style_color(style, color_hex)

        # Strip leaked python-docx template defaults and add bCs/szCs that
        # the high-level setters skip; do the same on the linked char style.
        self._normalize_heading_style_element(style.element, bold)
        linked_char = self._resolve_linked_char_style_element(style)
        if linked_char is not None:
            self._normalize_heading_style_element(linked_char, bold)

    @staticmethod
    def _make_spacing_el(before_dxa: int, after_dxa: int, line_dxa: int):
        sp = OxmlElement("w:spacing")
        sp.set(qn("w:before"), str(before_dxa))
        sp.set(qn("w:after"), str(after_dxa))
        sp.set(qn("w:line"), str(line_dxa))
        sp.set(qn("w:lineRule"), "auto")
        return sp

    def _sync_linked_char_style_color(self, paragraph_style, color_hex: str) -> None:
        """Mirror ``color_hex`` onto the paragraph style's linked char style
        (silent no-op if there is no link or it cannot be resolved)."""
        target = self._resolve_linked_char_style_element(paragraph_style)
        if target is None:
            return
        rPr = target.find(qn("w:rPr"))
        if rPr is None:
            rPr = OxmlElement("w:rPr")
            target.append(rPr)
        _force_color_on_rpr(rPr, color_hex)

    def _resolve_linked_char_style_element(self, paragraph_style):
        """Return the ``<w:style>`` element of the linked char style, or None."""
        link_el = paragraph_style.element.find(qn("w:link"))
        if link_el is None:
            return None
        char_style_id = link_el.get(qn("w:val"))
        if not char_style_id:
            return None
        for s in self.doc.styles.element.findall(qn("w:style")):
            if s.get(qn("w:styleId")) == char_style_id:
                return s
        return None

    @staticmethod
    def _normalize_heading_style_element(style_element, bold: bool) -> None:
        """Strip python-docx template defaults on built-in heading styles
        and add the bCs / szCs siblings that high-level setters skip.

        Removed: pPr/contextualSpacing (Title), pPr/numPr (Subtitle),
        rPr/i, rPr/iCs, rPr/spacing, rPr/kern, and theme-bound rFonts
        attributes (asciiTheme/hAnsiTheme/eastAsiaTheme/cstheme).
        Added: rPr/bCs (paired with rPr/b) for CJK / complex-script bold;
        rPr/szCs synced to rPr/sz so font.size actually applies.
        """
        pPr = style_element.find(qn("w:pPr"))
        if pPr is not None:
            for tag in ("w:contextualSpacing", "w:numPr"):
                for child in pPr.findall(qn(tag)):
                    pPr.remove(child)

        rPr = style_element.find(qn("w:rPr"))
        if rPr is None:
            return

        for tag in ("w:i", "w:iCs", "w:spacing", "w:kern"):
            for child in rPr.findall(qn(tag)):
                rPr.remove(child)

        rFonts = rPr.find(qn("w:rFonts"))
        if rFonts is not None:
            for attr in (
                "w:asciiTheme",
                "w:hAnsiTheme",
                "w:eastAsiaTheme",
                "w:cstheme",
            ):
                key = qn(attr)
                if key in rFonts.attrib:
                    del rFonts.attrib[key]

        if bold:
            b = rPr.find(qn("w:b"))
            if b is not None and rPr.find(qn("w:bCs")) is None:
                bCs = OxmlElement("w:bCs")
                b.addnext(bCs)

        sz = rPr.find(qn("w:sz"))
        if sz is not None:
            sz_val = sz.get(qn("w:val"))
            if sz_val:
                szCs = rPr.find(qn("w:szCs"))
                if szCs is None:
                    szCs = OxmlElement("w:szCs")
                    sz.addnext(szCs)
                szCs.set(qn("w:val"), sz_val)

    # -- 4. Content blocks ------------------------------------------------

    def add_heading(self, text: str = "", level: int = 1) -> None:
        self.doc.add_heading(text, level=level)

    def add_paragraph(
        self,
        text: str | None = None,
        runs: list[dict] | None = None,
        style: str | None = None,
        alignment: str | None = None,
    ) -> None:
        """Add a paragraph.

        - ``text``  : single-run plain text.
        - ``runs``  : list of run-specs (bold/italic/color/size...). If both
                      ``text`` and ``runs`` are given, ``runs`` wins.
        - ``style`` : built-in style name, e.g. 'List Bullet', 'List Number',
                      'Subtitle'.
        """
        p = self.doc.add_paragraph(style=style) if style else self.doc.add_paragraph()
        if alignment:
            p.alignment = _PARAGRAPH_ALIGN[alignment]
        if runs:
            for r in runs:
                run = p.add_run(r.get("text", ""))
                _apply_run_format(run, r)
        elif text is not None:
            p.add_run(text)

    def add_page_break(self) -> None:
        self.doc.add_page_break()

    # -- 5. Table ---------------------------------------------------------

    def add_table(
        self,
        data: list,
        col_widths_dxa: list[int] | None = None,
        total_width_dxa: int = DEFAULT_TABLE_TOTAL_DXA,
        border_color_hex: str = DEFAULT_CELL_BORDER_COLOR_HEX,
        border_size: int = DEFAULT_CELL_BORDER_SIZE,
        cell_margin_dxa: tuple | list = (0, 108, 0, 108),  # top, left, bottom, right
        header_shading_hex: str | None = None,
        alignment: str = "center",
        cell_v_align: str = "center",
    ) -> None:
        """Add a fixed-layout table.

        ``data`` is a list of rows. Each row is a list of cells. A cell can
        be a plain string OR a dict like
        ``{"text": "...", "bold": true, "color_hex": "FF0000"}``.

        The first row is auto-tagged with ``<w:tblHeader/>`` so it repeats
        on page breaks. Header shading is OFF by default — pass
        ``header_shading_hex="2972F4"`` to opt in.
        """
        if not data:
            return

        rows = len(data)
        cols = max(len(r) for r in data)
        col_widths = self._resolve_col_widths(cols, col_widths_dxa, total_width_dxa)

        table = self._create_blank_table(rows, cols, alignment)
        self._apply_table_width(table)
        self._apply_table_borders(
            table,
            DEFAULT_TABLE_BORDER_COLOR_HEX,
            DEFAULT_TABLE_BORDER_SIZE,
        )
        self._apply_table_fixed_layout(table)
        self._strip_table_look(table)
        self._apply_table_grid(table, col_widths)
        self._mark_header_row(table)
        self._fill_table_cells(
            table, data, col_widths,
            cell_margin_dxa, header_shading_hex, cell_v_align,
            border_color_hex, border_size,
        )

    # -- 5.1 Table internals ---------------------------------------------

    @staticmethod
    def _resolve_col_widths(
        cols: int,
        col_widths_dxa: list[int] | None,
        total_width_dxa: int,
    ) -> list[int]:
        if col_widths_dxa:
            return list(col_widths_dxa)
        base = total_width_dxa // cols
        widths = [base] * cols
        widths[-1] += total_width_dxa - base * cols  # absorb rounding
        return widths

    def _create_blank_table(self, rows: int, cols: int, alignment: str):
        # Intentionally no ``table.style = "Table Grid"`` — we provide all
        # visual properties explicitly, and a built-in style would leak its
        # own border / shading defaults.
        table = self.doc.add_table(rows=rows, cols=cols)
        table.alignment = _TABLE_ALIGN.get(alignment, WD_TABLE_ALIGNMENT.CENTER)
        return table

    @staticmethod
    def _apply_table_width(table) -> None:
        # ``tblW`` declares ``auto``; the actual width is dictated by
        # ``tblLayout=fixed`` plus the explicit ``<w:gridCol>`` widths.
        tblPr = table._tbl.tblPr
        tblW = OxmlElement("w:tblW")
        tblW.set(qn("w:w"), "0")
        tblW.set(qn("w:type"), "auto")
        _set_unique_child(tblPr, qn("w:tblW"), tblW, _TBL_PR_TBLW_ANCHORS)

    @staticmethod
    def _apply_table_borders(table, color_hex: str, size: int) -> None:
        # Table-level frame only; per-cell borders are added separately so
        # the grid stays visible on renderers that ignore <w:tblBorders>.
        tblPr = table._tbl.tblPr
        el = _make_borders_el(
            "tblBorders",
            ("top", "left", "bottom", "right", "insideH", "insideV"),
            color_hex,
            size,
        )
        _set_unique_child(tblPr, qn("w:tblBorders"), el, _TBL_PR_BORDERS_ANCHORS)

    @staticmethod
    def _apply_table_fixed_layout(table) -> None:
        tblPr = table._tbl.tblPr
        el = OxmlElement("w:tblLayout")
        el.set(qn("w:type"), "fixed")
        _set_unique_child(tblPr, qn("w:tblLayout"), el, _TBL_PR_LAYOUT_ANCHORS)

    @staticmethod
    def _strip_table_look(table) -> None:
        # We don't attach a table style, so <w:tblLook> (firstRow / banding /
        # ... toggles) is inert noise relative to the target docx.
        tblPr = table._tbl.tblPr
        for el in tblPr.findall(qn("w:tblLook")):
            tblPr.remove(el)

    @staticmethod
    def _apply_table_grid(table, col_widths_dxa: list[int]) -> None:
        grid = table._tbl.find(qn("w:tblGrid"))
        if grid is None:
            return
        for col in list(grid.findall(qn("w:gridCol"))):
            grid.remove(col)
        for w in col_widths_dxa:
            gc = OxmlElement("w:gridCol")
            gc.set(qn("w:w"), str(w))
            grid.append(gc)

    @staticmethod
    def _mark_header_row(table) -> None:
        # Tag the first row with <w:tblHeader/> so it repeats on page breaks.
        if not table.rows:
            return
        tr = table.rows[0]._tr
        trPr = tr.find(qn("w:trPr"))
        if trPr is None:
            trPr = OxmlElement("w:trPr")
            tr.insert(0, trPr)  # trPr precedes <w:tc> per the OOXML schema
        if trPr.find(qn("w:tblHeader")) is None:
            trPr.append(OxmlElement("w:tblHeader"))

    def _fill_table_cells(
        self,
        table,
        data: list,
        col_widths_dxa: list[int],
        cell_margin_dxa: tuple | list,
        header_shading_hex: str | None,
        cell_v_align: str,
        cell_border_color_hex: str,
        cell_border_size: int,
    ) -> None:
        cols = len(col_widths_dxa)
        for ri, row_data in enumerate(data):
            for ci in range(cols):
                cell = table.rows[ri].cells[ci]
                value = row_data[ci] if ci < len(row_data) else None

                self._set_cell_width(cell, col_widths_dxa[ci])
                self._write_cell_content(cell, value)

                # Apply tcBorders → shd → tcMar → vAlign in this order so
                # the anchor lookups in ``_set_unique_child`` resolve.
                self._apply_cell_borders(
                    cell, cell_border_color_hex, cell_border_size,
                )
                if ri == 0 and header_shading_hex:
                    self._apply_cell_shading(cell, header_shading_hex)
                self._apply_cell_margin(cell, cell_margin_dxa)
                self._apply_cell_v_align(cell, cell_v_align)

    @staticmethod
    def _set_cell_width(cell, width_dxa: int) -> None:
        # cell.width takes EMU-typed Length; convert dxa → EMU.
        cell.width = Emu(width_dxa * EMU_PER_DXA)

    @staticmethod
    def _write_cell_content(cell, value) -> None:
        # ``cell.text = ""`` leaves an empty <w:r/> placeholder that renders
        # as a stray empty run; clear runs on the first paragraph instead.
        p = cell.paragraphs[0]
        for run in list(p.runs):
            run._element.getparent().remove(run._element)
        if value is None:
            return
        if isinstance(value, dict):
            run = p.add_run(value.get("text", ""))
            _apply_run_format(run, value)
        else:
            p.add_run(str(value))

    @staticmethod
    def _apply_cell_borders(cell, color_hex: str, size: int) -> None:
        # Per-cell <w:tcBorders> in addition to the table-level frame so
        # the grid stays intact on renderers that disagree on which level
        # of border is authoritative.
        tcPr = cell._tc.get_or_add_tcPr()
        el = _make_borders_el(
            "tcBorders",
            ("top", "left", "bottom", "right"),
            color_hex,
            size,
        )
        _set_unique_child(tcPr, qn("w:tcBorders"), el, _TC_PR_BORDERS_ANCHORS)

    @staticmethod
    def _apply_cell_shading(cell, fill_hex: str) -> None:
        tcPr = cell._tc.get_or_add_tcPr()
        shd = parse_xml(
            f'<w:shd {nsdecls("w")} w:val="clear" '
            f'w:color="auto" w:fill="{fill_hex}"/>'
        )
        _set_unique_child(tcPr, qn("w:shd"), shd, _TC_PR_SHD_ANCHORS)

    @staticmethod
    def _apply_cell_margin(cell, margin_dxa: tuple | list) -> None:
        # margin_dxa = (top, left, bottom, right).
        top, left, bottom, right = margin_dxa
        tcPr = cell._tc.get_or_add_tcPr()
        el = parse_xml(
            f'<w:tcMar {nsdecls("w")}>'
            f'  <w:top w:w="{top}" w:type="dxa"/>'
            f'  <w:left w:w="{left}" w:type="dxa"/>'
            f'  <w:bottom w:w="{bottom}" w:type="dxa"/>'
            f'  <w:right w:w="{right}" w:type="dxa"/>'
            f'</w:tcMar>'
        )
        _set_unique_child(tcPr, qn("w:tcMar"), el, _TC_PR_MAR_ANCHORS)

    @staticmethod
    def _apply_cell_v_align(cell, val: str) -> None:
        tcPr = cell._tc.get_or_add_tcPr()
        el = parse_xml(f'<w:vAlign {nsdecls("w")} w:val="{val}"/>')
        _set_unique_child(tcPr, qn("w:vAlign"), el, _TC_PR_VALIGN_ANCHORS)


# ===========================================================================
# JSONL spec parsing
# ===========================================================================


def _iter_spec_commands(spec_path: str) -> Iterator[tuple[int, Any]]:
    """Yield ``(line_no, cmd)`` from a JSONL spec file.

    Tolerates blank lines, leading UTF-8 BOM, ``//`` / ``#`` comment lines,
    and JSON objects pretty-printed across multiple lines (uses
    ``raw_decode`` to consume one object at a time). ``line_no`` is the
    1-based line where each object *starts*.
    """
    text = _read_text(spec_path)

    if text.startswith("\ufeff"):
        text = text[1:]

    decoder = json.JSONDecoder()
    idx = 0
    n = len(text)

    # Incremental newline counter: scans only the disjoint segment
    # text[line_cursor:idx] each iteration → O(N) total.
    line_cursor = 0
    line_no = 1

    while idx < n:
        ch = text[idx]

        if ch.isspace():
            idx += 1
            continue

        if ch == "#" or text.startswith("//", idx):
            nl = text.find("\n", idx)
            if nl == -1:
                break
            idx = nl + 1
            continue

        line_no += text.count("\n", line_cursor, idx)
        line_cursor = idx
        start_line = line_no

        try:
            cmd, end = decoder.raw_decode(text, idx)
        except json.JSONDecodeError as e:
            raise SpecTypeError(f"Line {start_line}: invalid JSON: {e}") from e

        yield start_line, cmd
        idx = end


# ===========================================================================
# Dispatcher & runner
# ===========================================================================


def _dispatch(builder: DocxBuilder, action: str, params: dict) -> None:
    """执行单条已通过校验的命令。

    上游 ``_spec_validate_command`` 已确保 ``action`` 合法且 ``params``
    形态正确,这里直接派发到对应方法即可。
    """
    method = getattr(builder, action)
    method(**params)


def run_jsonl(spec_path: str, output: str) -> str:
    if not output:
        raise SpecTypeError("An output path must be provided.")

    # 1) 一次性把整份 JSONL 读出来,全部走完上游校验,再开始写文档。
    #    任何参数问题都会以 SpecTypeError("类型错误,无法执行") 抛出。
    #    命令总数受 MAX_COMMANDS 限制,超限立即中止以避免下游构建阶段
    #    因海量命令而 OOM / 卡死。
    commands: list[tuple[str, dict]] = []
    for line_no, cmd in _iter_spec_commands(spec_path):
        if len(commands) >= MAX_COMMANDS:
            raise SpecTypeError(
                f"Line {line_no}: 命令总数超过上限 {MAX_COMMANDS}"
            )
        action, params = _spec_validate_command(cmd, line_no)
        commands.append((action, params))

    # 2) 校验通过 → 实际生成文档并保存。
    builder = DocxBuilder()
    builder._init_defaults()
    for action, params in commands:
        _dispatch(builder, action, params)
    builder.save(output)

    return output


# ===========================================================================
# CLI
# ===========================================================================


def _pick_output_path(spec_path: str) -> str:
    """Pick a non-conflicting ``.docx`` path under the writable root.

    Filename derives from the spec's stem (``report.jsonl`` → ``report.docx``);
    on conflict a timestamp suffix is appended to avoid overwriting.
    """
    target_dir = os.path.join(_parse_roots(ENV_WRITABLE)[0], "docx")
    target_dir = _ensure_within(target_dir, ENV_WRITABLE)
    stem = Path(spec_path).stem or "document"
    if not re.fullmatch(r"[A-Za-z0-9_.\-]{1,128}", stem):
        stem = "document"

    candidate = os.path.join(target_dir, f"{stem}.docx")
    if not os.path.lexists(candidate):
        return candidate
    # Multi-user host: combine millisecond timestamp with PID to avoid
    # collisions between concurrent processes within the same millisecond.
    suffix = f"{int(time.time_ns() // 1_000_000)}_{os.getpid()}"
    candidate = os.path.join(target_dir, f"{stem}_{suffix}.docx")
    if not os.path.lexists(candidate):
        return candidate
    # 时间戳 + PID 仍然冲突属于极端异常情况,直接报错而非覆盖既有文件。
    raise FileExistsError(f"无法生成唯一的输出路径:{candidate} 已存在")


def main() -> None:
    parser = argparse.ArgumentParser(
        description="Build a .docx file from a JSONL spec."
    )
    parser.add_argument("spec", help="Path to the JSONL spec file")
    args = parser.parse_args()

    try:
        output = _pick_output_path(args.spec)
    except Exception as e:
        print("Error: failed to pick output path")
        sys.exit(2)

    try:
        saved = run_jsonl(args.spec, output=output)
    except TypeError as e:
        # 上游 JSONL 校验抛出的 SpecTypeError(继承 TypeError),统一
        # 转译成 "类型错误,无法执行" 提示。
        print(f"Error: 类型错误,无法执行: {e}", file=sys.stderr)
        sys.exit(2)
    except PermissionError:
        print("Error: 路径不在允许范围内", file=sys.stderr)
        sys.exit(2)
    except Exception:
        print("Error: 执行失败,请检查输入文件格式或稍后重试", file=sys.stderr)
        sys.exit(2)

    print(f"Successfully built {saved}")


if __name__ == "__main__":
    main()
SKILL.md
---
name: wecomcli-doc
description: 企微 doc 内容操作技能,包含新建在线文档、导入、读取、追加、覆盖写入等功能。仅当用户明确指定 'doc'、'docx'、'word'、'在线文档'、'office文档',或提供 https://doc.weixin.qq.com/doc/xxx 链接时触发。本技能不处理未指明类型的“文档”请求;凡是“创建文档 / 写文档 / 整理成文档 / 输出到文档”等泛化表达,默认都必须路由到 wecomcli-smartpage(智能文档),本技能不得抢占。若请求包含字段、记录、筛选、排序、统计、分组等结构化数据语义,严禁用 doc + markdown 静态表格变通替代,应考虑使用智能文档或者智能表格。公共管理操作请使用 wecomcli-doc-manage;在线表格操作请使用 wecomcli-sheet;智能表格操作请使用 wecomcli-smartsheet。
metadata:
  requires:
    bins: ["wecom-cli"]
---

# 企业微信doc文档管理

> 执行任何 `wecom-cli` 命令前,必须先读取并完成 `wecomcli-shared` 技能的公共前置检查。

资源型 skill,负责doc文档(`doc`)的新建、导入与内容读写。

## 适用范围

### 适用

- 新建 / 导入企微 doc 文档
- 读取 doc 文档内容
- 向 doc 文档追加一行 / 覆盖写入doc 文档

### 不适用

- 搜索文档 / 修改文档权限 / 重命名 / 加成员 → 改用 `wecomcli-doc-manage`

### 易混淆场景路由

- 用户说"创建文档 / 写文档 / 整理成文档" 且未指定 doc 类型 → 改用 `wecomcli-smartpage`(智能文档为默认)
- 用户给的链接是 `https://doc.weixin.qq.com/smartpage/...` 或者 `https://page.weixin.qq.com/smartpage/...` → 改用 `wecomcli-smartpage`
- 若遇到的 `docid` 以 `a1` 或者 `b1` 开头(形如 `a1_xxxx`, `b1_xxxx`)→ 改用 `wecomcli-smartpage`

## 接口路由表

路由表第二列若是 `references/xxx.md` 链接 → 必须先用 `read` 工具读完该文件,再构造命令。

| 用户意图 | 参考位置                                                          |
|---|---------------------------------------------------------------|
| 新建doc文档(在线) | 见下方「新建doc文档」                                                  |
| 导入本地文件为企微doc文档 | 见下方「导入doc文档」                                                  |
| 读取doc文档内容 | 见下方「读取doc文档内容」                                                |
| 追加文本到doc文档末尾 | [+contents-append](references/doc-contents-append.md)       |
| 全量覆盖doc文档内容 | [+contents-overwrite](references/doc-contents-overwrite.md) |

### 写入语义裁定(追加 vs 覆盖)

- 默认追加:用户用「写入 / 写到 / 记录 / 补充 / 加进去 / 记一下」等中性动词,且未明确要求清空或替换时,一律走 `append`(追加,不破坏原有内容)。
- 仅显式覆盖:仅当用户明确出现「覆盖 / 重写 / 替换 / 清空重写 / 整个换成」等强语义词时,才走 `overwrite`。

## 接口详述

### 新建doc文档

新建企微doc文档统一走「**生成 `.docx` → 导入**」两步流程:

1. 生成 `.docx` 文件:按 [+doc-create](references/doc-create.md) 生成 `.docx` 文件。
2. 导入为企微doc文档:使用下方「导入doc文档」接口将生成的 `.docx` 文件导入为企微doc文档。注意import导入的时候 `file_name` 应和文档标题保持一致。

### 导入doc文档

把本地文件(`.doc` / `.docx` / `.txt`)导入为企微doc文档。

**命令**

```bash
wecom-cli doc import --json '<JSON 参数>'
```

**参数**

| 字段          | 类型 | 必填 | 默认值 | 语义 |
|-------------|---|---|---|---|
| `doc_type`  | string | 是 | `doc` | 固定为 `doc`(doc文档) |
| `file_name` | string | 是 | — | 二进制文件名(含后缀),用于业务判断源文件类型 |
| `file_path` | string | 是 | — | 源文件的本地绝对路径 |
| `passwd`    | string | 否 | — | Office 文件加密密码(若有) |

**返回**

| 字段 | 类型 | 说明 |
|---|---|---|
| `docid` | string | 导入完成后的文档 ID |
| `url` | string | 导入完成后的访问链接 |
| `task_status` | string | 任务状态枚举,如 `succ` 成功 |

### 读取doc文档内容

读取**doc文档**的文档内容。

**命令**

```bash
wecom-cli doc contents get --json '<JSON 参数>'
```

**参数**

| 字段 | 类型 | 必填 | 默认值 | 语义                                      |
|---|---|----|---|-----------------------------------------|
| `docid` | string | 是  | — | doc文档 ID                                |
| `content_type` | string | 否  | `markdown` | 返回内容格式枚举:`text` / `markdown` / `ooxml`; |

**返回**

| 字段 | 类型 | 说明 |
|---|---|---|
| `url` | string | 文档访问链接 |
| `name` | string | 文档名称 |
| `content` | string | 文档内容较短时直接返回的原文 |
| `file_path` | string | 文档内容较长时自动落盘的**本地文件路径**;需用 Read 工具读取路径内文本后再展示 |
| `document` | object | `content_type=ooxml` 时返回的文档对象 |
| `version` | int | 文档版本号 |

## 跨技能依赖

| 依赖技能 | 何时触发 | 使用被依赖 skill 做什么                                                                                                             |
|---|---|-----------------------------------------------------------------------------------------------------------------------------|
| `wecomcli-doc-manage` | 用户只给文档名称/关键词,需先拿 `docid` 再读写内容 | 使用 `wecomcli-doc-manage` skill 搜索文档拿 `docid`                                                                                   |
| `wecomcli-smartpage` | 读取doc文档内容后,用户要求"做成智能文档/排版成 smartpage" | 使用 `wecomcli-smartpage` skill 生成智能文档                                                                                           |

> 参数缺失 / `docid` 搜索多候选等歧义场景,用简洁自然语言仅追问缺失或有歧义的信息;有候选项时在文字中列出供用户选择,不得自行猜测。

## `docid` 使用规则

`docid`仅cli使用。
最终展示用户时,不应展示 `docid`,而是使用文档 URL:

```
[doc_name](doc_url)
```


`docid` 是文档的唯一标识符,调用任何文档内容操作技能时均需提供。禁止自造 `docid`,按以下优先级获取:

1. 从文档链接提取(优先):用户提供了企微文档 URL 时,直接从 URL 中解析。URL 格式为 `https://doc.weixin.qq.com/<type>/<docid>?scode=...`,取 `/<type>/` 后、`?` 前的部分即为 docid。
2. 通过文档搜索获取(备选):用户仅提供文档名称或关键词、未给链接时,先调用 `wecomcli-doc-manage` 搜索文档,从返回结果中取 `docid`。
3. 用户直接提供:用户明确给出了完整 `docid`,可直接使用,无需再提取或搜索。
wecomcli-doc · Trendende Agent Skills | Mengbi