Skill: hshgogogo/outline-revision-annotator (version 1.1.0)

--- name: outline-revision-annotator description: Use when comparing a revised PDF outline, script, treatment, beat sheet, season map, or character bio against an older DOCX/PDF version and you need a high-signal change report plus visible PDF markup for structural additions or rewrites. 当需要对比新版 PDF 大纲、剧本、人物小传、季纲、分集梗概等与旧版 DOCX/PDF 版本，并生成只标注结构性新增或重写的高信号差异报告与可见 PDF 标注时使用；也用于 AI 刚改完这些内容后自动补一份新的对比标注版 PDF。 license: MIT --- # Outline Revision Annotator Compare a new outline/script PDF with an older baseline, then produce: - a UTF-8 markdown report - a DOCX report generated from that markdown - an annotated PDF with visible yellow note boxes in the page margin This skill is for structural revision review, not line editing. ## Invocation Hooks ### Explicit hook - `$outline-revision-annotator` ### English trigger phrases - compare revised outline PDF with old DOCX - compare new and old script versions - annotate structural changes in PDF - generate an annotated diff PDF - mark only structural additions or rewrites - compare character bio versions - create a high-signal change report ### 中文触发词 - 对比新旧大纲 - 对比新版 PDF 和旧版 DOCX - 对比剧本改动 - 对比人物小传版本 - 生成对比报告 - 生成标注版 PDF - 结构性改动 - 只标新增和重写 - 差异标注 - 对比标注版 ### Auto-follow hook Run this skill automatically after the agent itself edits any of the following: - outline - script - treatment - beat sheet - season map - episode breakdown - character bio - role profile Automatic follow-up applies when all of these are true: - there is an older baseline version to compare against - the revised draft is already a PDF, or can be exported to PDF in the current environment - the user did not explicitly opt out of comparison output When the auto-follow hook fires, generate a fresh: - `compare_report.md` - `compare_report.docx` - annotated comparison PDF If no baseline exists, or the revised draft cannot be turned into a PDF, say what is missing and stop. 中文规则： - 当 AI 自己刚改完大纲、剧本、人物小传、季纲、分集梗概等内容后，如果存在旧版本基线，并且新版可获得 PDF，就自动触发本 skill - 自动补产物时，不需要等用户再次提醒“再生成一份对比版” - 如果缺旧版基线，或当前环境无法拿到新版 PDF，就明确说明缺什么，不要假装已完成 ## Use This Skill For - Story bibles, season outlines, beat sheets, treatments, scripts, and character bios - `new.pdf` versus `old.docx` review workflows - `new.pdf` versus `old.pdf` review workflows when the old version is also a PDF - Chinese-language documents where PDF popup comments often render as garbled text - Deliverables that need to be readable without clicking annotation popups ## Do Not Use It For - punctuation or wording polish - paragraph-level copy edits that do not change story function - literal redline review - mark-everything diffs ## Qualification Rule Mark only changes that alter structure or story function: - new season engine, thematic spine, or professional framework - new antagonist, institutional pressure line, or evaluation thread - a character arc rewritten to serve a different dramatic job - ending reset from a closed loop to a next-season hook - a case line upgraded from emotional texture to ethical or institutional conflict Do not mark: - sentence rewrites with the same dramatic function - softened or intensified phrasing - richer detail that does not change structure ## Workflow ### 1. Make sure the revised draft exists as PDF If the agent has just edited a DOCX, Markdown, or other text source, first export or obtain a revised PDF. The renderer marks the revised PDF, not the editable source file. ### 2. Extract the sources Run the helper script first. It produces plain text and page/block maps for the new PDF, plus plain text for the old baseline. - Windows commands: [references/windows.md](references/windows.md) - macOS commands: [references/macos.md](references/macos.md) ### 3. Compare semantically Read the extracted text and decide which changes are structural. Then author two files: - `compare_report.md` - `annotation_manifest.json` Manifest schema and examples: [references/manifest-schema.md](references/manifest-schema.md) ### 4. Render the final deliverables Run the same script in `render` mode. It will: - create an annotated PDF with visible margin boxes - copy the markdown report as UTF-8 - convert the markdown report into DOCX - write a normalized manifest copy into the output folder ### 5. Visually verify the PDF Open the annotated PDF and check: - the right-side yellow box text is visible without clicking - Chinese text is not garbled - note boxes do not cover the source paragraph - only structural changes were marked If something is wrong, read [references/troubleshooting.md](references/troubleshooting.md). ## Non-Negotiable Rules - On Windows PowerShell, never pipe Chinese Python source directly into `python -`. Save a real UTF-8 `.py` file and run the file. - Do not rely on popup PDF comments for Chinese review notes. Write visible note boxes into the page margin. - If a change is stylistic rather than structural, leave it unmarked. - Prefer an ASCII fallback filename if a downstream tool or browser tab cannot handle Chinese filenames cleanly. - If the auto-follow hook fired, do not silently skip the compare PDF. Either generate it or state the concrete blocker. ## Deliverables Checklist - `annotated.pdf` - `compare_report.md` - `compare_report.docx` - `annotation_manifest.normalized.json` ## Script Entry Points The bundled script is: - `scripts/build_outline_diff_outputs.py` Supported modes: - `extract`: dump source text and PDF block maps - `render`: build the annotated PDF and report outputs ## What the Script Does Not Decide The script is deterministic. The agent still decides: - which changes count as structural - which blocks should be grouped into one note - how to phrase the comparison report - how to phrase each yellow-box explanation Keep that judgment in the agent. Keep extraction and rendering in the script.

