@panda-agent/panda-cli 0.1.28 → 0.1.30

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 (167) hide show
  1. package/dist/panda-cli-ink.bundle.mjs +267 -258
  2. package/package.json +6 -4
  3. package/skills/.gitkeep +0 -0
  4. package/skills/README.md +13 -0
  5. package/skills/docx/.skill-metadata.yaml +173 -0
  6. package/skills/docx/LICENSE.txt +30 -0
  7. package/skills/docx/SKILL.md +589 -0
  8. package/skills/docx/scripts/__init__.py +1 -0
  9. package/skills/docx/scripts/accept_changes.py +206 -0
  10. package/skills/docx/scripts/comment.py +442 -0
  11. package/skills/docx/scripts/office/helpers/__init__.py +1 -0
  12. package/skills/docx/scripts/office/helpers/merge_runs.py +190 -0
  13. package/skills/docx/scripts/office/helpers/simplify_redlines.py +185 -0
  14. package/skills/docx/scripts/office/pack.py +167 -0
  15. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -0
  16. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -0
  17. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -0
  18. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -0
  19. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -0
  20. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -0
  21. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -0
  22. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -0
  23. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -0
  24. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -0
  25. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -0
  26. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -0
  27. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -0
  28. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -0
  29. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -0
  30. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -0
  31. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -0
  32. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -0
  33. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -0
  34. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -0
  35. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -0
  36. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -0
  37. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -0
  38. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -0
  39. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -0
  40. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -0
  41. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -0
  42. package/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd +42 -0
  43. package/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd +50 -0
  44. package/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd +49 -0
  45. package/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd +33 -0
  46. package/skills/docx/scripts/office/schemas/mce/mc.xsd +75 -0
  47. package/skills/docx/scripts/office/schemas/microsoft/wml-2010.xsd +560 -0
  48. package/skills/docx/scripts/office/schemas/microsoft/wml-2012.xsd +67 -0
  49. package/skills/docx/scripts/office/schemas/microsoft/wml-2018.xsd +14 -0
  50. package/skills/docx/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -0
  51. package/skills/docx/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -0
  52. package/skills/docx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -0
  53. package/skills/docx/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -0
  54. package/skills/docx/scripts/office/soffice.py +194 -0
  55. package/skills/docx/scripts/office/unpack.py +145 -0
  56. package/skills/docx/scripts/office/validate.py +114 -0
  57. package/skills/docx/scripts/office/validators/__init__.py +16 -0
  58. package/skills/docx/scripts/office/validators/base.py +733 -0
  59. package/skills/docx/scripts/office/validators/docx.py +354 -0
  60. package/skills/docx/scripts/office/validators/pptx.py +230 -0
  61. package/skills/docx/scripts/office/validators/redlining.py +212 -0
  62. package/skills/docx/scripts/templates/comments.xml +3 -0
  63. package/skills/docx/scripts/templates/commentsExtended.xml +3 -0
  64. package/skills/docx/scripts/templates/commentsExtensible.xml +3 -0
  65. package/skills/docx/scripts/templates/commentsIds.xml +3 -0
  66. package/skills/docx/scripts/templates/people.xml +3 -0
  67. package/skills/frontend-design/LICENSE.txt +177 -0
  68. package/skills/frontend-design/SKILL.md +42 -0
  69. package/skills/pdf/.skill-metadata.yaml +273 -0
  70. package/skills/pdf/LICENSE.txt +30 -0
  71. package/skills/pdf/SKILL.md +324 -0
  72. package/skills/pdf/advanced-reference.md +609 -0
  73. package/skills/pdf/form-filling-guide.md +318 -0
  74. package/skills/pdf/forms.md +294 -0
  75. package/skills/pdf/reference.md +612 -0
  76. package/skills/pdf/scripts/check_bounding_boxes.py +198 -0
  77. package/skills/pdf/scripts/check_fillable_fields.py +64 -0
  78. package/skills/pdf/scripts/convert_pdf_to_images.py +102 -0
  79. package/skills/pdf/scripts/create_validation_image.py +125 -0
  80. package/skills/pdf/scripts/extract_form_field_info.py +220 -0
  81. package/skills/pdf/scripts/extract_form_structure.py +202 -0
  82. package/skills/pdf/scripts/fill_fillable_fields.py +205 -0
  83. package/skills/pdf/scripts/fill_pdf_form_with_annotations.py +193 -0
  84. package/skills/pptx-generator/SKILL.md +204 -0
  85. package/skills/pptx-generator/assets/styles/business.json +8 -0
  86. package/skills/pptx-generator/assets/styles/minimal.json +8 -0
  87. package/skills/pptx-generator/assets/styles/modern.json +8 -0
  88. package/skills/pptx-generator/assets/templates/ppt_data_template.json +40 -0
  89. package/skills/pptx-generator/references/collaboration_guide.md +381 -0
  90. package/skills/pptx-generator/references/json_format_spec.md +215 -0
  91. package/skills/pptx-generator/references/layout_guide.md +290 -0
  92. package/skills/pptx-generator/scripts/json_validator.py +194 -0
  93. package/skills/pptx-generator/scripts/pptx_builder.py +340 -0
  94. package/skills/pptx-generator/scripts/pptx_validator.py +162 -0
  95. package/skills/skill-creator/LICENSE.txt +202 -0
  96. package/skills/skill-creator/SKILL.md +479 -0
  97. package/skills/skill-creator/agents/analyzer.md +274 -0
  98. package/skills/skill-creator/agents/comparator.md +202 -0
  99. package/skills/skill-creator/agents/grader.md +223 -0
  100. package/skills/skill-creator/assets/eval_review.html +146 -0
  101. package/skills/skill-creator/eval-viewer/generate_review.py +471 -0
  102. package/skills/skill-creator/eval-viewer/viewer.html +1325 -0
  103. package/skills/skill-creator/references/schemas.md +430 -0
  104. package/skills/skill-creator/scripts/__init__.py +0 -0
  105. package/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
  106. package/skills/skill-creator/scripts/generate_report.py +326 -0
  107. package/skills/skill-creator/scripts/improve_description.py +248 -0
  108. package/skills/skill-creator/scripts/package_skill.py +136 -0
  109. package/skills/skill-creator/scripts/quick_validate.py +103 -0
  110. package/skills/skill-creator/scripts/run_eval.py +310 -0
  111. package/skills/skill-creator/scripts/run_loop.py +332 -0
  112. package/skills/skill-creator/scripts/utils.py +47 -0
  113. package/skills/xlsx/.skill-metadata.yaml +185 -0
  114. package/skills/xlsx/LICENSE.txt +30 -0
  115. package/skills/xlsx/SKILL.md +233 -0
  116. package/skills/xlsx/scripts/office/helpers/__init__.py +1 -0
  117. package/skills/xlsx/scripts/office/helpers/merge_runs.py +226 -0
  118. package/skills/xlsx/scripts/office/helpers/simplify_redlines.py +198 -0
  119. package/skills/xlsx/scripts/office/pack.py +162 -0
  120. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -0
  121. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -0
  122. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -0
  123. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -0
  124. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -0
  125. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -0
  126. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -0
  127. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -0
  128. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -0
  129. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -0
  130. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -0
  131. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -0
  132. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -0
  133. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -0
  134. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -0
  135. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -0
  136. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -0
  137. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -0
  138. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -0
  139. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -0
  140. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -0
  141. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -0
  142. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -0
  143. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -0
  144. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -0
  145. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -0
  146. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -0
  147. package/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd +42 -0
  148. package/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd +50 -0
  149. package/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd +49 -0
  150. package/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd +33 -0
  151. package/skills/xlsx/scripts/office/schemas/mce/mc.xsd +75 -0
  152. package/skills/xlsx/scripts/office/schemas/microsoft/wml-2010.xsd +560 -0
  153. package/skills/xlsx/scripts/office/schemas/microsoft/wml-2012.xsd +67 -0
  154. package/skills/xlsx/scripts/office/schemas/microsoft/wml-2018.xsd +14 -0
  155. package/skills/xlsx/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -0
  156. package/skills/xlsx/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -0
  157. package/skills/xlsx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -0
  158. package/skills/xlsx/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -0
  159. package/skills/xlsx/scripts/office/soffice.py +185 -0
  160. package/skills/xlsx/scripts/office/unpack.py +146 -0
  161. package/skills/xlsx/scripts/office/validate.py +108 -0
  162. package/skills/xlsx/scripts/office/validators/__init__.py +13 -0
  163. package/skills/xlsx/scripts/office/validators/base.py +800 -0
  164. package/skills/xlsx/scripts/office/validators/docx.py +383 -0
  165. package/skills/xlsx/scripts/office/validators/pptx.py +250 -0
  166. package/skills/xlsx/scripts/office/validators/redlining.py +229 -0
  167. package/skills/xlsx/scripts/recalc.py +296 -0
