---
name: sophnet-docx
description: Create, read, edit, and manipulate Word documents (.docx files). Use when the user wants to work with Word documents, including creating reports, memos, letters, templates, or performing operations like find-and-replace, tracked changes, comments, or content extraction.
---

# DOCX creation, editing, and analysis

## MANDATORY: Working Directory

**EVERY command in this skill MUST be executed from THIS skill's directory.** Before running ANY command — Python, Node.js, or bash script — you MUST `cd` into this skill's directory first. Determine the absolute path of this `SKILL.md` file and use its parent directory.

```bash
SKILL_DIR="<absolute-path-to-this-skills-sophnet-docx-directory>"
cd "$SKILL_DIR"
```

**NEVER run commands from the repository root or any other directory.** If you do, `uv run` won't find `pyproject.toml`, and `python` won't have access to required packages.

## MANDATORY: Cleanup — Zero Residual Files

**CRITICAL: After every task, `$SKILL_DIR` must contain ONLY these permanent files:** `SKILL.md`, `pyproject.toml`, `uv.lock`, `package.json`, `package-lock.json`, `node_modules/`, `scripts/`, `.venv/`. **NOTHING else.** No `.js` scripts, no `unpacked*/` directories, no `.py` scripts, no generated `.docx` files.

Three enforced rules (non-negotiable):

1. **JS scripts MUST self-delete.** Every `.js` file you create MUST include `try { fs.unlinkSync(__filename); } catch(e) {}` as the last line inside the final `.then()` or at script end. Additionally, run with: `cd "$SKILL_DIR" && node _tmp_docx.js; rm -f _tmp_docx.js`
2. **Unpack to `/tmp/` only.** NEVER unpack into `$SKILL_DIR`. Use `/tmp/_tmp_docx_unpacked/` and `rm -rf` it after packing.
3. **Python scripts go to `/tmp/`.** Write them to `/tmp/_tmp_*.py` and delete after execution.

## Overview

A .docx file is a ZIP archive containing XML files.

## Python Runtime (uv)

**CRITICAL: All Python execution in this skill MUST use `uv run --project .` from the skill directory. NEVER use bare `python3`, `python`, or `pip install` directly — the required packages (python-docx, lxml, etc.) are ONLY available inside the uv virtual environment defined by this skill's `pyproject.toml`. Direct `python3` will fail with ModuleNotFoundError.**

First, ensure the environment is set up (run once per session):

```bash
cd "$SKILL_DIR"
bash scripts/ensure_uv_env.sh
```

Then ALL Python commands must use this prefix:

```bash
cd "$SKILL_DIR" && uv run --project . python <script-or-module>
```

This applies to both the provided scripts AND any inline Python code you write. For inline code, use:

```bash
cd "$SKILL_DIR" && uv run --project . python -c "import docx; ..."
```

## MANDATORY: Upload After Every Create/Edit

**CRITICAL: After creating or modifying a DOCX file, you MUST run `scripts/upload_file.sh --url-only` and include the URL in the final reply.** This is NOT optional. Do NOT return local file paths. You may include brief summary text, but the reply must contain the download URL.

## Delivery

Local DOCX creation/editing does not require any SophNet API key.

**IMPORTANT: After creating or modifying a DOCX, ALWAYS upload it and return the download URL.** This is the default behavior — do not skip the upload step.

```bash
cd "$SKILL_DIR" && bash scripts/upload_file.sh --file <absolute-path-to-docx> --url-only
```

**Note:** The `cd "$SKILL_DIR"` prefix is MANDATORY — `scripts/upload_file.sh` is relative to the skill directory. Without it, the command fails with `No such file or directory`.

Upload command output contract:

- `FILE_PATH=<absolute-path>`
- `UPLOAD_STATUS=uploaded|skipped`
- `DOWNLOAD_URL=<https://...>` (present only when uploaded)
- With `--url-only` and successful upload: output is exactly one line, the raw `https://...` URL