path: agents/openai.yaml
content: interface:
  display_name: "Outline Revision Annotator"
  short_description: "Compare revised drafts and generate structural-change annotated PDFs. / 对比修订稿并生成结构改动标注 PDF。"
  default_prompt: "Use $outline-revision-annotator to compare a revised PDF outline, script, or character bio against an older version and generate a new structural-change annotated PDF. / 用 $outline-revision-annotator 对比新版 PDF 大纲、剧本或人物小传与旧版，并生成新的结构改动标注版 PDF。"


path: references/macos.md
content: # macOS Workflow

Use this when running the workflow in Terminal or iTerm on macOS.

## 1. Install dependencies

```bash
python3 -m pip install --upgrade pymupdf python-docx
```

## 2. Create a clean working folder

```bash
mkdir -p ./work ./output
```

## 3. Export the revised draft to PDF if needed

If AI has just edited a DOCX, Markdown, or text draft, first obtain a revised PDF. This skill annotates the revised PDF, not the editable source.

## 4. Extract the source text and PDF block map

```bash
python3 ./scripts/build_outline_diff_outputs.py extract \
  --new-pdf "/Users/you/path/new-outline.pdf" \
  --old-source "/Users/you/path/old-outline.docx" \
  --output-dir "./work"
```

This creates:

- `./work/new_pdf_full_text.txt`
- `./work/new_pdf_blocks.json`
- `./work/old_source_full_text.txt`

The old baseline can be `.docx`, `.pdf`, `.txt`, or `.md`.

## 5. Compare the two versions

Read the extracted files and manually create:

- `./work/compare_report.md`
- `./work/annotation_manifest.json`

Manifest format: [manifest-schema.md](manifest-schema.md)

## 6. Render the deliverables

```bash
python3 ./scripts/build_outline_diff_outputs.py render \
  --new-pdf "/Users/you/path/new-outline.pdf" \
  --old-source "/Users/you/path/old-outline.docx" \
  --manifest-json "./work/annotation_manifest.json" \
  --report-md "./work/compare_report.md" \
  --output-dir "./output"
```

## 7. Auto-follow hook after AI edits

If the agent itself has just revised an outline, script, treatment, beat sheet, season map, or character bio:

- do not wait for a second user reminder
- obtain or export the revised PDF
- reuse the previous version as `--old-source`
- regenerate the annotated comparison PDF and reports

If no baseline or revised PDF exists, report the blocker explicitly.

## 8. Verify the PDF

Open the output PDF and check:

- visible yellow note boxes show Chinese correctly
- only structural changes are marked
- note boxes are outside the source text area

## macOS Notes

