15 KiB
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:
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)
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
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:
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:
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:
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:
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:
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:
# 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:
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:
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:
{ text: "Milliarden von Parametern speichern das „Wissen" des Modells", ... }
Right — use Unicode escapes:
{ 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):
// 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.
// 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:
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:
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:
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
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
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:
grep -rn "slideNumber" slides_*.js