@heylemon/lemonade 0.0.4 → 0.0.6

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 (106) hide show
  1. package/dist/build-info.json +3 -3
  2. package/dist/canvas-host/a2ui/.bundle.hash +1 -1
  3. package/dist/gateway/skills-http.js +74 -19
  4. package/package.json +1 -1
  5. package/skills/docx/SKILL.md +25 -30
  6. package/skills/docx/scripts/accept_changes.py +0 -17
  7. package/skills/docx/scripts/comment.py +10 -39
  8. package/skills/docx/scripts/office/helpers/merge_runs.py +1 -33
  9. package/skills/docx/scripts/office/helpers/simplify_redlines.py +0 -43
  10. package/skills/docx/scripts/office/pack.py +0 -30
  11. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -1499
  12. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -1085
  13. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -3081
  14. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -287
  15. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -1676
  16. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -174
  17. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -582
  18. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -4439
  19. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -570
  20. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -116
  21. package/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd +42 -42
  22. package/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd +50 -50
  23. package/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd +49 -49
  24. package/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd +33 -33
  25. package/skills/docx/scripts/office/soffice.py +0 -55
  26. package/skills/docx/scripts/office/unpack.py +5 -27
  27. package/skills/docx/scripts/office/validate.py +19 -14
  28. package/skills/docx/scripts/office/validators/base.py +48 -224
  29. package/skills/docx/scripts/office/validators/docx.py +44 -117
  30. package/skills/docx/scripts/office/validators/pptx.py +2 -42
  31. package/skills/docx/scripts/office/validators/redlining.py +3 -40
  32. package/skills/pdf/SKILL.md +22 -15
  33. package/skills/pdf/{FORMS.md → forms.md} +0 -14
  34. package/skills/pdf/scripts/check_bounding_boxes.py +0 -5
  35. package/skills/pdf/scripts/check_fillable_fields.py +0 -1
  36. package/skills/pdf/scripts/convert_pdf_to_images.py +0 -2
  37. package/skills/pdf/scripts/create_validation_image.py +0 -4
  38. package/skills/pdf/scripts/extract_form_field_info.py +1 -31
  39. package/skills/pdf/scripts/extract_form_structure.py +0 -9
  40. package/skills/pdf/scripts/fill_fillable_fields.py +0 -23
  41. package/skills/pdf/scripts/fill_pdf_form_with_annotations.py +3 -38
  42. package/skills/pptx/SKILL.md +2 -29
  43. package/skills/pptx/editing.md +2 -2
  44. package/skills/pptx/pptxgenjs.md +53 -8
  45. package/skills/pptx/scripts/add_slide.py +0 -30
  46. package/skills/pptx/scripts/clean.py +0 -23
  47. package/skills/pptx/scripts/office/helpers/merge_runs.py +1 -33
  48. package/skills/pptx/scripts/office/helpers/simplify_redlines.py +0 -43
  49. package/skills/pptx/scripts/office/pack.py +0 -30
  50. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -1499
  51. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -1085
  52. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -3081
  53. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -287
  54. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -1676
  55. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -174
  56. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -582
  57. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -4439
  58. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -570
  59. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -116
  60. package/skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd +42 -42
  61. package/skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd +50 -50
  62. package/skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd +49 -49
  63. package/skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd +33 -33
  64. package/skills/pptx/scripts/office/soffice.py +0 -55
  65. package/skills/pptx/scripts/office/unpack.py +5 -27
  66. package/skills/pptx/scripts/office/validate.py +19 -14
  67. package/skills/pptx/scripts/office/validators/base.py +48 -224
  68. package/skills/pptx/scripts/office/validators/docx.py +44 -117
  69. package/skills/pptx/scripts/office/validators/pptx.py +2 -42
  70. package/skills/pptx/scripts/office/validators/redlining.py +3 -40
  71. package/skills/pptx/scripts/thumbnail.py +0 -31
  72. package/skills/xlsx/SKILL.md +3 -26
  73. package/skills/xlsx/scripts/office/helpers/merge_runs.py +1 -33
  74. package/skills/xlsx/scripts/office/helpers/simplify_redlines.py +0 -43
  75. package/skills/xlsx/scripts/office/pack.py +0 -30
  76. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -1499
  77. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -1085
  78. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -3081
  79. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -287
  80. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -1676
  81. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -174
  82. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -582
  83. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -4439
  84. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -570
  85. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -116
  86. package/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd +42 -42
  87. package/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd +50 -50
  88. package/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd +49 -49
  89. package/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd +33 -33
  90. package/skills/xlsx/scripts/office/soffice.py +0 -55
  91. package/skills/xlsx/scripts/office/unpack.py +5 -27
  92. package/skills/xlsx/scripts/office/validate.py +19 -14
  93. package/skills/xlsx/scripts/office/validators/base.py +48 -224
  94. package/skills/xlsx/scripts/office/validators/docx.py +44 -117
  95. package/skills/xlsx/scripts/office/validators/pptx.py +2 -42
  96. package/skills/xlsx/scripts/office/validators/redlining.py +3 -40
  97. package/skills/xlsx/scripts/recalc.py +2 -26
  98. package/skills/docx/scripts/__init__.py +0 -1
  99. package/skills/docx/scripts/office/helpers/__init__.py +0 -0
  100. package/skills/docx/scripts/office/validators/__init__.py +0 -15
  101. package/skills/pptx/scripts/__init__.py +0 -0
  102. package/skills/pptx/scripts/office/helpers/__init__.py +0 -0
  103. package/skills/pptx/scripts/office/validators/__init__.py +0 -15
  104. package/skills/xlsx/scripts/office/helpers/__init__.py +0 -0
  105. package/skills/xlsx/scripts/office/validators/__init__.py +0 -15
  106. /package/skills/pdf/{REFERENCE.md → reference.md} +0 -0
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.0.4",
3
- "commit": "a26b38261e6a55844b0eed17963361b8374e9742",
4
- "builtAt": "2026-02-20T05:21:43.349Z"
2
+ "version": "0.0.6",
3
+ "commit": "726ef3eb29cf96569eaa0380126b995c82e43cf3",
4
+ "builtAt": "2026-02-20T05:47:58.066Z"
5
5
  }
