Initial commit: Hermes Agent Skills collection
This commit is contained in:
@@ -0,0 +1,445 @@
|
||||
# Corporate Template Adoption
|
||||
|
||||
Workflow for extracting a corporate design system from a `.pptx` template and applying it to a deck built with pptxgenjs.
|
||||
|
||||
## 1. Extract Theme Colors & Fonts
|
||||
|
||||
python-pptx's theme API (`slide_master.element.find(...)`) can raise `AttributeError`.
|
||||
Parse the XML directly via `zipfile` instead:
|
||||
|
||||
```python
|
||||
import zipfile, xml.etree.ElementTree as ET
|
||||
|
||||
with zipfile.ZipFile('template.pptx', 'r') as z:
|
||||
theme_files = [f for f in z.namelist() if 'theme' in f and f.endswith('.xml')]
|
||||
for tf in theme_files:
|
||||
xml = z.read(tf)
|
||||
root = ET.fromstring(xml)
|
||||
ns = {'a': 'http://schemas.openxmlformats.org/drawingml/2006/main'}
|
||||
# Colors
|
||||
for clr in root.findall('.//a:srgbClr', ns):
|
||||
print(f"Color: #{clr.get('val')}")
|
||||
# Fonts
|
||||
for latin in root.findall('.//a:latin', ns):
|
||||
print(f"Font: {latin.get('typeface')}")
|
||||
```
|
||||
|
||||
## 2. Extract Embedded Images (Logos, Icons)
|
||||
|
||||
```python
|
||||
import zipfile, os
|
||||
|
||||
with zipfile.ZipFile('template.pptx', 'r') as z:
|
||||
images = [f for f in z.namelist()
|
||||
if f.startswith('ppt/media/') and f.endswith(('.png', '.jpg', '.jpeg'))]
|
||||
for img in images:
|
||||
data = z.read(img)
|
||||
fname = os.path.basename(img)
|
||||
with open(f'/tmp/{fname}', 'wb') as f:
|
||||
f.write(data)
|
||||
```
|
||||
|
||||
Inspect each with `vision_analyze`. Logos may be white-on-transparent and
|
||||
invisible on white backgrounds — if so, build a text+shape logo instead.
|
||||
|
||||
## 3. Render Template Slides for Visual Reference
|
||||
|
||||
```bash
|
||||
python3 ~/.hermes/skills/productivity/powerpoint/scripts/office/soffice.py \
|
||||
--headless --convert-to pdf template.pptx
|
||||
mkdir -p tpl_qa
|
||||
pdftoppm -jpeg -r 100 -f 1 -l 5 template.pdf tpl_qa/tplate
|
||||
pdftoppm -jpeg -r 100 -f 32 -l 39 template.pdf tpl_qa/tplate
|
||||
```
|
||||
|
||||
Use `vision_analyze` on selected template slides to understand:
|
||||
- Background color (white, dark, gradient?)
|
||||
- Card style (dark/light, shadows, rounded/sharp corners)
|
||||
- Accent bar style (vertical/horizontal, thickness, position)
|
||||
- Logo placement (usually top right)
|
||||
- Typography (font family, sizes, weights)
|
||||
- Decorative elements (hexagons, circles, patterns)
|
||||
|
||||
## 4. Define Custom Layout Dimensions
|
||||
|
||||
If the template uses non-standard dimensions (e.g., 13.33" × 7.50"), define
|
||||
a custom layout in pptxgenjs **before adding any slides**:
|
||||
|
||||
```javascript
|
||||
pres.defineLayout({ name: "noris_wide", width: 13.33, height: 7.5 });
|
||||
pres.layout = "noris_wide"; // Must set AFTER defineLayout
|
||||
```
|
||||
|
||||
Common corporate template dimensions:
|
||||
- 13.33" × 7.50" — widescreen 16:9 (noris, many corporate templates)
|
||||
- 10" × 5.625" — LAYOUT_16x9 (pptxgenjs default)
|
||||
|
||||
All coordinate values in helper functions (logo position, card widths, etc.)
|
||||
must be recalculated for the new dimensions — a logo at `x: 8.5` on a 10"-wide
|
||||
slide needs `x: 11.13` on a 13.33"-wide slide.
|
||||
|
||||
## 5. Build Design-System Constants File
|
||||
|
||||
Create a `helpers.js` with the extracted colors, fonts, and reusable functions:
|
||||
|
||||
```javascript
|
||||
const C = {
|
||||
white: "FFFFFF",
|
||||
bgLight: "F2F2F2", // Card background
|
||||
petrol: "234D5B", // Primary brand color
|
||||
accent: "118291", // Accent teal
|
||||
burgundy: "9E1946", // Red accent
|
||||
text: "212427", // Body text
|
||||
textMid: "5F7D86", // Secondary text
|
||||
greyLt: "D3D3D3",
|
||||
greyMd: "A6A6A6",
|
||||
};
|
||||
const FONT = "Open Sans";
|
||||
```
|
||||
|
||||
Include helpers: `addLogo()`, `slideNumber()`, `card()`, `accentBar()`,
|
||||
`shieldBadge()`, `title()`, `subtitle()`, `footerBar()`.
|
||||
|
||||
## 6. Logo Extraction — Find and Use the REAL Logo
|
||||
|
||||
**Do not fall back to a text+shape logo approximation until you have exhausted
|
||||
all extraction options.** Users expect the actual corporate logo from the
|
||||
template, not a reconstruction. Only build a text-based logo (section 6c below)
|
||||
if no usable image exists after trying all techniques in sections 6a–6b.
|
||||
|
||||
### 6a. Identify the Logo Among Hundreds of Media Files
|
||||
|
||||
Templates may contain 100+ images. Find the logo systematically:
|
||||
|
||||
**Step 1 — Count image references across slides:**
|
||||
|
||||
```python
|
||||
import zipfile, xml.etree.ElementTree as ET, collections
|
||||
|
||||
with zipfile.ZipFile('template.pptx', 'r') as z:
|
||||
rel_counts = collections.Counter()
|
||||
slide_rels = sorted([f for f in z.namelist()
|
||||
if f.startswith('ppt/slides/_rels/') and f.endswith('.xml.rels')])
|
||||
for sf in slide_rels:
|
||||
tree = ET.parse(z.open(sf))
|
||||
ns = {'r': 'http://schemas.openxmlformats.org/package/2006/relationships'}
|
||||
for rel in tree.findall('.//r:Relationship', ns):
|
||||
target = rel.get('Target', '')
|
||||
if 'media/' in target:
|
||||
rel_counts[target.split('/')[-1]] += 1
|
||||
for img, cnt in rel_counts.most_common(10):
|
||||
print(f'{img:30s} appears on {cnt:3d} slides')
|
||||
```
|
||||
|
||||
The logo typically appears on 20+ slides. Decorative elements appear on 1-4.
|
||||
|
||||
**Step 2 — Check format and usability of top candidates:**
|
||||
|
||||
```python
|
||||
from PIL import Image
|
||||
import os
|
||||
|
||||
for img_name in top_candidates:
|
||||
path = f'/tmp/{img_name}'
|
||||
if img_name.endswith('.emf'):
|
||||
print(f'{img_name}: EMF format — may not render in LibreOffice/pptxgenjs')
|
||||
# EMF files are often tiny (100-200 bytes) and unusable
|
||||
continue
|
||||
if os.path.exists(path):
|
||||
im = Image.open(path)
|
||||
print(f'{img_name}: {im.size} mode={im.mode}')
|
||||
```
|
||||
|
||||
**Pitfall — EMF images:** The most-referenced image may be an EMF (Windows Enhanced
|
||||
Metafile) that LibreOffice cannot convert and pptxgenjs cannot embed. Check for
|
||||
PNG/JPG alternatives among the top candidates. In the noris template,
|
||||
`image1.emf` appeared on 26 slides but was only 188 bytes and unusable; the
|
||||
actual logo was `image2.png` (2268×336 RGBA), which was NOT the most-referenced
|
||||
image but was the largest PNG with the right aspect ratio for a logo.
|
||||
|
||||
**Step 3 — Inspect candidate images with `vision_analyze`:**
|
||||
|
||||
Send each top candidate to `vision_analyze` with:
|
||||
> "Is this a company logo? Describe the text, colors, and layout. Is it
|
||||
> suitable for white backgrounds? Dark backgrounds?"
|
||||
|
||||
### 6b. Multi-Part Logo Processing
|
||||
|
||||
Some logos are single images containing multiple color variants side-by-side
|
||||
(e.g., "noris" in white-on-dark-box next to "network." in dark-on-transparent).
|
||||
To use these:
|
||||
|
||||
**Find the split point by scanning pixel alpha:**
|
||||
|
||||
```python
|
||||
from PIL import Image
|
||||
|
||||
img = Image.open('logo.png')
|
||||
w, h = img.size
|
||||
px = img.load()
|
||||
|
||||
# Scan a row above the text (y=10) for the transition
|
||||
split_x = None
|
||||
found_opaque = False
|
||||
for x in range(w):
|
||||
r, g, b, a = px[x, 10]
|
||||
if a == 255 and r < 100: # opaque dark pixel (bg of left part)
|
||||
found_opaque = True
|
||||
elif found_opaque and a == 0: # transition to transparent
|
||||
split_x = x
|
||||
break
|
||||
```
|
||||
|
||||
**Crop and save variants:**
|
||||
|
||||
```python
|
||||
# For white slides: use as-is (left=dark box with white text, right=dark text)
|
||||
img.save('logo_white_bg.png')
|
||||
|
||||
# For dark slides: invert the RIGHT part only (dark text → white text)
|
||||
# Keep the LEFT part as-is (white text on dark box is already visible on dark bg)
|
||||
dark_ver = img.copy()
|
||||
dv_px = dark_ver.load()
|
||||
for x in range(w):
|
||||
for y in range(h):
|
||||
r, g, b, a = px[x, y]
|
||||
if x >= split_x and a > 0:
|
||||
dv_px[x, y] = (255-r, 255-g, 255-b, a) # invert right part
|
||||
dark_ver.save('logo_dark_bg.png')
|
||||
```
|
||||
|
||||
**Use in pptxgenjs with correct aspect ratio:**
|
||||
|
||||
```javascript
|
||||
function addLogo(slide, w = 1.8, dark = false) {
|
||||
const logoPath = dark ? "logo_dark_bg.png" : "logo_white_bg.png";
|
||||
const h = w / 6.74; // original aspect ratio (width/height)
|
||||
slide.addImage({
|
||||
path: logoPath,
|
||||
x: 13.33 - w - 0.4, y: 0.25, w, h,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 6c. Text-Based Logo (last resort only)
|
||||
|
||||
Only when no usable logo image can be extracted after all attempts above:
|
||||
|
||||
```javascript
|
||||
function addLogo(slide, w = 1.8) {
|
||||
const h = 0.32;
|
||||
const x = 13.33 - w - 0.4;
|
||||
const y = 0.25;
|
||||
const boxW = w * 0.35;
|
||||
slide.addShape("roundRect", {
|
||||
x, y, w: boxW, h,
|
||||
fill: { color: C.petrol }, line: { type: "none" },
|
||||
rectRadius: 0.03,
|
||||
});
|
||||
slide.addText("noris", {
|
||||
x, y, w: boxW, h,
|
||||
fontSize: 13, fontFace: FONT, bold: true,
|
||||
color: C.white, align: "center", valign: "middle", margin: 0,
|
||||
});
|
||||
slide.addText("network.", {
|
||||
x: x + boxW + 0.05, y, w: w - boxW - 0.05, h,
|
||||
fontSize: 13, fontFace: FONT, bold: true,
|
||||
color: C.petrol, align: "left", valign: "middle", margin: 0,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## 7. Non-ASCII Quote Pitfall
|
||||
|
||||
German curly quotes (`„…"` / `»…«`) cause `SyntaxError: Unexpected identifier`
|
||||
in Node.js string literals. The parser treats `"` as a string terminator and
|
||||
the following text as a bare identifier.
|
||||
|
||||
**Wrong:**
|
||||
```javascript
|
||||
{ text: "Milliarden von Parametern speichern das „Wissen" des Modells", ... }
|
||||
```
|
||||
|
||||
**Right** — use Unicode escapes:
|
||||
```javascript
|
||||
{ text: "Milliarden von Parametern speichern das \u201EWissen\u201C des Modells", ... }
|
||||
```
|
||||
|
||||
The `write_file` linter catches this on save. Always check the lint output
|
||||
after writing JS files with German text.
|
||||
|
||||
## 8. Design-Conformance QA
|
||||
|
||||
After rebuilding all slides, verify each slide matches the template's design
|
||||
language. Use a design-specific QA prompt:
|
||||
|
||||
```
|
||||
Check this slide for: 1) [company] corporate design (white bg, light grey cards,
|
||||
petrol #XXXXX accents, [Font] font) 2) logo visible top right 3) no text overlaps
|
||||
4) no layout issues 5) readable text. Reply CLEAN or list ISSUES.
|
||||
```
|
||||
|
||||
Run all slides through this check — a slide can be layout-clean but
|
||||
design-nonconformant (wrong background, missing logo, wrong card style).
|
||||
|
||||
## 9. PowerPoint Compatibility Validation (Post-Generation)
|
||||
|
||||
**Critical:** LibreOffice and PowerPoint have different tolerance levels for
|
||||
malformed OOXML. A PPTX that opens perfectly in LibreOffice (and therefore
|
||||
passes PDF→JPEG visual QA) may be refused by PowerPoint entirely.
|
||||
|
||||
The most common cause is **negative shape dimensions or positions** that
|
||||
pptxgenjs silently generates:
|
||||
|
||||
| Source Pattern | XML Result | Effect |
|
||||
|---|---|---|
|
||||
| LINE shape from node A (low y) to node B (high y) | `cy < 0` in `<a:ext>` | PowerPoint refuses to open |
|
||||
| LINE shape from node A (right) to node B (left) | `cx < 0` in `<a:ext>` | PowerPoint refuses to open |
|
||||
| LINE with equal source/target y | `cy = 0` in `<a:ext>` | PowerPoint refuses to open |
|
||||
| OVAL at `x: -2, y: -2` (decorative, partially off-slide) | `x < 0, y < 0` in `<a:off>` | PowerPoint refuses to open |
|
||||
|
||||
### Fix in Source Code
|
||||
|
||||
**Option A — Fix coordinates (when the shape is meaningful):**
|
||||
|
||||
```javascript
|
||||
// LINE shapes: always use Math.abs/min
|
||||
const dx = dstX - srcX;
|
||||
const dy = dstY - srcY;
|
||||
s.addShape(pres.shapes.LINE, {
|
||||
x: Math.min(srcX, dstX), y: Math.min(srcY, dstY),
|
||||
w: Math.abs(dx), h: Math.max(Math.abs(dy), 1), // min 1 EMU
|
||||
line: { color: "CCCCCC", width: 1 },
|
||||
});
|
||||
|
||||
// Decorative shapes: clamp to non-negative positions
|
||||
s.addShape(pres.shapes.OVAL, {
|
||||
x: 0, y: 0, w: 3, h: 3, // was x: -2, y: -2
|
||||
fill: { color: "F2F2F2" },
|
||||
});
|
||||
```
|
||||
|
||||
**Option B — Remove purely decorative shapes (often the better choice):**
|
||||
|
||||
When decorative shapes (background circles, hexagon patterns, ambient ovals)
|
||||
cause negative coordinates, consider removing them entirely rather than
|
||||
repositioning. They serve no informational purpose and their absence improves
|
||||
compatibility without affecting the design. In the noris deck, decorative
|
||||
OVALs at `x: -2, y: -2` on the title and closing slides were deleted outright
|
||||
— the slides looked cleaner without them and the compatibility issue vanished.
|
||||
|
||||
```javascript
|
||||
// Simply delete the decorative shape block — no replacement needed
|
||||
// s.addShape(pres.shapes.OVAL, { x: -2, y: -2, ... }); // REMOVED
|
||||
```
|
||||
|
||||
### Validate Before Delivery
|
||||
|
||||
After `node main.js`, run the validation script:
|
||||
|
||||
```bash
|
||||
python3 ~/.hermes/skills/productivity/visual-qa/scripts/validate_pptx_compat.py \
|
||||
output.pptx
|
||||
```
|
||||
|
||||
This checks for negative extents/offsets, malformed XML, missing required parts,
|
||||
broken relationship references, and missing Content_Type overrides. Exit code 0
|
||||
means clean; 1 means issues found.
|
||||
|
||||
**Add this to your standard build pipeline:**
|
||||
|
||||
```bash
|
||||
node main.js && \
|
||||
python3 scripts/validate_pptx_compat.py AI_101.pptx && \
|
||||
soffice --headless --convert-to pdf AI_101.pptx && \
|
||||
pdftoppm -jpeg -r 150 AI_101.pdf qa/slide
|
||||
```
|
||||
|
||||
The validation step takes <1 second and catches the #1 cause of "PowerPoint
|
||||
can't open the file" complaints before they happen.
|
||||
|
||||
## 10. Inserting New Slides into an Existing Deck
|
||||
|
||||
When a user asks to add new content slides to an existing pptxgenjs-built deck
|
||||
(e.g. "ergänze Folien zu Tokens, KV-Cache, aktiven Parametern"), follow this
|
||||
workflow:
|
||||
|
||||
### Step 1: Create a New Module File
|
||||
|
||||
Put new slide functions in a separate file (e.g. `slides_extra.js`) to avoid
|
||||
rewriting existing modules:
|
||||
|
||||
```javascript
|
||||
const { C, FONT, addLogo, slideNumber, card, accentBar, /* ... */ } = require("./helpers");
|
||||
|
||||
function slide_tokens(pres) {
|
||||
const s = pres.addSlide();
|
||||
s.background = { color: C.white };
|
||||
addLogo(s, 1.5);
|
||||
slideNumber(s, 13); // ← set to the FINAL slide number after insertion
|
||||
title(s, "Was ist ein Token?");
|
||||
// ...
|
||||
}
|
||||
|
||||
module.exports = { slide_tokens, /* other new slides */ };
|
||||
```
|
||||
|
||||
### Step 2: Update main.js — Insert at the Right Position
|
||||
|
||||
```javascript
|
||||
const se = require("./slides_extra");
|
||||
// ...
|
||||
s10.slide11_finetuning(pres);
|
||||
se.slide_transition_llm(pres); // ← new slide inserted here
|
||||
se.slide_tokens(pres); // ← new slide
|
||||
s10.slide12_llm_no_search(pres); // ← existing slide, now shifted
|
||||
// ...
|
||||
```
|
||||
|
||||
### Step 3: Renumber ALL slideNumber() Calls
|
||||
|
||||
Every `slideNumber(s, N)` in EVERY module file must be updated to reflect the
|
||||
new ordering. A single off-by-one makes the deck look unprofessional.
|
||||
|
||||
Track the mapping explicitly:
|
||||
```
|
||||
Old 1-11 → unchanged (1-11)
|
||||
New 12 → slide_transition_llm (NEW)
|
||||
New 13 → slide_tokens (NEW)
|
||||
Old 12 → slide12_llm_no_search → now 14
|
||||
Old 13 → slide13_not_intelligent → now 15
|
||||
Old 14 → slide14_moe → now 16
|
||||
New 17 → slide_active_params (NEW)
|
||||
New 18 → slide_kv_cache (NEW)
|
||||
Old 15 → slide15_noris → now 19
|
||||
Old 16 → slide16_summary → now 20
|
||||
Old 17 → slide17_thanks → now 21 (no slideNumber, it's the closing slide)
|
||||
```
|
||||
|
||||
### Step 4: Update Agenda and Summary Slides
|
||||
|
||||
- **Agenda**: add new entries, adjust numbering, reduce font size / spacing
|
||||
if the list grows beyond 9 items (12 items needs `fontSize: 16`, `y_step: 0.49`)
|
||||
- **Summary/Takeaways**: add new key points, compress spacing similarly
|
||||
|
||||
### Step 5: Rebuild + Validate + QA
|
||||
|
||||
```bash
|
||||
node main.js && \
|
||||
python3 scripts/validate_pptx_compat.py output.pptx && \
|
||||
soffice --headless --convert-to pdf output.pptx && \
|
||||
pdftoppm -jpeg -r 150 output.pdf qa/slide
|
||||
```
|
||||
|
||||
Then run visual QA on at least the new slides + agenda + summary.
|
||||
|
||||
### Pitfall: Forgetting to Renumber
|
||||
|
||||
The most common mistake is updating `main.js` insertion order but forgetting
|
||||
to update `slideNumber(s, N)` inside existing slide functions. The slides will
|
||||
appear in the right order but show wrong page numbers. Always grep for all
|
||||
`slideNumber` calls after restructuring:
|
||||
|
||||
```bash
|
||||
grep -rn "slideNumber" slides_*.js
|
||||
```
|
||||
Reference in New Issue
Block a user