- A committed `.py` file is still safer than ad hoc pasted code.
- If Chinese text does not render, install or point the script at a CJK system font.
- If Preview.app caches an older PDF, close and reopen the file after regeneration.


path: references/manifest-schema.md
content: # Manifest Schema

Create `annotation_manifest.json` as a JSON array.

Each item describes one structural addition or rewrite to mark on the new PDF.

## Minimal Example

```json
[
  {
    "id": "A",
    "page": 2,
    "blocks": [5],
    "kind": "rewrite",
    "title": "Relationship dynamic softened and repositioned",
    "note": "Old version exposed the ghostwriting secret as open conflict. New version rewrites this as restrained recognition and self-questioning.",
    "box_text": "A Rewrite\nOld: public exposure and direct humiliation.\nNew: restrained recognition, softer dynamic, self-questioning focus."
  },
  {
    "id": "B",
    "page": 3,
    "blocks": [0, 1],
    "kind": "add",
    "title": "Narrative medicine becomes an explicit thematic spine",
    "note": "This section newly defines narrative medicine and upgrades autobiography writing into a therapeutic / clinical method.",
    "box_text": "B Add\nNew explicit narrative-medicine framework. Writing is upgraded from biographical help to a therapeutic method."
  }
]
```

## Required Fields

- `id`: short label shown in the side tab, usually `A`, `B`, `C`
- `page`: page number in the new PDF
- `blocks`: zero-based PDF text block indexes from `new_pdf_blocks.json`
- `kind`: `add` or `rewrite`
- `title`: short internal label for the structural change
- `note`: full explanation for the report / manifest
- `box_text`: text written directly inside the yellow note box

## Numbering Rules

- `page` is normally one-based for human editing
- `blocks` are always zero-based because they come from PyMuPDF block indexes
- if an older manifest already uses zero-based page numbers, the renderer accepts it when a page value of `0` is present

## Grouping Rule

Use one manifest item per structural point, not one item per paragraph.

Good:

- a new institutional conflict line spanning two adjacent blocks
- a rewritten season ending spanning several consecutive blocks

Bad:

- separate items for every sentence in the same rewrite
- separate items for minor language polish inside the same structural beat


path: references/troubleshooting.md
content: # Troubleshooting

## Chinese text turns into `????`

Likely causes:

- Windows PowerShell piped inline Chinese source into `python -`
- the PDF viewer cannot render popup comment text correctly
- the script could not find a usable CJK font

Fix:

- run a real UTF-8 `.py` file instead of inline piped source
- use visible margin note boxes instead of popup comments
- rerun after ensuring the system has a Chinese-capable font

## The PDF note is clickable but not visible

You are probably using PDF popup comments or sticky notes.

Fix:

- render visible boxes into the page margin instead
- keep the explanation text inside the page content, not inside a popup annotation

## The yellow boxes overlap each other

Fix:

- merge nearby paragraphs into one manifest item
- shorten `box_text`
- reduce over-marking; only structural changes should remain

## The wrong text is highlighted

Fix:

- re-check `new_pdf_blocks.json`
- adjust the `blocks` list to cover the correct union of PDF text blocks
- remember that `blocks` are zero-based indexes

## The DOCX report opens but formatting looks plain

The bundled converter is intentionally simple and reliable.

Fix:

- keep markdown structure clean: headings, flat bullets, simple tables
- avoid nested markdown constructs if you want the DOCX export to stay predictable

## Chinese filename issues

Some viewers, browser tabs, or uploaders handle Chinese filenames poorly.

Fix:

- keep the real Chinese filename if the environment supports it
- otherwise also provide an ASCII alias for transport or publishing


path: references/windows.md
content: # Windows Workflow

Use this when running the workflow in PowerShell on Windows.

## 1. Install dependencies

```powershell
python -m pip install --upgrade pymupdf python-docx
```

## 2. Create a clean working folder

```powershell
New-Item -ItemType Directory -Force .\work | Out-Null
New-Item -ItemType Directory -Force .\output | Out-Null
```

## 3. Export the revised draft to PDF if needed

If AI has just edited a DOCX, Markdown, or text draft, first obtain a revised PDF. This skill annotates the revised PDF, not the editable source.

## 4. Extract the source text and PDF block map

```powershell
python .\scripts\build_outline_diff_outputs.py extract `
  --new-pdf "E:\path\new-outline.pdf" `
  --old-source "E:\path\old-outline.docx" `
  --output-dir ".\work"
```

This creates:

- `.\work\new_pdf_full_text.txt`
- `.\work\new_pdf_blocks.json`
- `.\work\old_source_full_text.txt`

The old baseline can be `.docx`, `.pdf`, `.txt`, or `.md`.

## 5. Compare the two versions

Read the extracted files and manually create:

- `.\work\compare_report.md`
- `.\work\annotation_manifest.json`

Manifest format: [manifest-schema.md](manifest-schema.md)

## 6. Render the deliverables

```powershell
python .\scripts\build_outline_diff_outputs.py render `
  --new-pdf "E:\path\new-outline.pdf" `
  --old-source "E:\path\old-outline.docx" `
  --manifest-json ".\work\annotation_manifest.json" `
  --report-md ".\work\compare_report.md" `
  --output-dir ".\output"
```

## 7. Auto-follow hook after AI edits

If the agent itself has just revised an outline, script, treatment, beat sheet, season map, or character bio:

- do not wait for a second user reminder
- obtain or export the revised PDF
- reuse the previous version as `--old-source`
- regenerate the annotated comparison PDF and reports

If no baseline or revised PDF exists, report the blocker explicitly.

## 8. Verify the PDF

Open the output PDF and check:

- visible yellow note boxes show Chinese correctly
- only structural changes are marked
- note boxes are outside the source text area

## Critical Windows Notes

- Do not do this:

```powershell
@'
print("中文")
'@ | python -
```

PowerShell often corrupts inline Chinese source before Python sees it.

- Do this instead:

```powershell
python .\scripts\build_outline_diff_outputs.py --help
```

or save any helper code into a real UTF-8 `.py` file first.

- If a viewer caches old PDFs, close the file before regenerating it.


path: scripts/build_outline_diff_outputs.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
import re
import shutil
import sys
from pathlib import Path

import fitz
from docx import Document
from docx.oxml.ns import qn
from docx.shared import Pt


KIND_STYLE = {
    "add": {
        "stroke": (1.0, 0.65, 0.0),
        "fill": (1.0, 0.96, 0.55),
        "tab": (1.0, 0.65, 0.0),
    },
    "rewrite": {
        "stroke": (0.94, 0.35, 0.1),
        "fill": (1.0, 0.86, 0.72),
        "tab": (0.94, 0.35, 0.1),
    },
}

WINDOWS_FONT_CANDIDATES = [
    Path(r"C:\Windows\Fonts\msyh.ttc"),
    Path(r"C:\Windows\Fonts\msyh.ttf"),
    Path(r"C:\Windows\Fonts\simhei.ttf"),
    Path(r"C:\Windows\Fonts\simsun.ttc"),
]

MAC_FONT_CANDIDATES = [
    Path("/System/Library/Fonts/PingFang.ttc"),
    Path("/System/Library/Fonts/Hiragino Sans GB.ttc"),
    Path("/System/Library/Fonts/Supplemental/Songti.ttc"),
    Path("/Library/Fonts/Arial Unicode.ttf"),
]

LINUX_FONT_CANDIDATES = [
    Path("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"),
    Path("/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc"),
    Path("/usr/share/fonts/opentype/noto/NotoSerifCJK-Regular.ttc"),
]


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Extract outline sources and render visible structural-change annotations on PDF."
    )
    subparsers = parser.add_subparsers(dest="command", required=True)

    extract = subparsers.add_parser("extract", help="Extract plain text and PDF block maps.")
    extract.add_argument("--new-pdf", required=True)
    extract.add_argument("--old-source")
    extract.add_argument("--old-docx")
    extract.add_argument("--output-dir", required=True)

    render = subparsers.add_parser("render", help="Render annotated PDF and report outputs.")
    render.add_argument("--new-pdf", required=True)
    render.add_argument("--old-source")
    render.add_argument("--old-docx")
    render.add_argument("--manifest-json", required=True)
    render.add_argument("--report-md", required=True)
    render.add_argument("--output-dir", required=True)
    render.add_argument("--annotated-pdf-name", default="annotated_outline.pdf")
    render.add_argument("--report-md-name", default="compare_report.md")
    render.add_argument("--report-docx-name", default="compare_report.docx")
    render.add_argument("--note-width", type=float, default=205.0)
    render.add_argument("--extra-page-width", type=float, default=245.0)
    return parser.parse_args()


