sophhub 0.2.1 → 0.2.3

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 (189) hide show
  1. package/package.json +1 -1
  2. package/skills/compact-context/skill.json +20 -0
  3. package/skills/compact-context/src/SKILL.md +133 -0
  4. package/skills/compact-context/src/scripts/check.sh +381 -0
  5. package/skills/compact-context/src/scripts/set-keep-recent.mjs +1337 -0
  6. package/skills/compact-context/src/scripts/setup.sh +96 -0
  7. package/skills/feishu-notes-assistant-universal/skill.json +20 -0
  8. package/skills/feishu-notes-assistant-universal/src/README.md +55 -0
  9. package/skills/feishu-notes-assistant-universal/src/SKILL.md +159 -0
  10. package/skills/feishu-notes-assistant-universal/src/bin/linux-amd64/lark-cli-openclaw +0 -0
  11. package/skills/feishu-notes-assistant-universal/src/bin/linux-arm64/lark-cli-openclaw +0 -0
  12. package/skills/feishu-notes-assistant-universal/src/scripts/_resolve_lark_cli.py +58 -0
  13. package/skills/feishu-notes-assistant-universal/src/scripts/openclaw_meeting_minutes.py +462 -0
  14. package/skills/feishu-notes-assistant-universal/src/scripts/openclaw_notes_crud.py +547 -0
  15. package/skills/feishu-notes-assistant-universal/src/scripts/openclaw_notes_crud_test.py +181 -0
  16. package/skills/feishu-notes-assistant-universal/src/scripts/run_meeting_minutes.py +80 -0
  17. package/skills/feishu-notes-assistant-universal/src/scripts/run_meeting_minutes.sh +5 -0
  18. package/skills/feishu-notes-assistant-universal/src/scripts/run_note_crud.py +32 -0
  19. package/skills/feishu-notes-assistant-universal/src/scripts/run_note_crud.sh +5 -0
  20. package/skills/flight-booking/skill.json +9 -2
  21. package/skills/flight-booking/src/scripts/flight_booking.py +2 -1
  22. package/skills/image-classify/skill.json +5 -5
  23. package/skills/image-classify/src/SKILL.md +60 -67
  24. package/skills/image-classify/src/scripts/face_search.py +400 -15
  25. package/skills/image-classify/src/scripts/send_dm_message.py +332 -0
  26. package/skills/md2pdf-converter/skill.json +20 -0
  27. package/skills/md2pdf-converter/src/SKILL.md +244 -0
  28. package/skills/md2pdf-converter/src/_meta.json +6 -0
  29. package/skills/md2pdf-converter/src/scripts/generate_emoji_mapping.py +74 -0
  30. package/skills/md2pdf-converter/src/scripts/md2pdf-local.sh +291 -0
  31. package/skills/sophnet-bot-client/skill.json +20 -0
  32. package/skills/sophnet-bot-client/src/SKILL.md +255 -0
  33. package/skills/sophnet-bot-client/src/pyproject.toml +13 -0
  34. package/skills/sophnet-bot-client/src/scripts/__init__.py +0 -0
  35. package/skills/sophnet-bot-client/src/scripts/bot_client_proxy.py +165 -0
  36. package/skills/sophnet-bot-client/src/scripts/bot_client_safe.sh +29 -0
  37. package/skills/sophnet-bot-client/src/scripts/bot_client_setup.py +502 -0
  38. package/skills/sophnet-bot-client/src/tests/__init__.py +0 -0
  39. package/skills/sophnet-bot-client/src/tests/test_bot_client_proxy.py +255 -0
  40. package/skills/sophnet-bot-client/src/tests/test_bot_client_setup.py +679 -0
  41. package/skills/sophnet-bot-client/src/uv.lock +8 -0
  42. package/skills/sophnet-docx/skill.json +20 -0
  43. package/skills/sophnet-docx/src/SKILL.md +463 -0
  44. package/skills/sophnet-docx/src/package-lock.json +208 -0
  45. package/skills/sophnet-docx/src/package.json +16 -0
  46. package/skills/sophnet-docx/src/pyproject.toml +11 -0
  47. package/skills/sophnet-docx/src/scripts/__init__.py +1 -0
  48. package/skills/sophnet-docx/src/scripts/accept_changes.py +135 -0
  49. package/skills/sophnet-docx/src/scripts/comment.py +318 -0
  50. package/skills/sophnet-docx/src/scripts/ensure_uv_env.sh +68 -0
  51. package/skills/sophnet-docx/src/scripts/office/helpers/__init__.py +0 -0
  52. package/skills/sophnet-docx/src/scripts/office/helpers/merge_runs.py +199 -0
  53. package/skills/sophnet-docx/src/scripts/office/helpers/simplify_redlines.py +197 -0
  54. package/skills/sophnet-docx/src/scripts/office/pack.py +159 -0
  55. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -0
  56. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -0
  57. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -0
  58. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -0
  59. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -0
  60. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -0
  61. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -0
  62. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -0
  63. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -0
  64. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -0
  65. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -0
  66. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -0
  67. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -0
  68. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -0
  69. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -0
  70. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -0
  71. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -0
  72. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -0
  73. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -0
  74. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -0
  75. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -0
  76. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -0
  77. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -0
  78. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -0
  79. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -0
  80. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -0
  81. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -0
  82. package/skills/sophnet-docx/src/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd +42 -0
  83. package/skills/sophnet-docx/src/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd +50 -0
  84. package/skills/sophnet-docx/src/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd +49 -0
  85. package/skills/sophnet-docx/src/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd +33 -0
  86. package/skills/sophnet-docx/src/scripts/office/schemas/mce/mc.xsd +75 -0
  87. package/skills/sophnet-docx/src/scripts/office/schemas/microsoft/wml-2010.xsd +560 -0
  88. package/skills/sophnet-docx/src/scripts/office/schemas/microsoft/wml-2012.xsd +67 -0
  89. package/skills/sophnet-docx/src/scripts/office/schemas/microsoft/wml-2018.xsd +14 -0
  90. package/skills/sophnet-docx/src/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -0
  91. package/skills/sophnet-docx/src/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -0
  92. package/skills/sophnet-docx/src/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -0
  93. package/skills/sophnet-docx/src/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -0
  94. package/skills/sophnet-docx/src/scripts/office/soffice.py +183 -0
  95. package/skills/sophnet-docx/src/scripts/office/unpack.py +132 -0
  96. package/skills/sophnet-docx/src/scripts/office/validate.py +111 -0
  97. package/skills/sophnet-docx/src/scripts/office/validators/__init__.py +15 -0
  98. package/skills/sophnet-docx/src/scripts/office/validators/base.py +847 -0
  99. package/skills/sophnet-docx/src/scripts/office/validators/docx.py +446 -0
  100. package/skills/sophnet-docx/src/scripts/office/validators/pptx.py +275 -0
  101. package/skills/sophnet-docx/src/scripts/office/validators/redlining.py +247 -0
  102. package/skills/sophnet-docx/src/scripts/templates/comments.xml +3 -0
  103. package/skills/sophnet-docx/src/scripts/templates/commentsExtended.xml +3 -0
  104. package/skills/sophnet-docx/src/scripts/templates/commentsExtensible.xml +3 -0
  105. package/skills/sophnet-docx/src/scripts/templates/commentsIds.xml +3 -0
  106. package/skills/sophnet-docx/src/scripts/templates/people.xml +3 -0
  107. package/skills/sophnet-docx/src/scripts/upload_file.sh +96 -0
  108. package/skills/sophnet-docx/src/uv.lock +320 -0
  109. package/skills/sophnet-pdf/skill.json +20 -0
  110. package/skills/sophnet-pdf/src/SKILL.md +413 -0
  111. package/skills/sophnet-pdf/src/forms.md +297 -0
  112. package/skills/sophnet-pdf/src/pyproject.toml +14 -0
  113. package/skills/sophnet-pdf/src/reference.md +612 -0
  114. package/skills/sophnet-pdf/src/scripts/check_bounding_boxes.py +65 -0
  115. package/skills/sophnet-pdf/src/scripts/check_fillable_fields.py +11 -0
  116. package/skills/sophnet-pdf/src/scripts/convert_pdf_to_images.py +33 -0
  117. package/skills/sophnet-pdf/src/scripts/create_validation_image.py +37 -0
  118. package/skills/sophnet-pdf/src/scripts/enhance_tutorial.py +558 -0
  119. package/skills/sophnet-pdf/src/scripts/ensure_uv_env.sh +68 -0
  120. package/skills/sophnet-pdf/src/scripts/extract_form_field_info.py +122 -0
  121. package/skills/sophnet-pdf/src/scripts/extract_form_structure.py +115 -0
  122. package/skills/sophnet-pdf/src/scripts/extract_pdf_content.py +35 -0
  123. package/skills/sophnet-pdf/src/scripts/fill_fillable_fields.py +98 -0
  124. package/skills/sophnet-pdf/src/scripts/fill_pdf_form_with_annotations.py +107 -0
  125. package/skills/sophnet-pdf/src/scripts/upload_file.sh +88 -0
  126. package/skills/sophnet-pdf/src/uv.lock +537 -0
  127. package/skills/sophnet-xlsx/skill.json +20 -0
  128. package/skills/sophnet-xlsx/src/SKILL.md +399 -0
  129. package/skills/sophnet-xlsx/src/pyproject.toml +11 -0
  130. package/skills/sophnet-xlsx/src/scripts/ensure_uv_env.sh +68 -0
  131. package/skills/sophnet-xlsx/src/scripts/office/helpers/__init__.py +0 -0
  132. package/skills/sophnet-xlsx/src/scripts/office/helpers/merge_runs.py +199 -0
  133. package/skills/sophnet-xlsx/src/scripts/office/helpers/simplify_redlines.py +197 -0
  134. package/skills/sophnet-xlsx/src/scripts/office/pack.py +159 -0
  135. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -0
  136. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -0
  137. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -0
  138. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -0
  139. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -0
  140. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -0
  141. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -0
  142. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -0
  143. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -0
  144. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -0
  145. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -0
  146. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -0
  147. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -0
  148. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -0
  149. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -0
  150. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -0
  151. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -0
  152. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -0
  153. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -0
  154. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -0
  155. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -0
  156. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -0
  157. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -0
  158. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -0
  159. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -0
  160. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -0
  161. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -0
  162. package/skills/sophnet-xlsx/src/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd +42 -0
  163. package/skills/sophnet-xlsx/src/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd +50 -0
  164. package/skills/sophnet-xlsx/src/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd +49 -0
  165. package/skills/sophnet-xlsx/src/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd +33 -0
  166. package/skills/sophnet-xlsx/src/scripts/office/schemas/mce/mc.xsd +75 -0
  167. package/skills/sophnet-xlsx/src/scripts/office/schemas/microsoft/wml-2010.xsd +560 -0
  168. package/skills/sophnet-xlsx/src/scripts/office/schemas/microsoft/wml-2012.xsd +67 -0
  169. package/skills/sophnet-xlsx/src/scripts/office/schemas/microsoft/wml-2018.xsd +14 -0
  170. package/skills/sophnet-xlsx/src/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -0
  171. package/skills/sophnet-xlsx/src/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -0
  172. package/skills/sophnet-xlsx/src/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -0
  173. package/skills/sophnet-xlsx/src/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -0
  174. package/skills/sophnet-xlsx/src/scripts/office/soffice.py +183 -0
  175. package/skills/sophnet-xlsx/src/scripts/office/unpack.py +132 -0
  176. package/skills/sophnet-xlsx/src/scripts/office/validate.py +111 -0
  177. package/skills/sophnet-xlsx/src/scripts/office/validators/__init__.py +15 -0
  178. package/skills/sophnet-xlsx/src/scripts/office/validators/base.py +847 -0
  179. package/skills/sophnet-xlsx/src/scripts/office/validators/docx.py +446 -0
  180. package/skills/sophnet-xlsx/src/scripts/office/validators/pptx.py +275 -0
  181. package/skills/sophnet-xlsx/src/scripts/office/validators/redlining.py +247 -0
  182. package/skills/sophnet-xlsx/src/scripts/recalc.py +184 -0
  183. package/skills/sophnet-xlsx/src/scripts/upload_file.sh +96 -0
  184. package/skills/sophnet-xlsx/src/uv.lock +319 -0
  185. package/skills/wechat-article-publisher/skill.json +20 -0
  186. package/skills/wechat-article-publisher/src/SKILL.md +60 -0
  187. package/skills/wechat-article-publisher/src/config.json +7 -0
  188. package/skills/wechat-article-publisher/src/pyproject.toml +12 -0
  189. package/skills/wechat-article-publisher/src/scripts/publish_wechat.py +825 -0
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "sophnet-xlsx",
3
+ "version": "1.0.0",
4
+ "types": [
5
+ "store"
6
+ ],
7
+ "displayName": "Excel表格处理",
8
+ "description": "",
9
+ "changelog": [
10
+ {
11
+ "version": "1.0.0",
12
+ "date": "2026-04-14",
13
+ "changes": [
14
+ "初次提交"
15
+ ]
16
+ }
17
+ ],
18
+ "createdAt": "2026-04-14",
19
+ "updatedAt": "2026-04-14"
20
+ }
@@ -0,0 +1,399 @@
1
+ ---
2
+ name: sophnet-xlsx
3
+ description: Create, edit, and analyze spreadsheet files (.xlsx, .xlsm, .csv, .tsv). Use when the user wants to open, read, edit, or create spreadsheet files, clean messy tabular data, or convert between tabular formats. Trigger when the user references spreadsheet files by name or path.
4
+ ---
5
+
6
+ # Requirements for Outputs
7
+
8
+ ## MANDATORY: Working Directory
9
+
10
+ **EVERY command in this skill MUST be executed from THIS skill's directory.** Before running ANY command — Python or bash script — you MUST `cd` into this skill's directory first. Determine the absolute path of this `SKILL.md` file and use its parent directory.
11
+
12
+ ```bash
13
+ SKILL_DIR="<absolute-path-to-this-skills-sophnet-xlsx-directory>"
14
+ cd "$SKILL_DIR"
15
+ ```
16
+
17
+ **NEVER run commands from the repository root or any other directory.** If you do, `uv run` won't find `pyproject.toml`, and `python` won't have access to required packages.
18
+
19
+ ## MANDATORY: Cleanup — Zero Residual Files
20
+
21
+ **CRITICAL: After every task, `$SKILL_DIR` must contain ONLY these permanent files:** `SKILL.md`, `pyproject.toml`, `uv.lock`, `scripts/`, `.venv/`. **NOTHING else.** No `.py` scripts, no generated `.xlsx` files.
22
+
23
+ Two enforced rules (non-negotiable):
24
+
25
+ 1. **Python scripts MUST go to `/tmp/`.** Always save scripts as `/tmp/_tmp_xlsx.py` (this exact name), execute, then delete:
26
+ ```bash
27
+ cd "$SKILL_DIR" && uv run --project . python /tmp/_tmp_xlsx.py; rm -f /tmp/_tmp_xlsx.py
28
+ ```
29
+ 2. **NEVER create any file inside `$SKILL_DIR`.** All output files go to the user-specified path or `/tmp/`.
30
+
31
+ ## MANDATORY: Upload After Every Create/Edit
32
+
33
+ **CRITICAL: After creating or modifying an XLSX file, you MUST run `scripts/upload_file.sh --url-only` and include the URL in the final reply.** This is NOT optional. Do NOT return local file paths. You may include brief summary text, but the reply must contain the download URL.
34
+
35
+ ## Delivery
36
+
37
+ Local spreadsheet creation/editing does not require any Sophnet API key.
38
+
39
+ **IMPORTANT: After creating or modifying an XLSX, ALWAYS upload it and return the download URL.** This is the default behavior — do not skip the upload step.
40
+
41
+ **CRITICAL: All Python execution in this skill MUST use `uv run --project .` from the skill directory. NEVER use bare `python3`, `python`, or `pip install` directly — the required packages (openpyxl, lxml, etc.) are ONLY available inside the uv virtual environment defined by this skill's `pyproject.toml`. Direct `python3` will fail with ModuleNotFoundError.**
42
+
43
+ First, ensure the environment is set up (run once per session):
44
+
45
+ ```bash
46
+ cd "$SKILL_DIR"
47
+ bash scripts/ensure_uv_env.sh
48
+ ```
49
+
50
+ Then ALL Python commands must use this prefix:
51
+
52
+ ```bash
53
+ cd "$SKILL_DIR" && uv run --project . python <script-or-module>
54
+ ```
55
+
56
+ This applies to both the provided scripts AND any inline Python code you write. For inline code, use:
57
+
58
+ ```bash
59
+ cd "$SKILL_DIR" && uv run --project . python -c "import openpyxl; ..."
60
+ ```
61
+
62
+ ```bash
63
+ cd "$SKILL_DIR" && bash scripts/upload_file.sh --file <absolute-path-to-xlsx> --url-only
64
+ ```
65
+
66
+ **Note:** The `cd "$SKILL_DIR"` prefix is MANDATORY — `scripts/upload_file.sh` is relative to the skill directory. Without it, the command fails with `No such file or directory`.
67
+
68
+ Upload command output contract:
69
+
70
+ - `FILE_PATH=<absolute-path>`
71
+ - `UPLOAD_STATUS=uploaded|skipped`
72
+ - `DOWNLOAD_URL=<https://...>` (present only when uploaded)
73
+ - With `--url-only` and successful upload: output is exactly one line, the raw `https://...` URL
74
+
75
+ Delivery rules:
76
+
77
+ - **ALWAYS `cd "$SKILL_DIR"` first, then call `bash scripts/upload_file.sh --url-only` after producing an XLSX file.**
78
+ - Final response for create/edit MUST include:
79
+ - success: a valid `https://...` URL
80
+ - missing API key fallback: `FILE_PATH=<absolute-path>`
81
+ - Do not include local file paths in create/edit responses.
82
+ - Keep URL output logic independent inside `sophnet-xlsx/scripts`. Do not call other skills' upload scripts.
83
+
84
+ ## All Excel files
85
+
86
+ ### Professional Font
87
+
88
+ - Use a consistent, professional font (e.g., Arial, Times New Roman) for all deliverables unless otherwise instructed by the user
89
+
90
+ ### Zero Formula Errors
91
+
92
+ - Every Excel model MUST be delivered with ZERO formula errors (#REF!, #DIV/0!, #VALUE!, #N/A, #NAME?)
93
+
94
+ ### Preserve Existing Templates (when updating templates)
95
+
96
+ - Study and EXACTLY match existing format, style, and conventions when modifying files
97
+ - Never impose standardized formatting on files with established patterns
98
+ - Existing template conventions ALWAYS override these guidelines
99
+
100
+ ## Financial models
101
+
102
+ ### Color Coding Standards
103
+
104
+ Unless otherwise stated by the user or existing template
105
+
106
+ #### Industry-Standard Color Conventions
107
+
108
+ - **Blue text (RGB: 0,0,255)**: Hardcoded inputs, and numbers users will change for scenarios
109
+ - **Black text (RGB: 0,0,0)**: ALL formulas and calculations
110
+ - **Green text (RGB: 0,128,0)**: Links pulling from other worksheets within same workbook
111
+ - **Red text (RGB: 255,0,0)**: External links to other files
112
+ - **Yellow background (RGB: 255,255,0)**: Key assumptions needing attention or cells that need to be updated
113
+
114
+ ### Number Formatting Standards
115
+
116
+ #### Required Format Rules
117
+
118
+ - **Years**: Format as text strings (e.g., "2024" not "2,024")
119
+ - **Currency**: Use $#,##0 format; ALWAYS specify units in headers ("Revenue ($mm)")
120
+ - **Zeros**: Use number formatting to make all zeros "-", including percentages (e.g., "$#,##0;($#,##0);-")
121
+ - **Percentages**: Default to 0.0% format (one decimal)
122
+ - **Multiples**: Format as 0.0x for valuation multiples (EV/EBITDA, P/E)
123
+ - **Negative numbers**: Use parentheses (123) not minus -123
124
+
125
+ ### Formula Construction Rules
126
+
127
+ #### Assumptions Placement
128
+
129
+ - Place ALL assumptions (growth rates, margins, multiples, etc.) in separate assumption cells
130
+ - Use cell references instead of hardcoded values in formulas
131
+ - Example: Use =B5*(1+$B$6) instead of =B5*1.05
132
+
133
+ #### Formula Error Prevention
134
+
135
+ - Verify all cell references are correct
136
+ - Check for off-by-one errors in ranges
137
+ - Ensure consistent formulas across all projection periods
138
+ - Test with edge cases (zero values, negative numbers)
139
+ - Verify no unintended circular references
140
+
141
+ #### Documentation Requirements for Hardcodes
142
+
143
+ - Comment or in cells beside (if end of table). Format: "Source: [System/Document], [Date], [Specific Reference], [URL if applicable]"
144
+ - Examples:
145
+ - "Source: Company 10-K, FY2024, Page 45, Revenue Note, [SEC EDGAR URL]"
146
+ - "Source: Company 10-Q, Q2 2025, Exhibit 99.1, [SEC EDGAR URL]"
147
+ - "Source: Bloomberg Terminal, 8/15/2025, AAPL US Equity"
148
+ - "Source: FactSet, 8/20/2025, Consensus Estimates Screen"
149
+
150
+ # XLSX creation, editing, and analysis
151
+
152
+ ## Overview
153
+
154
+ A user may ask you to create, edit, or analyze the contents of an .xlsx file. You have different tools and workflows available for different tasks.
155
+
156
+ ## Important Requirements
157
+
158
+ **LibreOffice for Formula Recalculation (optional)**: If LibreOffice (`soffice`) is installed, use `scripts/recalc.py` to recalculate formula values. If `soffice` is NOT available, **skip the recalculation step** — the formulas are still saved in the file and will calculate when opened in Excel/Google Sheets. Check first: `which soffice && echo "available" || echo "NOT available — skip recalc"`
159
+
160
+ ## Reading and analyzing data
161
+
162
+ ### Data analysis with pandas
163
+
164
+ For data analysis, visualization, and basic operations, use **pandas** which provides powerful data manipulation capabilities:
165
+
166
+ ```python
167
+ import pandas as pd
168
+
169
+ # Read Excel
170
+ df = pd.read_excel('file.xlsx') # Default: first sheet
171
+ all_sheets = pd.read_excel('file.xlsx', sheet_name=None) # All sheets as dict
172
+
173
+ # Analyze
174
+ df.head() # Preview data
175
+ df.info() # Column info
176
+ df.describe() # Statistics
177
+
178
+ # Write Excel
179
+ df.to_excel('output.xlsx', index=False)
180
+ ```
181
+
182
+ ## Excel File Workflows
183
+
184
+ ## CRITICAL: Use Formulas, Not Hardcoded Values
185
+
186
+ **Always use Excel formulas instead of calculating values in Python and hardcoding them.** This ensures the spreadsheet remains dynamic and updateable.
187
+
188
+ ### ❌ WRONG - Hardcoding Calculated Values
189
+
190
+ ```python
191
+ # Bad: Calculating in Python and hardcoding result
192
+ total = df['Sales'].sum()
193
+ sheet['B10'] = total # Hardcodes 5000
194
+
195
+ # Bad: Computing growth rate in Python
196
+ growth = (df.iloc[-1]['Revenue'] - df.iloc[0]['Revenue']) / df.iloc[0]['Revenue']
197
+ sheet['C5'] = growth # Hardcodes 0.15
198
+
199
+ # Bad: Python calculation for average
200
+ avg = sum(values) / len(values)
201
+ sheet['D20'] = avg # Hardcodes 42.5
202
+ ```
203
+
204
+ ### ✅ CORRECT - Using Excel Formulas
205
+
206
+ ```python
207
+ # Good: Let Excel calculate the sum
208
+ sheet['B10'] = '=SUM(B2:B9)'
209
+
210
+ # Good: Growth rate as Excel formula
211
+ sheet['C5'] = '=(C4-C2)/C2'
212
+
213
+ # Good: Average using Excel function
214
+ sheet['D20'] = '=AVERAGE(D2:D19)'
215
+ ```
216
+
217
+ This applies to ALL calculations - totals, percentages, ratios, differences, etc. The spreadsheet should be able to recalculate when source data changes.
218
+
219
+ ## Common Workflow
220
+
221
+ 1. **Choose tool**: pandas for data, openpyxl for formulas/formatting
222
+ 2. **Create/Load**: Create new workbook or load existing file
223
+ 3. **Modify**: Add/edit data, formulas, and formatting
224
+ 4. **Save**: Write to file
225
+ 5. **Recalculate formulas (MANDATORY IF USING FORMULAS)**: Use the scripts/recalc.py script
226
+ ```bash
227
+ uv run --project . python scripts/recalc.py output.xlsx
228
+ ```
229
+ 6. **Verify and fix any errors**:
230
+ - The script returns JSON with error details
231
+ - If `status` is `errors_found`, check `error_summary` for specific error types and locations
232
+ - Fix the identified errors and recalculate again
233
+ - Common errors to fix:
234
+ - `#REF!`: Invalid cell references
235
+ - `#DIV/0!`: Division by zero
236
+ - `#VALUE!`: Wrong data type in formula
237
+ - `#NAME?`: Unrecognized formula name
238
+ 7. **Upload and deliver URL** (ALWAYS do this):
239
+ - Run `cd "$SKILL_DIR" && bash scripts/upload_file.sh --file <absolute-path-to-xlsx> --url-only`
240
+ - Final response must include URL:
241
+ - success: include a raw `https://...` URL
242
+ - fallback: `FILE_PATH=<absolute-path>`
243
+
244
+ ### Creating new Excel files
245
+
246
+ ```python
247
+ # Using openpyxl for formulas and formatting
248
+ from openpyxl import Workbook
249
+ from openpyxl.styles import Font, PatternFill, Alignment
250
+
251
+ wb = Workbook()
252
+ sheet = wb.active
253
+
254
+ # Add data
255
+ sheet['A1'] = 'Hello'
256
+ sheet['B1'] = 'World'
257
+ sheet.append(['Row', 'of', 'data'])
258
+
259
+ # Add formula
260
+ sheet['B2'] = '=SUM(A1:A10)'
261
+
262
+ # Formatting
263
+ sheet['A1'].font = Font(bold=True, color='FF0000')
264
+ sheet['A1'].fill = PatternFill('solid', start_color='FFFF00')
265
+ sheet['A1'].alignment = Alignment(horizontal='center')
266
+
267
+ # Column width
268
+ sheet.column_dimensions['A'].width = 20
269
+
270
+ wb.save('output.xlsx')
271
+ ```
272
+
273
+ ### Editing existing Excel files
274
+
275
+ ```python
276
+ # Using openpyxl to preserve formulas and formatting
277
+ from openpyxl import load_workbook
278
+
279
+ # Load existing file
280
+ wb = load_workbook('existing.xlsx')
281
+ sheet = wb.active # or wb['SheetName'] for specific sheet
282
+
283
+ # Working with multiple sheets
284
+ for sheet_name in wb.sheetnames:
285
+ sheet = wb[sheet_name]
286
+ print(f"Sheet: {sheet_name}")
287
+
288
+ # Modify cells
289
+ sheet['A1'] = 'New Value'
290
+ sheet.insert_rows(2) # Insert row at position 2
291
+ sheet.delete_cols(3) # Delete column 3
292
+
293
+ # Add new sheet
294
+ new_sheet = wb.create_sheet('NewSheet')
295
+ new_sheet['A1'] = 'Data'
296
+
297
+ wb.save('modified.xlsx')
298
+ ```
299
+
300
+ ## Recalculating formulas
301
+
302
+ Excel files created or modified by openpyxl contain formulas as strings but not calculated values. Use the provided `scripts/recalc.py` script to recalculate formulas:
303
+
304
+ ```bash
305
+ uv run --project . python scripts/recalc.py <excel_file> [timeout_seconds]
306
+ ```
307
+
308
+ Example:
309
+
310
+ ```bash
311
+ uv run --project . python scripts/recalc.py output.xlsx 30
312
+ ```
313
+
314
+ The script:
315
+
316
+ - Automatically sets up LibreOffice macro on first run
317
+ - Recalculates all formulas in all sheets
318
+ - Scans ALL cells for Excel errors (#REF!, #DIV/0!, etc.)
319
+ - Returns JSON with detailed error locations and counts
320
+ - Works on both Linux and macOS
321
+
322
+ ## Formula Verification Checklist
323
+
324
+ Quick checks to ensure formulas work correctly:
325
+
326
+ ### Essential Verification
327
+
328
+ - [ ] **Test 2-3 sample references**: Verify they pull correct values before building full model
329
+ - [ ] **Column mapping**: Confirm Excel columns match (e.g., column 64 = BL, not BK)
330
+ - [ ] **Row offset**: Remember Excel rows are 1-indexed (DataFrame row 5 = Excel row 6)
331
+
332
+ ### Common Pitfalls
333
+
334
+ - [ ] **NaN handling**: Check for null values with `pd.notna()`
335
+ - [ ] **Far-right columns**: FY data often in columns 50+
336
+ - [ ] **Multiple matches**: Search all occurrences, not just first
337
+ - [ ] **Division by zero**: Check denominators before using `/` in formulas (#DIV/0!)
338
+ - [ ] **Wrong references**: Verify all cell references point to intended cells (#REF!)
339
+ - [ ] **Cross-sheet references**: Use correct format (Sheet1!A1) for linking sheets
340
+
341
+ ### Formula Testing Strategy
342
+
343
+ - [ ] **Start small**: Test formulas on 2-3 cells before applying broadly
344
+ - [ ] **Verify dependencies**: Check all cells referenced in formulas exist
345
+ - [ ] **Test edge cases**: Include zero, negative, and very large values
346
+
347
+ ### Interpreting scripts/recalc.py Output
348
+
349
+ The script returns JSON with error details:
350
+
351
+ ```json
352
+ {
353
+ "status": "success", // or "errors_found"
354
+ "total_errors": 0, // Total error count
355
+ "total_formulas": 42, // Number of formulas in file
356
+ "error_summary": {
357
+ // Only present if errors found
358
+ "#REF!": {
359
+ "count": 2,
360
+ "locations": ["Sheet1!B5", "Sheet1!C10"]
361
+ }
362
+ }
363
+ }
364
+ ```
365
+
366
+ ## Best Practices
367
+
368
+ ### Library Selection
369
+
370
+ - **pandas**: Best for data analysis, bulk operations, and simple data export
371
+ - **openpyxl**: Best for complex formatting, formulas, and Excel-specific features
372
+
373
+ ### Working with openpyxl
374
+
375
+ - Cell indices are 1-based (row=1, column=1 refers to cell A1)
376
+ - Use `data_only=True` to read calculated values: `load_workbook('file.xlsx', data_only=True)`
377
+ - **Warning**: If opened with `data_only=True` and saved, formulas are replaced with values and permanently lost
378
+ - For large files: Use `read_only=True` for reading or `write_only=True` for writing
379
+ - Formulas are preserved but not evaluated - use scripts/recalc.py to update values
380
+
381
+ ### Working with pandas
382
+
383
+ - Specify data types to avoid inference issues: `pd.read_excel('file.xlsx', dtype={'id': str})`
384
+ - For large files, read specific columns: `pd.read_excel('file.xlsx', usecols=['A', 'C', 'E'])`
385
+ - Handle dates properly: `pd.read_excel('file.xlsx', parse_dates=['date_column'])`
386
+
387
+ ## Code Style Guidelines
388
+
389
+ **IMPORTANT**: When generating Python code for Excel operations:
390
+
391
+ - Write minimal, concise Python code without unnecessary comments
392
+ - Avoid verbose variable names and redundant operations
393
+ - Avoid unnecessary print statements
394
+
395
+ **For Excel files themselves**:
396
+
397
+ - Add comments to cells with complex formulas or important assumptions
398
+ - Document data sources for hardcoded values
399
+ - Include notes for key calculations and model sections
@@ -0,0 +1,11 @@
1
+ [project]
2
+ name = "sophnet-xlsx"
3
+ version = "0.1.0"
4
+ description = "Skill-local runtime dependencies for sophnet-xlsx"
5
+ requires-python = ">=3.10"
6
+ dependencies = [
7
+ "defusedxml>=0.7.1",
8
+ "lxml>=5.2.0",
9
+ "openpyxl>=3.1.0",
10
+ "sophnet-tools>=0.0.1",
11
+ ]
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
6
+ VENV_PYTHON="$SKILL_DIR/.venv/bin/python"
7
+ QUIET=false
8
+ FORCE_SYNC=false
9
+
10
+ usage() {
11
+ cat <<'USAGE'
12
+ Usage:
13
+ bash ensure_uv_env.sh [options]
14
+
15
+ Options:
16
+ --quiet Reduce non-error output.
17
+ --force-sync Force uv sync even when .venv already exists.
18
+ USAGE
19
+ }
20
+
21
+ log() {
22
+ if [[ "$QUIET" != true ]]; then
23
+ echo "$1" >&2
24
+ fi
25
+ }
26
+
27
+ while [[ $# -gt 0 ]]; do
28
+ case "$1" in
29
+ --quiet)
30
+ QUIET=true
31
+ shift
32
+ ;;
33
+ --force-sync)
34
+ FORCE_SYNC=true
35
+ shift
36
+ ;;
37
+ -h|--help)
38
+ usage
39
+ exit 0
40
+ ;;
41
+ *)
42
+ echo "Unknown argument: $1" >&2
43
+ usage
44
+ exit 1
45
+ ;;
46
+ esac
47
+ done
48
+
49
+ if ! command -v uv >/dev/null 2>&1; then
50
+ echo "Error: uv is required but not found." >&2
51
+ echo "Install uv first: https://docs.astral.sh/uv/getting-started/installation/" >&2
52
+ exit 1
53
+ fi
54
+
55
+ need_sync=false
56
+
57
+ if [[ "$FORCE_SYNC" == true || ! -x "$VENV_PYTHON" ]]; then
58
+ need_sync=true
59
+ elif ! "$VENV_PYTHON" -c 'import defusedxml.minidom, lxml.etree, openpyxl' >/dev/null 2>&1; then
60
+ need_sync=true
61
+ fi
62
+
63
+ if [[ "$need_sync" == true ]]; then
64
+ log "Syncing uv environment at $SKILL_DIR/.venv"
65
+ uv sync --project "$SKILL_DIR" --no-dev
66
+ else
67
+ log "uv environment already ready: $SKILL_DIR/.venv"
68
+ fi
@@ -0,0 +1,199 @@
1
+ """Merge adjacent runs with identical formatting in DOCX.
2
+
3
+ Merges adjacent <w:r> elements that have identical <w:rPr> properties.
4
+ Works on runs in paragraphs and inside tracked changes (<w:ins>, <w:del>).
5
+
6
+ Also:
7
+ - Removes rsid attributes from runs (revision metadata that doesn't affect rendering)
8
+ - Removes proofErr elements (spell/grammar markers that block merging)
9
+ """
10
+
11
+ from pathlib import Path
12
+
13
+ import defusedxml.minidom
14
+
15
+
16
+ def merge_runs(input_dir: str) -> tuple[int, str]:
17
+ doc_xml = Path(input_dir) / "word" / "document.xml"
18
+
19
+ if not doc_xml.exists():
20
+ return 0, f"Error: {doc_xml} not found"
21
+
22
+ try:
23
+ dom = defusedxml.minidom.parseString(doc_xml.read_text(encoding="utf-8"))
24
+ root = dom.documentElement
25
+
26
+ _remove_elements(root, "proofErr")
27
+ _strip_run_rsid_attrs(root)
28
+
29
+ containers = {run.parentNode for run in _find_elements(root, "r")}
30
+
31
+ merge_count = 0
32
+ for container in containers:
33
+ merge_count += _merge_runs_in(container)
34
+
35
+ doc_xml.write_bytes(dom.toxml(encoding="UTF-8"))
36
+ return merge_count, f"Merged {merge_count} runs"
37
+
38
+ except Exception as e:
39
+ return 0, f"Error: {e}"
40
+
41
+
42
+
43
+
44
+ def _find_elements(root, tag: str) -> list:
45
+ results = []
46
+
47
+ def traverse(node):
48
+ if node.nodeType == node.ELEMENT_NODE:
49
+ name = node.localName or node.tagName
50
+ if name == tag or name.endswith(f":{tag}"):
51
+ results.append(node)
52
+ for child in node.childNodes:
53
+ traverse(child)
54
+
55
+ traverse(root)
56
+ return results
57
+
58
+
59
+ def _get_child(parent, tag: str):
60
+ for child in parent.childNodes:
61
+ if child.nodeType == child.ELEMENT_NODE:
62
+ name = child.localName or child.tagName
63
+ if name == tag or name.endswith(f":{tag}"):
64
+ return child
65
+ return None
66
+
67
+
68
+ def _get_children(parent, tag: str) -> list:
69
+ results = []
70
+ for child in parent.childNodes:
71
+ if child.nodeType == child.ELEMENT_NODE:
72
+ name = child.localName or child.tagName
73
+ if name == tag or name.endswith(f":{tag}"):
74
+ results.append(child)
75
+ return results
76
+
77
+
78
+ def _is_adjacent(elem1, elem2) -> bool:
79
+ node = elem1.nextSibling
80
+ while node:
81
+ if node == elem2:
82
+ return True
83
+ if node.nodeType == node.ELEMENT_NODE:
84
+ return False
85
+ if node.nodeType == node.TEXT_NODE and node.data.strip():
86
+ return False
87
+ node = node.nextSibling
88
+ return False
89
+
90
+
91
+
92
+
93
+ def _remove_elements(root, tag: str):
94
+ for elem in _find_elements(root, tag):
95
+ if elem.parentNode:
96
+ elem.parentNode.removeChild(elem)
97
+
98
+
99
+ def _strip_run_rsid_attrs(root):
100
+ for run in _find_elements(root, "r"):
101
+ for attr in list(run.attributes.values()):
102
+ if "rsid" in attr.name.lower():
103
+ run.removeAttribute(attr.name)
104
+
105
+
106
+
107
+
108
+ def _merge_runs_in(container) -> int:
109
+ merge_count = 0
110
+ run = _first_child_run(container)
111
+
112
+ while run:
113
+ while True:
114
+ next_elem = _next_element_sibling(run)
115
+ if next_elem and _is_run(next_elem) and _can_merge(run, next_elem):
116
+ _merge_run_content(run, next_elem)
117
+ container.removeChild(next_elem)
118
+ merge_count += 1
119
+ else:
120
+ break
121
+
122
+ _consolidate_text(run)
123
+ run = _next_sibling_run(run)
124
+
125
+ return merge_count
126
+
127
+
128
+ def _first_child_run(container):
129
+ for child in container.childNodes:
130
+ if child.nodeType == child.ELEMENT_NODE and _is_run(child):
131
+ return child
132
+ return None
133
+
134
+
135
+ def _next_element_sibling(node):
136
+ sibling = node.nextSibling
137
+ while sibling:
138
+ if sibling.nodeType == sibling.ELEMENT_NODE:
139
+ return sibling
140
+ sibling = sibling.nextSibling
141
+ return None
142
+
143
+
144
+ def _next_sibling_run(node):
145
+ sibling = node.nextSibling
146
+ while sibling:
147
+ if sibling.nodeType == sibling.ELEMENT_NODE:
148
+ if _is_run(sibling):
149
+ return sibling
150
+ sibling = sibling.nextSibling
151
+ return None
152
+
153
+
154
+ def _is_run(node) -> bool:
155
+ name = node.localName or node.tagName
156
+ return name == "r" or name.endswith(":r")
157
+
158
+
159
+ def _can_merge(run1, run2) -> bool:
160
+ rpr1 = _get_child(run1, "rPr")
161
+ rpr2 = _get_child(run2, "rPr")
162
+
163
+ if (rpr1 is None) != (rpr2 is None):
164
+ return False
165
+ if rpr1 is None:
166
+ return True
167
+ return rpr1.toxml() == rpr2.toxml()
168
+
169
+
170
+ def _merge_run_content(target, source):
171
+ for child in list(source.childNodes):
172
+ if child.nodeType == child.ELEMENT_NODE:
173
+ name = child.localName or child.tagName
174
+ if name != "rPr" and not name.endswith(":rPr"):
175
+ target.appendChild(child)
176
+
177
+
178
+ def _consolidate_text(run):
179
+ t_elements = _get_children(run, "t")
180
+
181
+ for i in range(len(t_elements) - 1, 0, -1):
182
+ curr, prev = t_elements[i], t_elements[i - 1]
183
+
184
+ if _is_adjacent(prev, curr):
185
+ prev_text = prev.firstChild.data if prev.firstChild else ""
186
+ curr_text = curr.firstChild.data if curr.firstChild else ""
187
+ merged = prev_text + curr_text
188
+
189
+ if prev.firstChild:
190
+ prev.firstChild.data = merged
191
+ else:
192
+ prev.appendChild(run.ownerDocument.createTextNode(merged))
193
+
194
+ if merged.startswith(" ") or merged.endswith(" "):
195
+ prev.setAttribute("xml:space", "preserve")
196
+ elif prev.hasAttribute("xml:space"):
197
+ prev.removeAttribute("xml:space")
198
+
199
+ run.removeChild(curr)