@cteno/sdk-linux-x64 0.0.0-stage → 2.10.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.daemon +31 -0
- package/README.md +4 -2
- package/agents/flow-analyzer/AGENT.md +71 -0
- package/bin/cteno-host-cli +4 -0
- package/bin/cteno-wiki +0 -0
- package/bin/rg +0 -0
- package/bin/secret-exec +0 -0
- package/manifest.json +12 -0
- package/package.json +33 -4
- package/plugins/agent-secrets/.cteno-plugin/plugin.json +133 -0
- package/plugins/agent-secrets/bin/linux-x64/fnox +4 -0
- package/plugins/agent-secrets/bin/linux-x64/secret-exec +0 -0
- package/plugins/agent-secrets/skills/secret/SKILL.md +201 -0
- package/plugins/cteno.browser/.cteno-plugin/plugin.json +65 -0
- package/plugins/cteno.browser/skills/browser/SKILL.md +28 -0
- package/plugins/cteno.inbox/.cteno-plugin/plugin.json +128 -0
- package/plugins/cteno.inbox/bin/all/cteno-connector-credential +31 -0
- package/plugins/cteno.inbox/bin/all/cteno-telegram-connector +33 -0
- package/plugins/cteno.inbox/bin/linux-x64/cteno-inbox +0 -0
- package/plugins/cteno.inbox/skills/cteno-inbox/SKILL.md +374 -0
- package/plugins/cteno.inbox/tools/inbox_focus/TOOL.md +44 -0
- package/plugins/cteno.memory/.cteno-plugin/plugin.json +176 -0
- package/plugins/cteno.memory/agents/llm-wiki/AGENT.md +96 -0
- package/plugins/cteno.memory/bin/linux-x64/cteno-wiki +0 -0
- package/plugins/cteno.memory/skills/codebase-wiki/SKILL.md +52 -0
- package/plugins/cteno.memory/skills/cteno.memory.native/SKILL.md +110 -0
- package/plugins/cteno.memory/tools/memory/TOOL.md +492 -0
- package/plugins/cteno.memory/ui/index.html +822 -0
- package/plugins/cteno.models/.cteno-plugin/plugin.json +191 -0
- package/plugins/cteno.websearch/.cteno-plugin/plugin.json +45 -0
- package/plugins/jev/.cteno-plugin/plugin.json +305 -0
- package/plugins/jev/skills/jev-browser/SKILL.md +66 -0
- package/plugins/jev/skills/jev-browser/jev_browser.py +335 -0
- package/skills/agent-create/SKILL.md +86 -0
- package/skills/cteno-host-cli/SKILL.md +248 -0
- package/skills/docx/LICENSE +23 -0
- package/skills/docx/NOTICE.md +8 -0
- package/skills/docx/SKILL.md +69 -0
- package/skills/docx/references/cjk_typography.md +357 -0
- package/skills/docx/references/design_principles.md +819 -0
- package/skills/docx/references/revisions-and-comments.md +88 -0
- package/skills/docx/references/track_changes_guide.md +200 -0
- package/skills/docx/references/typography_guide.md +294 -0
- package/skills/docx/scripts/cjk_fonts.py +244 -0
- package/skills/docx/scripts/docx_comments.py +289 -0
- package/skills/docx/scripts/docx_common.py +92 -0
- package/skills/docx/scripts/docx_create.py +259 -0
- package/skills/docx/scripts/docx_edit.py +250 -0
- package/skills/docx/scripts/docx_read.py +149 -0
- package/skills/docx/scripts/docx_revisions.py +146 -0
- package/skills/docx/scripts/docx_template.py +70 -0
- package/skills/docx/scripts/docx_validate.py +156 -0
- package/skills/docx/scripts/render.py +250 -0
- package/skills/docx/tests/test_docx_skill.py +525 -0
- package/skills/home-devices/SKILL.md +121 -0
- package/skills/mcp/SKILL.md +72 -0
- package/skills/mcp/scripts/mcp_setup.py +439 -0
- package/skills/pdf/LICENSE +23 -0
- package/skills/pdf/NOTICE.md +8 -0
- package/skills/pdf/SKILL.md +63 -0
- package/skills/pdf/references/design-tokens.md +381 -0
- package/skills/pdf/references/forms.md +99 -0
- package/skills/pdf/references/nano-pdf-editing.md +40 -0
- package/skills/pdf/references/ocr-extraction.md +165 -0
- package/skills/pdf/scripts/_raster.py +76 -0
- package/skills/pdf/scripts/extract_marker.py +88 -0
- package/skills/pdf/scripts/extract_pymupdf.py +101 -0
- package/skills/pdf/scripts/pdf_create.py +191 -0
- package/skills/pdf/scripts/pdf_fill_form.py +97 -0
- package/skills/pdf/scripts/pdf_form_layout.py +168 -0
- package/skills/pdf/scripts/pdf_make_form.py +145 -0
- package/skills/pdf/scripts/pdf_merge.py +50 -0
- package/skills/pdf/scripts/pdf_meta.py +115 -0
- package/skills/pdf/scripts/pdf_page_image.py +99 -0
- package/skills/pdf/scripts/pdf_read.py +153 -0
- package/skills/pdf/scripts/pdf_secure.py +71 -0
- package/skills/pdf/scripts/pdf_split.py +84 -0
- package/skills/pdf/scripts/pdf_stamp.py +143 -0
- package/skills/pdf/scripts/pdf_watermark.py +51 -0
- package/skills/pdf/scripts/render.py +250 -0
- package/skills/pdf/tests/test_pdf_skill.py +415 -0
- package/skills/pptx/LICENSE +23 -0
- package/skills/pptx/NOTICE.md +8 -0
- package/skills/pptx/SKILL.md +62 -0
- package/skills/pptx/references/design-system.md +392 -0
- package/skills/pptx/references/pptxgenjs.md +420 -0
- package/skills/pptx/references/slide-types.md +413 -0
- package/skills/pptx/scripts/cjk_fonts.py +244 -0
- package/skills/pptx/scripts/pptx_create.py +240 -0
- package/skills/pptx/scripts/pptx_edit.py +436 -0
- package/skills/pptx/scripts/pptx_from_template.py +88 -0
- package/skills/pptx/scripts/pptx_read.py +131 -0
- package/skills/pptx/scripts/pptx_render.py +96 -0
- package/skills/pptx/scripts/render.py +250 -0
- package/skills/pptx/tests/test_powerpoint_skill.py +477 -0
- package/skills/skill-create/SKILL.md +114 -0
- package/skills/video-generation/SKILL.md +27 -0
- package/skills/xlsx/LICENSE +23 -0
- package/skills/xlsx/NOTICE.md +8 -0
- package/skills/xlsx/SKILL.md +63 -0
- package/skills/xlsx/references/format.md +71 -0
- package/skills/xlsx/references/restructuring.md +71 -0
- package/skills/xlsx/scripts/csv_to_xlsx.py +110 -0
- package/skills/xlsx/scripts/formula_check.py +428 -0
- package/skills/xlsx/scripts/render.py +250 -0
- package/skills/xlsx/scripts/xlsx_create.py +265 -0
- package/skills/xlsx/scripts/xlsx_edit.py +269 -0
- package/skills/xlsx/scripts/xlsx_read.py +166 -0
- package/skills/xlsx/scripts/xlsx_recalc.py +119 -0
- package/skills/xlsx/scripts/xlsx_restructure.py +343 -0
- package/skills/xlsx/scripts/xlsx_to_csv.py +75 -0
- package/skills/xlsx/tests/test_xlsx_skill.py +544 -0
- package/tools/create_goal/TOOL.md +36 -0
- package/tools/image_generation/TOOL.md +81 -0
- package/tools/skill/TOOL.md +117 -0
- package/tools/update_profile_notes/TOOL.md +39 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Make Chinese/Japanese/Korean text render with a real CJK font in a .docx or .pptx.
|
|
3
|
+
|
|
4
|
+
Why: Word and PowerPoint pick the font for CJK characters from a separate slot
|
|
5
|
+
(`w:eastAsia` in Word, `<a:ea>` in PowerPoint). Most generators (python-docx,
|
|
6
|
+
python-pptx, docx-js, pptxgenjs) only set the Latin slot, so CJK text falls back
|
|
7
|
+
to whatever the viewer has - SimSun on one machine, a tofu box or a
|
|
8
|
+
mismatched-width font on another. This script sets the East Asian slot on the
|
|
9
|
+
document defaults, every style, and every run/text run, plus the eastAsia
|
|
10
|
+
language, without touching Latin fonts.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
python3 cjk_fonts.py FILE [-o OUT] [--body FONT] [--heading FONT] [--lang zh-CN]
|
|
14
|
+
|
|
15
|
+
Defaults (safe on Windows Office, macOS Office, WPS and LibreOffice):
|
|
16
|
+
--body "Microsoft YaHei" for pptx, "SimSun" for docx (宋体 正文 / 雅黑 屏显)
|
|
17
|
+
--heading "Microsoft YaHei" for pptx, "SimHei" for docx (黑体 标题)
|
|
18
|
+
For government documents (GB/T 9704) use --body "FangSong_GB2312" --heading "SimHei".
|
|
19
|
+
|
|
20
|
+
Docx headings (styles Heading*/Title, or outline-level paragraphs) get --heading,
|
|
21
|
+
everything else --body. In pptx, title placeholders get --heading.
|
|
22
|
+
Prints JSON {"ok": true, "file": OUT, "runs": N, "styles": N}.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import shutil
|
|
31
|
+
import sys
|
|
32
|
+
import tempfile
|
|
33
|
+
import zipfile
|
|
34
|
+
|
|
35
|
+
from lxml import etree
|
|
36
|
+
|
|
37
|
+
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
|
38
|
+
A = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
|
39
|
+
P = "http://schemas.openxmlformats.org/presentationml/2006/main"
|
|
40
|
+
NS = {"w": W, "a": A, "p": P}
|
|
41
|
+
q = lambda ns, tag: f"{{{NS[ns]}}}{tag}" # noqa: E731
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _rfonts(rpr, font: str) -> None:
|
|
45
|
+
rf = rpr.find(q("w", "rFonts"))
|
|
46
|
+
if rf is None:
|
|
47
|
+
rf = etree.Element(q("w", "rFonts"))
|
|
48
|
+
rpr.insert(0, rf) # rFonts is the first child of rPr in the schema
|
|
49
|
+
rf.set(q("w", "eastAsia"), font)
|
|
50
|
+
for theme_attr in ("eastAsiaTheme",):
|
|
51
|
+
rf.attrib.pop(q("w", theme_attr), None)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _lang(rpr, lang: str) -> None:
|
|
55
|
+
lg = rpr.find(q("w", "lang"))
|
|
56
|
+
if lg is None:
|
|
57
|
+
lg = etree.SubElement(rpr, q("w", "lang"))
|
|
58
|
+
lg.set(q("w", "eastAsia"), lang)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _ensure(parent, tag: str, first: bool = False):
|
|
62
|
+
el = parent.find(tag)
|
|
63
|
+
if el is None:
|
|
64
|
+
el = etree.Element(tag)
|
|
65
|
+
if first:
|
|
66
|
+
parent.insert(0, el)
|
|
67
|
+
else:
|
|
68
|
+
parent.append(el)
|
|
69
|
+
return el
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
HEADING_STYLE = re.compile(r"^(heading|title|subtitle|标题)", re.I)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def fix_docx_styles(xml: bytes, body: str, heading: str, lang: str) -> tuple[bytes, int, set[str]]:
|
|
76
|
+
root = etree.fromstring(xml)
|
|
77
|
+
n = 0
|
|
78
|
+
heading_ids: set[str] = set()
|
|
79
|
+
dd = root.find(q("w", "docDefaults"))
|
|
80
|
+
if dd is None:
|
|
81
|
+
dd = etree.Element(q("w", "docDefaults"))
|
|
82
|
+
root.insert(0, dd)
|
|
83
|
+
rpd = _ensure(dd, q("w", "rPrDefault"), first=True)
|
|
84
|
+
rpr = _ensure(rpd, q("w", "rPr"))
|
|
85
|
+
_rfonts(rpr, body)
|
|
86
|
+
_lang(rpr, lang)
|
|
87
|
+
for st in root.findall(q("w", "style")):
|
|
88
|
+
sid = st.get(q("w", "styleId"), "")
|
|
89
|
+
name_el = st.find(q("w", "name"))
|
|
90
|
+
name = name_el.get(q("w", "val"), "") if name_el is not None else ""
|
|
91
|
+
if st.get(q("w", "type")) not in ("paragraph", "character"):
|
|
92
|
+
continue
|
|
93
|
+
is_heading = bool(HEADING_STYLE.match(name) or HEADING_STYLE.match(sid))
|
|
94
|
+
if is_heading:
|
|
95
|
+
heading_ids.add(sid)
|
|
96
|
+
rpr = st.find(q("w", "rPr"))
|
|
97
|
+
if rpr is None:
|
|
98
|
+
rpr = etree.SubElement(st, q("w", "rPr"))
|
|
99
|
+
_rfonts(rpr, heading if is_heading else body)
|
|
100
|
+
n += 1
|
|
101
|
+
return etree.tostring(root, xml_declaration=True, encoding="UTF-8", standalone=True), n, heading_ids
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def fix_docx_part(xml: bytes, body: str, heading: str, heading_ids: set[str]) -> tuple[bytes, int]:
|
|
105
|
+
root = etree.fromstring(xml)
|
|
106
|
+
n = 0
|
|
107
|
+
for para in root.iter(q("w", "p")):
|
|
108
|
+
ppr = para.find(q("w", "pPr"))
|
|
109
|
+
sid = ""
|
|
110
|
+
outline = False
|
|
111
|
+
if ppr is not None:
|
|
112
|
+
ps = ppr.find(q("w", "pStyle"))
|
|
113
|
+
sid = ps.get(q("w", "val"), "") if ps is not None else ""
|
|
114
|
+
outline = ppr.find(q("w", "outlineLvl")) is not None
|
|
115
|
+
font = heading if (sid in heading_ids or outline) else body
|
|
116
|
+
for run in para.findall(q("w", "r")) + para.findall(f".//{q('w', 'hyperlink')}/{q('w', 'r')}"):
|
|
117
|
+
rpr = run.find(q("w", "rPr"))
|
|
118
|
+
if rpr is None:
|
|
119
|
+
rpr = etree.Element(q("w", "rPr"))
|
|
120
|
+
run.insert(0, rpr)
|
|
121
|
+
rf = rpr.find(q("w", "rFonts"))
|
|
122
|
+
# keep an explicit East Asian choice the author already made
|
|
123
|
+
if rf is not None and rf.get(q("w", "eastAsia")):
|
|
124
|
+
continue
|
|
125
|
+
_rfonts(rpr, font)
|
|
126
|
+
n += 1
|
|
127
|
+
return etree.tostring(root, xml_declaration=True, encoding="UTF-8", standalone=True), n
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _a_ea(rpr, font: str) -> None:
|
|
131
|
+
ea = rpr.find(q("a", "ea"))
|
|
132
|
+
if ea is None:
|
|
133
|
+
ea = etree.Element(q("a", "ea"))
|
|
134
|
+
latin = rpr.find(q("a", "latin"))
|
|
135
|
+
# schema order inside rPr: ln, fill, effect, highlight, uLnTx/uLn, uFillTx/uFill, latin, ea, cs, sym, hlinkClick...
|
|
136
|
+
if latin is not None:
|
|
137
|
+
latin.addnext(ea)
|
|
138
|
+
else:
|
|
139
|
+
anchor = None
|
|
140
|
+
for tag in ("cs", "sym", "hlinkClick", "hlinkMouseOver", "rtl", "extLst"):
|
|
141
|
+
anchor = rpr.find(q("a", tag))
|
|
142
|
+
if anchor is not None:
|
|
143
|
+
break
|
|
144
|
+
if anchor is not None:
|
|
145
|
+
anchor.addprevious(ea)
|
|
146
|
+
else:
|
|
147
|
+
rpr.append(ea)
|
|
148
|
+
ea.set("typeface", font)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def fix_pptx_part(xml: bytes, body: str, heading: str, lang: str, is_theme: bool) -> tuple[bytes, int]:
|
|
152
|
+
root = etree.fromstring(xml)
|
|
153
|
+
n = 0
|
|
154
|
+
if is_theme:
|
|
155
|
+
for tag, font in (("majorFont", heading), ("minorFont", body)):
|
|
156
|
+
for el in root.iter(q("a", tag)):
|
|
157
|
+
ea = el.find(q("a", "ea"))
|
|
158
|
+
if ea is not None:
|
|
159
|
+
ea.set("typeface", font)
|
|
160
|
+
n += 1
|
|
161
|
+
return etree.tostring(root, xml_declaration=True, encoding="UTF-8", standalone=True), n
|
|
162
|
+
for sp in root.iter(q("p", "sp")):
|
|
163
|
+
ph = sp.find(f".//{q('p', 'ph')}")
|
|
164
|
+
is_title = ph is not None and ph.get("type") in ("title", "ctrTitle")
|
|
165
|
+
font = heading if is_title else body
|
|
166
|
+
for r in sp.iter(q("a", "r"), q("a", "fld")):
|
|
167
|
+
rpr = r.find(q("a", "rPr"))
|
|
168
|
+
if rpr is None:
|
|
169
|
+
rpr = etree.Element(q("a", "rPr"))
|
|
170
|
+
r.insert(0, rpr)
|
|
171
|
+
rpr.set("lang", rpr.get("lang") or lang)
|
|
172
|
+
rpr.set("altLang", rpr.get("altLang") or "en-US")
|
|
173
|
+
_a_ea(rpr, font)
|
|
174
|
+
n += 1
|
|
175
|
+
for epr in sp.iter(q("a", "endParaRPr")):
|
|
176
|
+
_a_ea(epr, font)
|
|
177
|
+
for tc in root.iter(q("a", "tc")):
|
|
178
|
+
for r in tc.iter(q("a", "r")):
|
|
179
|
+
rpr = r.find(q("a", "rPr"))
|
|
180
|
+
if rpr is None:
|
|
181
|
+
rpr = etree.Element(q("a", "rPr"))
|
|
182
|
+
r.insert(0, rpr)
|
|
183
|
+
_a_ea(rpr, body)
|
|
184
|
+
n += 1
|
|
185
|
+
return etree.tostring(root, xml_declaration=True, encoding="UTF-8", standalone=True), n
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def process(path: str, out: str, body: str | None, heading: str | None, lang: str) -> dict:
|
|
189
|
+
ext = os.path.splitext(path)[1].lower()
|
|
190
|
+
if ext not in (".docx", ".dotx", ".pptx", ".potx"):
|
|
191
|
+
raise SystemExit(f"unsupported file type {ext} (docx/dotx/pptx/potx)")
|
|
192
|
+
is_docx = ext in (".docx", ".dotx")
|
|
193
|
+
body = body or ("SimSun" if is_docx else "Microsoft YaHei")
|
|
194
|
+
heading = heading or ("SimHei" if is_docx else "Microsoft YaHei")
|
|
195
|
+
runs = styles = 0
|
|
196
|
+
with zipfile.ZipFile(path) as zin:
|
|
197
|
+
names = zin.namelist()
|
|
198
|
+
data = {n: zin.read(n) for n in names}
|
|
199
|
+
infos = {i.filename: i for i in zin.infolist()}
|
|
200
|
+
if is_docx:
|
|
201
|
+
heading_ids: set[str] = set()
|
|
202
|
+
if "word/styles.xml" in data:
|
|
203
|
+
data["word/styles.xml"], styles, heading_ids = fix_docx_styles(data["word/styles.xml"], body, heading, lang)
|
|
204
|
+
for n in names:
|
|
205
|
+
if re.match(r"word/(document|header\d*|footer\d*|footnotes|endnotes|comments)\.xml$", n):
|
|
206
|
+
data[n], k = fix_docx_part(data[n], body, heading, heading_ids)
|
|
207
|
+
runs += k
|
|
208
|
+
else:
|
|
209
|
+
for n in names:
|
|
210
|
+
if re.match(r"ppt/theme/theme\d+\.xml$", n):
|
|
211
|
+
data[n], k = fix_pptx_part(data[n], body, heading, lang, True)
|
|
212
|
+
styles += k
|
|
213
|
+
elif re.match(r"ppt/(slides/slide|slideLayouts/slideLayout|slideMasters/slideMaster|notesSlides/notesSlide)\d+\.xml$", n):
|
|
214
|
+
data[n], k = fix_pptx_part(data[n], body, heading, lang, False)
|
|
215
|
+
runs += k
|
|
216
|
+
fd, tmp = tempfile.mkstemp(suffix=ext, dir=os.path.dirname(os.path.abspath(out)) or ".")
|
|
217
|
+
os.close(fd)
|
|
218
|
+
with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as zout:
|
|
219
|
+
for n in names: # keep original order: [Content_Types].xml must stay first
|
|
220
|
+
info = infos[n]
|
|
221
|
+
zi = zipfile.ZipInfo(n, date_time=info.date_time)
|
|
222
|
+
zi.compress_type = zipfile.ZIP_DEFLATED
|
|
223
|
+
zout.writestr(zi, data[n])
|
|
224
|
+
shutil.move(tmp, out)
|
|
225
|
+
return {"ok": True, "file": out, "runs": runs, "styles": styles, "body": body, "heading": heading}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def main(argv=None) -> int:
|
|
229
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
230
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
231
|
+
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0], epilog=__doc__,
|
|
232
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
233
|
+
ap.add_argument("file")
|
|
234
|
+
ap.add_argument("-o", "--output", help="default: rewrite FILE in place")
|
|
235
|
+
ap.add_argument("--body")
|
|
236
|
+
ap.add_argument("--heading")
|
|
237
|
+
ap.add_argument("--lang", default="zh-CN")
|
|
238
|
+
a = ap.parse_args(argv)
|
|
239
|
+
print(json.dumps(process(a.file, a.output or a.file, a.body, a.heading, a.lang), ensure_ascii=False))
|
|
240
|
+
return 0
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
if __name__ == "__main__":
|
|
244
|
+
sys.exit(main())
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# MIT License. Part of the Hermes docx skill.
|
|
3
|
+
"""List, add, and delete comments in a .docx.
|
|
4
|
+
|
|
5
|
+
Subcommands:
|
|
6
|
+
list JSON per comment: id, author, initials, date, text, anchored_text
|
|
7
|
+
add add a comment anchored to the first occurrence of --target
|
|
8
|
+
delete remove a comment (and its range markers) by --id
|
|
9
|
+
|
|
10
|
+
Examples:
|
|
11
|
+
docx_comments.py list report.docx
|
|
12
|
+
docx_comments.py add report.docx --target "Q3 revenue" \
|
|
13
|
+
--text "Needs a source" --author "Reviewer" -o out.docx
|
|
14
|
+
docx_comments.py delete report.docx --id 0 -o out.docx
|
|
15
|
+
|
|
16
|
+
Uses the native python-docx comments API (>= 1.2) when available; falls
|
|
17
|
+
back to building word/comments.xml and the range markers directly for
|
|
18
|
+
older versions (or when --xml is passed). Listing and deletion always
|
|
19
|
+
work at the XML level so they handle documents from any producer.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import datetime as _dt
|
|
25
|
+
import json
|
|
26
|
+
import sys
|
|
27
|
+
from copy import deepcopy
|
|
28
|
+
|
|
29
|
+
from docx import Document
|
|
30
|
+
from docx.opc.constants import RELATIONSHIP_TYPE as RT
|
|
31
|
+
from lxml import etree
|
|
32
|
+
|
|
33
|
+
from docx_common import iter_part_roots
|
|
34
|
+
|
|
35
|
+
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
|
36
|
+
COMMENTS_CT = ("application/vnd.openxmlformats-officedocument"
|
|
37
|
+
".wordprocessingml.comments+xml")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def q(tag: str) -> str:
|
|
41
|
+
return f"{{{W}}}{tag}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------- reading
|
|
45
|
+
|
|
46
|
+
def _comments_root(doc):
|
|
47
|
+
"""Return the XML root of the comments part, or None."""
|
|
48
|
+
for rel in doc.part.rels.values():
|
|
49
|
+
if rel.reltype == RT.COMMENTS:
|
|
50
|
+
part = rel.target_part
|
|
51
|
+
el = getattr(part, "_element", None)
|
|
52
|
+
if el is not None:
|
|
53
|
+
return el
|
|
54
|
+
return etree.fromstring(part.blob)
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _anchored_texts(doc) -> dict:
|
|
59
|
+
"""Map comment id -> document text between its range markers."""
|
|
60
|
+
anchored: dict[str, list[str]] = {}
|
|
61
|
+
for root in iter_part_roots(doc):
|
|
62
|
+
active: set[str] = set()
|
|
63
|
+
for el in root.iter():
|
|
64
|
+
if el.tag == q("commentRangeStart"):
|
|
65
|
+
cid = el.get(q("id"))
|
|
66
|
+
active.add(cid)
|
|
67
|
+
anchored.setdefault(cid, [])
|
|
68
|
+
elif el.tag == q("commentRangeEnd"):
|
|
69
|
+
active.discard(el.get(q("id")))
|
|
70
|
+
elif el.tag == q("t") and active:
|
|
71
|
+
for cid in active:
|
|
72
|
+
anchored[cid].append(el.text or "")
|
|
73
|
+
return {cid: "".join(parts) for cid, parts in anchored.items()}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def list_comments(doc) -> list:
|
|
77
|
+
root = _comments_root(doc)
|
|
78
|
+
if root is None:
|
|
79
|
+
return []
|
|
80
|
+
anchored = _anchored_texts(doc)
|
|
81
|
+
out = []
|
|
82
|
+
for c in root.iter(q("comment")):
|
|
83
|
+
cid = c.get(q("id"))
|
|
84
|
+
text = "\n".join(
|
|
85
|
+
"".join(t.text or "" for t in p.iter(q("t")))
|
|
86
|
+
for p in c.iter(q("p")))
|
|
87
|
+
out.append({"id": cid, "author": c.get(q("author")),
|
|
88
|
+
"initials": c.get(q("initials")),
|
|
89
|
+
"date": c.get(q("date")), "text": text,
|
|
90
|
+
"anchored_text": anchored.get(cid, "")})
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------- anchoring
|
|
95
|
+
|
|
96
|
+
def _split_run(para, run_el, offset: int):
|
|
97
|
+
"""Split a run element at text offset; return the new right-hand run."""
|
|
98
|
+
text = "".join(t.text or "" for t in run_el.iter(q("t")))
|
|
99
|
+
right = deepcopy(run_el)
|
|
100
|
+
run_el.addnext(right)
|
|
101
|
+
for el, s in ((run_el, text[:offset]), (right, text[offset:])):
|
|
102
|
+
for t in list(el.iter(q("t"))):
|
|
103
|
+
el.remove(t)
|
|
104
|
+
t = etree.SubElement(el, q("t"))
|
|
105
|
+
t.text = s
|
|
106
|
+
t.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
|
|
107
|
+
return right
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def find_anchor_runs(doc, target: str):
|
|
111
|
+
"""Isolate `target`'s first occurrence into whole runs; return them."""
|
|
112
|
+
from docx_common import iter_all_paragraphs
|
|
113
|
+
for para in iter_all_paragraphs(doc):
|
|
114
|
+
full = para.text
|
|
115
|
+
start = full.find(target)
|
|
116
|
+
if start < 0:
|
|
117
|
+
continue
|
|
118
|
+
end = start + len(target)
|
|
119
|
+
pos = 0
|
|
120
|
+
covered = []
|
|
121
|
+
for run_el in para._p.iter(q("r")):
|
|
122
|
+
rtext = "".join(t.text or "" for t in run_el.iter(q("t")))
|
|
123
|
+
r_start, r_end = pos, pos + len(rtext)
|
|
124
|
+
pos = r_end
|
|
125
|
+
if r_end <= start or r_start >= end:
|
|
126
|
+
continue
|
|
127
|
+
if r_start < start: # split off the left part
|
|
128
|
+
run_el = _split_run(para, run_el, start - r_start)
|
|
129
|
+
r_start = start
|
|
130
|
+
if r_end > end: # split off the right part
|
|
131
|
+
_split_run(para, run_el, end - r_start)
|
|
132
|
+
covered.append(run_el)
|
|
133
|
+
return para, covered
|
|
134
|
+
return None, []
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------- adding
|
|
138
|
+
|
|
139
|
+
def _next_id(doc) -> int:
|
|
140
|
+
root = _comments_root(doc)
|
|
141
|
+
if root is None:
|
|
142
|
+
return 0
|
|
143
|
+
ids = [int(c.get(q("id"), "0")) for c in root.iter(q("comment"))
|
|
144
|
+
if c.get(q("id"), "").isdigit()]
|
|
145
|
+
return max(ids) + 1 if ids else 0
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def add_comment_native(doc, runs, text, author, initials):
|
|
149
|
+
from docx.text.run import Run
|
|
150
|
+
run_objs = [Run(r, None) for r in runs]
|
|
151
|
+
comment = doc.add_comment(run_objs, text=text, author=author,
|
|
152
|
+
initials=initials or "")
|
|
153
|
+
return str(comment.comment_id)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def add_comment_xml(doc, runs, text, author, initials) -> str:
|
|
157
|
+
cid = str(_next_id(doc))
|
|
158
|
+
root = _comments_root(doc)
|
|
159
|
+
if root is None:
|
|
160
|
+
root = etree.fromstring(
|
|
161
|
+
f'<w:comments xmlns:w="{W}"/>'.encode())
|
|
162
|
+
from docx.opc.packuri import PackURI
|
|
163
|
+
from docx.opc.part import Part
|
|
164
|
+
blob = etree.tostring(root, xml_declaration=True,
|
|
165
|
+
encoding="UTF-8", standalone=True)
|
|
166
|
+
part = Part(PackURI("/word/comments.xml"), COMMENTS_CT, blob,
|
|
167
|
+
doc.part.package)
|
|
168
|
+
doc.part.relate_to(part, RT.COMMENTS)
|
|
169
|
+
# keep a live element on the part so edits reach save()
|
|
170
|
+
part._element = root
|
|
171
|
+
part.blob_ = None
|
|
172
|
+
|
|
173
|
+
def _blob(self=part):
|
|
174
|
+
return etree.tostring(self._element, xml_declaration=True,
|
|
175
|
+
encoding="UTF-8", standalone=True)
|
|
176
|
+
part.__class__ = type("CommentsXmlPart", (Part,),
|
|
177
|
+
{"blob": property(lambda self: _blob(self))})
|
|
178
|
+
now = _dt.datetime.now(_dt.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
179
|
+
comment = etree.SubElement(root, q("comment"))
|
|
180
|
+
comment.set(q("id"), cid)
|
|
181
|
+
comment.set(q("author"), author)
|
|
182
|
+
if initials:
|
|
183
|
+
comment.set(q("initials"), initials)
|
|
184
|
+
comment.set(q("date"), now)
|
|
185
|
+
p = etree.SubElement(comment, q("p"))
|
|
186
|
+
r = etree.SubElement(p, q("r"))
|
|
187
|
+
t = etree.SubElement(r, q("t"))
|
|
188
|
+
t.text = text
|
|
189
|
+
# range markers around the anchor runs + reference run after them
|
|
190
|
+
first, last = runs[0], runs[-1]
|
|
191
|
+
start = first.makeelement(q("commentRangeStart"), {q("id"): cid})
|
|
192
|
+
first.addprevious(start)
|
|
193
|
+
end = last.makeelement(q("commentRangeEnd"), {q("id"): cid})
|
|
194
|
+
last.addnext(end)
|
|
195
|
+
ref_run = last.makeelement(q("r"), {})
|
|
196
|
+
ref = etree.SubElement(ref_run, q("commentReference"))
|
|
197
|
+
ref.set(q("id"), cid)
|
|
198
|
+
end.addnext(ref_run)
|
|
199
|
+
return cid
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------- deleting
|
|
203
|
+
|
|
204
|
+
def delete_comment(doc, cid: str) -> bool:
|
|
205
|
+
root = _comments_root(doc)
|
|
206
|
+
found = False
|
|
207
|
+
if root is not None:
|
|
208
|
+
for c in list(root.iter(q("comment"))):
|
|
209
|
+
if c.get(q("id")) == cid:
|
|
210
|
+
c.getparent().remove(c)
|
|
211
|
+
found = True
|
|
212
|
+
for part_root in iter_part_roots(doc):
|
|
213
|
+
for tag in ("commentRangeStart", "commentRangeEnd",
|
|
214
|
+
"commentReference"):
|
|
215
|
+
for el in list(part_root.iter(q(tag))):
|
|
216
|
+
if el.get(q("id")) == cid:
|
|
217
|
+
parent = el.getparent()
|
|
218
|
+
# remove the wrapping run for reference marks
|
|
219
|
+
if tag == "commentReference" and parent.tag == q("r"):
|
|
220
|
+
parent.getparent().remove(parent)
|
|
221
|
+
else:
|
|
222
|
+
parent.remove(el)
|
|
223
|
+
found = True
|
|
224
|
+
return found
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def main() -> int:
|
|
228
|
+
ap = argparse.ArgumentParser(
|
|
229
|
+
description="List, add, or delete comments in a .docx.")
|
|
230
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
231
|
+
|
|
232
|
+
p = sub.add_parser("list", help="list comments as JSON")
|
|
233
|
+
p.add_argument("path", help="input .docx")
|
|
234
|
+
|
|
235
|
+
p = sub.add_parser("add", help="add a comment anchored to text")
|
|
236
|
+
p.add_argument("path", help="input .docx")
|
|
237
|
+
p.add_argument("-o", "--output", help="output path (default: in place)")
|
|
238
|
+
p.add_argument("--target", required=True,
|
|
239
|
+
help="anchor: first occurrence of this text")
|
|
240
|
+
p.add_argument("--text", required=True, help="comment body")
|
|
241
|
+
p.add_argument("--author", default="Hermes")
|
|
242
|
+
p.add_argument("--initials", default="")
|
|
243
|
+
p.add_argument("--xml", action="store_true",
|
|
244
|
+
help="force the XML fallback (skip native API)")
|
|
245
|
+
|
|
246
|
+
p = sub.add_parser("delete", help="delete a comment by id")
|
|
247
|
+
p.add_argument("path", help="input .docx")
|
|
248
|
+
p.add_argument("-o", "--output", help="output path (default: in place)")
|
|
249
|
+
p.add_argument("--id", required=True, help="comment id")
|
|
250
|
+
|
|
251
|
+
args = ap.parse_args()
|
|
252
|
+
doc = Document(args.path)
|
|
253
|
+
|
|
254
|
+
if args.cmd == "list":
|
|
255
|
+
print(json.dumps({"ok": True, "comments": list_comments(doc)},
|
|
256
|
+
ensure_ascii=False))
|
|
257
|
+
return 0
|
|
258
|
+
|
|
259
|
+
if args.cmd == "add":
|
|
260
|
+
_para, runs = find_anchor_runs(doc, args.target)
|
|
261
|
+
if not runs:
|
|
262
|
+
print(json.dumps({"ok": False,
|
|
263
|
+
"error": f"target not found: {args.target}"}))
|
|
264
|
+
return 1
|
|
265
|
+
native = hasattr(doc, "add_comment") and not args.xml
|
|
266
|
+
if native:
|
|
267
|
+
cid = add_comment_native(doc, runs, args.text, args.author,
|
|
268
|
+
args.initials)
|
|
269
|
+
else:
|
|
270
|
+
cid = add_comment_xml(doc, runs, args.text, args.author,
|
|
271
|
+
args.initials)
|
|
272
|
+
result = {"ok": True, "comment_id": cid,
|
|
273
|
+
"native_api": native, "anchored_to": args.target}
|
|
274
|
+
else: # delete
|
|
275
|
+
if not delete_comment(doc, args.id):
|
|
276
|
+
print(json.dumps({"ok": False,
|
|
277
|
+
"error": f"no comment with id {args.id}"}))
|
|
278
|
+
return 1
|
|
279
|
+
result = {"ok": True, "deleted_id": args.id}
|
|
280
|
+
|
|
281
|
+
out = args.output or args.path
|
|
282
|
+
doc.save(out)
|
|
283
|
+
result["output"] = out
|
|
284
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
285
|
+
return 0
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
if __name__ == "__main__":
|
|
289
|
+
sys.exit(main())
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# MIT License. Shared helpers for the docx skill scripts.
|
|
3
|
+
"""Shared helpers: paragraph iteration and run-preserving text replacement."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def iter_all_paragraphs(doc, include_headers_footers: bool = True):
|
|
8
|
+
"""Yield every paragraph in body, tables (recursively), headers, footers."""
|
|
9
|
+
yield from _iter_container(doc)
|
|
10
|
+
if include_headers_footers:
|
|
11
|
+
for section in doc.sections:
|
|
12
|
+
for part in (
|
|
13
|
+
section.header, section.footer,
|
|
14
|
+
section.first_page_header, section.first_page_footer,
|
|
15
|
+
section.even_page_header, section.even_page_footer,
|
|
16
|
+
):
|
|
17
|
+
if part is not None:
|
|
18
|
+
yield from _iter_container(part)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _iter_container(container):
|
|
22
|
+
yield from container.paragraphs
|
|
23
|
+
for table in container.tables:
|
|
24
|
+
yield from _iter_table(table)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _iter_table(table):
|
|
28
|
+
for row in table.rows:
|
|
29
|
+
for cell in row.cells:
|
|
30
|
+
yield from cell.paragraphs
|
|
31
|
+
for nested in cell.tables:
|
|
32
|
+
yield from _iter_table(nested)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def iter_part_roots(doc):
|
|
36
|
+
"""Yield the XML root of the body plus every header/footer part."""
|
|
37
|
+
yield doc.element.body
|
|
38
|
+
seen = set()
|
|
39
|
+
for section in doc.sections:
|
|
40
|
+
for part in (
|
|
41
|
+
section.header, section.footer,
|
|
42
|
+
section.first_page_header, section.first_page_footer,
|
|
43
|
+
section.even_page_header, section.even_page_footer,
|
|
44
|
+
):
|
|
45
|
+
if part is not None and id(part._element) not in seen:
|
|
46
|
+
seen.add(id(part._element))
|
|
47
|
+
yield part._element
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def replace_in_paragraph(para, old: str, new: str) -> int:
|
|
51
|
+
"""Replace `old` with `new` in a paragraph, preserving run formatting.
|
|
52
|
+
|
|
53
|
+
Strategy: first replace occurrences fully contained in a single run
|
|
54
|
+
(formatting fully preserved). If the needle spans multiple runs, the
|
|
55
|
+
matched runs are collapsed: the replacement inherits the formatting of
|
|
56
|
+
the run where the match starts. Returns number of replacements made.
|
|
57
|
+
"""
|
|
58
|
+
if not old or old not in para.text:
|
|
59
|
+
return 0
|
|
60
|
+
count = 0
|
|
61
|
+
# Pass 1: within-run replacements.
|
|
62
|
+
for run in para.runs:
|
|
63
|
+
if old in run.text:
|
|
64
|
+
count += run.text.count(old)
|
|
65
|
+
run.text = run.text.replace(old, new)
|
|
66
|
+
# Pass 2: cross-run occurrences.
|
|
67
|
+
while old in para.text:
|
|
68
|
+
runs = para.runs
|
|
69
|
+
# Map paragraph text offsets to (run_index, offset_in_run).
|
|
70
|
+
full = "".join(r.text for r in runs)
|
|
71
|
+
start = full.find(old)
|
|
72
|
+
if start < 0:
|
|
73
|
+
break
|
|
74
|
+
end = start + len(old)
|
|
75
|
+
pos = 0
|
|
76
|
+
spans = [] # (run_idx, cut_start, cut_end) portions inside the match
|
|
77
|
+
for i, r in enumerate(runs):
|
|
78
|
+
r_start, r_end = pos, pos + len(r.text)
|
|
79
|
+
if r_end > start and r_start < end:
|
|
80
|
+
spans.append((i, max(start, r_start) - r_start,
|
|
81
|
+
min(end, r_end) - r_start))
|
|
82
|
+
pos = r_end
|
|
83
|
+
first = True
|
|
84
|
+
for i, cs, ce in spans:
|
|
85
|
+
t = runs[i].text
|
|
86
|
+
if first:
|
|
87
|
+
runs[i].text = t[:cs] + new + t[ce:]
|
|
88
|
+
first = False
|
|
89
|
+
else:
|
|
90
|
+
runs[i].text = t[:cs] + t[ce:]
|
|
91
|
+
count += 1
|
|
92
|
+
return count
|