@heylemon/lemonade 0.1.7 → 0.1.9
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.
- package/dist/build-info.json +3 -3
- package/dist/canvas-host/a2ui/.bundle.hash +1 -1
- package/package.json +1 -1
- package/skills/docx/SKILL.md +161 -23
- package/skills/docx/scripts/fix_styles.py +394 -0
- package/skills/docx/scripts/fix_tables.py +118 -0
- package/skills/pptx/SKILL.md +52 -0
- package/skills/pptx/pptxgenjs.md +49 -7
- package/skills/pptx/scripts/create_pptx.js +2 -2
package/dist/build-info.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
b3ce500e7c71ed0a3415b4d2d8e472926cb0135cdb8a2f66ab05abb127c334ac
|
package/package.json
CHANGED
package/skills/docx/SKILL.md
CHANGED
|
@@ -7,14 +7,30 @@ description: "Create professional, polished documents — reports, memos, propos
|
|
|
7
7
|
|
|
8
8
|
Create polished, production-ready documents using docx-js, the clean standards-compliant Node.js library for producing .docx files. This skill writes bespoke code for each request rather than using JSON templates — you have full control over the document API.
|
|
9
9
|
|
|
10
|
+
## ⚠️ MANDATORY: Post-Processing After Every Build
|
|
11
|
+
|
|
12
|
+
docx-js has TWO bugs that cause documents to look wrong in Apple Pages and Google Docs. **After generating ANY document, you MUST run BOTH scripts:**
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
# Step 1: Fix table column grids (tables break in Pages without this)
|
|
16
|
+
python scripts/fix_tables.py output.docx
|
|
17
|
+
|
|
18
|
+
# Step 2: Fix heading styles (headings show wrong colors/fonts in Pages without this)
|
|
19
|
+
python scripts/fix_styles.py output.docx
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
These are not optional. Without fix_tables.py, tables render as 1-character-wide columns in Pages. Without fix_styles.py, headings appear in default blue instead of your custom colors. Run both immediately after `node create-doc.js`, before delivering the file. The scripts are located in the same skill folder as this SKILL.md.
|
|
23
|
+
|
|
10
24
|
## Quick Reference
|
|
11
25
|
|
|
12
26
|
| Task | Approach | Tool |
|
|
13
27
|
|------|----------|------|
|
|
14
|
-
| **Create new document** | Write docx-js code
|
|
28
|
+
| **Create new document** | Write docx-js code, then run both fix scripts | Node.js + docx-js |
|
|
15
29
|
| **Read/analyze existing** | Unzip → read document.xml or use pandoc for content extraction | unzip or pandoc |
|
|
16
30
|
| **Edit existing file** | Unzip → modify XML → repack into .docx | unzip + JSZip |
|
|
17
31
|
| **Validate output** | Check XML, fonts, structure, paragraphs | `python scripts/validate.py` |
|
|
32
|
+
| **Fix tables (MANDATORY)** | Patch tblGrid for cross-platform | `python scripts/fix_tables.py output.docx` |
|
|
33
|
+
| **Fix styles (MANDATORY)** | Patch heading styles for cross-platform | `python scripts/fix_styles.py output.docx` |
|
|
18
34
|
| **Visual QA** | Convert to PDF then to images | soffice + pdftoppm |
|
|
19
35
|
|
|
20
36
|
---
|
|
@@ -102,33 +118,37 @@ const COLORS = {
|
|
|
102
118
|
|
|
103
119
|
### Creating Headings with Styles
|
|
104
120
|
|
|
105
|
-
|
|
121
|
+
**CRITICAL: Use `children` with TextRun, NOT the `run` property.** The `run` property puts formatting in `pPr/rPr` (paragraph default run properties) but the actual `<w:r>` gets no `<w:rPr>`. Word inherits from `pPr/rPr` just fine, but Apple Pages reads from `styles.xml` instead and ignores inline paragraph-level overrides. By using `children` with an explicit `TextRun`, the formatting goes directly on the run itself, which Pages always respects.
|
|
106
122
|
|
|
107
123
|
```javascript
|
|
124
|
+
// ✅ CORRECT — formatting on the TextRun (works in Pages)
|
|
108
125
|
new Paragraph({
|
|
109
|
-
text: "Section Title",
|
|
110
126
|
heading: HeadingLevel.HEADING_1,
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
127
|
+
spacing: { before: 480, after: 160 },
|
|
128
|
+
children: [
|
|
129
|
+
new TextRun({
|
|
130
|
+
text: "Section Title",
|
|
131
|
+
bold: true,
|
|
132
|
+
size: TYPOGRAPHY.h1,
|
|
133
|
+
color: COLORS.primary,
|
|
134
|
+
font: "Arial"
|
|
135
|
+
})
|
|
136
|
+
]
|
|
120
137
|
});
|
|
121
138
|
|
|
139
|
+
// ❌ WRONG — formatting on `run` property (breaks in Pages)
|
|
122
140
|
new Paragraph({
|
|
123
141
|
text: "Subsection",
|
|
124
|
-
heading: HeadingLevel.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
spacing: { before: 360, after: 120 }
|
|
142
|
+
heading: HeadingLevel.HEADING_1,
|
|
143
|
+
style: "Heading1",
|
|
144
|
+
run: { bold: true, size: TYPOGRAPHY.h1, color: COLORS.primary, font: "Arial" },
|
|
145
|
+
spacing: { before: 480, after: 160 }
|
|
129
146
|
});
|
|
130
147
|
```
|
|
131
148
|
|
|
149
|
+
Also note: use `italics: true` (with 's'), not `italic: true`. The latter silently does nothing in docx-js.
|
|
150
|
+
```
|
|
151
|
+
|
|
132
152
|
### Lists: Never Use Unicode Bullets
|
|
133
153
|
|
|
134
154
|
CRITICAL: Never manually insert bullet characters like •, ◦, ▪. Use the list APIs:
|
|
@@ -324,7 +344,8 @@ Or use a more sophisticated approach with field codes (requires XML manipulation
|
|
|
324
344
|
- Set both table width AND individual cell widths in DXA
|
|
325
345
|
- Use `ShadingType.CLEAR` for cell backgrounds
|
|
326
346
|
- Use `HeadingLevel.HEADING_1`, etc., with `outlineLevel` for TOC
|
|
327
|
-
-
|
|
347
|
+
- Use `children` with TextRun for headings, NOT the `run` property (Pages ignores `run`)
|
|
348
|
+
- Use `italics: true` (with 's'), not `italic: true`
|
|
328
349
|
- Set `spacing.before` and `spacing.after` explicitly on headings
|
|
329
350
|
|
|
330
351
|
**DON'T:**
|
|
@@ -336,6 +357,8 @@ Or use a more sophisticated approach with field codes (requires XML manipulation
|
|
|
336
357
|
- Forget cell width specifications (columns collapse)
|
|
337
358
|
- Use `ShadingType.SOLID` (use CLEAR)
|
|
338
359
|
- Assume A4 page size (always set explicitly to US Letter)
|
|
360
|
+
- Use `run` property on headings (use `children` with TextRun instead — Pages ignores `run`)
|
|
361
|
+
- Use `italic: true` without the 's' (use `italics: true`)
|
|
339
362
|
- Mix font styles — stick to 2 fonts (heading + body)
|
|
340
363
|
|
|
341
364
|
---
|
|
@@ -366,7 +389,7 @@ Even if the code is perfect, content matters. Follow these principles:
|
|
|
366
389
|
### Callouts
|
|
367
390
|
- Use callouts for key decisions, critical warnings, or bottom-line takeaways.
|
|
368
391
|
- Maximum one per major section — if everything is highlighted, nothing stands out.
|
|
369
|
-
-
|
|
392
|
+
- **Always use paragraph borders** (left border + shading), never narrow table cells as accent stripes. Narrow table cells break in Apple Pages. See "Cross-Platform Compatibility" section.
|
|
370
393
|
|
|
371
394
|
---
|
|
372
395
|
|
|
@@ -428,7 +451,39 @@ The validator checks:
|
|
|
428
451
|
- Empty headings
|
|
429
452
|
- Manual bullet characters (should use List APIs)
|
|
430
453
|
|
|
431
|
-
### 2.
|
|
454
|
+
### 2. Fix Tables for Cross-Platform (MANDATORY if document contains tables)
|
|
455
|
+
|
|
456
|
+
docx-js has a bug where table grid definitions (`tblGrid`) don't match cell widths. This causes tables to render as 1-character-wide columns in Apple Pages, Google Docs, and LibreOffice. Run this fix on every document that contains tables:
|
|
457
|
+
|
|
458
|
+
```bash
|
|
459
|
+
python scripts/fix_tables.py report.docx
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
This script:
|
|
463
|
+
- Reads each table's first-row cell widths (`tcW`)
|
|
464
|
+
- Patches the `tblGrid` column definitions to match
|
|
465
|
+
- Adds `tblLayout type="fixed"` for consistent rendering
|
|
466
|
+
- Overwrites the file in place (or pass a second arg for a new file)
|
|
467
|
+
|
|
468
|
+
**Always run this after generating any document with tables.** Without it, tables will look correct in Word but break in Pages and Google Docs.
|
|
469
|
+
|
|
470
|
+
### 3. Fix Heading Styles for Cross-Platform (MANDATORY)
|
|
471
|
+
|
|
472
|
+
docx-js generates default blue heading styles in `styles.xml` regardless of your inline formatting. Word uses inline run properties, but Pages reads `styles.xml` first, causing headings to appear in the wrong color/font/size.
|
|
473
|
+
|
|
474
|
+
```bash
|
|
475
|
+
python scripts/fix_styles.py report.docx
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
This script:
|
|
479
|
+
- Reads each heading's actual inline formatting (color, font, size, bold) from the document
|
|
480
|
+
- Patches `styles.xml` heading style definitions to match
|
|
481
|
+
- Adds paragraph spacing to style definitions
|
|
482
|
+
- Overwrites the file in place (or pass a second arg for a new file)
|
|
483
|
+
|
|
484
|
+
**Always run this after generating any document with headings.** Without it, headings will look correct in Word but show default blue colors in Pages.
|
|
485
|
+
|
|
486
|
+
### 4. Visual Check (PDF Conversion)
|
|
432
487
|
|
|
433
488
|
```bash
|
|
434
489
|
# Convert to PDF
|
|
@@ -440,13 +495,15 @@ pdftoppm -jpeg -r 150 report.pdf page
|
|
|
440
495
|
# View: page-1.jpg, page-2.jpg, etc.
|
|
441
496
|
```
|
|
442
497
|
|
|
443
|
-
###
|
|
498
|
+
### 5. Fix & Re-validate
|
|
444
499
|
|
|
445
500
|
If the validator reports issues or the PDF looks off:
|
|
446
501
|
1. Fix the docx-js code
|
|
447
502
|
2. Re-run to regenerate .docx
|
|
448
|
-
3.
|
|
449
|
-
4.
|
|
503
|
+
3. Run `fix_tables.py` again (step 2)
|
|
504
|
+
4. Run `fix_styles.py` again (step 3)
|
|
505
|
+
5. Re-validate with `validate.py`
|
|
506
|
+
6. Re-convert to PDF if visual issues
|
|
450
507
|
|
|
451
508
|
Iterate until both validation passes and visual output looks polished.
|
|
452
509
|
|
|
@@ -591,6 +648,85 @@ sudo apt-get install libreoffice poppler-utils # soffice, pdftoppm
|
|
|
591
648
|
|
|
592
649
|
---
|
|
593
650
|
|
|
651
|
+
## Cross-Platform Compatibility (Pages, Google Docs, LibreOffice)
|
|
652
|
+
|
|
653
|
+
Documents must look correct in Apple Pages, Google Docs, and LibreOffice — not just Microsoft Word. These apps interpret .docx features differently and many advanced Word features break silently.
|
|
654
|
+
|
|
655
|
+
### Features That Break in Pages
|
|
656
|
+
|
|
657
|
+
**Narrow decorative table cells (< 200 DXA):**
|
|
658
|
+
Pages collapses very narrow cells, causing adjacent text to render vertically (one character per line). This is the most common Pages rendering bug.
|
|
659
|
+
|
|
660
|
+
- ❌ **Never:** Use a 120 DXA cell as a colored accent stripe next to a content cell
|
|
661
|
+
- ❌ **Never:** Use single-cell tables as horizontal rule/divider decorations
|
|
662
|
+
- ✅ **Instead:** Use paragraph borders for accent effects:
|
|
663
|
+
|
|
664
|
+
```javascript
|
|
665
|
+
// Callout box — cross-platform safe
|
|
666
|
+
// Uses left border on the paragraph itself, not a narrow table cell
|
|
667
|
+
new Paragraph({
|
|
668
|
+
spacing: { before: 200, after: 200 },
|
|
669
|
+
indent: { left: 360 },
|
|
670
|
+
border: {
|
|
671
|
+
left: { style: BorderStyle.SINGLE, size: 12, color: "2E86AB", space: 10 }
|
|
672
|
+
},
|
|
673
|
+
shading: { fill: "E8F4F8", type: ShadingType.CLEAR },
|
|
674
|
+
children: [
|
|
675
|
+
new TextRun({ text: "Decision needed: ", bold: true, size: 22, color: "1B3A5C" }),
|
|
676
|
+
new TextRun({ text: "Approve the $400K budget by February 28.", size: 22, color: "2C3E50" })
|
|
677
|
+
]
|
|
678
|
+
})
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
**Decorative tables as horizontal lines:**
|
|
682
|
+
```javascript
|
|
683
|
+
// ❌ BREAKS in Pages — tiny table used as accent line
|
|
684
|
+
new Table({
|
|
685
|
+
width: { size: 2880, type: WidthType.DXA },
|
|
686
|
+
rows: [new TableRow({ children: [new TableCell({
|
|
687
|
+
width: { size: 2880, type: WidthType.DXA },
|
|
688
|
+
shading: { fill: "2E86AB", type: ShadingType.CLEAR },
|
|
689
|
+
children: [new Paragraph({ children: [new TextRun({ text: " ", size: 4 })] })]
|
|
690
|
+
})] })]
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
// ✅ WORKS everywhere — paragraph bottom border as accent line
|
|
694
|
+
new Paragraph({
|
|
695
|
+
spacing: { after: 200 },
|
|
696
|
+
border: {
|
|
697
|
+
bottom: { style: BorderStyle.SINGLE, size: 6, color: "2E86AB", space: 8 }
|
|
698
|
+
},
|
|
699
|
+
children: [] // Empty paragraph with just a bottom border
|
|
700
|
+
})
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
### Other Cross-Platform Pitfalls
|
|
704
|
+
|
|
705
|
+
- **Text boxes / floating elements**: Pages and Google Docs don't support them — content disappears or overlaps
|
|
706
|
+
- **Custom XML / content controls**: Ignored by non-Word apps
|
|
707
|
+
- **Complex nested tables**: Keep nesting to 1 level max; Pages handles deeply nested tables poorly
|
|
708
|
+
- **Tab stops for alignment**: Use tables instead — tab rendering varies between apps
|
|
709
|
+
- **Form fields**: Only work in Word
|
|
710
|
+
- **Watermarks**: Render differently or not at all in Pages
|
|
711
|
+
- **SmartArt / ActiveX**: Not supported outside Word
|
|
712
|
+
|
|
713
|
+
### Safe Cross-Platform Features
|
|
714
|
+
|
|
715
|
+
These features render consistently across all apps:
|
|
716
|
+
- Simple tables with explicit cell widths (minimum 400 DXA per cell)
|
|
717
|
+
- Paragraph borders (left, bottom, top — great for callouts and dividers)
|
|
718
|
+
- Paragraph shading/highlighting
|
|
719
|
+
- Bold, italic, underline, font color, font size
|
|
720
|
+
- Heading styles (Heading 1-6)
|
|
721
|
+
- Bullet and numbered lists
|
|
722
|
+
- Page breaks
|
|
723
|
+
- Headers and footers with page numbers
|
|
724
|
+
- Images (inline, not floating)
|
|
725
|
+
|
|
726
|
+
### The 400 DXA Rule
|
|
727
|
+
|
|
728
|
+
**Never create a table cell narrower than 400 DXA (roughly 0.28 inches).** Cells below this width are rendered unpredictably in Pages and LibreOffice. If you need a thin accent stripe, use a paragraph left border instead.
|
|
729
|
+
|
|
594
730
|
## Troubleshooting
|
|
595
731
|
|
|
596
732
|
**Document won't open in Word:** XML malformed. Check for unclosed tags, invalid characters. Run `validate.py` to check structure.
|
|
@@ -603,6 +739,8 @@ sudo apt-get install libreoffice poppler-utils # soffice, pdftoppm
|
|
|
603
739
|
|
|
604
740
|
**TOC not generating:** docx-js doesn't support field codes natively. Use placeholder text or manually update in Word after opening.
|
|
605
741
|
|
|
742
|
+
**Text renders vertically in Pages:** A table cell is too narrow (< 200 DXA). Replace narrow decorative cells with paragraph borders. See "Cross-Platform Compatibility" section above.
|
|
743
|
+
|
|
606
744
|
---
|
|
607
745
|
|
|
608
746
|
## Example: Simple Report
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
fix_styles.py — Patch styles.xml in a .docx for Apple Pages compatibility.
|
|
4
|
+
|
|
5
|
+
docx-js generates styles.xml with THREE problems that break Pages rendering:
|
|
6
|
+
|
|
7
|
+
1. Empty docDefaults — <w:rPrDefault/> and <w:pPrDefault/> with no content.
|
|
8
|
+
Pages needs proper defaults (font, size, language, paragraph spacing) to
|
|
9
|
+
correctly interpret inline paragraph properties like w:jc (alignment).
|
|
10
|
+
|
|
11
|
+
2. Missing Normal style — No default paragraph style is defined. Pages needs
|
|
12
|
+
a Normal style with w:default="1" as the base for all paragraph rendering.
|
|
13
|
+
Without it, Pages ignores inline paragraph formatting like center alignment.
|
|
14
|
+
|
|
15
|
+
3. Wrong heading colors — Heading styles have default blue (#2E74B5) colors
|
|
16
|
+
regardless of what colors you use in the document. Pages reads styles.xml
|
|
17
|
+
instead of inline run properties.
|
|
18
|
+
|
|
19
|
+
This script fixes all three issues by:
|
|
20
|
+
- Adding proper docDefaults with font, size, language, and paragraph spacing
|
|
21
|
+
- Adding a Normal style with w:default="1" if it doesn't exist
|
|
22
|
+
- Reading actual heading formatting from the document and patching styles.xml
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
python fix_styles.py input.docx [output.docx]
|
|
26
|
+
|
|
27
|
+
If output is omitted, the file is modified in place.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import os
|
|
31
|
+
import shutil
|
|
32
|
+
import sys
|
|
33
|
+
import tempfile
|
|
34
|
+
import zipfile
|
|
35
|
+
from xml.etree import ElementTree as ET
|
|
36
|
+
|
|
37
|
+
NS = {
|
|
38
|
+
"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
|
|
39
|
+
"mc": "http://schemas.openxmlformats.org/markup-compatibility/2006",
|
|
40
|
+
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# Register all namespaces to preserve them on write
|
|
44
|
+
for prefix, uri in NS.items():
|
|
45
|
+
ET.register_namespace(prefix, uri)
|
|
46
|
+
|
|
47
|
+
# Register additional namespaces commonly in docx
|
|
48
|
+
EXTRA_NS = {
|
|
49
|
+
"wpc": "http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas",
|
|
50
|
+
"o": "urn:schemas-microsoft-com:office:office",
|
|
51
|
+
"m": "http://schemas.openxmlformats.org/officeDocument/2006/math",
|
|
52
|
+
"v": "urn:schemas-microsoft-com:vml",
|
|
53
|
+
"wp14": "http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing",
|
|
54
|
+
"wp": "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing",
|
|
55
|
+
"w10": "urn:schemas-microsoft-com:office:word",
|
|
56
|
+
"w14": "http://schemas.microsoft.com/office/word/2010/wordml",
|
|
57
|
+
"w15": "http://schemas.microsoft.com/office/word/2012/wordml",
|
|
58
|
+
"wpg": "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup",
|
|
59
|
+
"wpi": "http://schemas.microsoft.com/office/word/2010/wordprocessingInk",
|
|
60
|
+
"wne": "http://schemas.microsoft.com/office/word/2006/wordml",
|
|
61
|
+
"wps": "http://schemas.microsoft.com/office/word/2010/wordprocessingShape",
|
|
62
|
+
}
|
|
63
|
+
for prefix, uri in EXTRA_NS.items():
|
|
64
|
+
ET.register_namespace(prefix, uri)
|
|
65
|
+
|
|
66
|
+
W = NS["w"]
|
|
67
|
+
WNS = f"{{{W}}}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def fix_doc_defaults(root):
|
|
71
|
+
"""
|
|
72
|
+
Ensure docDefaults has proper rPrDefault and pPrDefault.
|
|
73
|
+
Pages needs these to correctly resolve inline paragraph properties.
|
|
74
|
+
"""
|
|
75
|
+
doc_defaults = root.find(f"{WNS}docDefaults")
|
|
76
|
+
if doc_defaults is None:
|
|
77
|
+
# Insert docDefaults as first child
|
|
78
|
+
doc_defaults = ET.SubElement(root, f"{WNS}docDefaults")
|
|
79
|
+
root.insert(0, doc_defaults)
|
|
80
|
+
|
|
81
|
+
fixed = False
|
|
82
|
+
|
|
83
|
+
# Fix rPrDefault
|
|
84
|
+
rpr_default = doc_defaults.find(f"{WNS}rPrDefault")
|
|
85
|
+
if rpr_default is None:
|
|
86
|
+
rpr_default = ET.SubElement(doc_defaults, f"{WNS}rPrDefault")
|
|
87
|
+
|
|
88
|
+
rpr = rpr_default.find(f"{WNS}rPr")
|
|
89
|
+
if rpr is None or len(rpr) == 0:
|
|
90
|
+
# Empty rPrDefault — add proper defaults
|
|
91
|
+
if rpr is not None:
|
|
92
|
+
doc_defaults.remove(rpr_default)
|
|
93
|
+
rpr_default = ET.SubElement(doc_defaults, f"{WNS}rPrDefault")
|
|
94
|
+
|
|
95
|
+
rpr = ET.SubElement(rpr_default, f"{WNS}rPr")
|
|
96
|
+
|
|
97
|
+
# Default fonts
|
|
98
|
+
fonts = ET.SubElement(rpr, f"{WNS}rFonts")
|
|
99
|
+
fonts.set(f"{WNS}asciiTheme", "minorHAnsi")
|
|
100
|
+
fonts.set(f"{WNS}eastAsiaTheme", "minorEastAsia")
|
|
101
|
+
fonts.set(f"{WNS}hAnsiTheme", "minorHAnsi")
|
|
102
|
+
fonts.set(f"{WNS}cstheme", "minorBidi")
|
|
103
|
+
|
|
104
|
+
# Default size (11pt = 22 half-points)
|
|
105
|
+
sz = ET.SubElement(rpr, f"{WNS}sz")
|
|
106
|
+
sz.set(f"{WNS}val", "22")
|
|
107
|
+
sz_cs = ET.SubElement(rpr, f"{WNS}szCs")
|
|
108
|
+
sz_cs.set(f"{WNS}val", "22")
|
|
109
|
+
|
|
110
|
+
# Language
|
|
111
|
+
lang = ET.SubElement(rpr, f"{WNS}lang")
|
|
112
|
+
lang.set(f"{WNS}val", "en-US")
|
|
113
|
+
lang.set(f"{WNS}eastAsia", "en-US")
|
|
114
|
+
lang.set(f"{WNS}bidi", "ar-SA")
|
|
115
|
+
|
|
116
|
+
fixed = True
|
|
117
|
+
|
|
118
|
+
# Fix pPrDefault
|
|
119
|
+
ppr_default = doc_defaults.find(f"{WNS}pPrDefault")
|
|
120
|
+
if ppr_default is None:
|
|
121
|
+
ppr_default = ET.SubElement(doc_defaults, f"{WNS}pPrDefault")
|
|
122
|
+
|
|
123
|
+
ppr = ppr_default.find(f"{WNS}pPr")
|
|
124
|
+
if ppr is None or len(ppr) == 0:
|
|
125
|
+
# Empty pPrDefault — add proper defaults
|
|
126
|
+
if ppr is not None:
|
|
127
|
+
doc_defaults.remove(ppr_default)
|
|
128
|
+
ppr_default = ET.SubElement(doc_defaults, f"{WNS}pPrDefault")
|
|
129
|
+
|
|
130
|
+
ppr = ET.SubElement(ppr_default, f"{WNS}pPr")
|
|
131
|
+
|
|
132
|
+
# Default paragraph spacing (after=200, line=276, lineRule=auto)
|
|
133
|
+
spacing = ET.SubElement(ppr, f"{WNS}spacing")
|
|
134
|
+
spacing.set(f"{WNS}after", "200")
|
|
135
|
+
spacing.set(f"{WNS}line", "276")
|
|
136
|
+
spacing.set(f"{WNS}lineRule", "auto")
|
|
137
|
+
|
|
138
|
+
fixed = True
|
|
139
|
+
|
|
140
|
+
return fixed
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def fix_normal_style(root):
|
|
144
|
+
"""
|
|
145
|
+
Ensure a proper Normal style exists with w:default="1".
|
|
146
|
+
Pages needs this as the base paragraph style to correctly resolve
|
|
147
|
+
inline formatting like center alignment and spacing.
|
|
148
|
+
"""
|
|
149
|
+
# Check if Normal style already exists
|
|
150
|
+
for style_el in root.findall(f"{WNS}style"):
|
|
151
|
+
style_id = style_el.get(f"{WNS}styleId", "")
|
|
152
|
+
if style_id == "Normal":
|
|
153
|
+
# Ensure it has w:default="1"
|
|
154
|
+
if style_el.get(f"{WNS}default") != "1":
|
|
155
|
+
style_el.set(f"{WNS}default", "1")
|
|
156
|
+
return True
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
# Normal style doesn't exist — create it
|
|
160
|
+
normal = ET.SubElement(root, f"{WNS}style")
|
|
161
|
+
normal.set(f"{WNS}type", "paragraph")
|
|
162
|
+
normal.set(f"{WNS}default", "1")
|
|
163
|
+
normal.set(f"{WNS}styleId", "Normal")
|
|
164
|
+
|
|
165
|
+
name = ET.SubElement(normal, f"{WNS}name")
|
|
166
|
+
name.set(f"{WNS}val", "Normal")
|
|
167
|
+
|
|
168
|
+
ET.SubElement(normal, f"{WNS}qFormat")
|
|
169
|
+
|
|
170
|
+
# Move it to be the first style (after docDefaults and latentStyles)
|
|
171
|
+
root.remove(normal)
|
|
172
|
+
insert_pos = 0
|
|
173
|
+
for i, child in enumerate(root):
|
|
174
|
+
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
|
|
175
|
+
if tag in ("docDefaults", "latentStyles"):
|
|
176
|
+
insert_pos = i + 1
|
|
177
|
+
root.insert(insert_pos, normal)
|
|
178
|
+
|
|
179
|
+
return True
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def extract_heading_styles_from_document(doc_tree):
|
|
183
|
+
"""
|
|
184
|
+
Scan document.xml for heading paragraphs and extract their inline formatting.
|
|
185
|
+
Returns dict: { 'Heading1': { 'color': '6B2D2D', 'sz': '36', ... }, ... }
|
|
186
|
+
"""
|
|
187
|
+
root = doc_tree.getroot()
|
|
188
|
+
body = root.find(f".//{WNS}body")
|
|
189
|
+
if body is None:
|
|
190
|
+
return {}
|
|
191
|
+
|
|
192
|
+
heading_styles = {}
|
|
193
|
+
|
|
194
|
+
for para in body.findall(f".//{WNS}p"):
|
|
195
|
+
p_pr = para.find(f"{WNS}pPr")
|
|
196
|
+
if p_pr is None:
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
p_style = p_pr.find(f"{WNS}pStyle")
|
|
200
|
+
if p_style is None:
|
|
201
|
+
continue
|
|
202
|
+
style_id = p_style.get(f"{WNS}val", "")
|
|
203
|
+
if not style_id.startswith("Heading"):
|
|
204
|
+
continue
|
|
205
|
+
if style_id in heading_styles:
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
fmt = {}
|
|
209
|
+
# Try inline run properties first (w:r/w:rPr)
|
|
210
|
+
run = para.find(f"{WNS}r")
|
|
211
|
+
if run is not None:
|
|
212
|
+
r_pr = run.find(f"{WNS}rPr")
|
|
213
|
+
if r_pr is not None:
|
|
214
|
+
_extract_rpr(r_pr, fmt)
|
|
215
|
+
|
|
216
|
+
# Fall back to paragraph default rPr (pPr/rPr)
|
|
217
|
+
if not fmt:
|
|
218
|
+
p_rpr = p_pr.find(f"{WNS}rPr")
|
|
219
|
+
if p_rpr is not None:
|
|
220
|
+
_extract_rpr(p_rpr, fmt)
|
|
221
|
+
|
|
222
|
+
# Extract paragraph spacing
|
|
223
|
+
spacing = p_pr.find(f"{WNS}spacing")
|
|
224
|
+
if spacing is not None:
|
|
225
|
+
before = spacing.get(f"{WNS}before")
|
|
226
|
+
after = spacing.get(f"{WNS}after")
|
|
227
|
+
if before:
|
|
228
|
+
fmt["spacing_before"] = before
|
|
229
|
+
if after:
|
|
230
|
+
fmt["spacing_after"] = after
|
|
231
|
+
|
|
232
|
+
if fmt:
|
|
233
|
+
heading_styles[style_id] = fmt
|
|
234
|
+
|
|
235
|
+
return heading_styles
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _extract_rpr(r_pr, fmt):
|
|
239
|
+
"""Extract formatting properties from a w:rPr element."""
|
|
240
|
+
color = r_pr.find(f"{WNS}color")
|
|
241
|
+
if color is not None:
|
|
242
|
+
fmt["color"] = color.get(f"{WNS}val", "")
|
|
243
|
+
|
|
244
|
+
sz = r_pr.find(f"{WNS}sz")
|
|
245
|
+
if sz is not None:
|
|
246
|
+
fmt["sz"] = sz.get(f"{WNS}val", "")
|
|
247
|
+
|
|
248
|
+
fonts = r_pr.find(f"{WNS}rFonts")
|
|
249
|
+
if fonts is not None:
|
|
250
|
+
fmt["font"] = fonts.get(f"{WNS}ascii", "")
|
|
251
|
+
|
|
252
|
+
bold = r_pr.find(f"{WNS}b")
|
|
253
|
+
if bold is not None:
|
|
254
|
+
fmt["bold"] = True
|
|
255
|
+
|
|
256
|
+
italics = r_pr.find(f"{WNS}i")
|
|
257
|
+
if italics is not None:
|
|
258
|
+
fmt["italics"] = True
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def patch_heading_styles(root, heading_styles):
|
|
262
|
+
"""Update heading style definitions with actual formatting from the document."""
|
|
263
|
+
patched = 0
|
|
264
|
+
|
|
265
|
+
for style_el in root.findall(f"{WNS}style"):
|
|
266
|
+
style_id = style_el.get(f"{WNS}styleId", "")
|
|
267
|
+
if style_id not in heading_styles:
|
|
268
|
+
continue
|
|
269
|
+
|
|
270
|
+
fmt = heading_styles[style_id]
|
|
271
|
+
|
|
272
|
+
# Patch run properties
|
|
273
|
+
r_pr = style_el.find(f"{WNS}rPr")
|
|
274
|
+
if r_pr is None:
|
|
275
|
+
r_pr = ET.SubElement(style_el, f"{WNS}rPr")
|
|
276
|
+
|
|
277
|
+
if "color" in fmt:
|
|
278
|
+
color = r_pr.find(f"{WNS}color")
|
|
279
|
+
if color is None:
|
|
280
|
+
color = ET.SubElement(r_pr, f"{WNS}color")
|
|
281
|
+
color.set(f"{WNS}val", fmt["color"])
|
|
282
|
+
|
|
283
|
+
if "sz" in fmt:
|
|
284
|
+
for tag_name in ["sz", "szCs"]:
|
|
285
|
+
el = r_pr.find(f"{WNS}{tag_name}")
|
|
286
|
+
if el is None:
|
|
287
|
+
el = ET.SubElement(r_pr, f"{WNS}{tag_name}")
|
|
288
|
+
el.set(f"{WNS}val", fmt["sz"])
|
|
289
|
+
|
|
290
|
+
if "font" in fmt:
|
|
291
|
+
fonts = r_pr.find(f"{WNS}rFonts")
|
|
292
|
+
if fonts is None:
|
|
293
|
+
fonts = ET.SubElement(r_pr, f"{WNS}rFonts")
|
|
294
|
+
for attr in ["ascii", "hAnsi", "eastAsia", "cs"]:
|
|
295
|
+
fonts.set(f"{WNS}{attr}", fmt["font"])
|
|
296
|
+
|
|
297
|
+
if fmt.get("bold"):
|
|
298
|
+
if r_pr.find(f"{WNS}b") is None:
|
|
299
|
+
ET.SubElement(r_pr, f"{WNS}b")
|
|
300
|
+
if r_pr.find(f"{WNS}bCs") is None:
|
|
301
|
+
ET.SubElement(r_pr, f"{WNS}bCs")
|
|
302
|
+
|
|
303
|
+
if fmt.get("italics"):
|
|
304
|
+
if r_pr.find(f"{WNS}i") is None:
|
|
305
|
+
ET.SubElement(r_pr, f"{WNS}i")
|
|
306
|
+
|
|
307
|
+
# Patch paragraph spacing
|
|
308
|
+
if "spacing_before" in fmt or "spacing_after" in fmt:
|
|
309
|
+
p_pr = style_el.find(f"{WNS}pPr")
|
|
310
|
+
if p_pr is None:
|
|
311
|
+
p_pr = ET.SubElement(style_el, f"{WNS}pPr")
|
|
312
|
+
spacing = p_pr.find(f"{WNS}spacing")
|
|
313
|
+
if spacing is None:
|
|
314
|
+
spacing = ET.SubElement(p_pr, f"{WNS}spacing")
|
|
315
|
+
if "spacing_before" in fmt:
|
|
316
|
+
spacing.set(f"{WNS}before", fmt["spacing_before"])
|
|
317
|
+
if "spacing_after" in fmt:
|
|
318
|
+
spacing.set(f"{WNS}after", fmt["spacing_after"])
|
|
319
|
+
|
|
320
|
+
patched += 1
|
|
321
|
+
|
|
322
|
+
return patched
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def fix_styles(input_path, output_path=None):
|
|
326
|
+
if output_path is None:
|
|
327
|
+
output_path = input_path
|
|
328
|
+
|
|
329
|
+
tmp_dir = tempfile.mkdtemp(prefix="fix_styles_")
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
extract_dir = os.path.join(tmp_dir, "extracted")
|
|
333
|
+
with zipfile.ZipFile(input_path, "r") as zf:
|
|
334
|
+
zf.extractall(extract_dir)
|
|
335
|
+
|
|
336
|
+
doc_path = os.path.join(extract_dir, "word", "document.xml")
|
|
337
|
+
styles_path = os.path.join(extract_dir, "word", "styles.xml")
|
|
338
|
+
|
|
339
|
+
if not os.path.exists(doc_path) or not os.path.exists(styles_path):
|
|
340
|
+
print("Error: Missing document.xml or styles.xml")
|
|
341
|
+
return
|
|
342
|
+
|
|
343
|
+
doc_tree = ET.parse(doc_path)
|
|
344
|
+
styles_tree = ET.parse(styles_path)
|
|
345
|
+
styles_root = styles_tree.getroot()
|
|
346
|
+
|
|
347
|
+
fixes = []
|
|
348
|
+
|
|
349
|
+
# Fix 1: docDefaults
|
|
350
|
+
if fix_doc_defaults(styles_root):
|
|
351
|
+
fixes.append("docDefaults")
|
|
352
|
+
|
|
353
|
+
# Fix 2: Normal style
|
|
354
|
+
if fix_normal_style(styles_root):
|
|
355
|
+
fixes.append("Normal style")
|
|
356
|
+
|
|
357
|
+
# Fix 3: Heading styles
|
|
358
|
+
heading_styles = extract_heading_styles_from_document(doc_tree)
|
|
359
|
+
if heading_styles:
|
|
360
|
+
patched = patch_heading_styles(styles_root, heading_styles)
|
|
361
|
+
if patched:
|
|
362
|
+
fixes.append(f"{patched} heading style(s)")
|
|
363
|
+
|
|
364
|
+
if not fixes:
|
|
365
|
+
print(f"No fixes needed in {output_path}")
|
|
366
|
+
return
|
|
367
|
+
|
|
368
|
+
# Write back
|
|
369
|
+
styles_tree.write(styles_path, xml_declaration=True, encoding="UTF-8")
|
|
370
|
+
|
|
371
|
+
# Repack
|
|
372
|
+
tmp_docx = os.path.join(tmp_dir, "output.docx")
|
|
373
|
+
with zipfile.ZipFile(tmp_docx, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
374
|
+
for root_dir, _, files in os.walk(extract_dir):
|
|
375
|
+
for f in files:
|
|
376
|
+
full_path = os.path.join(root_dir, f)
|
|
377
|
+
arc_name = os.path.relpath(full_path, extract_dir)
|
|
378
|
+
zf.write(full_path, arc_name)
|
|
379
|
+
|
|
380
|
+
shutil.copy2(tmp_docx, output_path)
|
|
381
|
+
print(f"Fixed {', '.join(fixes)} in {output_path}")
|
|
382
|
+
|
|
383
|
+
finally:
|
|
384
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
if __name__ == "__main__":
|
|
388
|
+
if len(sys.argv) < 2:
|
|
389
|
+
print("Usage: python fix_styles.py input.docx [output.docx]")
|
|
390
|
+
sys.exit(1)
|
|
391
|
+
|
|
392
|
+
input_file = sys.argv[1]
|
|
393
|
+
output_file = sys.argv[2] if len(sys.argv) > 2 else None
|
|
394
|
+
fix_styles(input_file, output_file)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Post-process a docx-js generated .docx file to fix tblGrid values for Pages compatibility.
|
|
3
|
+
|
|
4
|
+
docx-js generates <w:gridCol w:w="100"/> for all columns regardless of actual cell widths.
|
|
5
|
+
Pages relies on tblGrid for layout, so tables render as 1-char-wide columns.
|
|
6
|
+
This script reads the tcW values from each table's first row and patches tblGrid to match.
|
|
7
|
+
"""
|
|
8
|
+
import sys
|
|
9
|
+
import zipfile
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
from lxml import etree
|
|
14
|
+
|
|
15
|
+
WORD_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
|
16
|
+
NSMAP = {'w': WORD_NS}
|
|
17
|
+
|
|
18
|
+
def fix_tables(xml_content):
|
|
19
|
+
"""Fix tblGrid values to match first-row cell widths."""
|
|
20
|
+
root = etree.fromstring(xml_content)
|
|
21
|
+
tables = root.findall('.//w:tbl', NSMAP)
|
|
22
|
+
fixes = 0
|
|
23
|
+
|
|
24
|
+
for tbl in tables:
|
|
25
|
+
grid = tbl.find('w:tblGrid', NSMAP)
|
|
26
|
+
if grid is None:
|
|
27
|
+
continue
|
|
28
|
+
|
|
29
|
+
# Get first row's cell widths
|
|
30
|
+
first_row = tbl.find('w:tr', NSMAP)
|
|
31
|
+
if first_row is None:
|
|
32
|
+
continue
|
|
33
|
+
|
|
34
|
+
cells = first_row.findall('w:tc', NSMAP)
|
|
35
|
+
cell_widths = []
|
|
36
|
+
for tc in cells:
|
|
37
|
+
tcPr = tc.find('w:tcPr', NSMAP)
|
|
38
|
+
if tcPr is not None:
|
|
39
|
+
tcW = tcPr.find('w:tcW', NSMAP)
|
|
40
|
+
if tcW is not None:
|
|
41
|
+
w_val = tcW.get(f'{{{WORD_NS}}}w')
|
|
42
|
+
w_type = tcW.get(f'{{{WORD_NS}}}type', 'dxa')
|
|
43
|
+
if w_type == 'dxa' and w_val:
|
|
44
|
+
try:
|
|
45
|
+
cell_widths.append(int(w_val))
|
|
46
|
+
except ValueError:
|
|
47
|
+
cell_widths.append(2340) # default fallback
|
|
48
|
+
else:
|
|
49
|
+
cell_widths.append(2340)
|
|
50
|
+
else:
|
|
51
|
+
cell_widths.append(2340)
|
|
52
|
+
else:
|
|
53
|
+
cell_widths.append(2340)
|
|
54
|
+
|
|
55
|
+
# Check if grid needs fixing (all gridCol are 100 = docx-js default)
|
|
56
|
+
grid_cols = grid.findall('w:gridCol', NSMAP)
|
|
57
|
+
all_100 = all(gc.get(f'{{{WORD_NS}}}w') == '100' for gc in grid_cols)
|
|
58
|
+
|
|
59
|
+
if all_100 and len(cell_widths) == len(grid_cols):
|
|
60
|
+
for gc, width in zip(grid_cols, cell_widths):
|
|
61
|
+
gc.set(f'{{{WORD_NS}}}w', str(width))
|
|
62
|
+
fixes += 1
|
|
63
|
+
|
|
64
|
+
# Also ensure tblLayout is set to fixed for Pages
|
|
65
|
+
tblPr = tbl.find('w:tblPr', NSMAP)
|
|
66
|
+
if tblPr is not None:
|
|
67
|
+
layout = tblPr.find('w:tblLayout', NSMAP)
|
|
68
|
+
if layout is None:
|
|
69
|
+
layout = etree.SubElement(tblPr, f'{{{WORD_NS}}}tblLayout')
|
|
70
|
+
layout.set(f'{{{WORD_NS}}}type', 'fixed')
|
|
71
|
+
|
|
72
|
+
return etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True), fixes
|
|
73
|
+
|
|
74
|
+
def fix_docx(input_path, output_path=None):
|
|
75
|
+
"""Fix a .docx file's table grids for Pages compatibility."""
|
|
76
|
+
if output_path is None:
|
|
77
|
+
output_path = input_path
|
|
78
|
+
|
|
79
|
+
import tempfile
|
|
80
|
+
tmp_dir = tempfile.mkdtemp(prefix='fix_tables_')
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
# Extract
|
|
84
|
+
with zipfile.ZipFile(input_path, 'r') as z:
|
|
85
|
+
z.extractall(tmp_dir)
|
|
86
|
+
|
|
87
|
+
# Fix document.xml
|
|
88
|
+
doc_path = os.path.join(tmp_dir, 'word', 'document.xml')
|
|
89
|
+
with open(doc_path, 'rb') as f:
|
|
90
|
+
xml_content = f.read()
|
|
91
|
+
|
|
92
|
+
fixed_xml, num_fixes = fix_tables(xml_content)
|
|
93
|
+
|
|
94
|
+
with open(doc_path, 'wb') as f:
|
|
95
|
+
f.write(fixed_xml)
|
|
96
|
+
|
|
97
|
+
# Repack
|
|
98
|
+
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zout:
|
|
99
|
+
for root_dir, dirs, files in os.walk(tmp_dir):
|
|
100
|
+
for file in files:
|
|
101
|
+
file_path = os.path.join(root_dir, file)
|
|
102
|
+
arcname = os.path.relpath(file_path, tmp_dir)
|
|
103
|
+
zout.write(file_path, arcname)
|
|
104
|
+
|
|
105
|
+
print(f"Fixed {num_fixes} table(s) in {output_path}")
|
|
106
|
+
return num_fixes
|
|
107
|
+
|
|
108
|
+
finally:
|
|
109
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
110
|
+
|
|
111
|
+
if __name__ == '__main__':
|
|
112
|
+
if len(sys.argv) < 2:
|
|
113
|
+
print("Usage: python fix_docx_tables.py <input.docx> [output.docx]")
|
|
114
|
+
sys.exit(1)
|
|
115
|
+
|
|
116
|
+
input_file = sys.argv[1]
|
|
117
|
+
output_file = sys.argv[2] if len(sys.argv) > 2 else input_file
|
|
118
|
+
fix_docx(input_file, output_file)
|
package/skills/pptx/SKILL.md
CHANGED
|
@@ -96,6 +96,57 @@ Use one font pairing for the entire deck. Consistency.
|
|
|
96
96
|
- Never center body text (left-align only)
|
|
97
97
|
- Use `paraSpaceAfter: 6` for bullet spacing, never `lineSpacing`
|
|
98
98
|
|
|
99
|
+
### Slide Boundary Rules (CRITICAL — Content Overflow)
|
|
100
|
+
|
|
101
|
+
**Nothing may extend beyond the slide edges.** Content that overflows is invisible — it gets cut off and looks broken in presentation mode.
|
|
102
|
+
|
|
103
|
+
Standard 16:9 slide dimensions: **10" wide × 5.625" tall**
|
|
104
|
+
|
|
105
|
+
Every element must satisfy these constraints:
|
|
106
|
+
- `x + w ≤ 10` (right edge)
|
|
107
|
+
- `y + h ≤ 5.625` (bottom edge)
|
|
108
|
+
- `x ≥ 0` and `y ≥ 0` (top-left origin)
|
|
109
|
+
|
|
110
|
+
**Before placing any element, calculate remaining space:**
|
|
111
|
+
```
|
|
112
|
+
available_height = 5.625 - current_y - 0.5 // 0.5" bottom margin
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
If content doesn't fit, you MUST do one of these (in order of preference):
|
|
116
|
+
1. **Split across two slides** — A second slide is always better than overflow
|
|
117
|
+
2. **Reduce font sizes** — Body → 12pt, stats → 48pt instead of 60pt, title → 18pt
|
|
118
|
+
3. **Remove items** — Fewer bullets, fewer cards. Less is more
|
|
119
|
+
4. **Tighten spacing** — Reduce gaps between elements to 0.2" minimum
|
|
120
|
+
|
|
121
|
+
**Safe content zones (with 0.5" margins):**
|
|
122
|
+
```
|
|
123
|
+
Title area: y = 0.5, h = 0.6
|
|
124
|
+
Content start: y = 1.2
|
|
125
|
+
Content end: y = 5.1 (0.5" bottom margin)
|
|
126
|
+
Usable content height: 3.9 inches — plan ALL layouts within this
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**Layout height budgets — know these before coding:**
|
|
130
|
+
|
|
131
|
+
| Layout Type | Title | Content Area | Max Items |
|
|
132
|
+
|---|---|---|---|
|
|
133
|
+
| Bullet list | 0.7" | 3.9" | 8 bullets max (0.35" each) |
|
|
134
|
+
| 2-column bullets | 0.7" | 3.9" | 5 bullets per column |
|
|
135
|
+
| Stat cards (1 row) | 0.7" | 1.8" for cards | 3-4 cards |
|
|
136
|
+
| Stat cards (2 rows) | 0.7" | 3.4" for cards | 6-8 cards |
|
|
137
|
+
| Comparison (side-by-side) | 0.7" | 3.2" max per card | 4 bullets per card |
|
|
138
|
+
| Table | 0.7" | 3.9" | 7 rows max (0.4" per row) |
|
|
139
|
+
| Title + subtitle + cards | 1.2" | 3.4" | 3 cards |
|
|
140
|
+
|
|
141
|
+
**Common overflow traps:**
|
|
142
|
+
- **Comparison layouts** — Two side-by-side cards are taller than you think. Budget 2.5-3" max per card, not 3.5". If each card has a title + 5 bullets, it needs ~3" minimum — split to two slides
|
|
143
|
+
- **Bullet lists with 6+ items** — Each bullet needs ~0.35". Six bullets = 2.1". Add title + spacing = 3.5"+
|
|
144
|
+
- **Stat cards stacked in 2 rows** — stat (60pt) + label (12pt) + padding = ~1.5" per row. Two rows + title = 4" before you add anything else
|
|
145
|
+
- **Tables with 5+ rows** — Header + 5 data rows = ~2.4". Add title + margins = 3.5"
|
|
146
|
+
- **"Just one more element"** — Adding a footnote, source line, or page number below a full slide. Check remaining space first
|
|
147
|
+
|
|
148
|
+
**Validation rule: After writing code for any slide, mentally verify that the lowest element's `y + h` does not exceed 5.5 inches (leaving 0.125" safety margin). If it does, refactor before moving to the next slide.**
|
|
149
|
+
|
|
99
150
|
### What to Avoid
|
|
100
151
|
|
|
101
152
|
- **Don't repeat the same layout** — Two content slides in a row can feel monotonous. Vary between text-heavy, stat cards, comparisons, and two-column layouts.
|
|
@@ -104,6 +155,7 @@ Use one font pairing for the entire deck. Consistency.
|
|
|
104
155
|
- **Don't default to blue** — Blue is overdone. It's the safe choice. Pick a palette that matches the content's emotional tone.
|
|
105
156
|
- **Never add accent lines under titles** — This is the hallmark of AI-generated slides. If you add an underline, make it purposeful (color bar on stat cards) not decorative.
|
|
106
157
|
- **Don't create text-only slides** — Every slide needs at least one visual anchor: a stat card, an accent color, an icon, or a highlighted quote.
|
|
158
|
+
- **Never let content overflow the slide** — If `y + h > 5.625`, content is cut off. Always calculate remaining space before placing elements. See "Slide Boundary Rules" above.
|
|
107
159
|
|
|
108
160
|
## QA Process
|
|
109
161
|
|
package/skills/pptx/pptxgenjs.md
CHANGED
|
@@ -29,16 +29,28 @@ Key point: Always create a fresh `PptxGenJS()` instance per presentation. Don't
|
|
|
29
29
|
Standard 16:9 layout is 10 inches wide by 5.625 inches tall. All positioning is in inches.
|
|
30
30
|
|
|
31
31
|
```javascript
|
|
32
|
-
// Slide area
|
|
33
|
-
const W = 10; // Width
|
|
34
|
-
const H = 5.625; // Height
|
|
32
|
+
// Slide area — HARD LIMITS (nothing may exceed these)
|
|
33
|
+
const W = 10; // Width (inches)
|
|
34
|
+
const H = 5.625; // Height (inches)
|
|
35
35
|
const M = 0.5; // Margin (standard)
|
|
36
|
-
const contentWidth = W - 2 * M; // 9 inches
|
|
36
|
+
const contentWidth = W - 2 * M; // 9 inches usable
|
|
37
37
|
|
|
38
38
|
// Common positioning
|
|
39
|
-
const titleY = M;
|
|
40
|
-
const contentStartY = M + 0.8; // After title
|
|
41
|
-
const
|
|
39
|
+
const titleY = M; // Top margin
|
|
40
|
+
const contentStartY = M + 0.8; // After title (y = 1.3)
|
|
41
|
+
const maxContentY = H - M; // 5.125 — absolute bottom of content
|
|
42
|
+
const contentHeight = maxContentY - contentStartY; // ~3.8" available
|
|
43
|
+
|
|
44
|
+
// BOUNDARY CHECK — use before placing every element:
|
|
45
|
+
// if (elementY + elementH > H) → content will overflow, reduce or split slide
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**CRITICAL: Every element must satisfy `y + h ≤ 5.625`.** If an element's bottom edge exceeds the slide height, it will be cut off in presentation mode. Always calculate remaining space before placing elements:
|
|
49
|
+
```javascript
|
|
50
|
+
const remainingHeight = H - currentY - M; // available space below current position
|
|
51
|
+
if (cardHeight > remainingHeight) {
|
|
52
|
+
// Split to next slide or reduce content
|
|
53
|
+
}
|
|
42
54
|
```
|
|
43
55
|
|
|
44
56
|
## Text & Formatting
|
|
@@ -622,3 +634,33 @@ slide.addText([
|
|
|
622
634
|
|
|
623
635
|
pres.writeFile({ fileName: "output.pptx" });
|
|
624
636
|
```
|
|
637
|
+
|
|
638
|
+
## Common Pitfalls: Stat Cards & Large Numbers
|
|
639
|
+
|
|
640
|
+
**Large numbers overflow their text boxes.** At 48pt+ with bold fonts like Arial Black, values like "12.4K", "$156K", or "$48K" are often wider than narrow card text boxes (2-3 inches). When they overflow, the text wraps and produces broken-looking results — e.g. "12.4" on one line and "K" huge on the next.
|
|
641
|
+
|
|
642
|
+
**Always use `shrinkText: true` on stat values:**
|
|
643
|
+
```javascript
|
|
644
|
+
slide.addText("$156K", {
|
|
645
|
+
x: cardX, y: cardY + 0.5, w: cardW, h: 1,
|
|
646
|
+
fontSize: 52,
|
|
647
|
+
fontFace: "Arial Black",
|
|
648
|
+
color: accentColor,
|
|
649
|
+
bold: true,
|
|
650
|
+
align: "center",
|
|
651
|
+
valign: "middle",
|
|
652
|
+
shrinkText: true // CRITICAL — auto-scales text to fit the box
|
|
653
|
+
});
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
This tells PowerPoint to auto-shrink the font size if the text doesn't fit. It's invisible when text fits fine, but prevents wrapping when values are wider than expected.
|
|
657
|
+
|
|
658
|
+
**When to use `shrinkText: true`:**
|
|
659
|
+
- All stat card values (any font ≥ 36pt in a box < 3" wide)
|
|
660
|
+
- Slide titles on section dividers (large font + long titles)
|
|
661
|
+
- Any text box where the content length is unpredictable
|
|
662
|
+
|
|
663
|
+
**Alternative approaches if `shrinkText` isn't sufficient:**
|
|
664
|
+
- Reduce font size for longer values: `fontSize: value.length > 4 ? 40 : 52`
|
|
665
|
+
- Widen cards: use 3 cards per row instead of 4
|
|
666
|
+
- Abbreviate values: "$156K" instead of "$156,000"
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* JSON spec format: see references/spec-format.md
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
const pptxgen = require("pptxgenjs");
|
|
12
|
+
const fs = require("fs");
|
|
13
13
|
|
|
14
14
|
// ═══════════════════════════════════════════
|
|
15
15
|
// DEFAULT THEME
|