def ensure_dir(path: Path) -> None:
    path.mkdir(parents=True, exist_ok=True)


def write_text(path: Path, text: str) -> None:
    path.write_text(text, encoding="utf-8")


def read_text(path: Path) -> str:
    return path.read_text(encoding="utf-8-sig")


def normalize_whitespace(text: str) -> str:
    return re.sub(r"\s+", " ", text).strip()


def find_cjk_font() -> str | None:
    candidates = WINDOWS_FONT_CANDIDATES + MAC_FONT_CANDIDATES + LINUX_FONT_CANDIDATES
    for candidate in candidates:
        if candidate.exists():
            return str(candidate)
    return None


def read_docx_text(docx_path: Path) -> str:
    document = Document(str(docx_path))
    parts: list[str] = []

    for paragraph in document.paragraphs:
        text = paragraph.text.strip()
        if text:
            parts.append(text)

    for table in document.tables:
        for row in table.rows:
            cells = [normalize_whitespace(cell.text) for cell in row.cells]
            cells = [cell for cell in cells if cell]
            if cells:
                parts.append(" | ".join(cells))

    return "\n".join(parts).strip()


def resolve_old_source(args: argparse.Namespace) -> Path:
    chosen = getattr(args, "old_source", None) or getattr(args, "old_docx", None)
    if not chosen:
        raise ValueError("Provide --old-source <path> or legacy --old-docx <path>.")
    return Path(chosen)


def read_source_text(source_path: Path) -> str:
    suffix = source_path.suffix.lower()
    if suffix == ".docx":
        return read_docx_text(source_path)
    if suffix == ".pdf":
        pdf_text, _ = extract_pdf_blocks(source_path)
        return pdf_text
    if suffix in {".txt", ".md"}:
        return read_text(source_path)
    raise ValueError(f"Unsupported old source type: {source_path.suffix}. Expected .docx, .pdf, .txt, or .md")


def extract_pdf_blocks(pdf_path: Path) -> tuple[str, list[dict]]:
    pdf = fitz.open(str(pdf_path))
    full_text_pages: list[str] = []
    page_maps: list[dict] = []

    for page_number, page in enumerate(pdf, start=1):
        page_text = page.get_text("text").strip()
        full_text_pages.append(page_text)
        blocks = []
        for block_index, block in enumerate(page.get_text("blocks")):
            x0, y0, x1, y1, text = block[:5]
            clean_text = text.strip()
            if not clean_text:
                continue
            blocks.append(
                {
                    "index": block_index,
                    "bbox": [round(x0, 1), round(y0, 1), round(x1, 1), round(y1, 1)],
                    "preview": normalize_whitespace(clean_text)[:180],
                    "text": clean_text,
                }
            )
        page_maps.append({"page": page_number, "blocks": blocks})

    pdf.close()
    return "\n\n".join(full_text_pages).strip(), page_maps


def command_extract(args: argparse.Namespace) -> int:
    new_pdf = Path(args.new_pdf)
    old_source = resolve_old_source(args)
    output_dir = Path(args.output_dir)
    ensure_dir(output_dir)

    pdf_text, pdf_blocks = extract_pdf_blocks(new_pdf)
    old_source_text = read_source_text(old_source)

    write_text(output_dir / "new_pdf_full_text.txt", pdf_text)
    write_text(output_dir / "old_source_full_text.txt", old_source_text)
    write_text(output_dir / "new_pdf_blocks.json", json.dumps(pdf_blocks, ensure_ascii=False, indent=2))
    write_text(
        output_dir / "source_files.json",
        json.dumps(
            {
                "new_pdf": str(new_pdf),
                "old_source": str(old_source),
                "generated_files": [
                    "new_pdf_full_text.txt",
                    "old_source_full_text.txt",
                    "new_pdf_blocks.json",
                ],
            },
            ensure_ascii=False,
            indent=2,
        ),
    )

    print(f"Wrote extraction files to {output_dir}")
    return 0