@@ -1 +1 @@
1
- d27c866a9f91a1bf3b7a29bef001583ee57f61540140511ed6840c4b5dfcd5eb
1
+ 51eb12e5a38916af8a01151207c0974086f45b5a7c962e824bb8ebd5fe8887d1
@@ -92,6 +92,25 @@ export async function handleSkillsHttpRequest(req, res, opts) {
92
92
  }
93
93
  // ─── Helpers ───────────────────────────────────────────────────────────────────
94
94
  const MANAGED_SKILLS_DIR = path.join(CONFIG_DIR, "skills");
95
+ function getBuiltInSkillNames() {
96
+ const bundledDir = resolveBundledSkillsDir();
97
+ if (!bundledDir || !fs.existsSync(bundledDir))
98
+ return new Set();
99
+ const loadSkills = (params) => {
100
+ const loaded = loadSkillsFromDir(params);
101
+ if (Array.isArray(loaded))
102
+ return loaded;
103
+ if (loaded &&
104
+ typeof loaded === "object" &&
105
+ "skills" in loaded &&
106
+ Array.isArray(loaded.skills)) {
107
+ return loaded.skills;
108
+ }
109
+ return [];
110
+ };
111
+ const skills = loadSkills({ dir: bundledDir, source: "lemonade-bundled" });
112
+ return new Set(skills.map((s) => s.name));
113
+ }
95
114
  function loadAllSkillEntries() {
96
115
  const config = loadConfig();
97
116
  const loadSkills = (params) => {
@@ -175,43 +194,55 @@ async function handleCreateSkill(req, res) {
175
194
  sendInvalidRequest(res, "name is invalid after sanitization");
176
195
  return true;
177
196
  }
178
- const skillDir = path.join(MANAGED_SKILLS_DIR, safeName);
179
- const skillFile = path.join(skillDir, "SKILL.md");
180
- // Check if already exists
181
- if (fs.existsSync(skillFile)) {
197
+ const builtInNames = getBuiltInSkillNames();
198
+ let finalName = safeName;
199
+ if (builtInNames.has(safeName)) {
200
+ finalName = `${safeName}-custom`;
201
+ }
202
+ const customSkillDir = path.join(MANAGED_SKILLS_DIR, finalName);
203
+ const customSkillFile = path.join(customSkillDir, "SKILL.md");
204
+ if (fs.existsSync(customSkillFile)) {
182
205
  sendJson(res, 409, {
183
- error: { message: `Skill '${safeName}' already exists`, type: "conflict" },
206
+ error: {
207
+ message: `You already have a custom skill '${finalName}'. You can replace its content or rename your new skill.`,
208
+ type: "conflict",
209
+ },
184
210
  });
185
211
  return true;
186
212
  }
187
213
  // Build SKILL.md
188
214
  let fileContent;
189
215
  if (content.startsWith("---")) {
190
- // Content already has frontmatter
191
216
  fileContent = content;
192
217
  }
193
218
  else {
194
- fileContent = `---\nname: ${safeName}\ndescription: "${description || "Custom skill"}"\n---\n\n${content}`;
219
+ fileContent = `---\nname: ${finalName}\ndescription: "${description || "Custom skill"}"\n---\n\n${content}`;
195
220
  }
196
- await fsp.mkdir(skillDir, { recursive: true });
197
- await fsp.writeFile(skillFile, fileContent, "utf-8");
198
- // Enable in config
221
+ await fsp.mkdir(customSkillDir, { recursive: true });
222
+ await fsp.writeFile(customSkillFile, fileContent, "utf-8");
199
223
  const config = loadConfig();
200
224
  if (!config.skills)
201
225
  config.skills = {};
202
226
  if (!config.skills.entries)
203
227
  config.skills.entries = {};
204
- config.skills.entries[safeName] = { enabled: true };
228
+ // If this shadows a built-in, disable the built-in and enable the custom one
229
+ if (finalName !== safeName && builtInNames.has(safeName)) {
230
+ config.skills.entries[safeName] = { ...config.skills.entries[safeName], enabled: false };
231
+ }
232
+ config.skills.entries[finalName] = { enabled: true };
205
233
  await writeConfigFile(config);
206
234
  sendJson(res, 201, {
207
235
  ok: true,
208
236
  skill: {
209
- name: safeName,
237
+ name: finalName,
210
238
  description: description || "Custom skill",
211
- path: skillFile,
239
+ path: customSkillFile,
212
240
  isBuiltIn: false,
213
241
  enabled: true,
214
242
  },
243
+ ...(finalName !== safeName
244
+ ? { note: `Renamed to '${finalName}' because '${safeName}' is a built-in skill. The built-in version has been deactivated.` }
245
+ : {}),
215
246
  });
216
247
  return true;
217
248
  }
@@ -219,9 +250,9 @@ async function handleUpdateSkill(req, res, skillName) {
219
250
  const body = (await readJsonBodyOrError(req, res, 1_000_000));
220
251
  if (!body)
221
252
  return true;
253
+ const builtInNames = getBuiltInSkillNames();
222
254
  const config = loadConfig();
223
255
  const updated = {};
224
- // Update enabled state in config
225
256
  if (typeof body.enabled === "boolean") {
226
257
  if (!config.skills)
227
258
  config.skills = {};
@@ -234,12 +265,19 @@ async function handleUpdateSkill(req, res, skillName) {
234
265
  await writeConfigFile(config);
235
266
  updated.enabled = body.enabled;
236
267
  }
237
- // Update content on disk (only for user/managed skills)
238
268
  if (typeof body.content === "string") {
269
+ if (builtInNames.has(skillName)) {
270
+ sendJson(res, 403, {
271
+ error: {
272
+ message: `'${skillName}' is a built-in skill and its content cannot be modified. You can only enable or disable it.`,
273
+ type: "forbidden",
274
+ },
275
+ });
276
+ return true;
277
+ }
239
278
  const skillFile = path.join(MANAGED_SKILLS_DIR, skillName, "SKILL.md");
240
279
  if (fs.existsSync(skillFile)) {
241
280
  let newContent = body.content.trim();
242
- // Ensure frontmatter exists
243
281
  if (!newContent.startsWith("---")) {
244
282
  const desc = typeof body.description === "string" ? body.description : "Custom skill";
245
283
  newContent = `---\nname: ${skillName}\ndescription: "${desc}"\n---\n\n${newContent}`;
@@ -261,6 +299,16 @@ async function handleUpdateSkill(req, res, skillName) {
261
299
  return true;
262
300
  }
263
301
  async function handleDeleteSkill(res, skillName) {
302
+ const builtInNames = getBuiltInSkillNames();
303
+ if (builtInNames.has(skillName)) {
304
+ sendJson(res, 403, {
305
+ error: {
306
+ message: `'${skillName}' is a built-in skill and cannot be deleted. You can only enable or disable it.`,
307
+ type: "forbidden",
308
+ },
309
+ });
310
+ return true;
311
+ }
264
312
  const skillDir = path.join(MANAGED_SKILLS_DIR, skillName);
265
313
  if (!fs.existsSync(skillDir)) {
266
314
  sendJson(res, 404, {
@@ -268,14 +316,21 @@ async function handleDeleteSkill(res, skillName) {
268
316
  });
269
317
  return true;
270
318
  }
271
- // Remove directory
272
319
  await fsp.rm(skillDir, { recursive: true, force: true });
273
- // Remove from config
274
320
  const config = loadConfig();
275
321
  if (config.skills?.entries?.[skillName]) {
276
322
  delete config.skills.entries[skillName];
277
- await writeConfigFile(config);
278
323
  }
324
+ // If deleting a custom override (e.g. "pdf-custom"), re-enable the built-in
325
+ const baseSkillName = skillName.replace(/-custom$/, "");
326
+ if (baseSkillName !== skillName && builtInNames.has(baseSkillName)) {
327
+ if (!config.skills)
328
+ config.skills = {};
329
+ if (!config.skills.entries)
330
+ config.skills.entries = {};
331
+ config.skills.entries[baseSkillName] = { ...config.skills.entries[baseSkillName], enabled: true };
332
+ }
333
+ await writeConfigFile(config);
279
334
  sendJson(res, 200, { ok: true, deleted: skillName });
280
335
  return true;
281
336
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heylemon/lemonade",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "AI gateway CLI for Lemon - local AI assistant with integrations",
5
5
  "publishConfig": {
6
6
  "access": "restricted"
@@ -1,30 +1,11 @@
1
1
  ---
2
2
  name: docx
3
- description: "Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. When Claude needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks"
3
+ description: "Use this skill whenever the user wants to create, read, edit, or manipulate Word documents (.docx files). Triggers include: any mention of \"Word doc\", \"word document\", \".docx\", or requests to produce professional documents with formatting like tables of contents, headings, page numbers, or letterheads. Also use when extracting or reorganizing content from .docx files, inserting or replacing images in documents, performing find-and-replace in Word files, working with tracked changes or comments, or converting content into a polished Word document. If the user asks for a \"report\", \"memo\", \"letter\", \"template\", or similar deliverable as a Word or .docx file, use this skill. Do NOT use for PDFs, spreadsheets, Google Docs, or general coding tasks unrelated to document generation."
4
4
  license: Proprietary. LICENSE.txt has complete terms
5
5
  ---
6
6
 
7
7
  # DOCX creation, editing, and analysis
8
8
 
9
- ## IMPORTANT: Save to Desktop
10
-
11
- **Always save created `.docx` files to `~/Desktop/`** (e.g. `~/Desktop/document.docx`). Never save to the agent workspace or hidden directories — the user needs easy access to the file.
12
-
13
- ## CRITICAL: Integration Priority
14
-
15
- ### 1. `lemon-docs` CLI (For Google Docs)
16
- If user wants a Google Doc (shareable, collaborative), use `lemon-docs`:
17
- - `lemon-docs create "Title"` - Create a new Google Doc
18
- - `lemon-docs read <id>` - Read a document
19
-
20
- ### 2. Local DOCX (For Files)
21
- If user wants a local `.docx` file, or `lemon-docs` is not connected, use the local creation methods below.
22
-
23
- ### 3. Browser (LAST RESORT)
24
- Only if `lemon-docs` CLI fails AND user explicitly requests Google Docs in browser.
25
-
26
- ---
27
-
28
9
  ## Overview
29
10
 
30
11
  A .docx file is a ZIP archive containing XML files.
@@ -67,14 +48,14 @@ pdftoppm -jpeg -r 150 document.pdf page
67
48
  To produce a clean document with all tracked changes accepted (requires LibreOffice):
68
49
 
69
50
  ```bash
70
- python scripts/accept_changes.py input.docx ~/Desktop/output.docx
51
+ python scripts/accept_changes.py input.docx output.docx
71
52
  ```
72
53
 
73
54
  ---
74
55
 
75
56
  ## Creating New Documents
76
57
 
77
- Generate .docx files with JavaScript. Install: `npm install -g docx`
58
+ Generate .docx files with JavaScript, then validate. Install: `npm install -g docx`
78
59
 
79
60
  ### Setup
80
61
  ```javascript
@@ -87,6 +68,12 @@ const doc = new Document({ sections: [{ children: [/* content */] }] });
87
68
  Packer.toBuffer(doc).then(buffer => fs.writeFileSync("doc.docx", buffer));
88
69
  ```
89
70
 
71
+ ### Validation
72
+ After creating the file, validate it. If validation fails, unpack, fix the XML, and repack.
73
+ ```bash
74
+ python scripts/office/validate.py doc.docx
75
+ ```
76
+
90
77
  ### Page Size
91
78
 
92
79
  ```javascript
@@ -113,6 +100,16 @@ sections: [{
113
100
  | US Letter | 12,240 | 15,840 | 9,360 |
114
101
  | A4 (default) | 11,906 | 16,838 | 9,026 |
115
102
 
103
+ **Landscape orientation:** docx-js swaps width/height internally, so pass portrait dimensions and let it handle the swap:
104
+ ```javascript
105
+ size: {
106
+ width: 12240, // Pass SHORT edge as width
107
+ height: 15840, // Pass LONG edge as height
108
+ orientation: PageOrientation.LANDSCAPE // docx-js swaps them in the XML
109
+ },
110
+ // Content width = 15840 - left margin - right margin (uses the long edge)
111
+ ```
112
+
116
113
  ### Styles (Override Built-in Headings)
117
114
 
118
115
  Use Arial as the default font (universally supported). Keep titles black for readability.
@@ -184,8 +181,8 @@ const border = { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" };
184
181
  const borders = { top: border, bottom: border, left: border, right: border };
185
182
 
186
183
  new Table({
187
- width: { size: 100, type: WidthType.PERCENTAGE }, // Always set table width
188
- columnWidths: [4680, 4680], // Set at table level (DXA: 1440 = 1 inch)
184
+ width: { size: 9360, type: WidthType.DXA }, // Always use DXA (percentages break in Google Docs)
185
+ columnWidths: [4680, 4680], // Must sum to table width (DXA: 1440 = 1 inch)
189
186
  rows: [
190
187
  new TableRow({
191
188
  children: [
@@ -204,13 +201,9 @@ new Table({
204
201
 
205
202
  **Table width calculation:**
206
203
 
207
- Use `WidthType.PERCENTAGE` for simplicity, or `WidthType.DXA` for precise control:
204
+ Always use `WidthType.DXA` `WidthType.PERCENTAGE` breaks in Google Docs.
208
205
 
209
206
  ```javascript
210
- // Option 1: Percentage (recommended - automatically fits content area)
211
- width: { size: 100, type: WidthType.PERCENTAGE }
212
-
213
- // Option 2: DXA (precise control)
214
207
  // Table width = sum of columnWidths = content width
215
208
  // US Letter with 1" margins: 12240 - 2880 = 9360 DXA
216
209
  width: { size: 9360, type: WidthType.DXA },
@@ -218,6 +211,7 @@ columnWidths: [7000, 2360] // Must sum to table width
218
211
  ```
219
212
 
220
213
  **Width rules:**
214
+ - **Always use `WidthType.DXA`** — never `WidthType.PERCENTAGE` (incompatible with Google Docs)
221
215
  - Table width must equal the sum of `columnWidths`
222
216
  - Cell `width` must match corresponding `columnWidth`
223
217
  - Cell `margins` are internal padding - they reduce content area, not add to cell width
@@ -276,11 +270,12 @@ sections: [{
276
270
  ### Critical Rules for docx-js
277
271
 
278
272
  - **Set page size explicitly** - docx-js defaults to A4; use US Letter (12240 x 15840 DXA) for US documents
273
+ - **Landscape: pass portrait dimensions** - docx-js swaps width/height internally; pass short edge as `width`, long edge as `height`, and set `orientation: PageOrientation.LANDSCAPE`
279
274
  - **Never use `\n`** - use separate Paragraph elements
280
275
  - **Never use unicode bullets** - use `LevelFormat.BULLET` with numbering config
281
276
  - **PageBreak must be in Paragraph** - standalone creates invalid XML
282
277
  - **ImageRun requires `type`** - always specify png/jpg/etc
283
- - **Always set table `width`** - use `{ size: 100, type: WidthType.PERCENTAGE }` for full width
278
+ - **Always set table `width` with DXA** - never use `WidthType.PERCENTAGE` (breaks in Google Docs)
284
279
  - **Tables need dual widths** - `columnWidths` array AND cell `width`, both must match
285
280
  - **Table width = sum of columnWidths** - for DXA, ensure they add up exactly
286
281
  - **Always add cell margins** - use `margins: { top: 80, bottom: 80, left: 120, right: 120 }` for readable padding
@@ -1,4 +1,3 @@
1
- #!/usr/bin/env python3
2
1
  """Accept all tracked changes in a DOCX file using LibreOffice.
3
2
 
4
3
  Requires LibreOffice (soffice) to be installed.
@@ -14,7 +13,6 @@ from office.soffice import get_soffice_env
14
13
 
15
14
  logger = logging.getLogger(__name__)
16
15
 
17
- # LibreOffice profile directory for macro storage
18
16
  LIBREOFFICE_PROFILE = "/tmp/libreoffice_docx_profile"
19
17
  MACRO_DIR = f"{LIBREOFFICE_PROFILE}/user/basic/Standard"
20
18
 
@@ -39,15 +37,6 @@ def accept_changes(
39
37
  input_file: str,
40
38
  output_file: str,
41
39
  ) -> tuple[None, str]:
42
- """Accept all tracked changes in a DOCX file and save to output file.
43
-
44
- Args:
45
- input_file: Path to input DOCX file with tracked changes
46
- output_file: Path to output DOCX file (will be created/overwritten)
47
-
48
- Returns:
49
- (None, message) - message indicates success or failure
50
- """
51
40
  input_path = Path(input_file)
52
41
  output_path = Path(output_file)
53
42
 
@@ -57,18 +46,15 @@ def accept_changes(
57
46
  if not input_path.suffix.lower() == ".docx":
58
47
  return None, f"Error: Input file is not a DOCX file: {input_file}"
59
48
 
60
- # Copy input file to output file location
61
49
  try:
62
50
  output_path.parent.mkdir(parents=True, exist_ok=True)
63
51
  shutil.copy2(input_path, output_path)
64
52
  except Exception as e:
65
53
  return None, f"Error: Failed to copy input file to output location: {e}"
66
54
 
67
- # Setup LibreOffice macro
68
55
  if not _setup_libreoffice_macro():
69
56
  return None, "Error: Failed to setup LibreOffice macro"
70
57
 
71
- # Run LibreOffice with macro to accept changes
72
58
  cmd = [
73
59
  "soffice",
74
60
  "--headless",
@@ -88,7 +74,6 @@ def accept_changes(
88
74
  env=get_soffice_env(),
89
75
  )
90
76
  except subprocess.TimeoutExpired:
91
- # Timeout is expected - LibreOffice may hang after completing
92
77
  return (
93
78
  None,
94
79
  f"Successfully accepted all tracked changes: {input_file} -> {output_file}",
@@ -104,14 +89,12 @@ def accept_changes(
104
89
 
105
90
 
106
91
  def _setup_libreoffice_macro() -> bool:
107
- """Setup LibreOffice macro for accepting tracked changes."""
108
92
  macro_dir = Path(MACRO_DIR)
109
93
  macro_file = macro_dir / "Module1.xba"
110
94
 
111
95
  if macro_file.exists() and "AcceptAllTrackedChanges" in macro_file.read_text():
112
96
  return True
113
97
 
114
- # Initialize LibreOffice if needed (use custom profile)
115
98
  if not macro_dir.exists():
116
99
  subprocess.run(
117
100
  [
@@ -1,4 +1,3 @@
1
- #!/usr/bin/env python3
2
1
  """Add comments to DOCX documents.
3
2
 
4
3
  Usage:
@@ -32,7 +31,6 @@ NS = {
32
31
  "w16cex": "http://schemas.microsoft.com/office/word/2018/wordml/cex",
33
32
  }
34
33
 
35
- # XML template for comment content in comments.xml
36
34
  COMMENT_XML = """\
37
35
  <w:comment w:id="{id}" w:author="{author}" w:date="{date}" w:initials="{initials}">
38
36
  <w:p w14:paraId="{para_id}" w14:textId="77777777">
@@ -51,7 +49,6 @@ COMMENT_XML = """\
51
49
  </w:p>
52
50
  </w:comment>"""
53
51
 
54
- # Output templates for marker placement instructions
55
52
  COMMENT_MARKER_TEMPLATE = """
56
53
  Add to document.xml (markers must be direct children of w:p, never inside w:r):
57
54
  <w:commentRangeStart w:id="{cid}"/>
@@ -69,42 +66,36 @@ Nest markers inside parent {pid}'s markers (markers must be direct children of w
69
66
 
70
67
 
71
68
  def _generate_hex_id() -> str:
72
- """Random 8-char hex ID (satisfies paraId < 0x80000000, durableId < 0x7FFFFFFF)."""
73
69
  return f"{random.randint(0, 0x7FFFFFFE):08X}"
74
70
 
75
71
 
76
- # Smart quotes to re-encode after DOM serialization (DOM decodes entities to Unicode)
77
72
  SMART_QUOTE_ENTITIES = {
78
- "\u201c": "&#x201C;", # Left double quote
79
- "\u201d": "&#x201D;", # Right double quote
80
- "\u2018": "&#x2018;", # Left single quote
81
- "\u2019": "&#x2019;", # Right single quote
73
+ "\u201c": "&#x201C;",
74
+ "\u201d": "&#x201D;",
75
+ "\u2018": "&#x2018;",
76
+ "\u2019": "&#x2019;",
82
77
  }
83
78
 
84
79
 
85
80
  def _encode_smart_quotes(text: str) -> str:
86
- """Re-encode smart quotes as XML entities after DOM serialization."""
87
81
  for char, entity in SMART_QUOTE_ENTITIES.items():
88
82
  text = text.replace(char, entity)
89
83
  return text
90
84
 
91
85
 
92
86
  def _append_xml(xml_path: Path, root_tag: str, content: str) -> None:
93
- """Append content as child of root element."""
94
87
  dom = defusedxml.minidom.parseString(xml_path.read_text(encoding="utf-8"))
95
88
  root = dom.getElementsByTagName(root_tag)[0]
96
89
  ns_attrs = " ".join(f'xmlns:{k}="{v}"' for k, v in NS.items())
97
90
  wrapper_dom = defusedxml.minidom.parseString(f"<root {ns_attrs}>{content}</root>")
98
- for child in wrapper_dom.documentElement.childNodes: # type: ignore
91
+ for child in wrapper_dom.documentElement.childNodes:
99
92
  if child.nodeType == child.ELEMENT_NODE:
100
93
  root.appendChild(dom.importNode(child, True))
101
- # Re-encode smart quotes that DOM decoded to Unicode
102
94
  output = _encode_smart_quotes(dom.toxml(encoding="UTF-8").decode("utf-8"))
103
95
  xml_path.write_text(output, encoding="utf-8")
104
96
 
105
97
 
106
98
  def _find_para_id(comments_path: Path, comment_id: int) -> str | None:
107
- """Find para_id for a comment ID."""
108
99
  dom = defusedxml.minidom.parseString(comments_path.read_text(encoding="utf-8"))
109
100
  for c in dom.getElementsByTagName("w:comment"):
110
101
  if c.getAttribute("w:id") == str(comment_id):
@@ -115,7 +106,6 @@ def _find_para_id(comments_path: Path, comment_id: int) -> str | None:
115
106
 
116
107
 
117
108
  def _get_next_rid(rels_path: Path) -> int:
118
- """Get the next available rId number from document.xml.rels."""
119
109
  dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8"))
120
110
  max_rid = 0
121
111
  for rel in dom.getElementsByTagName("Relationship"):
@@ -129,7 +119,6 @@ def _get_next_rid(rels_path: Path) -> int:
129
119
 
130
120
 
131
121
  def _has_relationship(rels_path: Path, target: str) -> bool:
132
- """Check if a relationship with given target exists."""
133
122
  dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8"))
134
123
  for rel in dom.getElementsByTagName("Relationship"):
135
124
  if rel.getAttribute("Target") == target:
@@ -138,7 +127,6 @@ def _has_relationship(rels_path: Path, target: str) -> bool:
138
127
 
139
128
 
140
129
  def _has_content_type(ct_path: Path, part_name: str) -> bool:
141
- """Check if a content type override with given part name exists."""
142
130
  dom = defusedxml.minidom.parseString(ct_path.read_text(encoding="utf-8"))
143
131
  for override in dom.getElementsByTagName("Override"):
144
132
  if override.getAttribute("PartName") == part_name:
@@ -147,19 +135,17 @@ def _has_content_type(ct_path: Path, part_name: str) -> bool:
147
135
 
148
136
 
149
137
  def _ensure_comment_relationships(unpacked_dir: Path) -> None:
150
- """Ensure word/_rels/document.xml.rels has comment relationships."""
151
138
  rels_path = unpacked_dir / "word" / "_rels" / "document.xml.rels"
152
139
  if not rels_path.exists():
153
140
  return
154
141
 
155
142
  if _has_relationship(rels_path, "comments.xml"):
156
- return # Already has comment relationships
143
+ return
157
144
 
158
145
  dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8"))
159
146
  root = dom.documentElement
160
147
  next_rid = _get_next_rid(rels_path)
161
148
 
162
- # Add relationship elements
163
149
  rels = [
164
150
  (
165
151
  "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments",
@@ -184,25 +170,23 @@ def _ensure_comment_relationships(unpacked_dir: Path) -> None:
184
170
  rel.setAttribute("Id", f"rId{next_rid}")
185
171
  rel.setAttribute("Type", rel_type)
186
172
  rel.setAttribute("Target", target)
187
- root.appendChild(rel) # type: ignore
173
+ root.appendChild(rel)
188
174
  next_rid += 1
189
175
 
190
176
  rels_path.write_bytes(dom.toxml(encoding="UTF-8"))
191
177
 
192
178
 
193
179
  def _ensure_comment_content_types(unpacked_dir: Path) -> None:
194
- """Ensure [Content_Types].xml has comment content types."""
195
180
  ct_path = unpacked_dir / "[Content_Types].xml"
196
181
  if not ct_path.exists():
197
182
  return
198
183
 
199
184
  if _has_content_type(ct_path, "/word/comments.xml"):
200
- return # Already has comment content types
185
+ return
201
186
 
202
187
  dom = defusedxml.minidom.parseString(ct_path.read_text(encoding="utf-8"))
203
188
  root = dom.documentElement
204
189
 
205
- # Add Override elements
206
190
  overrides = [
207
191
  (
208
192
  "/word/comments.xml",
@@ -226,7 +210,7 @@ def _ensure_comment_content_types(unpacked_dir: Path) -> None:
226
210
  override = dom.createElement("Override")
227
211
  override.setAttribute("PartName", part_name)
228
212
  override.setAttribute("ContentType", content_type)
229
- root.appendChild(override) # type: ignore
213
+ root.appendChild(override)
230
214
 
231
215
  ct_path.write_bytes(dom.toxml(encoding="UTF-8"))
232
216
 
@@ -239,14 +223,6 @@ def add_comment(
239
223
  initials: str = "C",
240
224
  parent_id: int | None = None,
241
225
  ) -> tuple[str, str]:
242
- """Add comment to unpacked DOCX.
243
-
244
- Args:
245
- text: Comment text, pre-escaped for XML (e.g., &amp; &#x2019;).
246
-
247
- Returns:
248
- (para_id, message) tuple.
249
- """
250
226
  word = Path(unpacked_dir) / "word"
251
227
  if not word.exists():
252
228
  return "", f"Error: {word} not found"
@@ -254,12 +230,10 @@ def add_comment(
254
230
  para_id, durable_id = _generate_hex_id(), _generate_hex_id()
255
231
  ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
256
232
 
257
- # comments.xml
258
233
  comments = word / "comments.xml"
259
234
  first_comment = not comments.exists()
260
235
  if first_comment:
261
236
  shutil.copy(TEMPLATE_DIR / "comments.xml", comments)
262
- # Add relationships and content types for comment files
263
237
  _ensure_comment_relationships(Path(unpacked_dir))
264
238
  _ensure_comment_content_types(Path(unpacked_dir))
265
239
  _append_xml(
@@ -271,11 +245,10 @@ def add_comment(
271
245
  date=ts,
272
246
  initials=initials,
273
247
  para_id=para_id,
274
- text=text, # Model provides pre-escaped XML content
248
+ text=text,
275
249
  ),
276
250
  )
277
251
 
278
- # commentsExtended.xml
279
252
  ext = word / "commentsExtended.xml"
280
253
  if not ext.exists():
281
254
  shutil.copy(TEMPLATE_DIR / "commentsExtended.xml", ext)
@@ -295,7 +268,6 @@ def add_comment(
295
268
  f'<w15:commentEx w15:paraId="{para_id}" w15:done="0"/>',
296
269
  )
297
270
 
298
- # commentsIds.xml
299
271
  ids = word / "commentsIds.xml"
300
272
  if not ids.exists():
301
273
  shutil.copy(TEMPLATE_DIR / "commentsIds.xml", ids)
@@ -305,7 +277,6 @@ def add_comment(
305
277
  f'<w16cid:commentId w16cid:paraId="{para_id}" w16cid:durableId="{durable_id}"/>',
306
278
  )
307
279
 
308
- # commentsExtensible.xml
309
280
  extensible = word / "commentsExtensible.xml"
310
281
  if not extensible.exists():
311
282
  shutil.copy(TEMPLATE_DIR / "commentsExtensible.xml", extensible)