@cteno/sdk-linux-x64 0.0.0-stage → 2.10.14

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.
Files changed (116) hide show
  1. package/LICENSE.daemon +31 -0
  2. package/README.md +4 -2
  3. package/agents/flow-analyzer/AGENT.md +71 -0
  4. package/bin/cteno-host-cli +4 -0
  5. package/bin/cteno-wiki +0 -0
  6. package/bin/rg +0 -0
  7. package/bin/secret-exec +0 -0
  8. package/manifest.json +12 -0
  9. package/package.json +33 -4
  10. package/plugins/agent-secrets/.cteno-plugin/plugin.json +133 -0
  11. package/plugins/agent-secrets/bin/linux-x64/fnox +4 -0
  12. package/plugins/agent-secrets/bin/linux-x64/secret-exec +0 -0
  13. package/plugins/agent-secrets/skills/secret/SKILL.md +201 -0
  14. package/plugins/cteno.browser/.cteno-plugin/plugin.json +65 -0
  15. package/plugins/cteno.browser/skills/browser/SKILL.md +28 -0
  16. package/plugins/cteno.inbox/.cteno-plugin/plugin.json +132 -0
  17. package/plugins/cteno.inbox/bin/all/cteno-connector-credential +31 -0
  18. package/plugins/cteno.inbox/bin/all/cteno-telegram-connector +33 -0
  19. package/plugins/cteno.inbox/bin/linux-x64/cteno-inbox +0 -0
  20. package/plugins/cteno.inbox/skills/cteno-inbox/SKILL.md +374 -0
  21. package/plugins/cteno.inbox/tools/inbox_focus/TOOL.md +44 -0
  22. package/plugins/cteno.memory/.cteno-plugin/plugin.json +176 -0
  23. package/plugins/cteno.memory/agents/llm-wiki/AGENT.md +96 -0
  24. package/plugins/cteno.memory/bin/linux-x64/cteno-wiki +0 -0
  25. package/plugins/cteno.memory/skills/codebase-wiki/SKILL.md +52 -0
  26. package/plugins/cteno.memory/skills/cteno.memory.native/SKILL.md +110 -0
  27. package/plugins/cteno.memory/tools/memory/TOOL.md +492 -0
  28. package/plugins/cteno.memory/ui/index.html +822 -0
  29. package/plugins/cteno.models/.cteno-plugin/plugin.json +191 -0
  30. package/plugins/cteno.websearch/.cteno-plugin/plugin.json +45 -0
  31. package/plugins/jev/.cteno-plugin/plugin.json +309 -0
  32. package/plugins/jev/skills/jev-browser/SKILL.md +66 -0
  33. package/plugins/jev/skills/jev-browser/jev_browser.py +335 -0
  34. package/skills/agent-create/SKILL.md +86 -0
  35. package/skills/cteno-host-cli/SKILL.md +256 -0
  36. package/skills/docx/LICENSE +23 -0
  37. package/skills/docx/NOTICE.md +8 -0
  38. package/skills/docx/SKILL.md +69 -0
  39. package/skills/docx/references/cjk_typography.md +357 -0
  40. package/skills/docx/references/design_principles.md +819 -0
  41. package/skills/docx/references/revisions-and-comments.md +88 -0
  42. package/skills/docx/references/track_changes_guide.md +200 -0
  43. package/skills/docx/references/typography_guide.md +294 -0
  44. package/skills/docx/scripts/cjk_fonts.py +244 -0
  45. package/skills/docx/scripts/docx_comments.py +289 -0
  46. package/skills/docx/scripts/docx_common.py +92 -0
  47. package/skills/docx/scripts/docx_create.py +259 -0
  48. package/skills/docx/scripts/docx_edit.py +250 -0
  49. package/skills/docx/scripts/docx_read.py +149 -0
  50. package/skills/docx/scripts/docx_revisions.py +146 -0
  51. package/skills/docx/scripts/docx_template.py +70 -0
  52. package/skills/docx/scripts/docx_validate.py +156 -0
  53. package/skills/docx/scripts/render.py +250 -0
  54. package/skills/docx/tests/test_docx_skill.py +525 -0
  55. package/skills/home-devices/SKILL.md +121 -0
  56. package/skills/mcp/SKILL.md +72 -0
  57. package/skills/mcp/scripts/mcp_setup.py +439 -0
  58. package/skills/pdf/LICENSE +23 -0
  59. package/skills/pdf/NOTICE.md +8 -0
  60. package/skills/pdf/SKILL.md +63 -0
  61. package/skills/pdf/references/design-tokens.md +381 -0
  62. package/skills/pdf/references/forms.md +99 -0
  63. package/skills/pdf/references/nano-pdf-editing.md +40 -0
  64. package/skills/pdf/references/ocr-extraction.md +165 -0
  65. package/skills/pdf/scripts/_raster.py +76 -0
  66. package/skills/pdf/scripts/extract_marker.py +88 -0
  67. package/skills/pdf/scripts/extract_pymupdf.py +101 -0
  68. package/skills/pdf/scripts/pdf_create.py +191 -0
  69. package/skills/pdf/scripts/pdf_fill_form.py +97 -0
  70. package/skills/pdf/scripts/pdf_form_layout.py +168 -0
  71. package/skills/pdf/scripts/pdf_make_form.py +145 -0
  72. package/skills/pdf/scripts/pdf_merge.py +50 -0
  73. package/skills/pdf/scripts/pdf_meta.py +115 -0
  74. package/skills/pdf/scripts/pdf_page_image.py +99 -0
  75. package/skills/pdf/scripts/pdf_read.py +153 -0
  76. package/skills/pdf/scripts/pdf_secure.py +71 -0
  77. package/skills/pdf/scripts/pdf_split.py +84 -0
  78. package/skills/pdf/scripts/pdf_stamp.py +143 -0
  79. package/skills/pdf/scripts/pdf_watermark.py +51 -0
  80. package/skills/pdf/scripts/render.py +250 -0
  81. package/skills/pdf/tests/test_pdf_skill.py +415 -0
  82. package/skills/pptx/LICENSE +23 -0
  83. package/skills/pptx/NOTICE.md +8 -0
  84. package/skills/pptx/SKILL.md +62 -0
  85. package/skills/pptx/references/design-system.md +392 -0
  86. package/skills/pptx/references/pptxgenjs.md +420 -0
  87. package/skills/pptx/references/slide-types.md +413 -0
  88. package/skills/pptx/scripts/cjk_fonts.py +244 -0
  89. package/skills/pptx/scripts/pptx_create.py +240 -0
  90. package/skills/pptx/scripts/pptx_edit.py +436 -0
  91. package/skills/pptx/scripts/pptx_from_template.py +88 -0
  92. package/skills/pptx/scripts/pptx_read.py +131 -0
  93. package/skills/pptx/scripts/pptx_render.py +96 -0
  94. package/skills/pptx/scripts/render.py +250 -0
  95. package/skills/pptx/tests/test_powerpoint_skill.py +477 -0
  96. package/skills/skill-create/SKILL.md +114 -0
  97. package/skills/video-generation/SKILL.md +27 -0
  98. package/skills/xlsx/LICENSE +23 -0
  99. package/skills/xlsx/NOTICE.md +8 -0
  100. package/skills/xlsx/SKILL.md +63 -0
  101. package/skills/xlsx/references/format.md +71 -0
  102. package/skills/xlsx/references/restructuring.md +71 -0
  103. package/skills/xlsx/scripts/csv_to_xlsx.py +110 -0
  104. package/skills/xlsx/scripts/formula_check.py +428 -0
  105. package/skills/xlsx/scripts/render.py +250 -0
  106. package/skills/xlsx/scripts/xlsx_create.py +265 -0
  107. package/skills/xlsx/scripts/xlsx_edit.py +269 -0
  108. package/skills/xlsx/scripts/xlsx_read.py +166 -0
  109. package/skills/xlsx/scripts/xlsx_recalc.py +119 -0
  110. package/skills/xlsx/scripts/xlsx_restructure.py +343 -0
  111. package/skills/xlsx/scripts/xlsx_to_csv.py +75 -0
  112. package/skills/xlsx/tests/test_xlsx_skill.py +544 -0
  113. package/tools/create_goal/TOOL.md +36 -0
  114. package/tools/image_generation/TOOL.md +81 -0
  115. package/tools/skill/TOOL.md +117 -0
  116. package/tools/update_profile_notes/TOOL.md +39 -0
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env python3
2
+ # MIT License. Part of the Hermes docx skill.
3
+ """Create a .docx document from a JSON spec.
4
+
5
+ Usage: docx_create.py spec.json output.docx
6
+ Run with --help for the spec format summary.
7
+
8
+ Spec (JSON object):
9
+ {
10
+ "page": {"width_mm": 210, "height_mm": 297,
11
+ "margins_mm": {"top": 25, "bottom": 25, "left": 20, "right": 20}},
12
+ "header": "text shown in page header",
13
+ "footer": "text shown in page footer",
14
+ "styles": [{"name": "MyStyle", "base": "Normal", "font": "Arial",
15
+ "size_pt": 12, "bold": true, "color": "1F4E79"}],
16
+ "blocks": [
17
+ {"type": "heading", "text": "Title", "level": 1},
18
+ {"type": "paragraph", "style": "MyStyle", "runs": [
19
+ {"text": "plain "}, {"text": "bold", "bold": true},
20
+ {"text": " italic", "italic": true},
21
+ {"text": " under", "underline": true}]},
22
+ {"type": "paragraph", "text": "shortcut: single plain run"},
23
+ {"type": "bullet_list", "items": ["a", "b"]},
24
+ {"type": "numbered_list", "items": ["one", "two"]},
25
+ {"type": "table", "header": ["Col1", "Col2"],
26
+ "rows": [["1", "2"]], "style": "Light Grid Accent 1",
27
+ "header_bold": true},
28
+ {"type": "image", "path": "pic.png", "width_mm": 60},
29
+ {"type": "page_break"},
30
+ {"type": "toc"}
31
+ ]
32
+ }
33
+
34
+ Paragraph extras: "align": "center"|"right"|"justify", "first_line_chars": 0 to cancel the indent.
35
+ Chinese defaults (spec contains Chinese): body paragraphs get a 2-character first-line indent
36
+ (top-level "first_line_chars" to change), line spacing 1.5 (top-level "line_spacing"),
37
+ and A4 paper with 25 mm margins unless "page" sets width_mm. Tables: cells vertically
38
+ centered, numbers right-aligned, header row repeats across pages.
39
+ CJK: when the spec contains Chinese/Japanese/Korean text, East Asian fonts are set
40
+ automatically (body SimSun, headings SimHei). Override with
41
+ `"cjk_fonts": {"body": "FangSong_GB2312", "heading": "SimHei"}` or disable with `"cjk_fonts": false`.
42
+
43
+ Extras: `"footer_page_numbers": true` at the top level adds a
44
+ "Page X of Y" footer built from PAGE/NUMPAGES fields, and a `toc` block
45
+ inserts a Table of Contents field. Field results are computed by
46
+ Word/LibreOffice when the file is opened, not by python-docx.
47
+ """
48
+ from __future__ import annotations
49
+
50
+ import argparse
51
+ import json
52
+ import os
53
+ import re
54
+ import sys
55
+
56
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
57
+
58
+ from docx import Document
59
+ from docx.enum.style import WD_STYLE_TYPE
60
+ from docx.enum.text import WD_BREAK
61
+ from docx.shared import Mm, Pt, RGBColor
62
+ from docx.oxml.ns import qn
63
+
64
+ _NUMERIC = re.compile(r"^[\s¥$€£+\-−]*[\d,.]+\s*(%|万|亿|元|万元|k|K|M)?\s*$")
65
+
66
+
67
+ def _tidy_table(table, has_header: bool) -> None:
68
+ """Cells: vertically centered, no inherited first-line indent / spacing,
69
+ numeric cells right-aligned, header row repeated on page breaks."""
70
+ from docx.enum.table import WD_ALIGN_VERTICAL
71
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
72
+ from docx.oxml import OxmlElement
73
+ for r, row in enumerate(table.rows):
74
+ for cell in row.cells:
75
+ cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
76
+ for para in cell.paragraphs:
77
+ pf = para.paragraph_format
78
+ pf.first_line_indent = Pt(0)
79
+ pf.space_before = Pt(2)
80
+ pf.space_after = Pt(2)
81
+ pf.line_spacing = 1.0
82
+ ind = para._p.get_or_add_pPr().find(qn("w:ind"))
83
+ if ind is not None and ind.get(qn("w:firstLineChars")) is not None:
84
+ ind.set(qn("w:firstLineChars"), "0")
85
+ if has_header and r == 0:
86
+ para.alignment = WD_ALIGN_PARAGRAPH.CENTER
87
+ elif _NUMERIC.match(cell.text or ""):
88
+ para.alignment = WD_ALIGN_PARAGRAPH.RIGHT
89
+ if has_header and table.rows:
90
+ tr_pr = table.rows[0]._tr.get_or_add_trPr()
91
+ if tr_pr.find(qn("w:tblHeader")) is None:
92
+ tr_pr.append(OxmlElement("w:tblHeader"))
93
+
94
+
95
+ def apply_page(doc, page: dict) -> None:
96
+ section = doc.sections[0]
97
+ if "width_mm" in page:
98
+ section.page_width = Mm(page["width_mm"])
99
+ if "height_mm" in page:
100
+ section.page_height = Mm(page["height_mm"])
101
+ m = page.get("margins_mm", {})
102
+ for side in ("top", "bottom", "left", "right"):
103
+ if side in m:
104
+ setattr(section, f"{side}_margin", Mm(m[side]))
105
+
106
+
107
+ def add_styles(doc, styles: list) -> None:
108
+ for s in styles:
109
+ style = doc.styles.add_style(s["name"], WD_STYLE_TYPE.PARAGRAPH)
110
+ if s.get("base"):
111
+ style.base_style = doc.styles[s["base"]]
112
+ font = style.font
113
+ if s.get("font"):
114
+ font.name = s["font"]
115
+ if s.get("size_pt"):
116
+ font.size = Pt(s["size_pt"])
117
+ if s.get("bold") is not None:
118
+ font.bold = s["bold"]
119
+ if s.get("italic") is not None:
120
+ font.italic = s["italic"]
121
+ if s.get("color"):
122
+ font.color.rgb = RGBColor.from_string(s["color"])
123
+
124
+
125
+ def add_runs(para, block: dict) -> None:
126
+ runs = block.get("runs")
127
+ if runs is None:
128
+ runs = [{"text": block.get("text", "")}]
129
+ for r in runs:
130
+ run = para.add_run(r.get("text", ""))
131
+ if r.get("bold"):
132
+ run.bold = True
133
+ if r.get("italic"):
134
+ run.italic = True
135
+ if r.get("underline"):
136
+ run.underline = True
137
+
138
+
139
+ CJK_INDENT = 0 # set to 2 by main() for Chinese documents unless the spec says otherwise
140
+
141
+
142
+ def add_block(doc, block: dict) -> None:
143
+ btype = block["type"]
144
+ if btype == "heading":
145
+ doc.add_heading(block.get("text", ""), level=block.get("level", 1))
146
+ elif btype == "paragraph":
147
+ para = doc.add_paragraph(style=block.get("style"))
148
+ add_runs(para, block)
149
+ indent = block.get("first_line_chars", CJK_INDENT)
150
+ if indent: # Chinese body text: first-line indent in characters, scales with font size
151
+ from docx.oxml.ns import qn
152
+ from docx.oxml import OxmlElement
153
+ ind = OxmlElement("w:ind")
154
+ ind.set(qn("w:firstLineChars"), str(int(indent * 100)))
155
+ para._p.get_or_add_pPr().append(ind)
156
+ if block.get("align"):
157
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
158
+ para.alignment = getattr(WD_ALIGN_PARAGRAPH, block["align"].upper())
159
+ elif btype == "bullet_list":
160
+ for item in block.get("items", []):
161
+ doc.add_paragraph(item, style="List Bullet")
162
+ elif btype == "numbered_list":
163
+ for item in block.get("items", []):
164
+ doc.add_paragraph(item, style="List Number")
165
+ elif btype == "table":
166
+ header = block.get("header", [])
167
+ rows = block.get("rows", [])
168
+ ncols = len(header) if header else (len(rows[0]) if rows else 1)
169
+ table = doc.add_table(rows=0, cols=ncols)
170
+ table.style = block.get("style", "Table Grid")
171
+ if header:
172
+ cells = table.add_row().cells
173
+ for i, text in enumerate(header):
174
+ cells[i].text = str(text)
175
+ if block.get("header_bold", True):
176
+ for para in cells[i].paragraphs:
177
+ for run in para.runs:
178
+ run.bold = True
179
+ for row in rows:
180
+ cells = table.add_row().cells
181
+ for i, text in enumerate(row):
182
+ cells[i].text = str(text)
183
+ _tidy_table(table, has_header=bool(header))
184
+ elif btype == "image":
185
+ width = Mm(block["width_mm"]) if block.get("width_mm") else None
186
+ doc.add_picture(block["path"], width=width)
187
+ elif btype == "page_break":
188
+ doc.add_paragraph().add_run().add_break(WD_BREAK.PAGE)
189
+ elif btype == "toc":
190
+ from docx_edit import _add_field
191
+ para = doc.add_paragraph()
192
+ _add_field(para, r' TOC \o "1-3" \h \z \u ',
193
+ "Table of contents - open in Word/LibreOffice and "
194
+ "update fields to populate.")
195
+ else:
196
+ raise ValueError(f"unknown block type: {btype}")
197
+
198
+
199
+ def main() -> int:
200
+ ap = argparse.ArgumentParser(
201
+ description="Create a .docx from a JSON spec.",
202
+ epilog="See the module docstring (top of this file) for the spec format.")
203
+ ap.add_argument("spec", help="path to JSON spec file")
204
+ ap.add_argument("output", help="path of .docx to write")
205
+ args = ap.parse_args()
206
+
207
+ with open(args.spec, encoding="utf-8") as f:
208
+ spec = json.load(f)
209
+
210
+ doc = Document()
211
+ global CJK_INDENT
212
+ has_cjk = bool(re.search(r"[\u3400-\u9fff]", json.dumps(spec, ensure_ascii=False)))
213
+ CJK_INDENT = spec.get("first_line_chars", 2 if has_cjk else 0)
214
+ if has_cjk and spec.get("line_spacing", 1.5):
215
+ normal = doc.styles["Normal"].paragraph_format
216
+ normal.line_spacing = spec.get("line_spacing", 1.5)
217
+ if has_cjk and not (spec.get("page") or {}).get("width_mm"):
218
+ # python-docx's default template is US Letter; Chinese documents are A4.
219
+ section = doc.sections[0]
220
+ section.page_width, section.page_height = Mm(210), Mm(297)
221
+ for side in ("top", "bottom", "left", "right"):
222
+ setattr(section, f"{side}_margin", Mm(25))
223
+ if spec.get("page"):
224
+ apply_page(doc, spec["page"])
225
+ if spec.get("styles"):
226
+ add_styles(doc, spec["styles"])
227
+ if spec.get("header"):
228
+ doc.sections[0].header.paragraphs[0].text = spec["header"]
229
+ if spec.get("footer"):
230
+ doc.sections[0].footer.paragraphs[0].text = spec["footer"]
231
+ for block in spec.get("blocks", []):
232
+ add_block(doc, block)
233
+ if spec.get("footer_page_numbers"):
234
+ from docx_edit import _add_field
235
+ para = doc.sections[0].footer.paragraphs[0]
236
+ zh = bool(re.search(r"[\u3400-\u9fff]", json.dumps(spec, ensure_ascii=False)))
237
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
238
+ para.alignment = WD_ALIGN_PARAGRAPH.CENTER
239
+ para.add_run("第 " if zh else "Page ")
240
+ _add_field(para, " PAGE ", "1")
241
+ para.add_run(" 页 共 " if zh else " of ")
242
+ _add_field(para, " NUMPAGES ", "1")
243
+ if zh:
244
+ para.add_run(" 页")
245
+ doc.save(args.output)
246
+ cjk = None
247
+ if spec.get("cjk_fonts", "auto") and re.search(r"[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af\uff00-\uffef]",
248
+ json.dumps(spec, ensure_ascii=False)):
249
+ from cjk_fonts import process as set_cjk
250
+ f = spec.get("cjk_fonts") if isinstance(spec.get("cjk_fonts"), dict) else {}
251
+ cjk = set_cjk(args.output, args.output, f.get("body"), f.get("heading"), f.get("lang", "zh-CN"))
252
+ print(json.dumps({"ok": True, "output": args.output,
253
+ "blocks": len(spec.get("blocks", [])),
254
+ "cjk_fonts": cjk and {"body": cjk["body"], "heading": cjk["heading"]}}, ensure_ascii=False))
255
+ return 0
256
+
257
+
258
+ if __name__ == "__main__":
259
+ sys.exit(main())
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env python3
2
+ # MIT License. Part of the Hermes docx skill.
3
+ """Edit an existing .docx in place (or to a new file).
4
+
5
+ Subcommands:
6
+ replace find-and-replace text, preserving run formatting
7
+ set-cell set the text of a table cell
8
+ insert insert a paragraph before a given body paragraph index
9
+ delete delete a body paragraph by index
10
+ style apply a paragraph style to a body paragraph by index
11
+ normalize merge adjacent runs with identical formatting
12
+ toc insert a Table of Contents field at a body paragraph index
13
+ page-numbers add "Page X of Y" (PAGE/NUMPAGES fields) to the footer
14
+
15
+ Examples:
16
+ docx_edit.py replace in.docx --find old --replace new -o out.docx
17
+ docx_edit.py set-cell in.docx --table 0 --row 1 --col 2 --text "42"
18
+ docx_edit.py insert in.docx --index 3 --text "New para" --style Normal
19
+ docx_edit.py delete in.docx --index 3
20
+ docx_edit.py style in.docx --index 0 --style "Heading 1"
21
+ docx_edit.py normalize in.docx -o out.docx
22
+ docx_edit.py toc in.docx --index 1 -o out.docx
23
+ docx_edit.py page-numbers in.docx -o out.docx
24
+
25
+ Field results (TOC entries, page numbers) are computed by Word or
26
+ LibreOffice when the document is opened, not by python-docx; until then
27
+ the fields show placeholder text.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import json
33
+ import sys
34
+
35
+ from docx import Document
36
+
37
+ from docx_common import iter_all_paragraphs, replace_in_paragraph
38
+
39
+ W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
40
+
41
+
42
+ def _q(tag: str) -> str:
43
+ return f"{{{W}}}{tag}"
44
+
45
+
46
+ def cmd_replace(doc, args) -> dict:
47
+ n = 0
48
+ for para in iter_all_paragraphs(doc):
49
+ n += replace_in_paragraph(para, args.find, args.replace)
50
+ return {"replacements": n}
51
+
52
+
53
+ def cmd_set_cell(doc, args) -> dict:
54
+ cell = doc.tables[args.table].cell(args.row, args.col)
55
+ cell.text = args.text
56
+ return {"table": args.table, "row": args.row, "col": args.col}
57
+
58
+
59
+ def cmd_insert(doc, args) -> dict:
60
+ paras = doc.paragraphs
61
+ if args.index < len(paras):
62
+ anchor = paras[args.index]
63
+ new_para = anchor.insert_paragraph_before(args.text, style=args.style)
64
+ else:
65
+ new_para = doc.add_paragraph(args.text, style=args.style)
66
+ return {"inserted_at": args.index, "text": new_para.text}
67
+
68
+
69
+ def cmd_delete(doc, args) -> dict:
70
+ para = doc.paragraphs[args.index]
71
+ el = para._element
72
+ el.getparent().remove(el)
73
+ return {"deleted_index": args.index}
74
+
75
+
76
+ def cmd_style(doc, args) -> dict:
77
+ doc.paragraphs[args.index].style = doc.styles[args.style]
78
+ return {"index": args.index, "style": args.style}
79
+
80
+
81
+ def _run_format_key(r_el) -> str:
82
+ """Canonical string for a run's w:rPr (None when absent)."""
83
+ from lxml import etree
84
+ rpr = r_el.find(_q("rPr"))
85
+ return "" if rpr is None else etree.tostring(rpr).decode("utf-8")
86
+
87
+
88
+ def cmd_normalize(doc) -> dict:
89
+ """Merge adjacent sibling runs with identical formatting."""
90
+ merged = 0
91
+ for para in iter_all_paragraphs(doc):
92
+ prev = None
93
+ for r_el in list(para._p):
94
+ if r_el.tag != _q("r"):
95
+ prev = None
96
+ continue
97
+ # only merge plain-text runs (no breaks, tabs, drawings...)
98
+ kids = {c.tag for c in r_el} - {_q("rPr"), _q("t")}
99
+ if kids:
100
+ prev = None
101
+ continue
102
+ if (prev is not None
103
+ and _run_format_key(prev) == _run_format_key(r_el)):
104
+ pt = prev.find(_q("t"))
105
+ ct = r_el.find(_q("t"))
106
+ if pt is None:
107
+ pt = prev.makeelement(_q("t"), {})
108
+ prev.append(pt)
109
+ pt.text = (pt.text or "") + ((ct.text or "")
110
+ if ct is not None else "")
111
+ pt.set("{http://www.w3.org/XML/1998/namespace}space",
112
+ "preserve")
113
+ r_el.getparent().remove(r_el)
114
+ merged += 1
115
+ else:
116
+ prev = r_el
117
+ return {"runs_merged": merged}
118
+
119
+
120
+ def _add_field(para, instr: str, placeholder: str) -> None:
121
+ """Append a complex field (begin/instrText/separate/result/end)."""
122
+ p = para._p
123
+ for ftype, extra in (("begin", None), (None, instr),
124
+ ("separate", None), (None, placeholder),
125
+ ("end", None)):
126
+ r = p.makeelement(_q("r"), {})
127
+ p.append(r)
128
+ if ftype is not None:
129
+ fld = r.makeelement(_q("fldChar"), {_q("fldCharType"): ftype})
130
+ r.append(fld)
131
+ elif extra is instr:
132
+ it = r.makeelement(_q("instrText"), {})
133
+ it.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
134
+ it.text = instr
135
+ r.append(it)
136
+ else:
137
+ t = r.makeelement(_q("t"), {})
138
+ t.text = extra
139
+ r.append(t)
140
+
141
+
142
+ def cmd_toc(doc, args) -> dict:
143
+ paras = doc.paragraphs
144
+ if args.index < len(paras):
145
+ para = paras[args.index].insert_paragraph_before("")
146
+ else:
147
+ para = doc.add_paragraph("")
148
+ _add_field(para, r' TOC \o "1-3" \h \z \u ',
149
+ "Table of contents - open in Word/LibreOffice and update "
150
+ "fields to populate.")
151
+ return {"toc_inserted_at": args.index}
152
+
153
+
154
+ def cmd_page_numbers(doc, args) -> dict:
155
+ footer = doc.sections[0].footer
156
+ para = footer.paragraphs[0] if footer.paragraphs \
157
+ else footer.add_paragraph()
158
+ para.add_run("Page ")
159
+ _add_field(para, " PAGE ", "1")
160
+ para.add_run(" of ")
161
+ _add_field(para, " NUMPAGES ", "1")
162
+ return {"footer_fields": ["PAGE", "NUMPAGES"]}
163
+
164
+
165
+ def main() -> int:
166
+ ap = argparse.ArgumentParser(description="Edit a .docx file.")
167
+ sub = ap.add_subparsers(dest="cmd", required=True)
168
+
169
+ def common(p):
170
+ p.add_argument("path", help="input .docx")
171
+ p.add_argument("-o", "--output",
172
+ help="output path (default: overwrite input)")
173
+
174
+ p = sub.add_parser("replace", help="find-and-replace text")
175
+ common(p)
176
+ p.add_argument("--find", required=True)
177
+ p.add_argument("--replace", required=True)
178
+ p.add_argument("--body-only", action="store_true",
179
+ help="skip headers/footers")
180
+
181
+ p = sub.add_parser("set-cell", help="set table cell text")
182
+ common(p)
183
+ p.add_argument("--table", type=int, required=True, help="table index")
184
+ p.add_argument("--row", type=int, required=True)
185
+ p.add_argument("--col", type=int, required=True)
186
+ p.add_argument("--text", required=True)
187
+
188
+ p = sub.add_parser("insert", help="insert paragraph at body index")
189
+ common(p)
190
+ p.add_argument("--index", type=int, required=True)
191
+ p.add_argument("--text", required=True)
192
+ p.add_argument("--style", default=None)
193
+
194
+ p = sub.add_parser("delete", help="delete body paragraph by index")
195
+ common(p)
196
+ p.add_argument("--index", type=int, required=True)
197
+
198
+ p = sub.add_parser("style", help="apply style to body paragraph")
199
+ common(p)
200
+ p.add_argument("--index", type=int, required=True)
201
+ p.add_argument("--style", required=True)
202
+
203
+ p = sub.add_parser("normalize",
204
+ help="merge adjacent runs with identical formatting")
205
+ common(p)
206
+
207
+ p = sub.add_parser("toc", help="insert a TOC field (Word computes it)")
208
+ common(p)
209
+ p.add_argument("--index", type=int, default=0,
210
+ help="body paragraph index to insert before (default 0)")
211
+
212
+ p = sub.add_parser("page-numbers",
213
+ help="add PAGE/NUMPAGES fields to the footer")
214
+ common(p)
215
+
216
+ args = ap.parse_args()
217
+ doc = Document(args.path)
218
+
219
+ if args.cmd == "replace":
220
+ if args.body_only:
221
+ n = 0
222
+ for para in iter_all_paragraphs(doc, include_headers_footers=False):
223
+ n += replace_in_paragraph(para, args.find, args.replace)
224
+ result = {"replacements": n}
225
+ else:
226
+ result = cmd_replace(doc, args)
227
+ elif args.cmd == "set-cell":
228
+ result = cmd_set_cell(doc, args)
229
+ elif args.cmd == "insert":
230
+ result = cmd_insert(doc, args)
231
+ elif args.cmd == "delete":
232
+ result = cmd_delete(doc, args)
233
+ elif args.cmd == "normalize":
234
+ result = cmd_normalize(doc)
235
+ elif args.cmd == "toc":
236
+ result = cmd_toc(doc, args)
237
+ elif args.cmd == "page-numbers":
238
+ result = cmd_page_numbers(doc, args)
239
+ else:
240
+ result = cmd_style(doc, args)
241
+
242
+ out = args.output or args.path
243
+ doc.save(out)
244
+ result.update({"ok": True, "output": out})
245
+ print(json.dumps(result, ensure_ascii=False))
246
+ return 0
247
+
248
+
249
+ if __name__ == "__main__":
250
+ sys.exit(main())
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env python3
2
+ # MIT License. Part of the Hermes docx skill.
3
+ """Read a .docx: text, structure outline, styles, images, revision detection.
4
+
5
+ Usage:
6
+ docx_read.py file.docx --text # full text incl. tables + headers/footers
7
+ docx_read.py file.docx --structure # JSON outline (headings, tables, counts)
8
+ docx_read.py file.docx --styles # JSON list of styles actually used
9
+ docx_read.py file.docx --images DIR # extract embedded images into DIR
10
+ docx_read.py file.docx --revisions # JSON: tracked changes / comments present?
11
+
12
+ Text output is JSON: {"body": [...], "tables": [[...rows]], "headers": [...],
13
+ "footers": [...]}. Body text is the accepted/as-is text (python-docx ignores
14
+ deleted-in-revision text and shows inserted text).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import os
21
+ import sys
22
+ import zipfile
23
+
24
+ from docx import Document
25
+
26
+
27
+ def table_to_rows(table) -> list:
28
+ return [[cell.text for cell in row.cells] for row in table.rows]
29
+
30
+
31
+ def extract_text(doc) -> dict:
32
+ out = {"body": [p.text for p in doc.paragraphs],
33
+ "tables": [table_to_rows(t) for t in doc.tables],
34
+ "headers": [], "footers": []}
35
+ for section in doc.sections:
36
+ out["headers"].extend(p.text for p in section.header.paragraphs)
37
+ out["footers"].extend(p.text for p in section.footer.paragraphs)
38
+ for t in section.header.tables:
39
+ out["headers"].append(json.dumps(table_to_rows(t), ensure_ascii=False))
40
+ for t in section.footer.tables:
41
+ out["footers"].append(json.dumps(table_to_rows(t), ensure_ascii=False))
42
+ return out
43
+
44
+
45
+ def extract_structure(doc) -> dict:
46
+ outline = []
47
+ for i, para in enumerate(doc.paragraphs):
48
+ style = para.style.name if para.style else ""
49
+ if style.startswith("Heading"):
50
+ try:
51
+ level = int(style.split()[-1])
52
+ except ValueError:
53
+ level = 1
54
+ outline.append({"index": i, "level": level, "text": para.text})
55
+ return {
56
+ "outline": outline,
57
+ "paragraph_count": len(doc.paragraphs),
58
+ "table_count": len(doc.tables),
59
+ "tables": [{"rows": len(t.rows), "cols": len(t.columns)}
60
+ for t in doc.tables],
61
+ "section_count": len(doc.sections),
62
+ }
63
+
64
+
65
+ def styles_used(doc) -> list:
66
+ used = set()
67
+ for para in doc.paragraphs:
68
+ if para.style:
69
+ used.add(para.style.name)
70
+ for run in para.runs:
71
+ if run.style:
72
+ used.add(run.style.name)
73
+ for table in doc.tables:
74
+ if table.style:
75
+ used.add(table.style.name)
76
+ for row in table.rows:
77
+ for cell in row.cells:
78
+ for para in cell.paragraphs:
79
+ if para.style:
80
+ used.add(para.style.name)
81
+ return sorted(used)
82
+
83
+
84
+ def extract_images(path: str, outdir: str) -> list:
85
+ os.makedirs(outdir, exist_ok=True)
86
+ written = []
87
+ with zipfile.ZipFile(path) as zf:
88
+ for name in zf.namelist():
89
+ if name.startswith("word/media/"):
90
+ target = os.path.join(outdir, os.path.basename(name))
91
+ with open(target, "wb") as f:
92
+ f.write(zf.read(name))
93
+ written.append(target)
94
+ return written
95
+
96
+
97
+ def detect_revisions(path: str) -> dict:
98
+ """Detect tracked changes and comments by scanning the raw XML parts."""
99
+ markers = {"insertions": b"<w:ins ", "deletions": b"<w:del ",
100
+ "format_changes": b"<w:rPrChange"}
101
+ result = {k: False for k in markers}
102
+ result["comments"] = False
103
+ with zipfile.ZipFile(path) as zf:
104
+ names = zf.namelist()
105
+ result["comments"] = any(n.startswith("word/comments") for n in names)
106
+ for name in names:
107
+ if name.startswith("word/") and name.endswith(".xml"):
108
+ data = zf.read(name)
109
+ for key, marker in markers.items():
110
+ if marker in data:
111
+ result[key] = True
112
+ result["has_tracked_changes"] = any(
113
+ result[k] for k in ("insertions", "deletions", "format_changes"))
114
+ return result
115
+
116
+
117
+ def main() -> int:
118
+ ap = argparse.ArgumentParser(description="Read/inspect a .docx file.")
119
+ ap.add_argument("path", help=".docx file to read")
120
+ g = ap.add_mutually_exclusive_group(required=True)
121
+ g.add_argument("--text", action="store_true", help="extract all text as JSON")
122
+ g.add_argument("--structure", action="store_true", help="outline JSON")
123
+ g.add_argument("--styles", action="store_true", help="styles used, JSON")
124
+ g.add_argument("--images", metavar="DIR", help="extract images to DIR")
125
+ g.add_argument("--revisions", action="store_true",
126
+ help="detect tracked changes / comments")
127
+ args = ap.parse_args()
128
+
129
+ if args.images:
130
+ print(json.dumps({"images": extract_images(args.path, args.images)},
131
+ ensure_ascii=False))
132
+ return 0
133
+ if args.revisions:
134
+ print(json.dumps(detect_revisions(args.path), ensure_ascii=False))
135
+ return 0
136
+
137
+ doc = Document(args.path)
138
+ if args.text:
139
+ out = extract_text(doc)
140
+ elif args.structure:
141
+ out = extract_structure(doc)
142
+ else:
143
+ out = {"styles": styles_used(doc)}
144
+ print(json.dumps(out, ensure_ascii=False, indent=2))
145
+ return 0
146
+
147
+
148
+ if __name__ == "__main__":
149
+ sys.exit(main())