def load_manifest(manifest_path: Path) -> tuple[list[dict], int]:
    raw = json.loads(read_text(manifest_path))
    if not isinstance(raw, list):
        raise ValueError("annotation manifest must be a JSON array")

    page_base = 1
    if any(isinstance(item, dict) and item.get("page") == 0 for item in raw):
        page_base = 0

    normalized: list[dict] = []
    for item in raw:
        if not isinstance(item, dict):
            raise ValueError("each manifest item must be an object")
        required = {"id", "page", "blocks", "kind", "title", "note", "box_text"}
        missing = sorted(required - set(item))
        if missing:
            raise ValueError(f"manifest item missing required field(s): {', '.join(missing)}")
        if item["kind"] not in KIND_STYLE:
            raise ValueError(f"unsupported kind '{item['kind']}', expected one of: {', '.join(KIND_STYLE)}")
        if not isinstance(item["blocks"], list) or not item["blocks"]:
            raise ValueError(f"manifest item {item['id']} must define a non-empty blocks list")

        normalized.append(
            {
                "id": str(item["id"]),
                "page": int(item["page"]),
                "blocks": [int(value) for value in item["blocks"]],
                "kind": item["kind"],
                "title": str(item["title"]),
                "note": str(item["note"]),
                "box_text": str(item["box_text"]),
                "box_height": float(item.get("box_height", 0) or 0),
            }
        )
    return normalized, page_base


def union_rect_for_blocks(blocks: list[tuple], indexes: list[int]) -> fitz.Rect:
    rect: fitz.Rect | None = None
    for block_index in indexes:
        try:
            block = blocks[block_index]
        except IndexError as exc:
            raise IndexError(f"block index {block_index} is out of range for page") from exc
        block_rect = fitz.Rect(block[:4])
        rect = block_rect if rect is None else rect | block_rect
    if rect is None:
        raise ValueError("could not resolve a rectangle for the given blocks")
    return rect


def draw_highlight(page: fitz.Page, rect: fitz.Rect, kind: str) -> None:
    style = KIND_STYLE[kind]
    shape = page.new_shape()
    shape.draw_rect(rect)
    shape.finish(
        color=style["stroke"],
        fill=style["fill"],
        width=1.0,
        fill_opacity=0.18,
        stroke_opacity=0.55,
    )
    shape.commit(overlay=True)


def draw_id_tab(page: fitz.Page, rect: fitz.Rect, item_id: str, kind: str) -> None:
    style = KIND_STYLE[kind]
    shape = page.new_shape()
    shape.draw_rect(rect)
    shape.finish(
        color=style["tab"],
        fill=style["tab"],
        width=0.8,
        fill_opacity=0.95,
        stroke_opacity=0.95,
    )
    shape.commit(overlay=True)
    page.insert_textbox(
        rect,
        item_id,
        fontname="helv",
        fontsize=9,
        color=(1, 1, 1),
        align=1,
        overlay=True,
    )


def estimate_box_height(text: str, minimum: float) -> float:
    lines = wrap_box_text(text)
    estimated = 22 + len(lines) * 14
    return max(minimum, estimated)


def wrap_box_text(text: str, max_chars: int = 21) -> list[str]:
    wrapped: list[str] = []
    for raw_paragraph in text.splitlines():
        paragraph = raw_paragraph.strip()
        if not paragraph:
            wrapped.append("")
            continue
        remaining = paragraph
        while remaining:
            wrapped.append(remaining[:max_chars])
            remaining = remaining[max_chars:]
    return wrapped or [""]