Delivery rules:

- **ALWAYS `cd "$SKILL_DIR"` first, then call `bash scripts/upload_file.sh --url-only` after producing a DOCX file.**
- Final response for create/edit MUST include:
  - success: a valid `https://...` URL
  - missing API key fallback: `FILE_PATH=<absolute-path>`
- Do not include local file paths in create/edit responses.
- Keep URL output logic independent inside `sophnet-docx/scripts`. Do not call other skills' upload scripts.

## Quick Reference

| Task                       | Approach                                                          |
| -------------------------- | ----------------------------------------------------------------- |
| Prepare Python environment | `bash scripts/ensure_uv_env.sh`                                   |
| Read/analyze content       | `pandoc` or unpack for raw XML                                    |
| Create new document        | Use `docx-js` - see Creating New Documents below                  |
| Edit existing document     | Unpack → edit XML → repack - see Editing Existing Documents below |
| Upload and print URL only  | `cd $SKILL_DIR && bash scripts/upload_file.sh --file /abs/path/output.docx --url-only` |

### Converting .doc to .docx (requires LibreOffice)

Legacy `.doc` files must be converted before editing. **Requires `soffice`; if not installed, ask the user to convert manually.**

```bash
cd "$SKILL_DIR" && uv run --project . python scripts/office/soffice.py --headless --convert-to docx document.doc
```

### Reading Content

```bash
# Text extraction with tracked changes
pandoc --track-changes=all document.docx -o output.md

# Raw XML access (always unpack to /tmp/, never to $SKILL_DIR)
uv run --project . python scripts/office/unpack.py document.docx /tmp/_tmp_docx_unpacked/
```

### Converting to Images (optional — requires LibreOffice)

**If `soffice` is not available, skip this step.** Check first: `which soffice && echo "available" || echo "NOT available — skip"`

```bash
cd "$SKILL_DIR" && uv run --project . python scripts/office/soffice.py --headless --convert-to pdf document.docx
pdftoppm -jpeg -r 150 document.pdf page
```

### Accepting Tracked Changes

To produce a clean document with all tracked changes accepted (requires LibreOffice):

```bash
uv run --project . python scripts/accept_changes.py input.docx output.docx
```

---

## Creating New Documents

Generate .docx files with JavaScript, then validate.

**CRITICAL: Run all `node` commands from `$SKILL_DIR`.** The `docx` package must be installed in the skill's local `node_modules/`. If missing, run: `cd "$SKILL_DIR" && npm install docx`

**CRITICAL: The JS script file MUST be named `_tmp_docx.js` (always this exact name) and MUST be deleted immediately after execution:**
```bash
cd "$SKILL_DIR" && node _tmp_docx.js; rm -f _tmp_docx.js
```

### Setup

**CRITICAL: Every `.js` script you write MUST include the self-cleanup line at the end** (`fs.unlinkSync(__filename)`). This ensures the temp script is always deleted, even if the shell-level `rm` is forgotten.

```javascript
const fs = require("fs");
const {
  Document,
  Packer,
  Paragraph,
  TextRun,
  Table,
  TableRow,
  TableCell,
  ImageRun,
  Header,
  Footer,
  AlignmentType,
  PageOrientation,
  LevelFormat,
  ExternalHyperlink,
  TableOfContents,
  HeadingLevel,
  BorderStyle,
  WidthType,
  ShadingType,
  VerticalAlign,
  PageNumber,
  PageBreak,
} = require("docx");

const doc = new Document({
  sections: [
    {
      children: [
        /* content */
      ],
    },
  ],
});
Packer.toBuffer(doc).then((buffer) => {
  fs.writeFileSync("/tmp/output.docx", buffer);
  // MANDATORY: self-cleanup — delete this script file
  try { fs.unlinkSync(__filename); } catch(e) {}
});
```

### Validation

After creating the file, validate it. If validation fails, unpack, fix the XML, and repack.

