Commit 3dbf401d authored by liguangyu06's avatar liguangyu06
Browse files

清理不应入库的本地产物与一次性脚本,使仓库与 .gitignore 一致。

parent 82116292
Copyright 2023 The Tektur Project Authors (https://www.github.com/hyvyys/Tektur)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Copyright 2019 The Work Sans Project Authors (https://github.com/weiweihuanghuang/Work-Sans)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Copyright 2023 The Young Serif Project Authors (https://github.com/noirblancrouge/YoungSerif)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
---
name: chrome-cdp
description: Interact with local Chrome browser session (only on explicit user approval after being asked to inspect, debug, or interact with a page open in Chrome)
---
# Chrome CDP
Lightweight Chrome DevTools Protocol CLI. Connects directly via WebSocket — no Puppeteer, works with 100+ tabs, instant connection.
## Prerequisites
- Chrome (or Chromium, Brave, Edge, Vivaldi) with remote debugging enabled: open `chrome://inspect/#remote-debugging` and toggle the switch
- Node.js 22+ (uses built-in WebSocket)
- If your browser's `DevToolsActivePort` is in a non-standard location, set `CDP_PORT_FILE` to its full path
## Commands
All commands use `scripts/cdp.mjs`. The `<target>` is a **unique** targetId prefix from `list`; copy the full prefix shown in the `list` output (for example `6BE827FA`). The CLI rejects ambiguous prefixes.
### List open pages
```bash
scripts/cdp.mjs list
```
### Take a screenshot
```bash
scripts/cdp.mjs shot <target> [file] # default: screenshot-<target>.png in runtime dir
```
Captures the **viewport only**. Scroll first with `eval` if you need content below the fold. Output includes the page's DPR and coordinate conversion hint (see **Coordinates** below).
### Accessibility tree snapshot
```bash
scripts/cdp.mjs snap <target>
```
### Evaluate JavaScript
```bash
scripts/cdp.mjs eval <target> <expr>
```
> **Watch out:** avoid index-based selection (`querySelectorAll(...)[i]`) across multiple `eval` calls when the DOM can change between them (e.g. after clicking Ignore, card indices shift). Collect all data in one `eval` or use stable selectors.
### Other commands
```bash
scripts/cdp.mjs html <target> [selector] # full page or element HTML
scripts/cdp.mjs nav <target> <url> # navigate and wait for load
scripts/cdp.mjs net <target> # resource timing entries
scripts/cdp.mjs click <target> <selector> # click element by CSS selector
scripts/cdp.mjs clickxy <target> <x> <y> # click at CSS pixel coords
scripts/cdp.mjs type <target> <text> # Input.insertText at current focus; works in cross-origin iframes unlike eval
scripts/cdp.mjs loadall <target> <selector> [ms] # click "load more" until gone (default 1500ms between clicks)
scripts/cdp.mjs evalraw <target> <method> [json] # raw CDP command passthrough
scripts/cdp.mjs open [url] # open new tab (each triggers Allow prompt)
scripts/cdp.mjs stop [target] # stop daemon(s)
```
## Coordinates
`shot` saves an image at native resolution: image pixels = CSS pixels × DPR. CDP Input events (`clickxy` etc.) take **CSS pixels**.
```
CSS px = screenshot image px / DPR
```
`shot` prints the DPR for the current page. Typical Retina (DPR=2): divide screenshot coords by 2.
## Tips
- Prefer `snap --compact` over `html` for page structure.
- Use `type` (not eval) to enter text in cross-origin iframes — `click`/`clickxy` to focus first, then `type`.
- Chrome shows an "Allow debugging" modal once per tab on first access. A background daemon keeps the session alive so subsequent commands need no further approval. Daemons auto-exit after 20 minutes of inactivity.
#!/usr/bin/env node
// cdp - lightweight Chrome DevTools Protocol CLI
// Uses raw CDP over WebSocket, no Puppeteer dependency.
// Requires Node 22+ (built-in WebSocket).
//
// Per-tab persistent daemon: page commands go through a daemon that holds
// the CDP session open. Chrome's "Allow debugging" modal fires once per
// daemon (= once per tab). Daemons auto-exit after 20min idle.
import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from 'fs';
import { homedir } from 'os';
import { resolve } from 'path';
import { spawn } from 'child_process';
import net from 'net';
const TIMEOUT = 15000;
const NAVIGATION_TIMEOUT = 30000;
const IDLE_TIMEOUT = 20 * 60 * 1000;
const DAEMON_CONNECT_RETRIES = 20;
const DAEMON_CONNECT_DELAY = 300;
const MIN_TARGET_PREFIX_LEN = 8;
const IS_WINDOWS = process.platform === 'win32';
if (!IS_WINDOWS) process.umask(0o077);
const RUNTIME_DIR = IS_WINDOWS
? resolve(process.env.LOCALAPPDATA || resolve(homedir(), 'AppData', 'Local'), 'cdp')
: process.env.XDG_RUNTIME_DIR
? resolve(process.env.XDG_RUNTIME_DIR, 'cdp')
: resolve(homedir(), '.cache', 'cdp');
try { mkdirSync(RUNTIME_DIR, { recursive: true, mode: 0o700 }); } catch {}
const PAGES_CACHE = resolve(RUNTIME_DIR, 'pages.json');
function sockPath(targetId) {
return IS_WINDOWS
? `\\\\.\\pipe\\cdp-${targetId}`
: resolve(RUNTIME_DIR, `cdp-${targetId}.sock`);
}
function getWsUrl() {
const home = homedir();
// macOS: ~/Library/Application Support/<name>/DevToolsActivePort
const macBrowsers = [
'Google/Chrome', 'Google/Chrome Beta', 'Google/Chrome for Testing',
'Chromium', 'BraveSoftware/Brave-Browser', 'Microsoft Edge',
];
// Linux: ~/.config/<name>/DevToolsActivePort
const linuxBrowsers = [
'google-chrome', 'google-chrome-beta', 'chromium',
'vivaldi', 'vivaldi-snapshot',
'BraveSoftware/Brave-Browser', 'microsoft-edge',
];
// Linux Flatpak: ~/.var/app/<app-id>/config/<name>/DevToolsActivePort
const flatpakBrowsers = [
['org.chromium.Chromium', 'chromium'],
['com.google.Chrome', 'google-chrome'],
['com.brave.Browser', 'BraveSoftware/Brave-Browser'],
['com.microsoft.Edge', 'microsoft-edge'],
['com.vivaldi.Vivaldi', 'vivaldi'],
];
const candidates = [
process.env.CDP_PORT_FILE,
...macBrowsers.flatMap(b => [
resolve(home, 'Library/Application Support', b, 'DevToolsActivePort'),
resolve(home, 'Library/Application Support', b, 'Default/DevToolsActivePort'),
]),
...linuxBrowsers.flatMap(b => [
resolve(home, '.config', b, 'DevToolsActivePort'),
resolve(home, '.config', b, 'Default/DevToolsActivePort'),
]),
...flatpakBrowsers.flatMap(([appId, name]) => [
resolve(home, '.var/app', appId, 'config', name, 'DevToolsActivePort'),
resolve(home, '.var/app', appId, 'config', name, 'Default/DevToolsActivePort'),
]),
// Windows: %LOCALAPPDATA%/<name>/User Data/DevToolsActivePort
...(IS_WINDOWS ? ['Google/Chrome', 'BraveSoftware/Brave-Browser', 'Microsoft/Edge'].flatMap(b => {
const base = process.env.LOCALAPPDATA || resolve(home, 'AppData/Local');
return [
resolve(base, b, 'User Data/DevToolsActivePort'),
resolve(base, b, 'User Data/Default/DevToolsActivePort'),
];
}) : []),
].filter(Boolean);
const portFile = candidates.find(p => existsSync(p));
if (!portFile) throw new Error('No DevToolsActivePort found. Enable remote debugging at chrome://inspect/#remote-debugging');
const lines = readFileSync(portFile, 'utf8').trim().split('\n');
if (lines.length < 2 || !lines[0] || !lines[1]) throw new Error(`Invalid DevToolsActivePort file: ${portFile}`);
const host = process.env.CDP_HOST || '127.0.0.1';
return `ws://${host}:${lines[0]}${lines[1]}`;
}
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
function resolvePrefix(prefix, candidates, noun = 'target', missingHint = '') {
const upper = prefix.toUpperCase();
const matches = candidates.filter(candidate => candidate.toUpperCase().startsWith(upper));
if (matches.length === 0) {
const hint = missingHint ? ` ${missingHint}` : '';
throw new Error(`No ${noun} matching prefix "${prefix}".${hint}`);
}
if (matches.length > 1) {
throw new Error(`Ambiguous prefix "${prefix}" — matches ${matches.length} ${noun}s. Use more characters.`);
}
return matches[0];
}
function getDisplayPrefixLength(targetIds) {
if (targetIds.length === 0) return MIN_TARGET_PREFIX_LEN;
const maxLen = Math.max(...targetIds.map(id => id.length));
for (let len = MIN_TARGET_PREFIX_LEN; len <= maxLen; len++) {
const prefixes = new Set(targetIds.map(id => id.slice(0, len).toUpperCase()));
if (prefixes.size === targetIds.length) return len;
}
return maxLen;
}
// ---------------------------------------------------------------------------
// CDP WebSocket client
// ---------------------------------------------------------------------------
class CDP {
#ws; #id = 0; #pending = new Map(); #eventHandlers = new Map(); #closeHandlers = [];
async connect(wsUrl) {
return new Promise((res, rej) => {
this.#ws = new WebSocket(wsUrl);
this.#ws.onopen = () => res();
this.#ws.onerror = (e) => rej(new Error('WebSocket error: ' + (e.message || e.type)));
this.#ws.onclose = () => this.#closeHandlers.forEach(h => h());
this.#ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.id && this.#pending.has(msg.id)) {
const { resolve, reject } = this.#pending.get(msg.id);
this.#pending.delete(msg.id);
if (msg.error) reject(new Error(msg.error.message));
else resolve(msg.result);
} else if (msg.method && this.#eventHandlers.has(msg.method)) {
for (const handler of [...this.#eventHandlers.get(msg.method)]) {
handler(msg.params || {}, msg);
}
}
};
});
}
send(method, params = {}, sessionId) {
const id = ++this.#id;
return new Promise((resolve, reject) => {
this.#pending.set(id, { resolve, reject });
const msg = { id, method, params };
if (sessionId) msg.sessionId = sessionId;
this.#ws.send(JSON.stringify(msg));
setTimeout(() => {
if (this.#pending.has(id)) {
this.#pending.delete(id);
reject(new Error(`Timeout: ${method}`));
}
}, TIMEOUT);
});
}
onEvent(method, handler) {
if (!this.#eventHandlers.has(method)) this.#eventHandlers.set(method, new Set());
const handlers = this.#eventHandlers.get(method);
handlers.add(handler);
return () => {
handlers.delete(handler);
if (handlers.size === 0) this.#eventHandlers.delete(method);
};
}
waitForEvent(method, timeout = TIMEOUT) {
let settled = false;
let off;
let timer;
const promise = new Promise((resolve, reject) => {
off = this.onEvent(method, (params) => {
if (settled) return;
settled = true;
clearTimeout(timer);
off();
resolve(params);
});
timer = setTimeout(() => {
if (settled) return;
settled = true;
off();
reject(new Error(`Timeout waiting for event: ${method}`));
}, timeout);
});
return {
promise,
cancel() {
if (settled) return;
settled = true;
clearTimeout(timer);
off?.();
},
};
}
onClose(handler) { this.#closeHandlers.push(handler); }
close() { this.#ws.close(); }
}
// ---------------------------------------------------------------------------
// Command implementations — return strings, take (cdp, sessionId)
// ---------------------------------------------------------------------------
async function getPages(cdp) {
const { targetInfos } = await cdp.send('Target.getTargets');
return targetInfos.filter(t => t.type === 'page' && !t.url.startsWith('chrome://'));
}
function formatPageList(pages) {
const prefixLen = getDisplayPrefixLength(pages.map(p => p.targetId));
return pages.map(p => {
const id = p.targetId.slice(0, prefixLen).padEnd(prefixLen);
const title = p.title.substring(0, 54).padEnd(54);
return `${id} ${title} ${p.url}`;
}).join('\n');
}
function shouldShowAxNode(node, compact = false) {
const role = node.role?.value || '';
const name = node.name?.value ?? '';
const value = node.value?.value;
if (compact && role === 'InlineTextBox') return false;
return role !== 'none' && role !== 'generic' && !(name === '' && (value === '' || value == null));
}
function formatAxNode(node, depth) {
const role = node.role?.value || '';
const name = node.name?.value ?? '';
const value = node.value?.value;
const indent = ' '.repeat(Math.min(depth, 10));
let line = `${indent}[${role}]`;
if (name !== '') line += ` ${name}`;
if (!(value === '' || value == null)) line += ` = ${JSON.stringify(value)}`;
return line;
}
function orderedAxChildren(node, nodesById, childrenByParent) {
const children = [];
const seen = new Set();
for (const childId of node.childIds || []) {
const child = nodesById.get(childId);
if (child && !seen.has(child.nodeId)) {
seen.add(child.nodeId);
children.push(child);
}
}
for (const child of childrenByParent.get(node.nodeId) || []) {
if (!seen.has(child.nodeId)) {
seen.add(child.nodeId);
children.push(child);
}
}
return children;
}
async function snapshotStr(cdp, sid, compact = false) {
const { nodes } = await cdp.send('Accessibility.getFullAXTree', {}, sid);
const nodesById = new Map(nodes.map(node => [node.nodeId, node]));
const childrenByParent = new Map();
for (const node of nodes) {
if (!node.parentId) continue;
if (!childrenByParent.has(node.parentId)) childrenByParent.set(node.parentId, []);
childrenByParent.get(node.parentId).push(node);
}
const lines = [];
const visited = new Set();
function visit(node, depth) {
if (!node || visited.has(node.nodeId)) return;
visited.add(node.nodeId);
if (shouldShowAxNode(node, compact)) lines.push(formatAxNode(node, depth));
for (const child of orderedAxChildren(node, nodesById, childrenByParent)) {
visit(child, depth + 1);
}
}
const roots = nodes.filter(node => !node.parentId || !nodesById.has(node.parentId));
for (const root of roots) visit(root, 0);
for (const node of nodes) visit(node, 0);
return lines.join('\n');
}
async function evalStr(cdp, sid, expression) {
await cdp.send('Runtime.enable', {}, sid);
const result = await cdp.send('Runtime.evaluate', {
expression, returnByValue: true, awaitPromise: true,
}, sid);
if (result.exceptionDetails) {
throw new Error(result.exceptionDetails.text || result.exceptionDetails.exception?.description);
}
const val = result.result.value;
return typeof val === 'object' ? JSON.stringify(val, null, 2) : String(val ?? '');
}
async function shotStr(cdp, sid, filePath, targetId) {
// Get device scale factor so we can report coordinate mapping
let dpr = 1;
try {
const metrics = await cdp.send('Page.getLayoutMetrics', {}, sid);
dpr = metrics.visualViewport?.clientWidth
? metrics.cssVisualViewport?.clientWidth
? Math.round((metrics.visualViewport.clientWidth / metrics.cssVisualViewport.clientWidth) * 100) / 100
: 1
: 1;
// Simpler: deviceScaleFactor is on the root Page metrics
const { deviceScaleFactor } = await cdp.send('Emulation.getDeviceMetricsOverride', {}, sid).catch(() => ({}));
if (deviceScaleFactor) dpr = deviceScaleFactor;
} catch {}
// Fallback: try to get DPR from JS
if (dpr === 1) {
try {
const raw = await evalStr(cdp, sid, 'window.devicePixelRatio');
const parsed = parseFloat(raw);
if (parsed > 0) dpr = parsed;
} catch {}
}
const { data } = await cdp.send('Page.captureScreenshot', { format: 'png' }, sid);
const out = filePath || resolve(RUNTIME_DIR, `screenshot-${(targetId || 'unknown').slice(0, 8)}.png`);
writeFileSync(out, Buffer.from(data, 'base64'));
const lines = [out];
lines.push(`Screenshot saved. Device pixel ratio (DPR): ${dpr}`);
lines.push(`Coordinate mapping:`);
lines.push(` Screenshot pixels → CSS pixels (for CDP Input events): divide by ${dpr}`);
lines.push(` e.g. screenshot point (${Math.round(100 * dpr)}, ${Math.round(200 * dpr)}) → CSS (100, 200) → use clickxy <target> 100 200`);
if (dpr !== 1) {
lines.push(` On this ${dpr}x display: CSS px = screenshot px / ${dpr} ≈ screenshot px × ${Math.round(100/dpr)/100}`);
}
return lines.join('\n');
}
async function htmlStr(cdp, sid, selector) {
const expr = selector
? `document.querySelector(${JSON.stringify(selector)})?.outerHTML || 'Element not found'`
: `document.documentElement.outerHTML`;
return evalStr(cdp, sid, expr);
}
async function waitForDocumentReady(cdp, sid, timeoutMs = NAVIGATION_TIMEOUT) {
const deadline = Date.now() + timeoutMs;
let lastState = '';
let lastError;
while (Date.now() < deadline) {
try {
const state = await evalStr(cdp, sid, 'document.readyState');
lastState = state;
if (state === 'complete') return;
} catch (e) {
lastError = e;
}
await sleep(200);
}
if (lastState) {
throw new Error(`Timed out waiting for navigation to finish (last readyState: ${lastState})`);
}
if (lastError) {
throw new Error(`Timed out waiting for navigation to finish (${lastError.message})`);
}
throw new Error('Timed out waiting for navigation to finish');
}
async function navStr(cdp, sid, url) {
try {
const parsed = new URL(url);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
throw new Error(`Only http/https URLs allowed, got: ${url}`);
} catch (e) {
if (e.message.startsWith('Only')) throw e;
throw new Error(`Invalid URL: ${url}`);
}
await cdp.send('Page.enable', {}, sid);
const loadEvent = cdp.waitForEvent('Page.loadEventFired', NAVIGATION_TIMEOUT);
const result = await cdp.send('Page.navigate', { url }, sid);
if (result.errorText) {
loadEvent.cancel();
throw new Error(result.errorText);
}
if (result.loaderId) {
await loadEvent.promise;
} else {
loadEvent.cancel();
}
await waitForDocumentReady(cdp, sid, 5000);
return `Navigated to ${url}`;
}
async function netStr(cdp, sid) {
const raw = await evalStr(cdp, sid, `JSON.stringify(performance.getEntriesByType('resource').map(e => ({
name: e.name.substring(0, 120), type: e.initiatorType,
duration: Math.round(e.duration), size: e.transferSize
})))`);
return JSON.parse(raw).map(e =>
`${String(e.duration).padStart(5)}ms ${String(e.size || '?').padStart(8)}B ${e.type.padEnd(8)} ${e.name}`
).join('\n');
}
// Click element by CSS selector
async function clickStr(cdp, sid, selector) {
if (!selector) throw new Error('CSS selector required');
const expr = `
(function() {
const el = document.querySelector(${JSON.stringify(selector)});
if (!el) return { ok: false, error: 'Element not found: ' + ${JSON.stringify(selector)} };
el.scrollIntoView({ block: 'center' });
el.click();
return { ok: true, tag: el.tagName, text: el.textContent.trim().substring(0, 80) };
})()
`;
const result = await evalStr(cdp, sid, expr);
const r = JSON.parse(result);
if (!r.ok) throw new Error(r.error);
return `Clicked <${r.tag}> "${r.text}"`;
}
// Click at CSS pixel coordinates using Input.dispatchMouseEvent
async function clickXyStr(cdp, sid, x, y) {
const cx = parseFloat(x);
const cy = parseFloat(y);
if (isNaN(cx) || isNaN(cy)) throw new Error('x and y must be numbers (CSS pixels)');
const base = { x: cx, y: cy, button: 'left', clickCount: 1, modifiers: 0 };
await cdp.send('Input.dispatchMouseEvent', { ...base, type: 'mouseMoved' }, sid);
await cdp.send('Input.dispatchMouseEvent', { ...base, type: 'mousePressed' }, sid);
await sleep(50);
await cdp.send('Input.dispatchMouseEvent', { ...base, type: 'mouseReleased' }, sid);
return `Clicked at CSS (${cx}, ${cy})`;
}
// Type text using Input.insertText (works in cross-origin iframes, unlike eval)
async function typeStr(cdp, sid, text) {
if (text == null || text === '') throw new Error('text required');
await cdp.send('Input.insertText', { text }, sid);
return `Typed ${text.length} characters`;
}
// Load-more: repeatedly click a button/selector until it disappears
async function loadAllStr(cdp, sid, selector, intervalMs = 1500) {
if (!selector) throw new Error('CSS selector required');
let clicks = 0;
const deadline = Date.now() + 5 * 60 * 1000; // 5-minute hard cap
while (Date.now() < deadline) {
const exists = await evalStr(cdp, sid,
`!!document.querySelector(${JSON.stringify(selector)})`
);
if (exists !== 'true') break;
const clickExpr = `
(function() {
const el = document.querySelector(${JSON.stringify(selector)});
if (!el) return false;
el.scrollIntoView({ block: 'center' });
el.click();
return true;
})()
`;
const clicked = await evalStr(cdp, sid, clickExpr);
if (clicked !== 'true') break;
clicks++;
await sleep(intervalMs);
}
return `Clicked "${selector}" ${clicks} time(s) until it disappeared`;
}
// Send a raw CDP command and return the result as JSON
async function evalRawStr(cdp, sid, method, paramsJson) {
if (!method) throw new Error('CDP method required (e.g. "DOM.getDocument")');
let params = {};
if (paramsJson) {
try { params = JSON.parse(paramsJson); }
catch { throw new Error(`Invalid JSON params: ${paramsJson}`); }
}
const result = await cdp.send(method, params, sid);
return JSON.stringify(result, null, 2);
}
// ---------------------------------------------------------------------------
// Per-tab daemon
// ---------------------------------------------------------------------------
async function runDaemon(targetId) {
const sp = sockPath(targetId);
const cdp = new CDP();
try {
await cdp.connect(getWsUrl());
} catch (e) {
process.stderr.write(`Daemon: cannot connect to Chrome: ${e.message}\n`);
process.exit(1);
}
let sessionId;
try {
const res = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
sessionId = res.sessionId;
} catch (e) {
process.stderr.write(`Daemon: attach failed: ${e.message}\n`);
cdp.close();
process.exit(1);
}
// Shutdown helpers
let alive = true;
function shutdown() {
if (!alive) return;
alive = false;
server.close();
if (!IS_WINDOWS) try { unlinkSync(sp); } catch {}
cdp.close();
process.exit(0);
}
// Exit if target goes away or Chrome disconnects
cdp.onEvent('Target.targetDestroyed', (params) => {
if (params.targetId === targetId) shutdown();
});
cdp.onEvent('Target.detachedFromTarget', (params) => {
if (params.sessionId === sessionId) shutdown();
});
cdp.onClose(() => shutdown());
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
// Idle timer
let idleTimer = setTimeout(shutdown, IDLE_TIMEOUT);
function resetIdle() {
clearTimeout(idleTimer);
idleTimer = setTimeout(shutdown, IDLE_TIMEOUT);
}
// Handle a command
async function handleCommand({ cmd, args }) {
resetIdle();
try {
let result;
switch (cmd) {
case 'list': {
const pages = await getPages(cdp);
result = formatPageList(pages);
break;
}
case 'list_raw': {
const pages = await getPages(cdp);
result = JSON.stringify(pages);
break;
}
case 'snap': case 'snapshot': result = await snapshotStr(cdp, sessionId, true); break;
case 'eval': result = await evalStr(cdp, sessionId, args[0]); break;
case 'shot': case 'screenshot': result = await shotStr(cdp, sessionId, args[0], targetId); break;
case 'html': result = await htmlStr(cdp, sessionId, args[0]); break;
case 'nav': case 'navigate': result = await navStr(cdp, sessionId, args[0]); break;
case 'net': case 'network': result = await netStr(cdp, sessionId); break;
case 'click': result = await clickStr(cdp, sessionId, args[0]); break;
case 'clickxy': result = await clickXyStr(cdp, sessionId, args[0], args[1]); break;
case 'type': result = await typeStr(cdp, sessionId, args[0]); break;
case 'loadall': result = await loadAllStr(cdp, sessionId, args[0], args[1] ? parseInt(args[1]) : 1500); break;
case 'evalraw': result = await evalRawStr(cdp, sessionId, args[0], args[1]); break;
case 'stop': return { ok: true, result: '', stopAfter: true };
default: return { ok: false, error: `Unknown command: ${cmd}` };
}
return { ok: true, result: result ?? '' };
} catch (e) {
return { ok: false, error: e.message };
}
}
// Unix socket server — NDJSON protocol
// Wire format: each message is one JSON object followed by \n (newline-delimited JSON).
// Request: { "id": <number>, "cmd": "<command>", "args": ["arg1", "arg2", ...] }
// Response: { "id": <number>, "ok": <boolean>, "result": "<string>" }
// or { "id": <number>, "ok": false, "error": "<message>" }
const server = net.createServer((conn) => {
let buf = '';
conn.on('data', (chunk) => {
buf += chunk.toString();
const lines = buf.split('\n');
buf = lines.pop(); // keep incomplete last line
for (const line of lines) {
if (!line.trim()) continue;
let req;
try {
req = JSON.parse(line);
} catch {
conn.write(JSON.stringify({ ok: false, error: 'Invalid JSON request', id: null }) + '\n');
continue;
}
handleCommand(req).then((res) => {
const payload = JSON.stringify({ ...res, id: req.id }) + '\n';
if (res.stopAfter) conn.end(payload, shutdown);
else conn.write(payload);
});
}
});
});
server.on('error', (e) => {
process.stderr.write(`Daemon server listen failed: ${e.message}\n`);
process.exit(1);
});
if (!IS_WINDOWS) try { unlinkSync(sp); } catch {}
server.listen(sp);
}
// ---------------------------------------------------------------------------
// CLI ↔ daemon communication
// ---------------------------------------------------------------------------
function connectToSocket(sp) {
return new Promise((resolve, reject) => {
const conn = net.connect(sp);
conn.on('connect', () => resolve(conn));
conn.on('error', reject);
});
}
async function getOrStartTabDaemon(targetId) {
const sp = sockPath(targetId);
// Try existing daemon
try { return await connectToSocket(sp); } catch {}
// Clean stale socket
if (!IS_WINDOWS) try { unlinkSync(sp); } catch {}
// Spawn daemon
const child = spawn(process.execPath, [process.argv[1], '_daemon', targetId], {
detached: true,
stdio: 'ignore',
});
child.unref();
// Wait for socket (includes time for user to click Allow)
for (let i = 0; i < DAEMON_CONNECT_RETRIES; i++) {
await sleep(DAEMON_CONNECT_DELAY);
try { return await connectToSocket(sp); } catch {}
}
throw new Error('Daemon failed to start — did you click Allow in Chrome?');
}
function sendCommand(conn, req) {
return new Promise((resolve, reject) => {
let buf = '';
let settled = false;
const cleanup = () => {
conn.off('data', onData);
conn.off('error', onError);
conn.off('end', onEnd);
conn.off('close', onClose);
};
const onData = (chunk) => {
buf += chunk.toString();
const idx = buf.indexOf('\n');
if (idx === -1) return;
settled = true;
cleanup();
resolve(JSON.parse(buf.slice(0, idx)));
conn.end();
};
const onError = (error) => {
if (settled) return;
settled = true;
cleanup();
reject(error);
};
const onEnd = () => {
if (settled) return;
settled = true;
cleanup();
reject(new Error('Connection closed before response'));
};
const onClose = () => {
if (settled) return;
settled = true;
cleanup();
reject(new Error('Connection closed before response'));
};
conn.on('data', onData);
conn.on('error', onError);
conn.on('end', onEnd);
conn.on('close', onClose);
req.id = 1;
conn.write(JSON.stringify(req) + '\n');
});
}
// ---------------------------------------------------------------------------
// Stop daemons
// ---------------------------------------------------------------------------
async function stopDaemons(targetPrefix) {
if (!existsSync(PAGES_CACHE)) return;
const pages = JSON.parse(readFileSync(PAGES_CACHE, 'utf8'));
const targets = targetPrefix
? [resolvePrefix(targetPrefix, pages.map(p => p.targetId), 'target')]
: pages.map(p => p.targetId);
for (const targetId of targets) {
const sp = sockPath(targetId);
try {
const conn = await connectToSocket(sp);
await sendCommand(conn, { cmd: 'stop' });
} catch {
if (!IS_WINDOWS) try { unlinkSync(sp); } catch {}
}
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
const USAGE = `cdp - lightweight Chrome DevTools Protocol CLI (no Puppeteer)
Usage: cdp <command> [args]
list List open pages (shows unique target prefixes)
snap <target> Accessibility tree snapshot
eval <target> <expr> Evaluate JS expression
shot <target> [file] Screenshot (default: screenshot-<target>.png in runtime dir); prints coordinate mapping
html <target> [selector] Get HTML (full page or CSS selector)
nav <target> <url> Navigate to URL and wait for load completion
net <target> Network performance entries
click <target> <selector> Click an element by CSS selector
clickxy <target> <x> <y> Click at CSS pixel coordinates (see coordinate note below)
type <target> <text> Type text at current focus via Input.insertText
Works in cross-origin iframes unlike eval-based approaches
loadall <target> <selector> [ms] Repeatedly click a "load more" button until it disappears
Optional interval in ms between clicks (default 1500)
evalraw <target> <method> [json] Send a raw CDP command; returns JSON result
e.g. evalraw <t> "DOM.getDocument" '{}'
open [url] Open a new tab (default: about:blank)
Note: each new tab triggers a fresh "Allow debugging?" prompt
stop [target] Stop daemon(s)
<target> is a unique targetId prefix from "cdp list". If a prefix is ambiguous,
use more characters.
COORDINATE SYSTEM
shot captures the viewport at the device's native resolution.
The screenshot image size = CSS pixels × DPR (device pixel ratio).
For CDP Input events (clickxy, etc.) you need CSS pixels, not image pixels.
CSS pixels = screenshot image pixels / DPR
shot prints the DPR and an example conversion for the current page.
Typical Retina (DPR=2): CSS px ≈ screenshot px × 0.5
If your viewer rescales the image further, account for that scaling too.
EVAL SAFETY NOTE
Avoid index-based DOM selection (querySelectorAll(...)[i]) across multiple
eval calls when the list can change between calls (e.g. after clicking
"Ignore" buttons on a feed — indices shift). Prefer stable selectors or
collect all data in a single eval.
DAEMON IPC (for advanced use / scripting)
Each tab runs a persistent daemon at Unix socket in the runtime dir (see below).
Protocol: newline-delimited JSON (one JSON object per line, UTF-8).
Request: {"id":<number>, "cmd":"<command>", "args":["arg1","arg2",...]}
Response: {"id":<number>, "ok":true, "result":"<string>"}
or {"id":<number>, "ok":false, "error":"<message>"}
Commands mirror the CLI: snap, eval, shot, html, nav, net, click, clickxy,
type, loadall, evalraw, stop. Use evalraw to send arbitrary CDP methods.
The socket disappears after 20 min of inactivity or when the tab closes.
`;
const NEEDS_TARGET = new Set([
'snap','snapshot','eval','shot','screenshot','html','nav','navigate',
'net','network','click','clickxy','type','loadall','evalraw',
]);
async function main() {
const [cmd, ...args] = process.argv.slice(2);
// Daemon mode (internal)
if (cmd === '_daemon') { await runDaemon(args[0]); return; }
if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
console.log(USAGE); process.exit(0);
}
if (cmd === 'list' || cmd === 'ls') {
const cdp = new CDP();
await cdp.connect(getWsUrl());
const pages = await getPages(cdp);
cdp.close();
writeFileSync(PAGES_CACHE, JSON.stringify(pages), { mode: 0o600 });
console.log(formatPageList(pages));
setTimeout(() => process.exit(0), 100);
return;
}
// Open new tab
if (cmd === 'open') {
const url = args[0] || 'about:blank';
const cdp = new CDP();
await cdp.connect(getWsUrl());
const { targetId } = await cdp.send('Target.createTarget', { url });
// Refresh cache; new tab may not appear in getTargets immediately, so add it manually
const pages = await getPages(cdp);
if (!pages.some(p => p.targetId === targetId)) {
pages.push({ targetId, title: url, url });
}
cdp.close();
writeFileSync(PAGES_CACHE, JSON.stringify(pages), { mode: 0o600 });
console.log(`Opened new tab: ${targetId.slice(0, 8)} ${url}`);
console.log('Note: this tab will need "Allow debugging?" approval on first access.');
return;
}
// Stop
if (cmd === 'stop') {
await stopDaemons(args[0]);
return;
}
// Page commands — need target prefix
if (!NEEDS_TARGET.has(cmd)) {
console.error(`Unknown command: ${cmd}\n`);
console.log(USAGE);
process.exit(1);
}
const targetPrefix = args[0];
if (!targetPrefix) {
console.error('Error: target ID required. Run "cdp list" first.');
process.exit(1);
}
// Resolve prefix → full targetId from pages cache
if (!existsSync(PAGES_CACHE)) {
console.error('No page list cached. Run "cdp list" first.');
process.exit(1);
}
const pages = JSON.parse(readFileSync(PAGES_CACHE, 'utf8'));
const targetId = resolvePrefix(targetPrefix, pages.map(p => p.targetId), 'target', 'Run "cdp list".');
const conn = await getOrStartTabDaemon(targetId);
const cmdArgs = args.slice(1);
if (cmd === 'eval') {
const expr = cmdArgs.join(' ');
if (!expr) { console.error('Error: expression required'); process.exit(1); }
cmdArgs[0] = expr;
} else if (cmd === 'type') {
// Join all remaining args as text (allows spaces)
const text = cmdArgs.join(' ');
if (!text) { console.error('Error: text required'); process.exit(1); }
cmdArgs[0] = text;
} else if (cmd === 'evalraw') {
// args: [method, ...jsonParts] — join json parts in case of spaces
if (!cmdArgs[0]) { console.error('Error: CDP method required'); process.exit(1); }
if (cmdArgs.length > 2) cmdArgs[1] = cmdArgs.slice(1).join(' ');
}
if ((cmd === 'nav' || cmd === 'navigate') && !cmdArgs[0]) {
console.error('Error: URL required');
process.exit(1);
}
const response = await sendCommand(conn, { cmd, args: cmdArgs });
if (response.ok) {
if (response.result) console.log(response.result);
} else {
console.error('Error:', response.error);
process.exitCode = 1;
}
}
main().catch(e => { console.error(e.message); process.exit(1); });
© 2025 Anthropic, PBC. All rights reserved.
LICENSE: Use of these materials (including all code, prompts, assets, files,
and other components of this Skill) is governed by your agreement with
Anthropic regarding use of Anthropic's services. If no separate agreement
exists, use is governed by Anthropic's Consumer Terms of Service or
Commercial Terms of Service, as applicable:
https://www.anthropic.com/legal/consumer-terms
https://www.anthropic.com/legal/commercial-terms
Your applicable agreement is referred to as the "Agreement." "Services" are
as defined in the Agreement.
ADDITIONAL RESTRICTIONS: Notwithstanding anything in the Agreement to the
contrary, users may not:
- Extract these materials from the Services or retain copies of these
materials outside the Services
- Reproduce or copy these materials, except for temporary copies created
automatically during authorized use of the Services
- Create derivative works based on these materials
- Distribute, sublicense, or transfer these materials to any third party
- Make, offer to sell, sell, or import any inventions embodied in these
materials
- Reverse engineer, decompile, or disassemble these materials
The receipt, viewing, or possession of these materials does not convey or
imply any license or right beyond those expressly granted above.
Anthropic retains all right, title, and interest in these materials,
including all copyrights, patents, and other intellectual property rights.
---
name: docx
description: "Use this skill whenever the user wants to create, read, edit, or manipulate Word documents (.docx files). Triggers include: any mention of 'Word doc', 'word document', '.docx', or requests to produce professional documents with formatting like tables of contents, headings, page numbers, or letterheads. Also use when extracting or reorganizing content from .docx files, inserting or replacing images in documents, performing find-and-replace in Word files, working with tracked changes or comments, or converting content into a polished Word document. If the user asks for a 'report', 'memo', 'letter', 'template', or similar deliverable as a Word or .docx file, use this skill. Do NOT use for PDFs, spreadsheets, Google Docs, or general coding tasks unrelated to document generation."
license: Proprietary. LICENSE.txt has complete terms
---
# DOCX creation, editing, and analysis
## Overview
A .docx file is a ZIP archive containing XML files.
## Quick Reference
| Task | Approach |
|------|----------|
| 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 |
### Converting .doc to .docx
Legacy `.doc` files must be converted before editing:
```bash
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
python scripts/office/unpack.py document.docx unpacked/
```
### Converting to Images
```bash
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
python scripts/accept_changes.py input.docx output.docx
```
---
## Creating New Documents
Generate .docx files with JavaScript, then validate. Install: `npm install -g docx`
### Setup
```javascript
const { Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell, ImageRun,
Header, Footer, AlignmentType, PageOrientation, LevelFormat, ExternalHyperlink,
InternalHyperlink, Bookmark, FootnoteReferenceRun, PositionalTab,
PositionalTabAlignment, PositionalTabRelativeTo, PositionalTabLeader,
TabStopType, TabStopPosition, Column, SectionType,
TableOfContents, HeadingLevel, BorderStyle, WidthType, ShadingType,
VerticalAlign, PageNumber, PageBreak } = require('docx');
const doc = new Document({ sections: [{ children: [/* content */] }] });
Packer.toBuffer(doc).then(buffer => fs.writeFileSync("doc.docx", buffer));
```
### Validation
After creating the file, validate it. If validation fails, unpack, fix the XML, and repack.
```bash
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")] })
```
### Hyperlinks
```javascript
// External link
new Paragraph({
children: [new ExternalHyperlink({
children: [new TextRun({ text: "Click here", style: "Hyperlink" })],
link: "https://example.com",
})]
})
// Internal link (bookmark + reference)
// 1. Create bookmark at destination
new Paragraph({ heading: HeadingLevel.HEADING_1, children: [
new Bookmark({ id: "chapter1", children: [new TextRun("Chapter 1")] }),
]})
// 2. Link to it
new Paragraph({ children: [new InternalHyperlink({
children: [new TextRun({ text: "See Chapter 1", style: "Hyperlink" })],
anchor: "chapter1",
})]})
```
### Footnotes
```javascript
const doc = new Document({
footnotes: {
1: { children: [new Paragraph("Source: Annual Report 2024")] },
2: { children: [new Paragraph("See appendix for methodology")] },
},
sections: [{
children: [new Paragraph({
children: [
new TextRun("Revenue grew 15%"),
new FootnoteReferenceRun(1),
new TextRun(" using adjusted metrics"),
new FootnoteReferenceRun(2),
],
})]
}]
});
```
### Tab Stops
```javascript
// Right-align text on same line (e.g., date opposite a title)
new Paragraph({
children: [
new TextRun("Company Name"),
new TextRun("\tJanuary 2025"),
],
tabStops: [{ type: TabStopType.RIGHT, position: TabStopPosition.MAX }],
})
// Dot leader (e.g., TOC-style)
new Paragraph({
children: [
new TextRun("Introduction"),
new TextRun({ children: [
new PositionalTab({
alignment: PositionalTabAlignment.RIGHT,
relativeTo: PositionalTabRelativeTo.MARGIN,
leader: PositionalTabLeader.DOT,
}),
"3",
]}),
],
})
```
### Multi-Column Layouts
```javascript
// Equal-width columns
sections: [{
properties: {
column: {
count: 2, // number of columns
space: 720, // gap between columns in DXA (720 = 0.5 inch)
equalWidth: true,
separate: true, // vertical line between columns
},
},
children: [/* content flows naturally across columns */]
}]
// Custom-width columns (equalWidth must be false)
sections: [{
properties: {
column: {
equalWidth: false,
children: [
new Column({ width: 5400, space: 720 }),
new Column({ width: 3240 }),
],
},
},
children: [/* content */]
}]
```
Force a column break with a new section using `type: SectionType.NEXT_COLUMN`.
### Table of Contents
```javascript
// CRITICAL: Headings must use HeadingLevel ONLY - no custom styles
new TableOfContents("Table of Contents", { hyperlink: true, headingStyleRange: "1-3" })
```
### 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 edge as `width`, long edge as `height`, and set `orientation: PageOrientation.LANDSCAPE`
- **Never use `\n`** - use separate Paragraph elements
- **Never use unicode bullets** - use `LevelFormat.BULLET` with numbering config
- **PageBreak must be in Paragraph** - standalone creates invalid XML
- **ImageRun requires `type`** - always specify png/jpg/etc
- **Always set table `width` with DXA** - never use `WidthType.PERCENTAGE` (breaks in Google Docs)
- **Tables need dual widths** - `columnWidths` array AND cell `width`, both must match
- **Table width = sum of columnWidths** - for DXA, ensure they add up exactly
- **Always add cell margins** - use `margins: { top: 80, bottom: 80, left: 120, right: 120 }` for readable padding
- **Use `ShadingType.CLEAR`** - never SOLID for table shading
- **Never use tables as dividers/rules** - cells have minimum height and render as empty boxes (including in headers/footers); use `border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: "2E75B6", space: 1 } }` on a Paragraph instead. For two-column footers, use tab stops (see Tab Stops section), not tables
- **TOC requires HeadingLevel only** - no custom styles on heading paragraphs
- **Override built-in styles** - use exact IDs: "Heading1", "Heading2", etc.
- **Include `outlineLevel`** - required for TOC (0 for H1, 1 for H2, etc.)
---
## Editing Existing Documents
**Follow all 3 steps in order.**
### Step 1: Unpack
```bash
python scripts/office/unpack.py document.docx unpacked/
```
Extracts XML, pretty-prints, merges adjacent runs, and converts smart quotes to XML entities (`&#x201C;` etc.) so they survive editing. Use `--merge-runs false` to skip run merging.
### Step 2: Edit XML
Edit files in `unpacked/word/`. See XML Reference below for patterns.
**Use "Claude" as the author** for tracked changes and comments, unless the user explicitly requests use of a different name.
**Use the Edit tool directly for string replacement. Do not write Python scripts.** Scripts introduce unnecessary complexity. The Edit tool shows exactly what is being replaced.
**CRITICAL: Use smart quotes for new content.** When adding text with apostrophes or quotes, use XML entities to produce smart quotes:
```xml
<!-- Use these entities for professional typography -->
<w:t>Here&#x2019;s a quote: &#x201C;Hello&#x201D;</w:t>
```
| Entity | Character |
|--------|-----------|
| `&#x2018;` | ‘ (left single) |
| `&#x2019;` | ’ (right single / apostrophe) |
| `&#x201C;` | “ (left double) |
| `&#x201D;` | ” (right double) |
**Adding comments:** Use `comment.py` to handle boilerplate across multiple XML files (text must be pre-escaped XML):
```bash
python scripts/comment.py unpacked/ 0 "Comment text with &amp; and &#x2019;"
python scripts/comment.py unpacked/ 1 "Reply text" --parent 0 # reply to comment 0
python scripts/comment.py unpacked/ 0 "Text" --author "Custom Author" # custom author name
```
Then add markers to document.xml (see Comments in XML Reference).
### Step 3: Pack
```bash
python scripts/office/pack.py unpacked/ output.docx --original document.docx
```
Validates with auto-repair, condenses XML, and creates DOCX. Use `--validate false` to skip.
**Auto-repair will fix:**
- `durableId` >= 0x7FFFFFFF (regenerates valid ID)
- Missing `xml:space="preserve"` on `<w:t>` with whitespace
**Auto-repair won't fix:**
- Malformed XML, invalid element nesting, missing relationships, schema violations
### Common Pitfalls
- **Replace entire `<w:r>` elements**: When adding tracked changes, replace the whole `<w:r>...</w:r>` block with `<w:del>...<w:ins>...` as siblings. Don't inject tracked change tags inside a run.
- **Preserve `<w:rPr>` formatting**: Copy the original run's `<w:rPr>` block into your tracked change runs to maintain bold, font size, etc.
---
## XML Reference
### Schema Compliance
- **Element order in `<w:pPr>`**: `<w:pStyle>`, `<w:numPr>`, `<w:spacing>`, `<w:ind>`, `<w:jc>`, `<w:rPr>` last
- **Whitespace**: Add `xml:space="preserve"` to `<w:t>` with leading/trailing spaces
- **RSIDs**: Must be 8-digit hex (e.g., `00AB1234`)
### Tracked Changes
**Insertion:**
```xml
<w:ins w:id="1" w:author="Claude" w:date="2025-01-01T00:00:00Z">
<w:r><w:t>inserted text</w:t></w:r>
</w:ins>
```
**Deletion:**
```xml
<w:del w:id="2" w:author="Claude" w:date="2025-01-01T00:00:00Z">
<w:r><w:delText>deleted text</w:delText></w:r>
</w:del>
```
**Inside `<w:del>`**: Use `<w:delText>` instead of `<w:t>`, and `<w:delInstrText>` instead of `<w:instrText>`.
**Minimal edits** - only mark what changes:
```xml
<!-- Change "30 days" to "60 days" -->
<w:r><w:t>The term is </w:t></w:r>
<w:del w:id="1" w:author="Claude" w:date="...">
<w:r><w:delText>30</w:delText></w:r>
</w:del>
<w:ins w:id="2" w:author="Claude" w:date="...">
<w:r><w:t>60</w:t></w:r>
</w:ins>
<w:r><w:t> days.</w:t></w:r>
```
**Deleting entire paragraphs/list items** - when removing ALL content from a paragraph, also mark the paragraph mark as deleted so it merges with the next paragraph. Add `<w:del/>` inside `<w:pPr><w:rPr>`:
```xml
<w:p>
<w:pPr>
<w:numPr>...</w:numPr> <!-- list numbering if present -->
<w:rPr>
<w:del w:id="1" w:author="Claude" w:date="2025-01-01T00:00:00Z"/>
</w:rPr>
</w:pPr>
<w:del w:id="2" w:author="Claude" w:date="2025-01-01T00:00:00Z">
<w:r><w:delText>Entire paragraph content being deleted...</w:delText></w:r>
</w:del>
</w:p>
```
Without the `<w:del/>` in `<w:pPr><w:rPr>`, accepting changes leaves an empty paragraph/list item.
**Rejecting another author's insertion** - nest deletion inside their insertion:
```xml
<w:ins w:author="Jane" w:id="5">
<w:del w:author="Claude" w:id="10">
<w:r><w:delText>their inserted text</w:delText></w:r>
</w:del>
</w:ins>
```
**Restoring another author's deletion** - add insertion after (don't modify their deletion):
```xml
<w:del w:author="Jane" w:id="5">
<w:r><w:delText>deleted text</w:delText></w:r>
</w:del>
<w:ins w:author="Claude" w:id="10">
<w:r><w:t>deleted text</w:t></w:r>
</w:ins>
```
### Comments
After running `comment.py` (see Step 2), add markers to document.xml. For replies, use `--parent` flag and nest markers inside the parent's.
**CRITICAL: `<w:commentRangeStart>` and `<w:commentRangeEnd>` are siblings of `<w:r>`, never inside `<w:r>`.**
```xml
<!-- Comment markers are direct children of w:p, never inside w:r -->
<w:commentRangeStart w:id="0"/>
<w:del w:id="1" w:author="Claude" w:date="2025-01-01T00:00:00Z">
<w:r><w:delText>deleted</w:delText></w:r>
</w:del>
<w:r><w:t> more text</w:t></w:r>
<w:commentRangeEnd w:id="0"/>
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="0"/></w:r>
<!-- Comment 0 with reply 1 nested inside -->
<w:commentRangeStart w:id="0"/>
<w:commentRangeStart w:id="1"/>
<w:r><w:t>text</w:t></w:r>
<w:commentRangeEnd w:id="1"/>
<w:commentRangeEnd w:id="0"/>
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="0"/></w:r>
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="1"/></w:r>
```
### Images
1. Add image file to `word/media/`
2. Add relationship to `word/_rels/document.xml.rels`:
```xml
<Relationship Id="rId5" Type=".../image" Target="media/image1.png"/>
```
3. Add content type to `[Content_Types].xml`:
```xml
<Default Extension="png" ContentType="image/png"/>
```
4. Reference in document.xml:
```xml
<w:drawing>
<wp:inline>
<wp:extent cx="914400" cy="914400"/> <!-- EMUs: 914400 = 1 inch -->
<a:graphic>
<a:graphicData uri=".../picture">
<pic:pic>
<pic:blipFill><a:blip r:embed="rId5"/></pic:blipFill>
</pic:pic>
</a:graphicData>
</a:graphic>
</wp:inline>
</w:drawing>
```
---
## Dependencies
- **pandoc**: Text extraction
- **docx**: `npm install -g docx` (new documents)
- **LibreOffice**: PDF conversion (auto-configured for sandboxed environments via `scripts/office/soffice.py`)
- **Poppler**: `pdftoppm` for images
"""Accept all tracked changes in a DOCX file using LibreOffice.
Requires LibreOffice (soffice) to be installed.
"""
import argparse
import logging
import shutil
import subprocess
from pathlib import Path
from office.soffice import get_soffice_env
logger = logging.getLogger(__name__)
LIBREOFFICE_PROFILE = "/tmp/libreoffice_docx_profile"
MACRO_DIR = f"{LIBREOFFICE_PROFILE}/user/basic/Standard"
ACCEPT_CHANGES_MACRO = """<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE script:module PUBLIC "-//OpenOffice.org//DTD OfficeDocument 1.0//EN" "module.dtd">
<script:module xmlns:script="http://openoffice.org/2000/script" script:name="Module1" script:language="StarBasic">
Sub AcceptAllTrackedChanges()
Dim document As Object
Dim dispatcher As Object
document = ThisComponent.CurrentController.Frame
dispatcher = createUnoService("com.sun.star.frame.DispatchHelper")
dispatcher.executeDispatch(document, ".uno:AcceptAllTrackedChanges", "", 0, Array())
ThisComponent.store()
ThisComponent.close(True)
End Sub
</script:module>"""
def accept_changes(
input_file: str,
output_file: str,
) -> tuple[None, str]:
input_path = Path(input_file)
output_path = Path(output_file)
if not input_path.exists():
return None, f"Error: Input file not found: {input_file}"
if not input_path.suffix.lower() == ".docx":
return None, f"Error: Input file is not a DOCX file: {input_file}"
try:
output_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(input_path, output_path)
except Exception as e:
return None, f"Error: Failed to copy input file to output location: {e}"
if not _setup_libreoffice_macro():
return None, "Error: Failed to setup LibreOffice macro"
cmd = [
"soffice",
"--headless",
f"-env:UserInstallation=file://{LIBREOFFICE_PROFILE}",
"--norestore",
"vnd.sun.star.script:Standard.Module1.AcceptAllTrackedChanges?language=Basic&location=application",
str(output_path.absolute()),
]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30,
check=False,
env=get_soffice_env(),
)
except subprocess.TimeoutExpired:
return (
None,
f"Successfully accepted all tracked changes: {input_file} -> {output_file}",
)
if result.returncode != 0:
return None, f"Error: LibreOffice failed: {result.stderr}"
return (
None,
f"Successfully accepted all tracked changes: {input_file} -> {output_file}",
)
def _setup_libreoffice_macro() -> bool:
macro_dir = Path(MACRO_DIR)
macro_file = macro_dir / "Module1.xba"
if macro_file.exists() and "AcceptAllTrackedChanges" in macro_file.read_text():
return True
if not macro_dir.exists():
subprocess.run(
[
"soffice",
"--headless",
f"-env:UserInstallation=file://{LIBREOFFICE_PROFILE}",
"--terminate_after_init",
],
capture_output=True,
timeout=10,
check=False,
env=get_soffice_env(),
)
macro_dir.mkdir(parents=True, exist_ok=True)
try:
macro_file.write_text(ACCEPT_CHANGES_MACRO)
return True
except Exception as e:
logger.warning(f"Failed to setup LibreOffice macro: {e}")
return False
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Accept all tracked changes in a DOCX file"
)
parser.add_argument("input_file", help="Input DOCX file with tracked changes")
parser.add_argument(
"output_file", help="Output DOCX file (clean, no tracked changes)"
)
args = parser.parse_args()
_, message = accept_changes(args.input_file, args.output_file)
print(message)
if "Error" in message:
raise SystemExit(1)
"""Add comments to DOCX documents.
Usage:
python comment.py unpacked/ 0 "Comment text"
python comment.py unpacked/ 1 "Reply text" --parent 0
Text should be pre-escaped XML (e.g., &amp; for &, &#x2019; for smart quotes).
After running, add markers to document.xml:
<w:commentRangeStart w:id="0"/>
... commented content ...
<w:commentRangeEnd w:id="0"/>
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="0"/></w:r>
"""
import argparse
import random
import shutil
import sys
from datetime import datetime, timezone
from pathlib import Path
import defusedxml.minidom
TEMPLATE_DIR = Path(__file__).parent / "templates"
NS = {
"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
"w14": "http://schemas.microsoft.com/office/word/2010/wordml",
"w15": "http://schemas.microsoft.com/office/word/2012/wordml",
"w16cid": "http://schemas.microsoft.com/office/word/2016/wordml/cid",
"w16cex": "http://schemas.microsoft.com/office/word/2018/wordml/cex",
}
COMMENT_XML = """\
<w:comment w:id="{id}" w:author="{author}" w:date="{date}" w:initials="{initials}">
<w:p w14:paraId="{para_id}" w14:textId="77777777">
<w:r>
<w:rPr><w:rStyle w:val="CommentReference"/></w:rPr>
<w:annotationRef/>
</w:r>
<w:r>
<w:rPr>
<w:color w:val="000000"/>
<w:sz w:val="20"/>
<w:szCs w:val="20"/>
</w:rPr>
<w:t>{text}</w:t>
</w:r>
</w:p>
</w:comment>"""
COMMENT_MARKER_TEMPLATE = """
Add to document.xml (markers must be direct children of w:p, never inside w:r):
<w:commentRangeStart w:id="{cid}"/>
<w:r>...</w:r>
<w:commentRangeEnd w:id="{cid}"/>
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="{cid}"/></w:r>"""
REPLY_MARKER_TEMPLATE = """
Nest markers inside parent {pid}'s markers (markers must be direct children of w:p, never inside w:r):
<w:commentRangeStart w:id="{pid}"/><w:commentRangeStart w:id="{cid}"/>
<w:r>...</w:r>
<w:commentRangeEnd w:id="{cid}"/><w:commentRangeEnd w:id="{pid}"/>
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="{pid}"/></w:r>
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="{cid}"/></w:r>"""
def _generate_hex_id() -> str:
return f"{random.randint(0, 0x7FFFFFFE):08X}"
SMART_QUOTE_ENTITIES = {
"\u201c": "&#x201C;",
"\u201d": "&#x201D;",
"\u2018": "&#x2018;",
"\u2019": "&#x2019;",
}
def _encode_smart_quotes(text: str) -> str:
for char, entity in SMART_QUOTE_ENTITIES.items():
text = text.replace(char, entity)
return text
def _append_xml(xml_path: Path, root_tag: str, content: str) -> None:
dom = defusedxml.minidom.parseString(xml_path.read_text(encoding="utf-8"))
root = dom.getElementsByTagName(root_tag)[0]
ns_attrs = " ".join(f'xmlns:{k}="{v}"' for k, v in NS.items())
wrapper_dom = defusedxml.minidom.parseString(f"<root {ns_attrs}>{content}</root>")
for child in wrapper_dom.documentElement.childNodes:
if child.nodeType == child.ELEMENT_NODE:
root.appendChild(dom.importNode(child, True))
output = _encode_smart_quotes(dom.toxml(encoding="UTF-8").decode("utf-8"))
xml_path.write_text(output, encoding="utf-8")
def _find_para_id(comments_path: Path, comment_id: int) -> str | None:
dom = defusedxml.minidom.parseString(comments_path.read_text(encoding="utf-8"))
for c in dom.getElementsByTagName("w:comment"):
if c.getAttribute("w:id") == str(comment_id):
for p in c.getElementsByTagName("w:p"):
if pid := p.getAttribute("w14:paraId"):
return pid
return None
def _get_next_rid(rels_path: Path) -> int:
dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8"))
max_rid = 0
for rel in dom.getElementsByTagName("Relationship"):
rid = rel.getAttribute("Id")
if rid and rid.startswith("rId"):
try:
max_rid = max(max_rid, int(rid[3:]))
except ValueError:
pass
return max_rid + 1
def _has_relationship(rels_path: Path, target: str) -> bool:
dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8"))
for rel in dom.getElementsByTagName("Relationship"):
if rel.getAttribute("Target") == target:
return True
return False
def _has_content_type(ct_path: Path, part_name: str) -> bool:
dom = defusedxml.minidom.parseString(ct_path.read_text(encoding="utf-8"))
for override in dom.getElementsByTagName("Override"):
if override.getAttribute("PartName") == part_name:
return True
return False
def _ensure_comment_relationships(unpacked_dir: Path) -> None:
rels_path = unpacked_dir / "word" / "_rels" / "document.xml.rels"
if not rels_path.exists():
return
if _has_relationship(rels_path, "comments.xml"):
return
dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8"))
root = dom.documentElement
next_rid = _get_next_rid(rels_path)
rels = [
(
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments",
"comments.xml",
),
(
"http://schemas.microsoft.com/office/2011/relationships/commentsExtended",
"commentsExtended.xml",
),
(
"http://schemas.microsoft.com/office/2016/09/relationships/commentsIds",
"commentsIds.xml",
),
(
"http://schemas.microsoft.com/office/2018/08/relationships/commentsExtensible",
"commentsExtensible.xml",
),
]
for rel_type, target in rels:
rel = dom.createElement("Relationship")
rel.setAttribute("Id", f"rId{next_rid}")
rel.setAttribute("Type", rel_type)
rel.setAttribute("Target", target)
root.appendChild(rel)
next_rid += 1
rels_path.write_bytes(dom.toxml(encoding="UTF-8"))
def _ensure_comment_content_types(unpacked_dir: Path) -> None:
ct_path = unpacked_dir / "[Content_Types].xml"
if not ct_path.exists():
return
if _has_content_type(ct_path, "/word/comments.xml"):
return
dom = defusedxml.minidom.parseString(ct_path.read_text(encoding="utf-8"))
root = dom.documentElement
overrides = [
(
"/word/comments.xml",
"application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml",
),
(
"/word/commentsExtended.xml",
"application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml",
),
(
"/word/commentsIds.xml",
"application/vnd.openxmlformats-officedocument.wordprocessingml.commentsIds+xml",
),
(
"/word/commentsExtensible.xml",
"application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtensible+xml",
),
]
for part_name, content_type in overrides:
override = dom.createElement("Override")
override.setAttribute("PartName", part_name)
override.setAttribute("ContentType", content_type)
root.appendChild(override)
ct_path.write_bytes(dom.toxml(encoding="UTF-8"))
def add_comment(
unpacked_dir: str,
comment_id: int,
text: str,
author: str = "Claude",
initials: str = "C",
parent_id: int | None = None,
) -> tuple[str, str]:
word = Path(unpacked_dir) / "word"
if not word.exists():
return "", f"Error: {word} not found"
para_id, durable_id = _generate_hex_id(), _generate_hex_id()
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
comments = word / "comments.xml"
first_comment = not comments.exists()
if first_comment:
shutil.copy(TEMPLATE_DIR / "comments.xml", comments)
_ensure_comment_relationships(Path(unpacked_dir))
_ensure_comment_content_types(Path(unpacked_dir))
_append_xml(
comments,
"w:comments",
COMMENT_XML.format(
id=comment_id,
author=author,
date=ts,
initials=initials,
para_id=para_id,
text=text,
),
)
ext = word / "commentsExtended.xml"
if not ext.exists():
shutil.copy(TEMPLATE_DIR / "commentsExtended.xml", ext)
if parent_id is not None:
parent_para = _find_para_id(comments, parent_id)
if not parent_para:
return "", f"Error: Parent comment {parent_id} not found"
_append_xml(
ext,
"w15:commentsEx",
f'<w15:commentEx w15:paraId="{para_id}" w15:paraIdParent="{parent_para}" w15:done="0"/>',
)
else:
_append_xml(
ext,
"w15:commentsEx",
f'<w15:commentEx w15:paraId="{para_id}" w15:done="0"/>',
)
ids = word / "commentsIds.xml"
if not ids.exists():
shutil.copy(TEMPLATE_DIR / "commentsIds.xml", ids)
_append_xml(
ids,
"w16cid:commentsIds",
f'<w16cid:commentId w16cid:paraId="{para_id}" w16cid:durableId="{durable_id}"/>',
)
extensible = word / "commentsExtensible.xml"
if not extensible.exists():
shutil.copy(TEMPLATE_DIR / "commentsExtensible.xml", extensible)
_append_xml(
extensible,
"w16cex:commentsExtensible",
f'<w16cex:commentExtensible w16cex:durableId="{durable_id}" w16cex:dateUtc="{ts}"/>',
)
action = "reply" if parent_id is not None else "comment"
return para_id, f"Added {action} {comment_id} (para_id={para_id})"
if __name__ == "__main__":
p = argparse.ArgumentParser(description="Add comments to DOCX documents")
p.add_argument("unpacked_dir", help="Unpacked DOCX directory")
p.add_argument("comment_id", type=int, help="Comment ID (must be unique)")
p.add_argument("text", help="Comment text")
p.add_argument("--author", default="Claude", help="Author name")
p.add_argument("--initials", default="C", help="Author initials")
p.add_argument("--parent", type=int, help="Parent comment ID (for replies)")
args = p.parse_args()
para_id, msg = add_comment(
args.unpacked_dir,
args.comment_id,
args.text,
args.author,
args.initials,
args.parent,
)
print(msg)
if "Error" in msg:
sys.exit(1)
cid = args.comment_id
if args.parent is not None:
print(REPLY_MARKER_TEMPLATE.format(pid=args.parent, cid=cid))
else:
print(COMMENT_MARKER_TEMPLATE.format(cid=cid))
"""Merge adjacent runs with identical formatting in DOCX.
Merges adjacent <w:r> elements that have identical <w:rPr> properties.
Works on runs in paragraphs and inside tracked changes (<w:ins>, <w:del>).
Also:
- Removes rsid attributes from runs (revision metadata that doesn't affect rendering)
- Removes proofErr elements (spell/grammar markers that block merging)
"""
from pathlib import Path
import defusedxml.minidom
def merge_runs(input_dir: str) -> tuple[int, str]:
doc_xml = Path(input_dir) / "word" / "document.xml"
if not doc_xml.exists():
return 0, f"Error: {doc_xml} not found"
try:
dom = defusedxml.minidom.parseString(doc_xml.read_text(encoding="utf-8"))
root = dom.documentElement
_remove_elements(root, "proofErr")
_strip_run_rsid_attrs(root)
containers = {run.parentNode for run in _find_elements(root, "r")}
merge_count = 0
for container in containers:
merge_count += _merge_runs_in(container)
doc_xml.write_bytes(dom.toxml(encoding="UTF-8"))
return merge_count, f"Merged {merge_count} runs"
except Exception as e:
return 0, f"Error: {e}"
def _find_elements(root, tag: str) -> list:
results = []
def traverse(node):
if node.nodeType == node.ELEMENT_NODE:
name = node.localName or node.tagName
if name == tag or name.endswith(f":{tag}"):
results.append(node)
for child in node.childNodes:
traverse(child)
traverse(root)
return results
def _get_child(parent, tag: str):
for child in parent.childNodes:
if child.nodeType == child.ELEMENT_NODE:
name = child.localName or child.tagName
if name == tag or name.endswith(f":{tag}"):
return child
return None
def _get_children(parent, tag: str) -> list:
results = []
for child in parent.childNodes:
if child.nodeType == child.ELEMENT_NODE:
name = child.localName or child.tagName
if name == tag or name.endswith(f":{tag}"):
results.append(child)
return results
def _is_adjacent(elem1, elem2) -> bool:
node = elem1.nextSibling
while node:
if node == elem2:
return True
if node.nodeType == node.ELEMENT_NODE:
return False
if node.nodeType == node.TEXT_NODE and node.data.strip():
return False
node = node.nextSibling
return False
def _remove_elements(root, tag: str):
for elem in _find_elements(root, tag):
if elem.parentNode:
elem.parentNode.removeChild(elem)
def _strip_run_rsid_attrs(root):
for run in _find_elements(root, "r"):
for attr in list(run.attributes.values()):
if "rsid" in attr.name.lower():
run.removeAttribute(attr.name)
def _merge_runs_in(container) -> int:
merge_count = 0
run = _first_child_run(container)
while run:
while True:
next_elem = _next_element_sibling(run)
if next_elem and _is_run(next_elem) and _can_merge(run, next_elem):
_merge_run_content(run, next_elem)
container.removeChild(next_elem)
merge_count += 1
else:
break
_consolidate_text(run)
run = _next_sibling_run(run)
return merge_count
def _first_child_run(container):
for child in container.childNodes:
if child.nodeType == child.ELEMENT_NODE and _is_run(child):
return child
return None
def _next_element_sibling(node):
sibling = node.nextSibling
while sibling:
if sibling.nodeType == sibling.ELEMENT_NODE:
return sibling
sibling = sibling.nextSibling
return None
def _next_sibling_run(node):
sibling = node.nextSibling
while sibling:
if sibling.nodeType == sibling.ELEMENT_NODE:
if _is_run(sibling):
return sibling
sibling = sibling.nextSibling
return None
def _is_run(node) -> bool:
name = node.localName or node.tagName
return name == "r" or name.endswith(":r")
def _can_merge(run1, run2) -> bool:
rpr1 = _get_child(run1, "rPr")
rpr2 = _get_child(run2, "rPr")
if (rpr1 is None) != (rpr2 is None):
return False
if rpr1 is None:
return True
return rpr1.toxml() == rpr2.toxml()
def _merge_run_content(target, source):
for child in list(source.childNodes):
if child.nodeType == child.ELEMENT_NODE:
name = child.localName or child.tagName
if name != "rPr" and not name.endswith(":rPr"):
target.appendChild(child)
def _consolidate_text(run):
t_elements = _get_children(run, "t")
for i in range(len(t_elements) - 1, 0, -1):
curr, prev = t_elements[i], t_elements[i - 1]
if _is_adjacent(prev, curr):
prev_text = prev.firstChild.data if prev.firstChild else ""
curr_text = curr.firstChild.data if curr.firstChild else ""
merged = prev_text + curr_text
if prev.firstChild:
prev.firstChild.data = merged
else:
prev.appendChild(run.ownerDocument.createTextNode(merged))
if merged.startswith(" ") or merged.endswith(" "):
prev.setAttribute("xml:space", "preserve")
elif prev.hasAttribute("xml:space"):
prev.removeAttribute("xml:space")
run.removeChild(curr)
"""Simplify tracked changes by merging adjacent w:ins or w:del elements.
Merges adjacent <w:ins> elements from the same author into a single element.
Same for <w:del> elements. This makes heavily-redlined documents easier to
work with by reducing the number of tracked change wrappers.
Rules:
- Only merges w:ins with w:ins, w:del with w:del (same element type)
- Only merges if same author (ignores timestamp differences)
- Only merges if truly adjacent (only whitespace between them)
"""
import xml.etree.ElementTree as ET
import zipfile
from pathlib import Path
import defusedxml.minidom
WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
def simplify_redlines(input_dir: str) -> tuple[int, str]:
doc_xml = Path(input_dir) / "word" / "document.xml"
if not doc_xml.exists():
return 0, f"Error: {doc_xml} not found"
try:
dom = defusedxml.minidom.parseString(doc_xml.read_text(encoding="utf-8"))
root = dom.documentElement
merge_count = 0
containers = _find_elements(root, "p") + _find_elements(root, "tc")
for container in containers:
merge_count += _merge_tracked_changes_in(container, "ins")
merge_count += _merge_tracked_changes_in(container, "del")
doc_xml.write_bytes(dom.toxml(encoding="UTF-8"))
return merge_count, f"Simplified {merge_count} tracked changes"
except Exception as e:
return 0, f"Error: {e}"
def _merge_tracked_changes_in(container, tag: str) -> int:
merge_count = 0
tracked = [
child
for child in container.childNodes
if child.nodeType == child.ELEMENT_NODE and _is_element(child, tag)
]
if len(tracked) < 2:
return 0
i = 0
while i < len(tracked) - 1:
curr = tracked[i]
next_elem = tracked[i + 1]
if _can_merge_tracked(curr, next_elem):
_merge_tracked_content(curr, next_elem)
container.removeChild(next_elem)
tracked.pop(i + 1)
merge_count += 1
else:
i += 1
return merge_count
def _is_element(node, tag: str) -> bool:
name = node.localName or node.tagName
return name == tag or name.endswith(f":{tag}")
def _get_author(elem) -> str:
author = elem.getAttribute("w:author")
if not author:
for attr in elem.attributes.values():
if attr.localName == "author" or attr.name.endswith(":author"):
return attr.value
return author
def _can_merge_tracked(elem1, elem2) -> bool:
if _get_author(elem1) != _get_author(elem2):
return False
node = elem1.nextSibling
while node and node != elem2:
if node.nodeType == node.ELEMENT_NODE:
return False
if node.nodeType == node.TEXT_NODE and node.data.strip():
return False
node = node.nextSibling
return True
def _merge_tracked_content(target, source):
while source.firstChild:
child = source.firstChild
source.removeChild(child)
target.appendChild(child)
def _find_elements(root, tag: str) -> list:
results = []
def traverse(node):
if node.nodeType == node.ELEMENT_NODE:
name = node.localName or node.tagName
if name == tag or name.endswith(f":{tag}"):
results.append(node)
for child in node.childNodes:
traverse(child)
traverse(root)
return results
def get_tracked_change_authors(doc_xml_path: Path) -> dict[str, int]:
if not doc_xml_path.exists():
return {}
try:
tree = ET.parse(doc_xml_path)
root = tree.getroot()
except ET.ParseError:
return {}
namespaces = {"w": WORD_NS}
author_attr = f"{{{WORD_NS}}}author"
authors: dict[str, int] = {}
for tag in ["ins", "del"]:
for elem in root.findall(f".//w:{tag}", namespaces):
author = elem.get(author_attr)
if author:
authors[author] = authors.get(author, 0) + 1
return authors
def _get_authors_from_docx(docx_path: Path) -> dict[str, int]:
try:
with zipfile.ZipFile(docx_path, "r") as zf:
if "word/document.xml" not in zf.namelist():
return {}
with zf.open("word/document.xml") as f:
tree = ET.parse(f)
root = tree.getroot()
namespaces = {"w": WORD_NS}
author_attr = f"{{{WORD_NS}}}author"
authors: dict[str, int] = {}
for tag in ["ins", "del"]:
for elem in root.findall(f".//w:{tag}", namespaces):
author = elem.get(author_attr)
if author:
authors[author] = authors.get(author, 0) + 1
return authors
except (zipfile.BadZipFile, ET.ParseError):
return {}
def infer_author(modified_dir: Path, original_docx: Path, default: str = "Claude") -> str:
modified_xml = modified_dir / "word" / "document.xml"
modified_authors = get_tracked_change_authors(modified_xml)
if not modified_authors:
return default
original_authors = _get_authors_from_docx(original_docx)
new_changes: dict[str, int] = {}
for author, count in modified_authors.items():
original_count = original_authors.get(author, 0)
diff = count - original_count
if diff > 0:
new_changes[author] = diff
if not new_changes:
return default
if len(new_changes) == 1:
return next(iter(new_changes))
raise ValueError(
f"Multiple authors added new changes: {new_changes}. "
"Cannot infer which author to validate."
)
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment