@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
@@ -9,7 +9,6 @@ from pathlib import Path
9
9
 
10
10
 
11
11
  class RedliningValidator:
12
- """Validator for tracked changes in Word documents."""
13
12
 
14
13
  def __init__(self, unpacked_dir, original_docx, verbose=False, author="Claude"):
15
14
  self.unpacked_dir = Path(unpacked_dir)
@@ -21,29 +20,23 @@ class RedliningValidator:
21
20
  }
22
21
 
23
22
  def repair(self) -> int:
24
- """No auto-repairs for redlining validation. Returns 0."""
25
23
  return 0
26
24
 
27
25
  def validate(self):
28
- """Main validation method that returns True if valid, False otherwise."""
29
- # Verify unpacked directory exists and has correct structure
30
26
  modified_file = self.unpacked_dir / "word" / "document.xml"
31
27
  if not modified_file.exists():
32
28
  print(f"FAILED - Modified document.xml not found at {modified_file}")
33
29
  return False
34
30
 
35
- # First, check if there are any tracked changes by the author to validate
36
31
  try:
37
32
  import xml.etree.ElementTree as ET
38
33
 
39
34
  tree = ET.parse(modified_file)
40
35
  root = tree.getroot()
41
36
 
42
- # Check for w:del or w:ins tags by the specified author
43
37
  del_elements = root.findall(".//w:del", self.namespaces)
44
38
  ins_elements = root.findall(".//w:ins", self.namespaces)
45
39
 
46
- # Filter to only include changes by the specified author
47
40
  author_del_elements = [
48
41
  elem
49
42
  for elem in del_elements
@@ -55,21 +48,17 @@ class RedliningValidator:
55
48
  if elem.get(f"{{{self.namespaces['w']}}}author") == self.author
56
49
  ]
57
50
 
58
- # Redlining validation is only needed if tracked changes by the author have been used.
59
51
  if not author_del_elements and not author_ins_elements:
60
52
  if self.verbose:
61
53
  print(f"PASSED - No tracked changes by {self.author} found.")
62
54
  return True
63
55
 
64
56
  except Exception:
65
- # If we can't parse the XML, continue with full validation
66
57
  pass
67
58
 
68
- # Create temporary directory for unpacking original docx
69
59
  with tempfile.TemporaryDirectory() as temp_dir:
70
60
  temp_path = Path(temp_dir)
71
61
 
72
- # Unpack original docx
73
62
  try:
74
63
  with zipfile.ZipFile(self.original_docx, "r") as zip_ref:
75
64
  zip_ref.extractall(temp_path)
@@ -84,7 +73,6 @@ class RedliningValidator:
84
73
  )
85
74
  return False
86
75
 
87
- # Parse both XML files using xml.etree.ElementTree for redlining validation
88
76
  try:
89
77
  import xml.etree.ElementTree as ET
90
78
 
@@ -96,16 +84,13 @@ class RedliningValidator:
96
84
  print(f"FAILED - Error parsing XML files: {e}")
97
85
  return False
98
86
 
99
- # Remove the author's tracked changes from both documents
100
87
  self._remove_author_tracked_changes(original_root)
101
88
  self._remove_author_tracked_changes(modified_root)
102
89
 
103
- # Extract and compare text content
104
90
  modified_text = self._extract_text_content(modified_root)
105
91
  original_text = self._extract_text_content(original_root)
106
92
 
107
93
  if modified_text != original_text:
108
- # Show detailed character-level differences for each paragraph
109
94
  error_message = self._generate_detailed_diff(
110
95
  original_text, modified_text
111
96
  )
@@ -117,7 +102,6 @@ class RedliningValidator:
117
102
  return True
118
103
 
119
104
  def _generate_detailed_diff(self, original_text, modified_text):
120
- """Generate detailed word-level differences using git word diff."""
121
105
  error_parts = [
122
106
  f"FAILED - Document text doesn't match after removing {self.author}'s tracked changes",
123
107
  "",
@@ -132,7 +116,6 @@ class RedliningValidator:
132
116
  "",
133
117
  ]