@@ -0,0 +1,206 @@
1
+ """Resolve every tracked revision inside a Word document via LibreOffice automation.
2
+
3
+ Deploys a Basic macro into a dedicated LibreOffice user profile, opens the
4
+ target document headlessly, executes the macro to accept all changes, then
5
+ saves and closes.
6
+
7
+ LibreOffice (``soffice``) must be available on the system PATH.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import logging
14
+ import pathlib
15
+ import shutil
16
+ import subprocess
17
+ from dataclasses import dataclass
18
+ from typing import Final
19
+
20
+ import office.soffice as _lo
21
+
22
+ # ── Logging ──────────────────────────────────────────────────────────────────
23
+
24
+ _LOG: Final = logging.getLogger(__name__)
25
+
26
+ # ── Constants ────────────────────────────────────────────────────────────────
27
+
28
+ _PROFILE_ROOT: Final[str] = "/tmp/libreoffice_docx_profile"
29
+ _MACRO_FOLDER: Final[pathlib.Path] = pathlib.Path(
30
+ f"{_PROFILE_ROOT}/user/basic/Standard"
31
+ )
32
+ _MACRO_FILE: Final[pathlib.Path] = _MACRO_FOLDER / "Module1.xba"
33
+ _MACRO_ENTRY_POINT: Final[str] = "AcceptAllTrackedChanges"
34
+
35
+ _BASIC_MACRO_BODY: Final[str] = (
36
+ '<?xml version="1.0" encoding="UTF-8"?>\n'
37
+ '<!DOCTYPE script:module PUBLIC "-//OpenOffice.org//DTD OfficeDocument 1.0//EN" "module.dtd">\n'
38
+ '<script:module xmlns:script="http://openoffice.org/2000/script" '
39
+ 'script:name="Module1" script:language="StarBasic">\n'
40
+ " Sub AcceptAllTrackedChanges()\n"
41
+ " Dim document As Object\n"
42
+ " Dim dispatcher As Object\n"
43
+ "\n"
44
+ " document = ThisComponent.CurrentController.Frame\n"
45
+ ' dispatcher = createUnoService("com.sun.star.frame.DispatchHelper")\n'
46
+ "\n"
47
+ ' dispatcher.executeDispatch(document, ".uno:AcceptAllTrackedChanges", "", 0, Array())\n'
48
+ " ThisComponent.store()\n"
49
+ " ThisComponent.close(True)\n"
50
+ " End Sub\n"
51
+ "</script:module>"
52
+ )
53
+
54
+ _SOFFICE_TIMEOUT_SECONDS: Final[int] = 30
55
+ _PROFILE_INIT_TIMEOUT_SECONDS: Final[int] = 10
56
+ _USER_INSTALL_ARG: Final[str] = f"-env:UserInstallation=file://{_PROFILE_ROOT}"
57
+ _MACRO_URI: Final[str] = (
58
+ f"vnd.sun.star.script:Standard.Module1.{_MACRO_ENTRY_POINT}"
59
+ "?language=Basic&location=application"
60
+ )
61
+
62
+
63
+ # ── Data structures ──────────────────────────────────────────────────────────
64
+
65
+
66
+ @dataclass(frozen=True, slots=True)
67
+ class Result:
68
+ """Outcome of an accept-changes operation."""
69
+
70
+ success: bool
71
+ message: str
72
+
73
+
74
+ # ── Internal helpers ─────────────────────────────────────────────────────────
75
+
76
+
77
+ def _initialize_profile_directory() -> None:
78
+ """Launch soffice once to bootstrap the user profile directory tree."""
79
+ subprocess.run(
80
+ ["soffice", "--headless", _USER_INSTALL_ARG, "--terminate_after_init"],
81
+ capture_output=True,
82
+ timeout=_PROFILE_INIT_TIMEOUT_SECONDS,
83
+ check=False,
84
+ env=_lo.get_soffice_env(),
85
+ )
86
+ _MACRO_FOLDER.mkdir(parents=True, exist_ok=True)
87
+
88
+
89
+ def _provision_macro() -> bool:
90
+ """Ensure the LO Basic macro file exists and contains the expected code."""
91
+ if _MACRO_FILE.exists() and _MACRO_ENTRY_POINT in _MACRO_FILE.read_text():
92
+ return True
93
+
94
+ if not _MACRO_FOLDER.exists():
95
+ _initialize_profile_directory()
96
+
97
+ try:
98
+ _MACRO_FILE.write_text(_BASIC_MACRO_BODY)
99
+ return True
100
+ except Exception as exc:
101
+ _LOG.warning("Macro provisioning failed: %s", exc)
102
+ return False
103
+
104
+
105
+ def _validate_input(src_path: pathlib.Path) -> str | None:
106
+ """Return an error message if the input is invalid, else None."""
107
+ if not src_path.exists():
108
+ return f"Error: Input file not found: {src_path}"
109
+ if src_path.suffix.lower() != ".docx":
110
+ return f"Error: Input file is not a DOCX file: {src_path}"
111
+ return None
112
+
113
+
114
+ def _copy_source_to_destination(
115
+ src_path: pathlib.Path, dst_path: pathlib.Path
116
+ ) -> str | None:
117
+ """Copy the source file to the destination. Return error message on failure."""
118
+ try:
119
+ dst_path.parent.mkdir(parents=True, exist_ok=True)
120
+ shutil.copy2(src_path, dst_path)
121
+ return None
122
+ except Exception as exc:
123
+ return f"Error: Failed to copy input file to output location: {exc}"
124
+
125
+
126
+ def _run_macro(dst_path: pathlib.Path) -> Result:
127
+ """Invoke LibreOffice with the accept-changes macro on the given file."""
128
+ argv = [
129
+ "soffice",
130
+ "--headless",
131
+ _USER_INSTALL_ARG,
132
+ "--norestore",
133
+ _MACRO_URI,
134
+ str(dst_path.absolute()),
135
+ ]
136
+
137
+ try:
138
+ proc = subprocess.run(
139
+ argv,
140
+ capture_output=True,
141
+ text=True,
142
+ timeout=_SOFFICE_TIMEOUT_SECONDS,
143
+ check=False,
144
+ env=_lo.get_soffice_env(),
145
+ )
146
+ except subprocess.TimeoutExpired:
147
+ # Timeout is treated as success — LO sometimes hangs after completing.
148
+ return Result(success=True, message="")
149
+
150
+ if proc.returncode != 0:
151
+ return Result(success=False, message=f"Error: LibreOffice failed: {proc.stderr}")
152
+
153
+ return Result(success=True, message="")
154
+
155
+
156
+ # ── Public API ───────────────────────────────────────────────────────────────
157
+
158
+
159
+ def accept_changes(src: str, dst: str) -> tuple[None, str]:
160
+ """Accept all revisions in *src* and write the clean result to *dst*.
161
+
162
+ Returns:
163
+ A tuple of ``(None, message)`` where *message* describes the outcome.
164
+ """
165
+ src_path = pathlib.Path(src)
166
+ dst_path = pathlib.Path(dst)
167
+
168
+ # Validate input file.
169
+ error = _validate_input(src_path)
170
+ if error:
171
+ return None, error
172
+
173
+ # Copy source to destination for in-place macro editing.
174
+ error = _copy_source_to_destination(src_path, dst_path)
175
+ if error:
176
+ return None, error
177
+
178
+ # Deploy the macro if not already present.
179
+ if not _provision_macro():
180
+ return None, "Error: Failed to setup LibreOffice macro"
181
+
182
+ # Execute the macro.
183
+ result = _run_macro(dst_path)
184
+ if not result.success:
185
+ return None, result.message
186
+
187
+ return None, f"Successfully accepted all tracked changes: {src} -> {dst}"
188
+
189
+
190
+ # ── CLI entry point ──────────────────────────────────────────────────────────
191
+
192
+ if __name__ == "__main__":
193
+ ap = argparse.ArgumentParser(
194
+ description="Accept all tracked changes in a DOCX file"
195
+ )
196
+ ap.add_argument("input_file", help="Input DOCX file with tracked changes")
197
+ ap.add_argument(
198
+ "output_file", help="Output DOCX file (clean, no tracked changes)"
199
+ )
200
+ cli = ap.parse_args()
201
+
202
+ _, msg = accept_changes(cli.input_file, cli.output_file)
203
+ print(msg)
204
+
205
+ if "Error" in msg:
206
+ raise SystemExit(1)
@@ -0,0 +1,442 @@
1
+ """Insert review annotations into an unpacked DOCX package.
2
+
3
+ Typical invocation::
4
+
5
+ python comment.py unpacked/ 0 "Comment text"
6
+ python comment.py unpacked/ 1 "Reply text" --parent 0
7
+
8
+ All text values must already contain proper XML escaping
9
+ (e.g. ``&amp;`` for ``&``, ``&#x2019;`` for a smart apostrophe).
10
+
11
+ After execution, manually wire the markers into ``document.xml``::
12
+
13
+ <w:commentRangeStart w:id="0"/>
14
+ ... annotated content ...
15
+ <w:commentRangeEnd w:id="0"/>
16
+ <w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="0"/></w:r>
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import pathlib
23
+ import random
24
+ import shutil
25
+ import sys
26
+ from dataclasses import dataclass
27
+ from datetime import datetime, timezone
28
+ from typing import Final
29
+
30
+ import defusedxml.minidom
31
+
32
+ # ── Constants ────────────────────────────────────────────────────────────────
33
+
34
+ _TPL_DIR: Final[pathlib.Path] = pathlib.Path(__file__).parent / "templates"
35
+
36
+ _XML_NS_MAP: Final[dict[str, str]] = {
37
+ "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
38
+ "w14": "http://schemas.microsoft.com/office/word/2010/wordml",
39
+ "w15": "http://schemas.microsoft.com/office/word/2012/wordml",
40
+ "w16cid": "http://schemas.microsoft.com/office/word/2016/wordml/cid",
41
+ "w16cex": "http://schemas.microsoft.com/office/word/2018/wordml/cex",
42
+ }
43
+
44
+ _NS_DECLARATIONS: Final[str] = " ".join(
45
+ f'xmlns:{prefix}="{uri}"' for prefix, uri in _XML_NS_MAP.items()
46
+ )
47
+
48
+ _CURLY_QUOTE_MAP: Final[dict[str, str]] = {
49
+ "\u201c": "&#x201C;",
50
+ "\u201d": "&#x201D;",
51
+ "\u2018": "&#x2018;",
52
+ "\u2019": "&#x2019;",
53
+ }
54
+
55
+ # XML template for the comment body element.
56
+ _COMMENT_BODY_XML: Final[str] = (
57
+ '<w:comment w:id="{cid}" w:author="{who}" w:date="{when}" w:initials="{ini}">'
58
+ '<w:p w14:paraId="{pid}" w14:textId="77777777">'
59
+ "<w:r>"
60
+ '<w:rPr><w:rStyle w:val="CommentReference"/></w:rPr>'
61
+ "<w:annotationRef/>"
62
+ "</w:r>"
63
+ "<w:r>"
64
+ "<w:rPr>"
65
+ '<w:color w:val="000000"/>'
66
+ '<w:sz w:val="20"/>'
67
+ '<w:szCs w:val="20"/>'
68
+ "</w:rPr>"
69
+ "<w:t>{body}</w:t>"
70
+ "</w:r>"
71
+ "</w:p>"
72
+ "</w:comment>"
73
+ )
74
+
75
+ # User-facing hints for wiring markers into document.xml.
76
+ _STANDALONE_HINT: Final[str] = """
77
+ Add to document.xml (markers must be direct children of w:p, never inside w:r):
78
+ <w:commentRangeStart w:id="{cid}"/>
79
+ <w:r>...</w:r>
80
+ <w:commentRangeEnd w:id="{cid}"/>
81
+ <w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="{cid}"/></w:r>"""
82
+
83
+ _REPLY_HINT: Final[str] = """
84
+ Nest markers inside parent {pid}'s markers (markers must be direct children of w:p, never inside w:r):
85
+ <w:commentRangeStart w:id="{pid}"/><w:commentRangeStart w:id="{cid}"/>
86
+ <w:r>...</w:r>
87
+ <w:commentRangeEnd w:id="{cid}"/><w:commentRangeEnd w:id="{pid}"/>
88
+ <w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="{pid}"/></w:r>
89
+ <w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="{cid}"/></w:r>"""
90
+
91
+ # Relationship type URIs and their corresponding target file names.
92
+ _COMMENT_RELATIONSHIP_PAIRS: Final[list[tuple[str, str]]] = [
93
+ (
94
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments",
95
+ "comments.xml",
96
+ ),
97
+ (
98
+ "http://schemas.microsoft.com/office/2011/relationships/commentsExtended",
99
+ "commentsExtended.xml",
100
+ ),
101
+ (
102
+ "http://schemas.microsoft.com/office/2016/09/relationships/commentsIds",
103
+ "commentsIds.xml",
104
+ ),
105
+ (
106
+ "http://schemas.microsoft.com/office/2018/08/relationships/commentsExtensible",
107
+ "commentsExtensible.xml",
108
+ ),
109
+ ]
110
+
111
+ # Content type declarations for comment-related parts.
112
+ _COMMENT_CONTENT_TYPE_PARTS: Final[list[tuple[str, str]]] = [
113
+ (
114
+ "/word/comments.xml",
115
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml",
116
+ ),
117
+ (
118
+ "/word/commentsExtended.xml",
119
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml",
120
+ ),
121
+ (
122
+ "/word/commentsIds.xml",
123
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsIds+xml",
124
+ ),
125
+ (
126
+ "/word/commentsExtensible.xml",
127
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtensible+xml",
128
+ ),
129
+ ]
130
+
131
+
132
+ # ── Data structures ──────────────────────────────────────────────────────────
133
+
134
+
135
+ @dataclass(frozen=True, slots=True)
136
+ class CommentIdentifiers:
137
+ """Generated identifiers for a single comment."""
138
+
139
+ para_id: str
140
+ durable_id: str
141
+ timestamp: str
142
+
143
+
144
+ # ── Low-level XML helpers ────────────────────────────────────────────────────
145
+
146
+
147
+ def _rand_hex() -> str:
148
+ """Generate a random 8-character hex ID within the valid OOXML range."""
149
+ return f"{random.randint(0, 0x7FFFFFFE):08X}"
150
+
151
+
152
+ def _sanitize_curly_quotes(raw: str) -> str:
153
+ """Replace Unicode typographic quotes with XML entity references."""
154
+ result = raw
155
+ for char, entity in _CURLY_QUOTE_MAP.items():
156
+ result = result.replace(char, entity)
157
+ return result
158
+
159
+
160
+ def _inject_xml_fragment(
161
+ target_file: pathlib.Path, wrapper_tag: str, fragment: str
162
+ ) -> None:
163
+ """Parse *target_file*, append *fragment* as children of *wrapper_tag*, and save."""
164
+ doc = defusedxml.minidom.parseString(target_file.read_text(encoding="utf-8"))
165
+ container = doc.getElementsByTagName(wrapper_tag)[0]
166
+
167
+ tmp_doc = defusedxml.minidom.parseString(f"<root {_NS_DECLARATIONS}>{fragment}</root>")
168
+ for node in tmp_doc.documentElement.childNodes:
169
+ if node.nodeType == node.ELEMENT_NODE:
170
+ container.appendChild(doc.importNode(node, True))
171
+
172
+ serialised = _sanitize_curly_quotes(doc.toxml(encoding="UTF-8").decode("utf-8"))
173
+ target_file.write_text(serialised, encoding="utf-8")
174
+
175
+
176
+ # ── Relationship and content-type helpers ────────────────────────────────────
177
+
178
+
179
+ def _locate_para_id(comments_file: pathlib.Path, cid: int) -> str | None:
180
+ """Return the ``w14:paraId`` for the comment whose ``w:id`` equals *cid*."""
181
+ doc = defusedxml.minidom.parseString(comments_file.read_text(encoding="utf-8"))
182
+ for comment_node in doc.getElementsByTagName("w:comment"):
183
+ if comment_node.getAttribute("w:id") != str(cid):
184
+ continue
185
+ for p_node in comment_node.getElementsByTagName("w:p"):
186
+ val = p_node.getAttribute("w14:paraId")
187
+ if val:
188
+ return val
189
+ return None
190
+
191
+
192
+ def _highest_rid(rels_file: pathlib.Path) -> int:
193
+ """Find the highest numeric relationship ID in a .rels file."""
194
+ doc = defusedxml.minidom.parseString(rels_file.read_text(encoding="utf-8"))
195
+ top = 0
196
+ for rel_node in doc.getElementsByTagName("Relationship"):
197
+ raw = rel_node.getAttribute("Id")
198
+ if raw and raw.startswith("rId"):
199
+ try:
200
+ top = max(top, int(raw[3:]))
201
+ except ValueError:
202
+ continue
203
+ return top
204
+
205
+
206
+ def _target_exists(rels_file: pathlib.Path, tgt: str) -> bool:
207
+ """Check whether a relationship with the given Target already exists."""
208
+ doc = defusedxml.minidom.parseString(rels_file.read_text(encoding="utf-8"))
209
+ return any(
210
+ r.getAttribute("Target") == tgt
211
+ for r in doc.getElementsByTagName("Relationship")
212
+ )
213
+
214
+
215
+ def _part_declared(ct_file: pathlib.Path, part: str) -> bool:
216
+ """Check whether a content-type Override with the given PartName exists."""
217
+ doc = defusedxml.minidom.parseString(ct_file.read_text(encoding="utf-8"))
218
+ return any(
219
+ o.getAttribute("PartName") == part
220
+ for o in doc.getElementsByTagName("Override")
221
+ )
222
+
223
+
224
+ def _wire_comment_rels(root_dir: pathlib.Path) -> None:
225
+ """Register comment-related relationships in document.xml.rels."""
226
+ rels = root_dir / "word" / "_rels" / "document.xml.rels"
227
+ if not rels.exists():
228
+ return
229
+ if _target_exists(rels, "comments.xml"):
230
+ return
231
+
232
+ doc = defusedxml.minidom.parseString(rels.read_text(encoding="utf-8"))
233
+ parent_elem = doc.documentElement
234
+ rid_counter = _highest_rid(rels) + 1
235
+
236
+ for rtype, tgt in _COMMENT_RELATIONSHIP_PAIRS:
237
+ elem = doc.createElement("Relationship")
238
+ elem.setAttribute("Id", f"rId{rid_counter}")
239
+ elem.setAttribute("Type", rtype)
240
+ elem.setAttribute("Target", tgt)
241
+ parent_elem.appendChild(elem)
242
+ rid_counter += 1
243
+
244
+ rels.write_bytes(doc.toxml(encoding="UTF-8"))
245
+
246
+
247
+ def _wire_content_types(root_dir: pathlib.Path) -> None:
248
+ """Register comment-related content types in [Content_Types].xml."""
249
+ ct = root_dir / "[Content_Types].xml"
250
+ if not ct.exists():
251
+ return
252
+ if _part_declared(ct, "/word/comments.xml"):
253
+ return
254
+
255
+ doc = defusedxml.minidom.parseString(ct.read_text(encoding="utf-8"))
256
+ parent_elem = doc.documentElement
257
+
258
+ for pname, ctype in _COMMENT_CONTENT_TYPE_PARTS:
259
+ elem = doc.createElement("Override")
260
+ elem.setAttribute("PartName", pname)
261
+ elem.setAttribute("ContentType", ctype)
262
+ parent_elem.appendChild(elem)
263
+
264
+ ct.write_bytes(doc.toxml(encoding="UTF-8"))
265
+
266
+
267
+ # ── Comment XML part management ──────────────────────────────────────────────
268
+
269
+
270
+ def _ensure_comments_xml(word_dir: pathlib.Path, root_dir: pathlib.Path) -> pathlib.Path:
271
+ """Ensure comments.xml exists, bootstrapping from template if needed."""
272
+ cxml = word_dir / "comments.xml"
273
+ if not cxml.exists():
274
+ shutil.copy(_TPL_DIR / "comments.xml", cxml)
275
+ _wire_comment_rels(root_dir)
276
+ _wire_content_types(root_dir)
277
+ return cxml
278
+
279
+
280
+ def _ensure_extended_xml(word_dir: pathlib.Path) -> pathlib.Path:
281
+ """Ensure commentsExtended.xml exists."""
282
+ ext = word_dir / "commentsExtended.xml"
283
+ if not ext.exists():
284
+ shutil.copy(_TPL_DIR / "commentsExtended.xml", ext)
285
+ return ext
286
+
287
+
288
+ def _ensure_ids_xml(word_dir: pathlib.Path) -> pathlib.Path:
289
+ """Ensure commentsIds.xml exists."""
290
+ ids_file = word_dir / "commentsIds.xml"
291
+ if not ids_file.exists():
292
+ shutil.copy(_TPL_DIR / "commentsIds.xml", ids_file)
293
+ return ids_file
294
+
295
+
296
+ def _ensure_extensible_xml(word_dir: pathlib.Path) -> pathlib.Path:
297
+ """Ensure commentsExtensible.xml exists."""
298
+ efile = word_dir / "commentsExtensible.xml"
299
+ if not efile.exists():
300
+ shutil.copy(_TPL_DIR / "commentsExtensible.xml", efile)
301
+ return efile
302
+
303
+
304
+ def _write_comment_body(
305
+ cxml: pathlib.Path,
306
+ comment_id: int,
307
+ text: str,
308
+ author: str,
309
+ initials: str,
310
+ ids: CommentIdentifiers,
311
+ ) -> None:
312
+ """Append the comment element to comments.xml."""
313
+ _inject_xml_fragment(
314
+ cxml,
315
+ "w:comments",
316
+ _COMMENT_BODY_XML.format(
317
+ cid=comment_id,
318
+ who=author,
319
+ when=ids.timestamp,
320
+ ini=initials,
321
+ pid=ids.para_id,
322
+ body=text,
323
+ ),
324
+ )
325
+
326
+
327
+ def _write_extended_entry(
328
+ ext: pathlib.Path,
329
+ ids: CommentIdentifiers,
330
+ parent_para_id: str | None,
331
+ ) -> None:
332
+ """Append the extended comment entry (with optional parent link)."""
333
+ if parent_para_id is not None:
334
+ fragment = (
335
+ f'<w15:commentEx w15:paraId="{ids.para_id}" '
336
+ f'w15:paraIdParent="{parent_para_id}" w15:done="0"/>'
337
+ )
338
+ else:
339
+ fragment = f'<w15:commentEx w15:paraId="{ids.para_id}" w15:done="0"/>'
340
+ _inject_xml_fragment(ext, "w15:commentsEx", fragment)
341
+
342
+
343
+ def _write_ids_entry(ids_file: pathlib.Path, ids: CommentIdentifiers) -> None:
344
+ """Append the comment ID mapping entry."""
345
+ fragment = (
346
+ f'<w16cid:commentId w16cid:paraId="{ids.para_id}" '
347
+ f'w16cid:durableId="{ids.durable_id}"/>'
348
+ )
349
+ _inject_xml_fragment(ids_file, "w16cid:commentsIds", fragment)
350
+
351
+
352
+ def _write_extensible_entry(efile: pathlib.Path, ids: CommentIdentifiers) -> None:
353
+ """Append the extensible comment entry with UTC timestamp."""
354
+ fragment = (
355
+ f'<w16cex:commentExtensible w16cex:durableId="{ids.durable_id}" '
356
+ f'w16cex:dateUtc="{ids.timestamp}"/>'
357
+ )
358
+ _inject_xml_fragment(efile, "w16cex:commentsExtensible", fragment)
359
+
360
+
361
+ # ── Public API ───────────────────────────────────────────────────────────────
362
+
363
+
364
+ def add_comment(
365
+ unpacked_dir: str,
366
+ comment_id: int,
367
+ text: str,
368
+ author: str = "Claude",
369
+ initials: str = "C",
370
+ parent_id: int | None = None,
371
+ ) -> tuple[str, str]:
372
+ """Register a new comment (or reply) across all required XML parts.
373
+
374
+ Returns:
375
+ A tuple of ``(para_id, message)`` describing the outcome.
376
+ """
377
+ root_dir = pathlib.Path(unpacked_dir)
378
+ word_dir = root_dir / "word"
379
+
380
+ if not word_dir.exists():
381
+ return "", f"Error: {word_dir} not found"
382
+
383
+ # Generate unique identifiers for this comment.
384
+ ids = CommentIdentifiers(
385
+ para_id=_rand_hex(),
386
+ durable_id=_rand_hex(),
387
+ timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
388
+ )
389
+
390
+ # 1. comments.xml — main comment body.
391
+ cxml = _ensure_comments_xml(word_dir, root_dir)
392
+ _write_comment_body(cxml, comment_id, text, author, initials, ids)
393
+
394
+ # 2. commentsExtended.xml — parent linkage and done state.
395
+ ext = _ensure_extended_xml(word_dir)
396
+ parent_para_id: str | None = None
397
+ if parent_id is not None:
398
+ parent_para_id = _locate_para_id(cxml, parent_id)
399
+ if parent_para_id is None:
400
+ return "", f"Error: Parent comment {parent_id} not found"
401
+ _write_extended_entry(ext, ids, parent_para_id)
402
+
403
+ # 3. commentsIds.xml — paraId ↔ durableId mapping.
404
+ ids_file = _ensure_ids_xml(word_dir)
405
+ _write_ids_entry(ids_file, ids)
406
+
407
+ # 4. commentsExtensible.xml — UTC timestamp metadata.
408
+ efile = _ensure_extensible_xml(word_dir)
409
+ _write_extensible_entry(efile, ids)
410
+
411
+ kind = "reply" if parent_id is not None else "comment"
412
+ return ids.para_id, f"Added {kind} {comment_id} (para_id={ids.para_id})"
413
+
414
+
415
+ # ── CLI entry point ──────────────────────────────────────────────────────────
416
+
417
+ if __name__ == "__main__":
418
+ p = argparse.ArgumentParser(description="Add comments to DOCX documents")
419
+ p.add_argument("unpacked_dir", help="Unpacked DOCX directory")
420
+ p.add_argument("comment_id", type=int, help="Comment ID (must be unique)")
421
+ p.add_argument("text", help="Comment text")
422
+ p.add_argument("--author", default="Claude", help="Author name")
423
+ p.add_argument("--initials", default="C", help="Author initials")
424
+ p.add_argument("--parent", type=int, help="Parent comment ID (for replies)")
425
+ args = p.parse_args()
426
+
427
+ para_id, msg = add_comment(
428
+ args.unpacked_dir,
429
+ args.comment_id,
430
+ args.text,
431
+ args.author,
432
+ args.initials,
433
+ args.parent,
434
+ )
435
+ print(msg)
436
+ if "Error" in msg:
437
+ sys.exit(1)
438
+ cid = args.comment_id
439
+ if args.parent is not None:
440
+ print(_REPLY_HINT.format(pid=args.parent, cid=cid))
441
+ else:
442
+ print(_STANDALONE_HINT.format(cid=cid))
@@ -0,0 +1 @@
1
+ # Helpers sub-package – intentionally empty.