def draw_note_box(
    page: fitz.Page,
    x0: float,
    y0: float,
    width: float,
    text: str,
    kind: str,
    fontname: str,
    minimum_height: float,
) -> float:
    style = KIND_STYLE[kind]
    lines = wrap_box_text(text)
    height = max(minimum_height or 64.0, 22 + len(lines) * 14)
    rect = fitz.Rect(x0, y0, x0 + width, y0 + height)
    shape = page.new_shape()
    shape.draw_rect(rect)
    shape.finish(
        color=style["stroke"],
        fill=(1.0, 0.95, 0.15),
        width=1.0,
        fill_opacity=0.95,
        stroke_opacity=0.95,
    )
    shape.commit(overlay=True)
    baseline = rect.y0 + 18
    for index, line in enumerate(lines):
        page.insert_text(
            fitz.Point(rect.x0 + 8, baseline + index * 13),
            line,
            fontname=fontname,
            fontsize=10.5,
            color=(0.1, 0.1, 0.1),
            overlay=True,
        )
    return height


def choose_report_font_family() -> str:
    if sys.platform.startswith("win"):
        return "Microsoft YaHei"
    if sys.platform == "darwin":
        return "PingFang SC"
    return "Noto Sans CJK SC"


def style_run(run, font_family: str, size: float | None = None, bold: bool = False) -> None:
    run.font.name = font_family
    run_properties = run._element.get_or_add_rPr()
    run_properties.rFonts.set(qn("w:eastAsia"), font_family)
    if size is not None:
        run.font.size = Pt(size)
    run.bold = bold


def set_document_defaults(document: Document) -> None:
    font_family = choose_report_font_family()
    normal = document.styles["Normal"]
    normal.font.name = font_family
    run_properties = normal.element.get_or_add_rPr()
    run_properties.rFonts.set(qn("w:eastAsia"), font_family)
    normal.font.size = Pt(10.5)


def is_table_separator(line: str) -> bool:
    return bool(re.match(r"^\|\s*[-:| ]+\|?\s*$", line))


def parse_table_row(line: str) -> list[str]:
    return [cell.strip() for cell in line.strip().strip("|").split("|")]


def markdown_to_docx(md_path: Path, docx_path: Path) -> None:
    document = Document()
    set_document_defaults(document)
    font_family = choose_report_font_family()
    lines = read_text(md_path).splitlines()
    index = 0

    while index < len(lines):
        raw = lines[index].rstrip()
        stripped = raw.strip()

        if not stripped:
            index += 1
            continue

        heading_match = re.match(r"^(#{1,3})\s+(.*)$", raw)
        if heading_match:
            level = len(heading_match.group(1))
            text = heading_match.group(2).strip()
            paragraph = document.add_heading("", level=level)
            run = paragraph.add_run(text)
            style_run(run, font_family, size=16 - (level * 2), bold=True)
            index += 1
            continue

        if stripped.startswith("|") and index + 1 < len(lines) and is_table_separator(lines[index + 1].strip()):
            table_lines = [stripped]
            index += 2
            while index < len(lines) and lines[index].strip().startswith("|"):
                table_lines.append(lines[index].strip())
                index += 1

            rows = [parse_table_row(line) for line in table_lines]
            header = rows[0]
            body = rows[1:]
            table = document.add_table(rows=len(body) + 1, cols=len(header))
            table.style = "Table Grid"
            for col, value in enumerate(header):
                cell = table.cell(0, col)
                cell.text = ""
                run = cell.paragraphs[0].add_run(value)
                style_run(run, font_family, bold=True)
            for row_index, row_values in enumerate(body, start=1):
                for col, value in enumerate(row_values):
                    cell = table.cell(row_index, col)
                    cell.text = ""
                    run = cell.paragraphs[0].add_run(value)
                    style_run(run, font_family)
            continue

        bullet_match = re.match(r"^-\s+(.*)$", raw)
        if bullet_match:
            paragraph = document.add_paragraph(style="List Bullet")
            run = paragraph.add_run(bullet_match.group(1).strip())
            style_run(run, font_family)
            index += 1
            continue

        numbered_match = re.match(r"^\d+\.\s+(.*)$", raw)
        if numbered_match:
            paragraph = document.add_paragraph(style="List Number")
            run = paragraph.add_run(numbered_match.group(1).strip())
            style_run(run, font_family)
            index += 1
            continue

        paragraph = document.add_paragraph()
        run = paragraph.add_run(stripped)
        style_run(run, font_family)
        index += 1

    document.save(str(docx_path))