```bash
uv run --project . python scripts/office/validate.py doc.docx
```

### Page Size

```javascript
// CRITICAL: docx-js defaults to A4, not US Letter
// Always set page size explicitly for consistent results
sections: [
  {
    properties: {
      page: {
        size: {
          width: 12240, // 8.5 inches in DXA
          height: 15840, // 11 inches in DXA
        },
        margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 }, // 1 inch margins
      },
    },
    children: [
      /* content */
    ],
  },
];
```

**Common page sizes (DXA units, 1440 DXA = 1 inch):**

| Paper        | Width  | Height | Content Width (1" margins) |
| ------------ | ------ | ------ | -------------------------- |
| US Letter    | 12,240 | 15,840 | 9,360                      |
| A4 (default) | 11,906 | 16,838 | 9,026                      |

**Landscape orientation:** docx-js swaps width/height internally, so pass portrait dimensions and let it handle the swap:

```javascript
size: {
  width: 12240,   // Pass SHORT edge as width
  height: 15840,  // Pass LONG edge as height
  orientation: PageOrientation.LANDSCAPE  // docx-js swaps them in the XML
},
// Content width = 15840 - left margin - right margin (uses the long edge)
```

### Styles (Override Built-in Headings)

Use Arial as the default font (universally supported). Keep titles black for readability.

```javascript
const doc = new Document({
  styles: {
    default: { document: { run: { font: "Arial", size: 24 } } }, // 12pt default
    paragraphStyles: [
      // IMPORTANT: Use exact IDs to override built-in styles
      {
        id: "Heading1",
        name: "Heading 1",
        basedOn: "Normal",
        next: "Normal",
        quickFormat: true,
        run: { size: 32, bold: true, font: "Arial" },
        paragraph: { spacing: { before: 240, after: 240 }, outlineLevel: 0 },
      }, // outlineLevel required for TOC
      {
        id: "Heading2",
        name: "Heading 2",
        basedOn: "Normal",
        next: "Normal",
        quickFormat: true,
        run: { size: 28, bold: true, font: "Arial" },
        paragraph: { spacing: { before: 180, after: 180 }, outlineLevel: 1 },
      },
    ],
  },
  sections: [
    {
      children: [
        new Paragraph({ heading: HeadingLevel.HEADING_1, children: [new TextRun("Title")] }),
      ],
    },
  ],
});
```

### Lists (NEVER use unicode bullets)

```javascript
// ❌ WRONG - never manually insert bullet characters
new Paragraph({ children: [new TextRun("• Item")] }); // BAD
new Paragraph({ children: [new TextRun("\u2022 Item")] }); // BAD

// ✅ CORRECT - use numbering config with LevelFormat.BULLET
const doc = new Document({
  numbering: {
    config: [
      {
        reference: "bullets",
        levels: [
          {
            level: 0,
            format: LevelFormat.BULLET,
            text: "•",
            alignment: AlignmentType.LEFT,
            style: { paragraph: { indent: { left: 720, hanging: 360 } } },
          },
        ],
      },
      {
        reference: "numbers",
        levels: [
          {
            level: 0,
            format: LevelFormat.DECIMAL,
            text: "%1.",
            alignment: AlignmentType.LEFT,
            style: { paragraph: { indent: { left: 720, hanging: 360 } } },
          },
        ],
      },
    ],
  },
  sections: [
    {
      children: [
        new Paragraph({
          numbering: { reference: "bullets", level: 0 },
          children: [new TextRun("Bullet item")],
        }),
        new Paragraph({
          numbering: { reference: "numbers", level: 0 },
          children: [new TextRun("Numbered item")],
        }),
      ],
    },
  ],
});

// ⚠️ Each reference creates INDEPENDENT numbering
// Same reference = continues (1,2,3 then 4,5,6)
// Different reference = restarts (1,2,3 then 1,2,3)
```

### Tables

**CRITICAL: Tables need dual widths** - set both `columnWidths` on the table AND `width` on each cell. Without both, tables render incorrectly on some platforms.

```javascript
// CRITICAL: Always set table width for consistent rendering
// CRITICAL: Use ShadingType.CLEAR (not SOLID) to prevent black backgrounds
const border = { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" };
const borders = { top: border, bottom: border, left: border, right: border };

new Table({
  width: { size: 9360, type: WidthType.DXA }, // Always use DXA (percentages break in Google Docs)
  columnWidths: [4680, 4680], // Must sum to table width (DXA: 1440 = 1 inch)
  rows: [
    new TableRow({
      children: [
        new TableCell({
          borders,
          width: { size: 4680, type: WidthType.DXA }, // Also set on each cell
          shading: { fill: "D5E8F0", type: ShadingType.CLEAR }, // CLEAR not SOLID
          margins: { top: 80, bottom: 80, left: 120, right: 120 }, // Cell padding (internal, not added to width)
          children: [new Paragraph({ children: [new TextRun("Cell")] })],
        }),
      ],
    }),
  ],
});
```

**Table width calculation:**

Always use `WidthType.DXA` — `WidthType.PERCENTAGE` breaks in Google Docs.

```javascript
// Table width = sum of columnWidths = content width
// US Letter with 1" margins: 12240 - 2880 = 9360 DXA
width: { size: 9360, type: WidthType.DXA },
columnWidths: [7000, 2360]  // Must sum to table width
```

**Width rules:**

- **Always use `WidthType.DXA`** — never `WidthType.PERCENTAGE` (incompatible with Google Docs)
- Table width must equal the sum of `columnWidths`
- Cell `width` must match corresponding `columnWidth`
- Cell `margins` are internal padding - they reduce content area, not add to cell width
- For full-width tables: use content width (page width minus left and right margins)

### Images

```javascript
// CRITICAL: type parameter is REQUIRED
new Paragraph({
  children: [
    new ImageRun({
      type: "png", // Required: png, jpg, jpeg, gif, bmp, svg
      data: fs.readFileSync("image.png"),
      transformation: { width: 200, height: 150 },
      altText: { title: "Title", description: "Desc", name: "Name" }, // All three required
    }),
  ],
});
```

### Page Breaks

```javascript
// CRITICAL: PageBreak must be inside a Paragraph
new Paragraph({ children: [new PageBreak()] });

// Or use pageBreakBefore
new Paragraph({ pageBreakBefore: true, children: [new TextRun("New page")] });
```

### Table of Contents

```javascript
// CRITICAL: TableOfContents must be a DIRECT child of section's children array
// NEVER wrap it in a Paragraph — it is a block-level element like Paragraph and Table
sections: [
  {
    children: [
      // ✅ CORRECT - TableOfContents directly in children array
      new TableOfContents("Table of Contents", { hyperlink: true, headingStyleRange: "1-3" }),
      new Paragraph({ heading: HeadingLevel.HEADING_1, children: [new TextRun("Chapter 1")] }),
    ],
  },
];

// ❌ WRONG - wrapping in Paragraph creates nested <w:p> (invalid XML)
new Paragraph({ children: [new TableOfContents("TOC", { ... })] }); // BAD - causes XSD validation error
```

### Headers/Footers

```javascript
sections: [
  {
    properties: {
      page: { margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 } }, // 1440 = 1 inch
    },
    headers: {
      default: new Header({ children: [new Paragraph({ children: [new TextRun("Header")] })] }),
    },
    footers: {
      default: new Footer({
        children: [
          new Paragraph({
            children: [new TextRun("Page "), new TextRun({ children: [PageNumber.CURRENT] })],
          }),
        ],
      }),
    },
    children: [
      /* content */
    ],
  },
];
```

### Critical Rules for docx-js

- **Set page size explicitly** - docx-js defaults to A4; use US Letter (12240 x 15840 DXA) for US documents
- **Landscape: pass portrait dimensions** - docx-js swaps width/height internally; pass short
