Files
hermes-skills/productivity/visual-qa/references/template-adoption.md
T

15 KiB
Raw Blame History

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().

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 6a6b.

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