def command_render(args: argparse.Namespace) -> int:
    new_pdf = Path(args.new_pdf)
    old_source = resolve_old_source(args)
    manifest_path = Path(args.manifest_json)
    report_md = Path(args.report_md)
    output_dir = Path(args.output_dir)
    ensure_dir(output_dir)

    manifest, page_base = load_manifest(manifest_path)
    fontfile = find_cjk_font()
    pdf = fitz.open(str(new_pdf))

    if fontfile is None and any(any(ord(char) > 127 for char in item["box_text"]) for item in manifest):
        raise RuntimeError("No CJK font found. Install a Chinese-capable font or provide an environment with one.")

    manifest_out: list[dict] = []
    items_by_page: dict[int, list[dict]] = {}
    for item in manifest:
        page_index = item["page"] if page_base == 0 else item["page"] - 1
        items_by_page.setdefault(page_index, []).append(item)

    for page_index, items in items_by_page.items():
        page = pdf.load_page(page_index)
        original_rect = fitz.Rect(page.rect)
        page.set_mediabox(
            fitz.Rect(0, 0, original_rect.width + args.extra_page_width, original_rect.height)
        )

        fontname = "helv"
        if fontfile:
            page.insert_font(fontname="CJK", fontfile=fontfile)
            fontname = "CJK"

        blocks = page.get_text("blocks")
        resolved = []
        for item in items:
            rect = union_rect_for_blocks(blocks, item["blocks"])
            resolved.append((item, rect))
        resolved.sort(key=lambda pair: pair[1].y0)

        previous_bottom = 20.0
        for item, rect in resolved:
            draw_highlight(page, rect, item["kind"])

            note_x = original_rect.width + 32
            note_y = max(20.0, rect.y0)
            minimum_height = item["box_height"] or 64.0
            estimated_height = estimate_box_height(item["box_text"], minimum_height)
            if note_y < previous_bottom + 10:
                note_y = previous_bottom + 10
            max_y = page.rect.height - estimated_height - 20
            note_y = min(note_y, max_y)

            draw_id_tab(
                page,
                fitz.Rect(original_rect.width + 6, note_y, original_rect.width + 28, note_y + 18),
                item["id"],
                item["kind"],
            )
            used_height = draw_note_box(
                page,
                note_x,
                note_y,
                args.note_width,
                item["box_text"],
                item["kind"],
                fontname,
                minimum_height,
            )
            previous_bottom = note_y + used_height

            manifest_out.append(
                {
                    "id": item["id"],
                    "page": page_index + 1,
                    "kind": item["kind"],
                    "title": item["title"],
                    "note": item["note"],
                    "blocks": item["blocks"],
                    "rect": [round(rect.x0, 1), round(rect.y0, 1), round(rect.x1, 1), round(rect.y1, 1)],
                }
            )

    annotated_pdf = output_dir / args.annotated_pdf_name
    report_md_out = output_dir / args.report_md_name
    report_docx_out = output_dir / args.report_docx_name
    normalized_manifest_out = output_dir / "annotation_manifest.normalized.json"
    source_info_out = output_dir / "source_files.json"

    pdf.save(str(annotated_pdf))
    pdf.close()
    shutil.copy2(report_md, report_md_out)
    markdown_to_docx(report_md, report_docx_out)
    write_text(normalized_manifest_out, json.dumps(manifest_out, ensure_ascii=False, indent=2))
    write_text(
        source_info_out,
        json.dumps({"new_pdf": str(new_pdf), "old_source": str(old_source)}, ensure_ascii=False, indent=2),
    )

    print(f"Wrote annotated PDF: {annotated_pdf}")
    print(f"Wrote markdown report: {report_md_out}")
    print(f"Wrote DOCX report: {report_docx_out}")
    print(f"Wrote normalized manifest: {normalized_manifest_out}")
    return 0


def main() -> int:
    args = parse_args()
    if args.command == "extract":
        return command_extract(args)
    if args.command == "render":
        return command_render(args)
    raise ValueError(f"unsupported command {args.command}")


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


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)