@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,25 +1,19 @@
1
1
  import json
2
2
  import sys
3
- import os
4
3
 
5
4
  from pypdf import PdfReader, PdfWriter
6
5
  from pypdf.annotations import FreeText
7
6
 
8
7
 
9
- # Fills a PDF by adding text annotations defined in `fields.json`. See FORMS.md.
10
8
 
11
9
 
12
10
  def transform_from_image_coords(bbox, image_width, image_height, pdf_width, pdf_height):
13
- """Transform bounding box from image coordinates to PDF coordinates"""
14
- # Image coordinates: origin at top-left, y increases downward
15
- # PDF coordinates: origin at bottom-left, y increases upward
16
11
  x_scale = pdf_width / image_width
17
12
  y_scale = pdf_height / image_height
18
13
 
19
14
  left = bbox[0] * x_scale
20
15
  right = bbox[2] * x_scale
21
16
 
22
- # Flip Y coordinates for PDF
23
17
  top = pdf_height - (bbox[1] * y_scale)
24
18
  bottom = pdf_height - (bbox[3] * y_scale)
25
19
 
@@ -27,67 +21,43 @@ def transform_from_image_coords(bbox, image_width, image_height, pdf_width, pdf_
27
21
 
28
22
 
29
23
  def transform_from_pdf_coords(bbox, pdf_height):
30
- """Transform bounding box from pdfplumber coordinates to pypdf coordinates.
31
-
32
- pdfplumber uses y=0 at top, y increases downward (like images).
33
- pypdf FreeText expects y=0 at bottom, y increases upward.
34
- Both use the same scale (PDF points), so only Y needs flipping.
35
- """
36
24
  left = bbox[0]
37
25
  right = bbox[2]
38
26
 
39
- # bbox is [left, top, right, bottom] where top < bottom (y=0 at top)
40
- # pypdf wants [left, bottom, right, top] where bottom < top (y=0 at bottom)
41
- pypdf_top = pdf_height - bbox[1] # flip the "top" value
42
- pypdf_bottom = pdf_height - bbox[3] # flip the "bottom" value
27
+ pypdf_top = pdf_height - bbox[1]
28
+ pypdf_bottom = pdf_height - bbox[3]
43
29
 
44
30
  return left, pypdf_bottom, right, pypdf_top
45
31
 
46
32
 
47
33
  def fill_pdf_form(input_pdf_path, fields_json_path, output_pdf_path):
48
- """Fill the PDF form with data from fields.json"""
49
- input_abs = os.path.abspath(input_pdf_path)
50
- output_abs = os.path.abspath(output_pdf_path)
51
- if input_abs == output_abs:
52
- print("ERROR: Refusing to overwrite input PDF. Use a different output path.")
53
- sys.exit(1)
54
34
 
55
- # `fields.json` format described in FORMS.md.
56
35
  with open(fields_json_path, "r") as f:
57
36
  fields_data = json.load(f)
58
37
 
59
- # Open the PDF
60
38
  reader = PdfReader(input_pdf_path)
61
39
  writer = PdfWriter()
62
40
 
63
- # Copy all pages to writer
64
41
  writer.append(reader)
65
42
 
66
- # Get PDF dimensions for each page
67
43
  pdf_dimensions = {}
68
44
  for i, page in enumerate(reader.pages):
69
45
  mediabox = page.mediabox
70
46
  pdf_dimensions[i + 1] = [mediabox.width, mediabox.height]
71
47
 
72
- # Process each form field
73
48
  annotations = []
74
49
  for field in fields_data["form_fields"]:
75
50
  page_num = field["page_number"]
76
51
 
77
- # Get page dimensions and transform coordinates.
78
52
  page_info = next(p for p in fields_data["pages"] if p["page_number"] == page_num)
79
53
  pdf_width, pdf_height = pdf_dimensions[page_num]
80
54
 
81
- # Detect coordinate system: pdf_width/pdf_height = PDF coords, image_width/image_height = image coords
82
55
  if "pdf_width" in page_info:
83
- # PDF coordinates from structure extraction (pdfplumber style)
84
- # Only need Y-flip, no scaling
85
56
  transformed_entry_box = transform_from_pdf_coords(
86
57
  field["entry_bounding_box"],
87
58
  float(pdf_height)
88
59
  )
89
60
  else:
90
- # Image coordinates - need scaling and Y-flip
91
61
  image_width = page_info["image_width"]
92
62
  image_height = page_info["image_height"]
93
63
  transformed_entry_box = transform_from_image_coords(
@@ -96,7 +66,6 @@ def fill_pdf_form(input_pdf_path, fields_json_path, output_pdf_path):
96
66
  float(pdf_width), float(pdf_height)
97
67
  )
98
68
 
99
- # Skip empty fields
100
69
  if "entry_text" not in field or "text" not in field["entry_text"]:
101
70
  continue
102
71
  entry_text = field["entry_text"]
@@ -108,8 +77,6 @@ def fill_pdf_form(input_pdf_path, fields_json_path, output_pdf_path):
108
77
  font_size = str(entry_text.get("font_size", 14)) + "pt"
109
78
  font_color = entry_text.get("font_color", "000000")
110
79
 
111
- # Font size/color seems to not work reliably across viewers:
112
- # https://github.com/py-pdf/pypdf/issues/2084
113
80
  annotation = FreeText(
114
81
  text=text,
115
82
  rect=transformed_entry_box,
@@ -120,10 +87,8 @@ def fill_pdf_form(input_pdf_path, fields_json_path, output_pdf_path):
120
87
  background_color=None,
121
88
  )
122
89
  annotations.append(annotation)
123
- # page_number is 0-based for pypdf
124
90
  writer.add_annotation(page_number=page_num - 1, annotation=annotation)
125
91
 
126
- # Save the filled PDF
127
92
  with open(output_pdf_path, "wb") as output:
128
93
  writer.write(output)
129
94
 
@@ -139,4 +104,4 @@ if __name__ == "__main__":
139
104
  fields_json = sys.argv[2]
140
105
  output_pdf = sys.argv[3]
141
106
 
142
- fill_pdf_form(input_pdf, fields_json, output_pdf)
107
+ fill_pdf_form(input_pdf, fields_json, output_pdf)
@@ -1,38 +1,11 @@
1
1
  ---
2
2
  name: pptx
3
- description: "Presentation creation, editing, and analysis. When Claude needs to work with presentations (.pptx files) for: (1) Creating new presentations, (2) Modifying or editing content, (3) Working with layouts, (4) Adding comments or speaker notes, or any other presentation tasks"
3
+ description: "Use this skill any time a .pptx file is involved in any way — as input, output, or both. This includes: creating slide decks, pitch decks, or presentations; reading, parsing, or extracting text from any .pptx file (even if the extracted content will be used elsewhere, like in an email or summary); editing, modifying, or updating existing presentations; combining or splitting slide files; working with templates, layouts, speaker notes, or comments. Trigger whenever the user mentions \"deck,\" \"slides,\" \"presentation,\" or references a .pptx filename, regardless of what they plan to do with the content afterward. If a .pptx file needs to be opened, created, or touched, use this skill."
4
4
  license: Proprietary. LICENSE.txt has complete terms
5
5
  ---
6
6
 
7
7
  # PPTX Skill
8
8
 
9
- ## IMPORTANT: Save to Desktop
10
-
11
- **Always save created `.pptx` files to `~/Desktop/`** (e.g. `~/Desktop/presentation.pptx`). 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-slides` CLI (For Google Slides)
16
- If user wants a Google Slides presentation (shareable, collaborative), use `lemon-slides`:
17
- - `lemon-slides create "Title"` - Create a new presentation
18
- - `lemon-slides read <id>` - Read a presentation
19
-
20
- ### 2. Local PPTX (Recommended for Quality)
21
- For best results, create presentations locally using pptxgenjs:
22
- - Full control over every element
23
- - Professional, polished output
24
- - Files user can open in PowerPoint, Keynote, or upload to Google Slides
25
-
26
- ### 3. Browser (LAST RESORT)
27
- Only if `lemon-slides` CLI fails AND user explicitly requests Google Slides in browser.
28
-
29
- ## Workflow Summary (Local Creation)
30
-
31
- 1. **Create slides** using pptxgenjs (JavaScript)
32
- 2. **Generate `.pptx` file** locally
33
- 3. **QA the output** using visual inspection
34
- 4. **Deliver** the file to the user
35
-
36
9
  ## Quick Reference
37
10
 
38
11
  | Task | Guide |
@@ -161,7 +134,7 @@ Choose colors that match your topic — don't default to generic blue. Use these
161
134
  - **Don't create text-only slides** — add images, icons, charts, or visual elements; avoid plain title + bullets
162
135
  - **Don't forget text box padding** — when aligning lines or shapes with text edges, set `margin: 0` on the text box or offset the shape to account for padding
163
136
  - **Don't use low-contrast elements** — icons AND text need strong contrast against the background; avoid light text on light backgrounds or dark text on dark backgrounds
164
- - **NEVER use horizontal lines to seperate title and body** use whitespace or background color instead
137
+ - **NEVER use accent lines under titles** these are a hallmark of AI-generated slides; use whitespace or background color instead
165
138
 
166
139
  ---
167
140
 
@@ -39,7 +39,7 @@ When using an existing presentation as a template:
39
39
 
40
40
  6. **Clean**: `python scripts/clean.py unpacked/`
41
41
 
42
- 7. **Pack**: `python scripts/office/pack.py unpacked/ ~/Desktop/output.pptx --original template.pptx`
42
+ 7. **Pack**: `python scripts/office/pack.py unpacked/ output.pptx --original template.pptx`
43
43
 
44
44
  ---
45
45
 
@@ -81,7 +81,7 @@ Removes slides not in `<p:sldIdLst>`, unreferenced media, orphaned rels.
81
81
  ### pack.py
82
82
 
83
83
  ```bash
84
- python scripts/office/pack.py unpacked/ ~/Desktop/output.pptx --original input.pptx
84
+ python scripts/office/pack.py unpacked/ output.pptx --original input.pptx
85
85
  ```
86
86
 
87
87
  Validates, repairs, condenses XML, re-encodes smart quotes.
@@ -13,9 +13,7 @@ pres.title = 'Presentation Title';
13
13
  let slide = pres.addSlide();
14
14
  slide.addText("Hello World!", { x: 0.5, y: 0.5, fontSize: 36, color: "363636" });
15
15
 
16
- const os = require('os');
17
- const path = require('path');
18
- pres.writeFile({ fileName: path.join(os.homedir(), "Desktop", "Presentation.pptx") });
16
+ pres.writeFile({ fileName: "Presentation.pptx" });
19
17
  ```
20
18
 
21
19
  ## Layout Dimensions
@@ -37,6 +35,9 @@ slide.addText("Simple Text", {
37
35
  color: "363636", bold: true, align: "center", valign: "middle"
38
36
  });
39
37
 
38
+ // Character spacing (use charSpacing, not letterSpacing which is silently ignored)
39
+ slide.addText("SPACED TEXT", { x: 1, y: 1, w: 8, h: 1, charSpacing: 6 });
40
+
40
41
  // Rich text arrays
41
42
  slide.addText([
42
43
  { text: "Bold ", options: { bold: true } },
@@ -100,8 +101,35 @@ slide.addShape(pres.shapes.RECTANGLE, {
100
101
  x: 1, y: 1, w: 3, h: 2,
101
102
  fill: { color: "0088CC", transparency: 50 }
102
103
  });
104
+
105
+ // Rounded rectangle (rectRadius only works with ROUNDED_RECTANGLE, not RECTANGLE)
106
+ // ⚠️ Don't pair with rectangular accent overlays — they won't cover rounded corners. Use RECTANGLE instead.
107
+ slide.addShape(pres.shapes.ROUNDED_RECTANGLE, {
108
+ x: 1, y: 1, w: 3, h: 2,
109
+ fill: { color: "FFFFFF" }, rectRadius: 0.1
110
+ });
111
+
112
+ // With shadow
113
+ slide.addShape(pres.shapes.RECTANGLE, {
114
+ x: 1, y: 1, w: 3, h: 2,
115
+ fill: { color: "FFFFFF" },
116
+ shadow: { type: "outer", color: "000000", blur: 6, offset: 2, angle: 135, opacity: 0.15 }
117
+ });
103
118
  ```
104
119
 
120
+ Shadow options:
121
+
122
+ | Property | Type | Range | Notes |
123
+ |----------|------|-------|-------|
124
+ | `type` | string | `"outer"`, `"inner"` | |
125
+ | `color` | string | 6-char hex (e.g. `"000000"`) | No `#` prefix, no 8-char hex — see Common Pitfalls |
126
+ | `blur` | number | 0-100 pt | |
127
+ | `offset` | number | 0-200 pt | **Must be non-negative** — negative values corrupt the file |
128
+ | `angle` | number | 0-359 degrees | Direction the shadow falls (135 = bottom-right, 270 = upward) |
129
+ | `opacity` | number | 0.0-1.0 | Use this for transparency, never encode in color string |
130
+
131
+ To cast a shadow upward (e.g. on a footer bar), use `angle: 270` with a positive offset — do **not** use a negative offset.
132
+
105
133
  **Note**: Gradient fills are not natively supported. Use a gradient image as a background instead.
106
134
 
107
135
  ---
@@ -345,15 +373,32 @@ titleSlide.addText("My Title", { placeholder: "title" });
345
373
  color: "#FF0000" // ❌ WRONG
346
374
  ```
347
375
 
348
- 2. **Use `bullet: true`** - NEVER unicode symbols like "" (creates double bullets)
376
+ 2. **NEVER encode opacity in hex color strings** - 8-char colors (e.g., `"00000020"`) corrupt the file. Use the `opacity` property instead.
377
+ ```javascript
378
+ shadow: { type: "outer", blur: 6, offset: 2, color: "00000020" } // ❌ CORRUPTS FILE
379
+ shadow: { type: "outer", blur: 6, offset: 2, color: "000000", opacity: 0.12 } // ✅ CORRECT
380
+ ```
381
+
382
+ 3. **Use `bullet: true`** - NEVER unicode symbols like "•" (creates double bullets)
349
383
 
350
- 3. **Use `breakLine: true`** between array items or text runs together
384
+ 4. **Use `breakLine: true`** between array items or text runs together
351
385
 
352
- 4. **Avoid `lineSpacing` with bullets** - causes excessive gaps; use `paraSpaceAfter` instead
386
+ 5. **Avoid `lineSpacing` with bullets** - causes excessive gaps; use `paraSpaceAfter` instead
353
387
 
354
- 5. **Each presentation needs fresh instance** - don't reuse `pptxgen()` objects
388
+ 6. **Each presentation needs fresh instance** - don't reuse `pptxgen()` objects
389
+
390
+ 7. **NEVER reuse option objects across calls** - PptxGenJS mutates objects in-place (e.g. converting shadow values to EMU). Sharing one object between multiple calls corrupts the second shape.
391
+ ```javascript
392
+ const shadow = { type: "outer", blur: 6, offset: 2, color: "000000", opacity: 0.15 };
393
+ slide.addShape(pres.shapes.RECTANGLE, { shadow, ... }); // ❌ second call gets already-converted values
394
+ slide.addShape(pres.shapes.RECTANGLE, { shadow, ... });
395
+
396
+ const makeShadow = () => ({ type: "outer", blur: 6, offset: 2, color: "000000", opacity: 0.15 });
397
+ slide.addShape(pres.shapes.RECTANGLE, { shadow: makeShadow(), ... }); // ✅ fresh object each time
398
+ slide.addShape(pres.shapes.RECTANGLE, { shadow: makeShadow(), ... });
399
+ ```
355
400
 
356
- 6. **Don't use `ROUNDED_RECTANGLE` with accent borders** - rectangular overlay bars won't cover rounded corners. Use `RECTANGLE` instead.
401
+ 8. **Don't use `ROUNDED_RECTANGLE` with accent borders** - rectangular overlay bars won't cover rounded corners. Use `RECTANGLE` instead.
357
402
  ```javascript
358
403
  // ❌ WRONG: Accent bar doesn't cover rounded corners
359
404
  slide.addShape(pres.shapes.ROUNDED_RECTANGLE, { x: 1, y: 1, w: 3, h: 1.5, fill: { color: "FFFFFF" } });
@@ -1,4 +1,3 @@
1
- #!/usr/bin/env python3
2
1
  """Add a new slide to an unpacked PPTX directory.
3
2
 
4
3
  Usage: python add_slide.py <unpacked_dir> <source>
@@ -26,14 +25,12 @@ from pathlib import Path
26
25
 
27
26
 
28
27
  def get_next_slide_number(slides_dir: Path) -> int:
29
- """Find the next available slide number."""
30
28
  existing = [int(m.group(1)) for f in slides_dir.glob("slide*.xml")
31
29
  if (m := re.match(r"slide(\d+)\.xml", f.name))]
32
30
  return max(existing) + 1 if existing else 1
33
31
 
34
32
 
35
33
  def create_slide_from_layout(unpacked_dir: Path, layout_file: str) -> None:
36
- """Create a new slide from a layout template."""
37
34
  slides_dir = unpacked_dir / "ppt" / "slides"
38
35
  rels_dir = slides_dir / "_rels"
39
36
  layouts_dir = unpacked_dir / "ppt" / "slideLayouts"
@@ -43,13 +40,11 @@ def create_slide_from_layout(unpacked_dir: Path, layout_file: str) -> None:
43
40
  print(f"Error: {layout_path} not found", file=sys.stderr)
44
41
  sys.exit(1)
45
42
 
46
- # Auto-select destination name
47
43
  next_num = get_next_slide_number(slides_dir)
48
44
  dest = f"slide{next_num}.xml"
49
45
  dest_slide = slides_dir / dest
50
46
  dest_rels = rels_dir / f"{dest}.rels"
51
47
 
52
- # 1. Create minimal slide XML that references the layout
53
48
  slide_xml = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
54
49
  <p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
55
50
  <p:cSld>
@@ -75,7 +70,6 @@ def create_slide_from_layout(unpacked_dir: Path, layout_file: str) -> None:
75
70
  </p:sld>'''
76
71
  dest_slide.write_text(slide_xml, encoding="utf-8")
77
72
 
78
- # 2. Create relationships file pointing to the layout
79
73
  rels_dir.mkdir(exist_ok=True)
80
74
  rels_xml = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
81
75
  <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
@@ -83,33 +77,26 @@ def create_slide_from_layout(unpacked_dir: Path, layout_file: str) -> None:
83
77
  </Relationships>'''
84
78
  dest_rels.write_text(rels_xml, encoding="utf-8")
85
79
 
86
- # 3. Add to [Content_Types].xml
87
80
  _add_to_content_types(unpacked_dir, dest)
88
81
 
89
- # 4. Add relationship to presentation.xml.rels and get rId
90
82
  rid = _add_to_presentation_rels(unpacked_dir, dest)
91
83
 
92
- # 5. Get next slide ID
93
84
  next_slide_id = _get_next_slide_id(unpacked_dir)
94
85
 
95
- # Output
96
86
  print(f"Created {dest} from {layout_file}")
97
87
  print(f'Add to presentation.xml <p:sldIdLst>: <p:sldId id="{next_slide_id}" r:id="{rid}"/>')
98
88
 
99
89
 
100
90
  def duplicate_slide(unpacked_dir: Path, source: str) -> None:
101
- """Duplicate a slide and update all references."""
102
91
  slides_dir = unpacked_dir / "ppt" / "slides"
103
92
  rels_dir = slides_dir / "_rels"
104
93
 
105
94
  source_slide = slides_dir / source
106
95
 
107
- # Validate source exists
108
96
  if not source_slide.exists():
109
97
  print(f"Error: {source_slide} not found", file=sys.stderr)
110
98
  sys.exit(1)
111
99
 
112
- # Auto-select destination name
113
100
  next_num = get_next_slide_number(slides_dir)
114
101
  dest = f"slide{next_num}.xml"
115
102
  dest_slide = slides_dir / dest
@@ -117,14 +104,11 @@ def duplicate_slide(unpacked_dir: Path, source: str) -> None:
117
104
  source_rels = rels_dir / f"{source}.rels"
118
105
  dest_rels = rels_dir / f"{dest}.rels"
119
106
 
120
- # 1. Copy slide XML
121
107
  shutil.copy2(source_slide, dest_slide)
122
108
 
123
- # 2. Copy relationships file (if exists)
124
109
  if source_rels.exists():
125
110
  shutil.copy2(source_rels, dest_rels)
126
111
 
127
- # 3. Remove notes references from new rels file
128
112
  rels_content = dest_rels.read_text(encoding="utf-8")
129
113
  rels_content = re.sub(
130
114
  r'\s*<Relationship[^>]*Type="[^"]*notesSlide"[^>]*/>\s*',
@@ -133,22 +117,17 @@ def duplicate_slide(unpacked_dir: Path, source: str) -> None:
133
117
  )
134
118
  dest_rels.write_text(rels_content, encoding="utf-8")
135
119
 
136
- # 4. Add to [Content_Types].xml
137
120
  _add_to_content_types(unpacked_dir, dest)
138
121
 
139
- # 5. Add relationship to presentation.xml.rels
140
122
  rid = _add_to_presentation_rels(unpacked_dir, dest)
141
123
 
142
- # 6. Get next slide ID
143
124
  next_slide_id = _get_next_slide_id(unpacked_dir)
144
125
 
145
- # Output
146
126
  print(f"Created {dest} from {source}")
147
127
  print(f'Add to presentation.xml <p:sldIdLst>: <p:sldId id="{next_slide_id}" r:id="{rid}"/>')
148
128
 
149
129
 
150
130
  def _add_to_content_types(unpacked_dir: Path, dest: str) -> None:
151
- """Add new slide to [Content_Types].xml."""
152
131
  content_types_path = unpacked_dir / "[Content_Types].xml"
153
132
  content_types = content_types_path.read_text(encoding="utf-8")
154
133
 
@@ -160,7 +139,6 @@ def _add_to_content_types(unpacked_dir: Path, dest: str) -> None:
160
139
 
161
140
 
162
141
  def _add_to_presentation_rels(unpacked_dir: Path, dest: str) -> str:
163
- """Add relationship to presentation.xml.rels. Returns the new rId."""
164
142
  pres_rels_path = unpacked_dir / "ppt" / "_rels" / "presentation.xml.rels"
165
143
  pres_rels = pres_rels_path.read_text(encoding="utf-8")
166
144
 
@@ -178,7 +156,6 @@ def _add_to_presentation_rels(unpacked_dir: Path, dest: str) -> str:
178
156
 
179
157
 
180
158
  def _get_next_slide_id(unpacked_dir: Path) -> int:
181
- """Get the next available slide ID for presentation.xml."""
182
159
  pres_path = unpacked_dir / "ppt" / "presentation.xml"
183
160
  pres_content = pres_path.read_text(encoding="utf-8")
184
161
  slide_ids = [int(m) for m in re.findall(r'<p:sldId[^>]*id="(\d+)"', pres_content)]
@@ -186,16 +163,9 @@ def _get_next_slide_id(unpacked_dir: Path) -> int:
186
163
 
187
164
 
188
165
  def parse_source(source: str) -> tuple[str, str | None]:
189
- """Parse source argument to determine if it's a slide or layout.
190
-
191
- Returns:
192
- ("slide", None) if source is a slide file like "slide2.xml"
193
- ("layout", filename) if source is a layout like "slideLayout2.xml"
194
- """
195
166
  if source.startswith("slideLayout") and source.endswith(".xml"):
196
167
  return ("layout", source)
197
168
 
198
- # Otherwise treat as a slide file
199
169
  return ("slide", None)
200
170
 
201
171
 
@@ -1,4 +1,3 @@
1
- #!/usr/bin/env python3
2
1
  """Remove unreferenced files from an unpacked PPTX directory.
3
2
 
4
3
  Usage: python clean.py <unpacked_dir>
@@ -26,14 +25,12 @@ import re
26
25
 
27
26
 
28
27
  def get_slides_in_sldidlst(unpacked_dir: Path) -> set[str]:
29
- """Get slide filenames referenced in presentation.xml sldIdLst."""
30
28
  pres_path = unpacked_dir / "ppt" / "presentation.xml"
31
29
  pres_rels_path = unpacked_dir / "ppt" / "_rels" / "presentation.xml.rels"
32
30
 
33
31
  if not pres_path.exists() or not pres_rels_path.exists():
34
32
  return set()
35
33
 
36
- # Build rId -> slide filename mapping from presentation.xml.rels
37
34
  rels_dom = defusedxml.minidom.parse(str(pres_rels_path))
38
35
  rid_to_slide = {}
39
36
  for rel in rels_dom.getElementsByTagName("Relationship"):
@@ -43,16 +40,13 @@ def get_slides_in_sldidlst(unpacked_dir: Path) -> set[str]:
43
40
  if "slide" in rel_type and target.startswith("slides/"):
44
41
  rid_to_slide[rid] = target.replace("slides/", "")
45
42
 
46
- # Get rIds from sldIdLst in presentation.xml
47
43
  pres_content = pres_path.read_text(encoding="utf-8")
48
44
  referenced_rids = set(re.findall(r'<p:sldId[^>]*r:id="([^"]+)"', pres_content))
49
45
 
50
- # Return slide filenames that are in sldIdLst
51
46
  return {rid_to_slide[rid] for rid in referenced_rids if rid in rid_to_slide}
52
47
 
53
48
 
54
49
  def remove_orphaned_slides(unpacked_dir: Path) -> list[str]:
55
- """Remove slides not referenced in sldIdLst."""
56
50
  slides_dir = unpacked_dir / "ppt" / "slides"
57
51
  slides_rels_dir = slides_dir / "_rels"
58
52
  pres_rels_path = unpacked_dir / "ppt" / "_rels" / "presentation.xml.rels"
@@ -63,21 +57,17 @@ def remove_orphaned_slides(unpacked_dir: Path) -> list[str]:
63
57
  referenced_slides = get_slides_in_sldidlst(unpacked_dir)
64
58
  removed = []
65
59
 
66
- # Find and remove orphaned slide files
67
60
  for slide_file in slides_dir.glob("slide*.xml"):
68
61
  if slide_file.name not in referenced_slides:
69
- # Remove slide file
70
62
  rel_path = slide_file.relative_to(unpacked_dir)
71
63
  slide_file.unlink()
72
64
  removed.append(str(rel_path))
73
65
 
74
- # Remove slide rels file
75
66
  rels_file = slides_rels_dir / f"{slide_file.name}.rels"
76
67
  if rels_file.exists():
77
68
  rels_file.unlink()
78
69
  removed.append(str(rels_file.relative_to(unpacked_dir)))
79
70
 
80
- # Remove relationships from presentation.xml.rels for deleted slides
81
71
  if removed and pres_rels_path.exists():
82
72
  rels_dom = defusedxml.minidom.parse(str(pres_rels_path))
83
73
  changed = False
@@ -99,7 +89,6 @@ def remove_orphaned_slides(unpacked_dir: Path) -> list[str]:
99
89
 
100
90
 
101
91
  def remove_trash_directory(unpacked_dir: Path) -> list[str]:
102
- """Remove [trash] directory if it exists."""
103
92
  trash_dir = unpacked_dir / "[trash]"
104
93
  removed = []
105
94
 
@@ -115,7 +104,6 @@ def remove_trash_directory(unpacked_dir: Path) -> list[str]:
115
104
 
116
105
 
117
106
  def get_slide_referenced_files(unpacked_dir: Path) -> set:
118
- """Get files referenced directly from slides."""
119
107
  referenced = set()
120
108
  slides_rels_dir = unpacked_dir / "ppt" / "slides" / "_rels"
121
109
 
@@ -138,7 +126,6 @@ def get_slide_referenced_files(unpacked_dir: Path) -> set:
138
126
 
139
127
 
140
128
  def remove_orphaned_rels_files(unpacked_dir: Path) -> list[str]:
141
- """Remove .rels files for unreferenced resources."""
142
129
  resource_dirs = ["charts", "diagrams", "drawings"]
143
130
  removed = []
144
131
  slide_referenced = get_slide_referenced_files(unpacked_dir)
@@ -164,7 +151,6 @@ def remove_orphaned_rels_files(unpacked_dir: Path) -> list[str]:
164
151
 
165
152
 
166
153
  def get_referenced_files(unpacked_dir: Path) -> set:
167
- """Get all files referenced in .rels files."""
168
154
  referenced = set()
169
155
 
170
156
  for rels_file in unpacked_dir.rglob("*.rels"):
@@ -183,7 +169,6 @@ def get_referenced_files(unpacked_dir: Path) -> set:
183
169
 
184
170
 
185
171
  def remove_orphaned_files(unpacked_dir: Path, referenced: set) -> list[str]:
186
- """Remove files not in the referenced set."""
187
172
  resource_dirs = ["media", "embeddings", "charts", "diagrams", "tags", "drawings", "ink"]
188
173
  removed = []
189
174
 
@@ -200,7 +185,6 @@ def remove_orphaned_files(unpacked_dir: Path, referenced: set) -> list[str]:
200
185
  file_path.unlink()
201
186
  removed.append(str(rel_path))
202
187
 
203
- # Clean up unreferenced theme files
204
188
  theme_dir = unpacked_dir / "ppt" / "theme"
205
189
  if theme_dir.exists():
206
190
  for file_path in theme_dir.glob("theme*.xml"):
@@ -208,13 +192,11 @@ def remove_orphaned_files(unpacked_dir: Path, referenced: set) -> list[str]:
208
192
  if rel_path not in referenced:
209
193
  file_path.unlink()
210
194
  removed.append(str(rel_path))
211
- # Also remove corresponding .rels
212
195
  theme_rels = theme_dir / "_rels" / f"{file_path.name}.rels"
213
196
  if theme_rels.exists():
214
197
  theme_rels.unlink()
215
198
  removed.append(str(theme_rels.relative_to(unpacked_dir)))
216
199
 
217
- # Remove orphaned notes slides
218
200
  notes_dir = unpacked_dir / "ppt" / "notesSlides"
219
201
  if notes_dir.exists():
220
202
  for file_path in notes_dir.glob("*.xml"):
@@ -237,7 +219,6 @@ def remove_orphaned_files(unpacked_dir: Path, referenced: set) -> list[str]:
237
219
 
238
220
 
239
221
  def update_content_types(unpacked_dir: Path, removed_files: list[str]) -> None:
240
- """Remove Content-Type overrides for deleted files."""
241
222
  ct_path = unpacked_dir / "[Content_Types].xml"
242
223
  if not ct_path.exists():
243
224
  return
@@ -258,18 +239,14 @@ def update_content_types(unpacked_dir: Path, removed_files: list[str]) -> None:
258
239
 
259
240
 
260
241
  def clean_unused_files(unpacked_dir: Path) -> list[str]:
261
- """Remove all unreferenced files from the unpacked directory."""
262
242
  all_removed = []
263
243
 
264
- # Remove orphaned slides first (not in sldIdLst)
265
244
  slides_removed = remove_orphaned_slides(unpacked_dir)
266
245
  all_removed.extend(slides_removed)
267
246
 
268
- # Remove [trash] directory
269
247
  trash_removed = remove_trash_directory(unpacked_dir)
270
248
  all_removed.extend(trash_removed)
271
249
 
272
- # Keep cleaning until nothing more is removed
273
250
  while True:
274
251
  removed_rels = remove_orphaned_rels_files(unpacked_dir)
275
252
  referenced = get_referenced_files(unpacked_dir)