134
118
 
135
- # Show git word diff
136
119
  git_diff = self._get_git_word_diff(original_text, modified_text)
137
120
  if git_diff:
138
121
  error_parts.extend(["Differences:", "============", git_diff])
@@ -142,26 +125,23 @@ class RedliningValidator:
142
125
  return "\n".join(error_parts)
143
126
 
144
127
  def _get_git_word_diff(self, original_text, modified_text):
145
- """Generate word diff using git with character-level precision."""
146
128
  try:
147
129
  with tempfile.TemporaryDirectory() as temp_dir:
148
130
  temp_path = Path(temp_dir)
149
131
 
150
- # Create two files
151
132
  original_file = temp_path / "original.txt"
152
133
  modified_file = temp_path / "modified.txt"
153
134
 
154
135
  original_file.write_text(original_text, encoding="utf-8")
155
136
  modified_file.write_text(modified_text, encoding="utf-8")
156
137
 
157
- # Try character-level diff first for precise differences
158
138
  result = subprocess.run(
159
139
  [
160
140
  "git",
161
141
  "diff",
162
142
  "--word-diff=plain",
163
- "--word-diff-regex=.", # Character-by-character diff
164
- "-U0", # Zero lines of context - show only changed lines
143
+ "--word-diff-regex=.",
144
+ "-U0",
165
145
  "--no-index",
166
146
  str(original_file),
167
147
  str(modified_file),
@@ -171,9 +151,7 @@ class RedliningValidator:
171
151
  )
172
152
 
173
153
  if result.stdout.strip():
174
- # Clean up the output - remove git diff header lines
175
154
  lines = result.stdout.split("\n")
176
- # Skip the header lines (diff --git, index, +++, ---, @@)
177
155
  content_lines = []
178
156
  in_content = False
179
157
  for line in lines:
@@ -186,13 +164,12 @@ class RedliningValidator:
186
164
  if content_lines:
187
165
  return "\n".join(content_lines)
188
166
 
189
- # Fallback to word-level diff if character-level is too verbose
190
167
  result = subprocess.run(
191
168
  [
192
169
  "git",
193
170
  "diff",
194
171
  "--word-diff=plain",
195
- "-U0", # Zero lines of context
172
+ "-U0",
196
173
  "--no-index",
197
174
  str(original_file),
198
175
  str(modified_file),
@@ -214,18 +191,15 @@ class RedliningValidator:
214
191
  return "\n".join(content_lines)
215
192
 
216
193
  except (subprocess.CalledProcessError, FileNotFoundError, Exception):
217
- # Git not available or other error, return None to use fallback
218
194
  pass
219
195
 
220
196
  return None
221
197
 
222
198
  def _remove_author_tracked_changes(self, root):
223
- """Remove tracked changes authored by the specified author from the XML root."""
224
199
  ins_tag = f"{{{self.namespaces['w']}}}ins"
225
200
  del_tag = f"{{{self.namespaces['w']}}}del"
226
201
  author_attr = f"{{{self.namespaces['w']}}}author"
227
202
 
228
- # Remove w:ins elements
229
203
  for parent in root.iter():
230
204
  to_remove = []
231
205
  for child in parent:
@@ -234,7 +208,6 @@ class RedliningValidator:
234
208
  for elem in to_remove:
235
209
  parent.remove(elem)
236
210
 
237
- # Unwrap content in w:del elements where author matches
238
211
  deltext_tag = f"{{{self.namespaces['w']}}}delText"
239
212
  t_tag = f"{{{self.namespaces['w']}}}t"
240
213
 
@@ -244,36 +217,26 @@ class RedliningValidator:
244
217
  if child.tag == del_tag and child.get(author_attr) == self.author:
245
218
  to_process.append((child, list(parent).index(child)))
246
219
 
247
- # Process in reverse order to maintain indices
248
220
  for del_elem, del_index in reversed(to_process):
249
- # Convert w:delText to w:t before moving
250
221
  for elem in del_elem.iter():
251
222
  if elem.tag == deltext_tag:
252
223
  elem.tag = t_tag
253
224
 
254
- # Move all children of w:del to its parent before removing w:del
255
225
  for child in reversed(list(del_elem)):
256
226
  parent.insert(del_index, child)
257
227
  parent.remove(del_elem)
258
228
 
259
229
  def _extract_text_content(self, root):
260
- """Extract text content from Word XML, preserving paragraph structure.
261
-
262
- Empty paragraphs are skipped to avoid false positives when tracked
263
- insertions add only structural elements without text content.
264
- """
265
230
  p_tag = f"{{{self.namespaces['w']}}}p"
266
231
  t_tag = f"{{{self.namespaces['w']}}}t"
267
232
 
268
233
  paragraphs = []
269
234
  for p_elem in root.findall(f".//{p_tag}"):
270
- # Get all text elements within this paragraph
271
235
  text_parts = []
272
236
  for t_elem in p_elem.findall(f".//{t_tag}"):
273
237
  if t_elem.text:
274
238
  text_parts.append(t_elem.text)
275
239
  paragraph_text = "".join(text_parts)
276
- # Skip empty paragraphs - they don't affect content validation
277
240
  if paragraph_text:
278
241
  paragraphs.append(paragraph_text)
279
242
 
@@ -1,4 +1,3 @@
1
- #!/usr/bin/env python3
2
1
  """Create thumbnail grids from PowerPoint presentation slides.
3
2
 
4
3
  Creates a grid layout of slide thumbnails for quick visual analysis.
@@ -27,7 +26,6 @@ import defusedxml.minidom
27
26
  from office.soffice import get_soffice_env
28
27
  from PIL import Image, ImageDraw, ImageFont
29
28
 
30
- # Constants
31
29
  THUMBNAIL_WIDTH = 300
32
30
  CONVERSION_DPI = 100
33
31
  MAX_COLS = 6
@@ -71,7 +69,6 @@ def main():
71
69
  output_path = Path(f"{args.output_prefix}.jpg")
72
70
 
73
71
  try:
74
- # Get slide info (filenames and hidden status) in presentation order
75
72
  slide_info = get_slide_info(input_path)
76
73
 
77
74
  with tempfile.TemporaryDirectory() as temp_dir:
@@ -82,7 +79,6 @@ def main():
82
79
  print("Error: No slides found", file=sys.stderr)
83
80
  sys.exit(1)
84
81
 
85
- # Build slide list with images (visible) or placeholders (hidden)
86
82
  slides = build_slide_list(slide_info, visible_images, temp_path)
87
83
 
88
84
  grid_files = create_grids(slides, cols, THUMBNAIL_WIDTH, output_path)
@@ -97,12 +93,7 @@ def main():
97
93
 
98
94
 
99
95
  def get_slide_info(pptx_path: Path) -> list[dict]:
100
- """Get slide filenames and hidden status in presentation order.
101
-
102
- Returns list of dicts with 'name' and 'hidden' keys.
103
- """
104
96
  with zipfile.ZipFile(pptx_path, "r") as zf:
105
- # Read presentation.xml.rels to get rId -> slide filename mapping
106
97
  rels_content = zf.read("ppt/_rels/presentation.xml.rels").decode("utf-8")
107
98
  rels_dom = defusedxml.minidom.parseString(rels_content)
108
99
 
@@ -114,7 +105,6 @@ def get_slide_info(pptx_path: Path) -> list[dict]:
114
105
  if "slide" in rel_type and target.startswith("slides/"):
115
106
  rid_to_slide[rid] = target.replace("slides/", "")
116
107
 
117
- # Read presentation.xml to get slide order and hidden status
118
108
  pres_content = zf.read("ppt/presentation.xml").decode("utf-8")
119
109
  pres_dom = defusedxml.minidom.parseString(pres_content)
120
110
 
@@ -122,7 +112,6 @@ def get_slide_info(pptx_path: Path) -> list[dict]:
122
112
  for sld_id in pres_dom.getElementsByTagName("p:sldId"):
123
113
  rid = sld_id.getAttribute("r:id")
124
114
  if rid in rid_to_slide:
125
- # Check if slide is hidden (show="0")
126
115
  hidden = sld_id.getAttribute("show") == "0"
127
116
  slides.append({"name": rid_to_slide[rid], "hidden": hidden})
128
117
 
@@ -134,11 +123,6 @@ def build_slide_list(
134
123
  visible_images: list[Path],
135
124
  temp_dir: Path,
136
125
  ) -> list[tuple[Path, str]]:
137
- """Build list of (image_path, slide_name) tuples.
138
-
139
- Hidden slides get placeholder images.
140
- """
141
- # Get placeholder size from first visible image
142
126
  if visible_images:
143
127
  with Image.open(visible_images[0]) as img:
144
128
  placeholder_size = img.size
@@ -150,13 +134,11 @@ def build_slide_list(
150
134
 
151
135
  for info in slide_info:
152
136
  if info["hidden"]:
153
- # Create placeholder for hidden slide
154
137
  placeholder_path = temp_dir / f"hidden-{info['name']}.jpg"
155
138
  placeholder_img = create_hidden_placeholder(placeholder_size)
156
139
  placeholder_img.save(placeholder_path, "JPEG")
157
140
  slides.append((placeholder_path, f"{info['name']} (hidden)"))
158
141
  else:
159
- # Use visible image
160
142
  if visible_idx < len(visible_images):
161
143
  slides.append((visible_images[visible_idx], info["name"]))
162
144
  visible_idx += 1
@@ -165,7 +147,6 @@ def build_slide_list(
165
147
 
166
148
 
167
149
  def create_hidden_placeholder(size: tuple[int, int]) -> Image.Image:
168
- """Create placeholder image for hidden slides (gray with X pattern)."""
169
150
  img = Image.new("RGB", size, color="#F0F0F0")
170
151
  draw = ImageDraw.Draw(img)
171
152
  line_width = max(5, min(size) // 100)
@@ -175,10 +156,8 @@ def create_hidden_placeholder(size: tuple[int, int]) -> Image.Image:
175
156
 
176
157
 
177
158
  def convert_to_images(pptx_path: Path, temp_dir: Path) -> list[Path]:
178
- """Convert PowerPoint to images via PDF."""
179
159
  pdf_path = temp_dir / f"{pptx_path.stem}.pdf"
180
160
 
181
- # Convert to PDF
182
161
  result = subprocess.run(
183
162
  [
184
163
  "soffice",
@@ -196,7 +175,6 @@ def convert_to_images(pptx_path: Path, temp_dir: Path) -> list[Path]:
196
175
  if result.returncode != 0 or not pdf_path.exists():
197
176
  raise RuntimeError("PDF conversion failed")
198
177
 
199
- # Convert PDF to images
200
178
  result = subprocess.run(
201
179
  [
202
180
  "pdftoppm",
@@ -221,7 +199,6 @@ def create_grids(
221
199
  width: int,
222
200
  output_path: Path,
223
201
  ) -> list[str]:
224
- """Create thumbnail grids, max cols×(cols+1) images per grid."""
225
202
  max_per_grid = cols * (cols + 1)
226
203
  grid_files = []
227
204
 
@@ -250,21 +227,17 @@ def create_grid(
250
227
  cols: int,
251
228
  width: int,
252
229
  ) -> Image.Image:
253
- """Create a single thumbnail grid."""
254
230
  font_size = int(width * FONT_SIZE_RATIO)
255
231
  label_padding = int(font_size * LABEL_PADDING_RATIO)
256
232
 
257
- # Get dimensions from first image
258
233
  with Image.open(slides[0][0]) as img:
259
234
  aspect = img.height / img.width
260
235
  height = int(width * aspect)
261
236
 
262
- # Calculate grid size
263
237
  rows = (len(slides) + cols - 1) // cols
264
238
  grid_w = cols * width + (cols + 1) * GRID_PADDING
265
239
  grid_h = rows * (height + font_size + label_padding * 2) + (rows + 1) * GRID_PADDING
266
240
 
267
- # Create grid
268
241
  grid = Image.new("RGB", (grid_w, grid_h), "white")
269
242
  draw = ImageDraw.Draw(grid)
270
243
 
@@ -273,7 +246,6 @@ def create_grid(
273
246
  except Exception:
274
247
  font = ImageFont.load_default()
275
248
 
276
- # Place thumbnails
277
249
  for i, (img_path, slide_name) in enumerate(slides):
278
250
  row, col = i // cols, i % cols
279
251
  x = col * width + (col + 1) * GRID_PADDING
@@ -281,7 +253,6 @@ def create_grid(
281
253
  row * (height + font_size + label_padding * 2) + (row + 1) * GRID_PADDING
282
254
  )
283
255
 
284
- # Add slide filename label
285
256
  label = slide_name
286
257
  bbox = draw.textbbox((0, 0), label, font=font)
287
258
  text_w = bbox[2] - bbox[0]
@@ -292,7 +263,6 @@ def create_grid(
292
263
  font=font,
293
264
  )
294
265
 
295
- # Add thumbnail
296
266
  y_thumbnail = y_base + label_padding + font_size + label_padding
297
267
 
298
268
  with Image.open(img_path) as img:
@@ -302,7 +272,6 @@ def create_grid(
302
272
  ty = y_thumbnail + (height - h) // 2
303
273
  grid.paste(img, (tx, ty))
304
274
 
305
- # Add border
306
275
  if BORDER_WIDTH > 0:
307
276
  draw.rectangle(
308
277
  [
@@ -4,29 +4,6 @@ description: "Use this skill any time a spreadsheet file is the primary input or
4
4
  license: Proprietary. LICENSE.txt has complete terms
5
5
  ---
6
6
 
7
- # Excel / Spreadsheet Skill
8
-
9
- ## IMPORTANT: Save to Desktop
10
-
11
- **Always save created `.xlsx` and `.csv` files to `~/Desktop/`** (e.g. `~/Desktop/spreadsheet.xlsx`). 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-sheets` CLI (For Google Sheets)
16
- If user wants a Google Sheet (shareable, collaborative), use `lemon-sheets`:
17
- - `lemon-sheets create "Title"` - Create a new sheet
18
- - `lemon-sheets read <id>` - Read a spreadsheet
19
- - `lemon-sheets update <id>` - Update cell values
20
- - `lemon-sheets append <id>` - Add rows
21
-
22
- ### 2. Local XLSX (For Files)
23
- If user wants a local `.xlsx` file, or `lemon-sheets` is not connected, use the local creation methods below.
24
-
25
- ### 3. Browser (LAST RESORT)
26
- Only if `lemon-sheets` CLI fails AND user explicitly requests Google Sheets in browser.
27
-
28
- ---
29
-
30
7
  # Requirements for Outputs
31
8
 
32
9
  ## All Excel files
@@ -114,7 +91,7 @@ df.info() # Column info
114
91
  df.describe() # Statistics
115
92
 
116
93
  # Write Excel
117
- df.to_excel(os.path.expanduser('~/Desktop/output.xlsx'), index=False)
94
+ df.to_excel('output.xlsx', index=False)
118
95
  ```
119
96
 
120
97
  ## Excel File Workflows
@@ -197,7 +174,7 @@ sheet['A1'].alignment = Alignment(horizontal='center')
197
174
  # Column width
198
175
  sheet.column_dimensions['A'].width = 20
199
176
 
200
- wb.save(os.path.expanduser('~/Desktop/output.xlsx'))
177
+ wb.save('output.xlsx')
201
178
  ```
202
179
 
203
180
  ### Editing existing Excel files
@@ -224,7 +201,7 @@ sheet.delete_cols(3) # Delete column 3
224
201
  new_sheet = wb.create_sheet('NewSheet')
225
202
  new_sheet['A1'] = 'Data'
226
203
 
227
- wb.save(os.path.expanduser('~/Desktop/modified.xlsx'))
204
+ wb.save('modified.xlsx')
228
205
  ```
229
206
 
230
207
  ## Recalculating formulas
@@ -14,14 +14,6 @@ import defusedxml.minidom
14
14
 
15
15
 
16
16
  def merge_runs(input_dir: str) -> tuple[int, str]:
17
- """Merge adjacent runs in document.xml.
18
-
19
- Args:
20
- input_dir: Path to unpacked DOCX directory
21
-
22
- Returns:
23
- (merge_count, message)
24
- """
25
17
  doc_xml = Path(input_dir) / "word" / "document.xml"
26
18
 
27
19
  if not doc_xml.exists():
@@ -31,14 +23,11 @@ def merge_runs(input_dir: str) -> tuple[int, str]:
31
23
  dom = defusedxml.minidom.parseString(doc_xml.read_text(encoding="utf-8"))
32
24
  root = dom.documentElement
33
25
 
34
- # Clean up elements that block merging
35
26
  _remove_elements(root, "proofErr")
36
27
  _strip_run_rsid_attrs(root)
37
28
 
38
- # Find all containers that have runs
39
29
  containers = {run.parentNode for run in _find_elements(root, "r")}
40
30
 
41
- # Merge runs in each container
42
31
  merge_count = 0
43
32
  for container in containers:
44
33
  merge_count += _merge_runs_in(container)
@@ -50,11 +39,9 @@ def merge_runs(input_dir: str) -> tuple[int, str]:
50
39
  return 0, f"Error: {e}"
51
40
 
52
41
 
53
- # --- Element helpers ---
54
42
 
55
43
 
56
44
  def _find_elements(root, tag: str) -> list:
57
- """Find all elements matching tag name (with or without namespace)."""
58
45
  results = []
59
46
 
60
47
  def traverse(node):
@@ -70,7 +57,6 @@ def _find_elements(root, tag: str) -> list:
70
57
 
71
58
 
72
59
  def _get_child(parent, tag: str):
73
- """Get first child element matching tag name."""
74
60
  for child in parent.childNodes:
75
61
  if child.nodeType == child.ELEMENT_NODE:
76
62
  name = child.localName or child.tagName
@@ -80,7 +66,6 @@ def _get_child(parent, tag: str):
80
66
 
81
67
 
82
68
  def _get_children(parent, tag: str) -> list:
83
- """Get all direct child elements matching tag name."""
84
69
  results = []
85
70
  for child in parent.childNodes:
86
71
  if child.nodeType == child.ELEMENT_NODE:
@@ -91,7 +76,6 @@ def _get_children(parent, tag: str) -> list:
91
76
 
92
77
 
93
78
  def _is_adjacent(elem1, elem2) -> bool:
94
- """Check if two elements are adjacent (only whitespace between them)."""
95
79
  node = elem1.nextSibling
96
80
  while node:
97
81
  if node == elem2:
@@ -104,34 +88,28 @@ def _is_adjacent(elem1, elem2) -> bool:
104
88
  return False
105
89
 
106
90
 
107
- # --- Cleanup functions ---
108
91
 
109
92
 
110
93
  def _remove_elements(root, tag: str):
111
- """Remove all elements matching tag name."""
112
94
  for elem in _find_elements(root, tag):
113
95
  if elem.parentNode:
114
96
  elem.parentNode.removeChild(elem)
115
97
 
116
98
 
117
99
  def _strip_run_rsid_attrs(root):
118
- """Remove rsid attributes from all run elements."""
119
100
  for run in _find_elements(root, "r"):
120
101
  for attr in list(run.attributes.values()):
121
102
  if "rsid" in attr.name.lower():
122
103
  run.removeAttribute(attr.name)
123
104
 
124
105
 
125
- # --- Merge functions ---
126
106
 
127
107
 
128
108
  def _merge_runs_in(container) -> int:
129
- """Merge adjacent runs with identical formatting in a container element."""
130
109
  merge_count = 0
131
110
  run = _first_child_run(container)
132
111
 
133
112
  while run:
134
- # Absorb adjacent runs with same formatting
135
113
  while True:
136
114
  next_elem = _next_element_sibling(run)
137
115
  if next_elem and _is_run(next_elem) and _can_merge(run, next_elem):
@@ -148,7 +126,6 @@ def _merge_runs_in(container) -> int:
148
126
 
149
127
 
150
128
  def _first_child_run(container):
151
- """Get the first run child of a container."""
152
129
  for child in container.childNodes:
153
130
  if child.nodeType == child.ELEMENT_NODE and _is_run(child):
154
131
  return child
@@ -156,7 +133,6 @@ def _first_child_run(container):
156
133
 
157
134
 
158
135
  def _next_element_sibling(node):
159
- """Get the next element sibling, skipping text/whitespace nodes."""
160
136
  sibling = node.nextSibling
161
137
  while sibling:
162
138
  if sibling.nodeType == sibling.ELEMENT_NODE:
@@ -166,25 +142,21 @@ def _next_element_sibling(node):
166
142
 
167
143
 
168
144
  def _next_sibling_run(node):
169
- """Get the next sibling that is a run element."""
170
145
  sibling = node.nextSibling
171
146
  while sibling:
172
147
  if sibling.nodeType == sibling.ELEMENT_NODE:
173
148
  if _is_run(sibling):
174
149
  return sibling
175
- # Skip non-run elements (bookmarks, etc.) but keep looking
176
150
  sibling = sibling.nextSibling
177
151
  return None
178
152
 
179
153
 
180
154
  def _is_run(node) -> bool:
181
- """Check if node is a run element."""
182
155
  name = node.localName or node.tagName
183
156
  return name == "r" or name.endswith(":r")
184
157
 
185
158
 
186
159
  def _can_merge(run1, run2) -> bool:
187
- """Check if two runs have identical formatting."""
188
160
  rpr1 = _get_child(run1, "rPr")
189
161
  rpr2 = _get_child(run2, "rPr")
190
162
 
@@ -192,11 +164,10 @@ def _can_merge(run1, run2) -> bool:
192
164
  return False
193
165
  if rpr1 is None:
194
166
  return True
195
- return rpr1.toxml() == rpr2.toxml() # type: ignore
167
+ return rpr1.toxml() == rpr2.toxml()
196
168
 
197
169
 
198
170
  def _merge_run_content(target, source):
199
- """Move content from source run to target run (excluding rPr)."""
200
171
  for child in list(source.childNodes):
201
172
  if child.nodeType == child.ELEMENT_NODE:
202
173
  name = child.localName or child.tagName
@@ -205,10 +176,8 @@ def _merge_run_content(target, source):
205
176
 
206
177
 
207
178
  def _consolidate_text(run):
208
- """Merge adjacent <w:t> elements within a run."""
209
179
  t_elements = _get_children(run, "t")
210
180
 
211
- # Work backwards to safely remove elements
212
181
  for i in range(len(t_elements) - 1, 0, -1):
213
182
  curr, prev = t_elements[i], t_elements[i - 1]
214
183
 
@@ -222,7 +191,6 @@ def _consolidate_text(run):
222
191
  else:
223
192
  prev.appendChild(run.ownerDocument.createTextNode(merged))
224
193
 
225
- # Preserve whitespace if needed
226
194
  if merged.startswith(" ") or merged.endswith(" "):
227
195
  prev.setAttribute("xml:space", "preserve")
228
196
  elif prev.hasAttribute("xml:space"):