Initial commit: Hermes Agent Skills collection
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
---
|
||||
name: visual-qa
|
||||
description: "Automated visual QA of rendered documents (slides, PDFs, diagrams, screenshots). Detect overlap, overflow, contrast issues by sending images to a vision-capable model. Trigger on 'QA', 'review slides', 'check presentation', 'visual inspection', or when converting documents to images for review."
|
||||
---
|
||||
|
||||
# Visual QA Skill
|
||||
|
||||
Automated visual quality assurance of rendered documents — presentations, PDFs, diagrams, screenshots. Sends images to a vision-capable LLM and gets structured feedback on layout issues.
|
||||
|
||||
## When to Use
|
||||
|
||||
- After generating or editing a .pptx deck (pair with the `powerpoint` skill)
|
||||
- After rendering PDFs, diagrams, or HTML artifacts to images
|
||||
- Before delivering visual content to a user
|
||||
- Any time you need "fresh eyes" on rendered output
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Render to images** (if not already done):
|
||||
```bash
|
||||
# PPTX → PDF → JPEGs
|
||||
soffice --headless --convert-to pdf input.pptx
|
||||
pdftoppm -jpeg -r 150 input.pdf slide
|
||||
# Produces slide-01.jpg, slide-02.jpg, etc.
|
||||
```
|
||||
|
||||
2. **Verify a vision-capable model exists** on your endpoint (see [references/model-capabilities.md](references/model-capabilities.md)):
|
||||
```bash
|
||||
curl -s <base_url>/models -H "Authorization: Bearer <key>" | python3 -m json.tool | grep -i model
|
||||
```
|
||||
Not all models accept images — test with one image first.
|
||||
|
||||
3. **Run batch QA** — use the batch script for efficiency:
|
||||
```bash
|
||||
NORIS_API_KEY=<key> python scripts/batch_vision_qa.py /path/to/slides/ <num_slides>
|
||||
```
|
||||
|
||||
4. **Fix issues found**, re-render affected slides, re-verify.
|
||||
|
||||
## Key Techniques
|
||||
|
||||
### Direct API Calls for Batch Processing
|
||||
|
||||
For QA of many images, calling the vision model API directly is far more efficient than individual `vision_analyze` tool calls. A 17-slide deck processes in ~22 seconds via batch API vs. 17 sequential tool calls.
|
||||
|
||||
Pattern (Python with `urllib.request`):
|
||||
|
||||
```python
|
||||
import urllib.request, json, base64
|
||||
|
||||
with open(slide_path, "rb") as f:
|
||||
img_b64 = base64.b64encode(f.read()).decode()
|
||||
|
||||
payload = {
|
||||
"model": "<vision_model>",
|
||||
"messages": [{"role": "user", "content": [
|
||||
{"type": "text", "text": "<QA prompt>"},
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}}
|
||||
]}],
|
||||
"max_tokens": 300
|
||||
}
|
||||
|
||||
req = urllib.request.Request(
|
||||
"<base_url>/chat/completions",
|
||||
data=json.dumps(payload).encode(),
|
||||
headers={"Authorization": "Bearer <key>", "Content-Type": "application/json"}
|
||||
)
|
||||
resp = urllib.request.urlopen(req, timeout=60)
|
||||
result = json.loads(resp.read())["choices"][0]["message"]["content"]
|
||||
```
|
||||
|
||||
### Structured QA Prompt
|
||||
|
||||
Use a prompt that forces structured JSON output for easy parsing. The base prompt checks overlap, overflow, and contrast:
|
||||
|
||||
```
|
||||
Analyze this slide for: 1) TEXT OVERLAP, 2) TEXT OVERFLOW, 3) CONTRAST issues.
|
||||
Respond as JSON: {"slide": N, "overlap": "none|<desc>", "overflow": "none|<desc>", "contrast": "ok|<desc>", "overall": "clean|issues found"}
|
||||
```
|
||||
|
||||
For comprehensive QA, expand the prompt to also check symbols and content consistency:
|
||||
|
||||
```
|
||||
Analyze this slide for: 1) TEXT OVERLAP, 2) TEXT OVERFLOW, 3) CONTRAST, 4) LAYOUT (misalignment, spacing, margins),
|
||||
5) CONTENT (unclear messaging, placeholder text, spelling, logical inconsistencies), 6) SYMBOLS (broken/missing
|
||||
Unicode characters showing as boxes or empty circles).
|
||||
Respond as JSON: {"slide": N, "overlap": "...", "overflow": "...", "contrast": "...", "layout": "...",
|
||||
"content": "...", "symbols": "...", "overall": "clean|issues found"}
|
||||
```
|
||||
|
||||
### Two-Tier QA Strategy
|
||||
|
||||
A single batch pass with the base prompt catches obvious layout issues but **misses rendering defects and content problems** that require closer inspection. Use a two-tier approach:
|
||||
|
||||
1. **Tier 1 — Batch scan** (breadth): Send all slides through the batch script with the expanded prompt above. Identifies which slides have potential issues.
|
||||
|
||||
2. **Tier 2 — Targeted deep-dive** (depth): For slides flagged in Tier 1 (or slides with complex elements like diagrams, badges, special characters), use individual `vision_analyze` calls with **element-specific questions**:
|
||||
- "Do the Unicode symbols ∅, ≡, ⚙ inside the circles render correctly or show as boxes?"
|
||||
- "Are the connecting lines between neurons visible? What color are they?"
|
||||
- "Does the example value '42' match the description 'Temperatur = 23°C'?"
|
||||
- "Is there adequate padding between the text 'API' and the circle edge?"
|
||||
|
||||
Targeted prompts catch what generic prompts miss — the vision model focuses on the specific element rather than scanning the whole slide.
|
||||
|
||||
### Model Capability Detection
|
||||
|
||||
Before batch QA, verify the target model accepts image input. Some models return `400: At most 0 image(s)` or claim "I cannot see images" even with proper `image_url` format. Test with a single image first.
|
||||
|
||||
See [references/model-capabilities.md](references/model-capabilities.md) for tested models.
|
||||
|
||||
## Config: vision_analyze Tool
|
||||
|
||||
The `vision_analyze` tool reads model config from TWO places in `~/.hermes/config.yaml`:
|
||||
|
||||
- `auxiliary.vision.*` — provider, model, base_url, api_key
|
||||
- Top-level `vision.*` — model, base_url, api_key (overrides auxiliary)
|
||||
|
||||
Both must have the correct model name (including any `release/` prefix) and non-empty base_url + api_key. Use `hermes config set` to update:
|
||||
|
||||
```bash
|
||||
hermes config set auxiliary.vision.model "vllm/release/<model>"
|
||||
hermes config set auxiliary.vision.base_url "https://<endpoint>/v1"
|
||||
hermes config set auxiliary.vision.api_key "<key>"
|
||||
hermes config set vision.model "vllm/release/<model>"
|
||||
hermes config set vision.base_url "https://<endpoint>/v1"
|
||||
hermes config set vision.api_key "<key>"
|
||||
```
|
||||
|
||||
Changes require a session restart to take effect in the `vision_analyze` tool. For immediate use without restart, call the API directly (see above).
|
||||
|
||||
## Corporate Template Adoption QA
|
||||
|
||||
When a user provides a `.pptx` template and asks to rebuild a deck to match its design, the QA phase must verify **design conformance** in addition to layout correctness:
|
||||
|
||||
1. **Template extraction QA** — render template slides to JPEGs and inspect with `vision_analyze` to understand: background color, card style (dark/light, shadows, corners), accent bar style, logo placement, typography, decorative elements.
|
||||
2. **Theme extraction** — parse `ppt/theme/theme1.xml` via `zipfile` + `xml.etree.ElementTree` (not python-pptx's theme API, which can raise `AttributeError`). Extract `<a:srgbClr>` values and `<a:latin typeface="...">` font names.
|
||||
3. **Embedded image inspection** — extract images from `ppt/media/` via `zipfile`, inspect each with `vision_analyze`. Logos may be white-on-transparent (invisible on white backgrounds). If unusable, build a text+shape logo instead.
|
||||
4. **Post-rebuild QA prompt** — add design-conformance checks to the standard QA prompt:
|
||||
```
|
||||
Check this slide for: 1) noris corporate design (white bg, light grey cards #F2F2F2,
|
||||
petrol #234D5B accents, Open Sans font) 2) logo visible top right 3) no text overlaps
|
||||
4) no layout issues 5) readable text. Reply CLEAN or list ISSUES.
|
||||
```
|
||||
5. **Non-ASCII quote pitfall in source code** — German curly quotes (`„…"` / `»…«`) cause `SyntaxError: Unexpected identifier` in Node.js string literals. Use `\u201E` (left double) and `\u201C` (right double) Unicode escapes instead. The `write_file` linter catches this but only if the quotes land inside a JS string.
|
||||
6. **Logo extraction — use the REAL logo, not a text approximation** — When a user provides a `.pptx` template and asks to use its design, they expect the actual corporate logo image, not a reconstructed text+shape approximation. Identify the logo by counting image references across slide `_rels/*.xml.rels` files (logo appears on 20+ slides). **Pitfall:** the most-referenced image may be an EMF (unusable in LibreOffice/pptxgenjs) — check for PNG/JPG alternatives. Multi-part logos (two color variants in one image) require pixel-alpha scanning to find the split point and selective inversion for dark-background variants. See [references/template-adoption.md](references/template-adoption.md) §6 for the full workflow.
|
||||
|
||||
See [references/template-adoption.md](references/template-adoption.md) for the full extraction workflow with code snippets, including §10 (inserting new slides into an existing deck — module organization, renumbering, agenda/summary updates).
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Model name prefixes matter**: `vllm/gemma-4-31b-it` ≠ `vllm/release/gemma-4-31b-it`. Wrong prefix → "no keys found that support model" error.
|
||||
- **Empty base_url/api_key in auxiliary.vision**: Even if top-level `vision.*` is correct, empty auxiliary fields can cause failures. Set both sections.
|
||||
- **Not all LLMs are multimodal**: A model serving text completions may reject image inputs. Always test one image first.
|
||||
- **vision_analyze caches config**: After `hermes config set`, the running session may still use old values. Direct API calls bypass this.
|
||||
- **Rate limiting**: Add 0.5s sleep between batch calls to avoid throttling.
|
||||
- **Unicode symbols silently fail in LibreOffice PDF rendering**: Characters like ∅, ≡, ⚙, ★, ✓, Σ, Ω, → may look fine in PowerPoint but render as blank circles or broken boxes when exported to PDF via `soffice --headless`. Since visual QA relies on PDF→JPEG conversion, these defects are visible in QA images but the **generic batch prompt may not flag them** — it sees "a circle with something in it" and passes it. Always use targeted Tier 2 prompts that explicitly ask "do the symbols inside the circles render correctly?" for slides with badges or icon placeholders.
|
||||
- **Content consistency requires targeted prompts**: A batch prompt won't catch logical inconsistencies like "example shows 42 but description says 23°C" unless the prompt explicitly asks about content consistency. Add content checks to the expanded prompt and follow up with targeted questions.
|
||||
- **PowerPoint rejects negative shape dimensions/positions — LibreOffice doesn't**: pptxgenjs silently emits negative `cx`/`cy` (extent) or `x`/`y` (offset) values in the OOXML when LINE shapes connect nodes bottom-to-top or right-to-left, or when decorative OVALs are placed partially off-slide (`x: -2, y: -2`). LibreOffice opens these files fine and the PDF→JPEG render looks correct, so visual QA passes — but PowerPoint refuses to open the file. After generating a PPTX, always run `scripts/validate_pptx_compat.py <file.pptx>` to catch negative extents/offsets before delivery. Fix in source: use `Math.abs()` for dimensions, `Math.min()` for positions, and clamp decorative shapes to non-negative coordinates. See [references/template-adoption.md](references/template-adoption.md) §9.
|
||||
|
||||
## Narrative Structure Review
|
||||
|
||||
After creating or substantially expanding a deck, review the **narrative flow** ("roter Faden") before delivery. A deck can be visually clean but structurally confused.
|
||||
|
||||
### Consolidation Rules
|
||||
|
||||
- **Max 8 agenda points** — beyond that, the audience loses the thread. Group related slides under one agenda item.
|
||||
- **Max 5 takeaways** — distill into the fewest memorable points. Merge related ones (e.g., "ML learns from data" + "parameters store knowledge" → one takeaway).
|
||||
- **One concept per slide** — if a slide teaches two things, split it or merge into a progression.
|
||||
|
||||
### Flow Audit Steps
|
||||
|
||||
1. **List all slides** with titles and the concept they teach.
|
||||
2. **Identify redundancy**: slides repeating the same idea at different depths (e.g., "KI ist nicht intelligent" as both slide 3 and slide 17).
|
||||
3. **Identify interruptions**: deep-dives that break the narrative arc (e.g., tensor dimensionality between neural networks and LLMs).
|
||||
4. **Check proximity of linked concepts**: Attention-Mechanismus and KV-Cache are technically coupled — 7 slides apart breaks the connection. Keep linked concepts adjacent.
|
||||
5. **Propose merges**: combine slides teaching the same concept (e.g., MoE architecture + Dense-vs-MoE comparison → one slide).
|
||||
6. **Propose cuts**: slides that don't advance the narrative.
|
||||
|
||||
### Output Format
|
||||
|
||||
Present as a table mapping new agenda items to current slides with actions (Merge/Consolidate/Keep/Cut), then list ≤5 proposed takeaways showing which current points they unify.
|
||||
|
||||
### Pitfall: Expanding Without Consolidating
|
||||
|
||||
Adding content slides to an existing deck is common. Each addition shifts slide numbers, grows the agenda, and lengthens the summary. After expansion, always run a flow audit and propose consolidation — a 23-slide deck with 14 agenda points and 12 takeaways is harder to follow than 18 slides with 8 and 5.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `scripts/batch_vision_qa.py` | Batch QA script — sends all slides to vision model, reports issues |
|
||||
| `scripts/validate_pptx_compat.py` | PPTX structural validation — catches negative dimensions/positions that LibreOffice tolerates but PowerPoint rejects |
|
||||
| `references/model-capabilities.md` | Tested model vision capabilities (vLLM/Noris) |
|
||||
| `references/iterative-fix-verify-loop.md` | Multi-cycle QA pattern: fix → re-render → re-QA until zero defects |
|
||||
| `references/template-adoption.md` | Corporate template extraction: theme colors, fonts, **logo identification & multi-part processing** (§6), design-system rebuild |
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `powerpoint` — PPTX creation/editing; this skill handles the visual QA step of its workflow
|
||||
@@ -0,0 +1,58 @@
|
||||
# Iterative Fix-Verify Loop for Presentation QA
|
||||
|
||||
Pattern for achieving zero-defect presentations through repeated visual QA cycles.
|
||||
Derived from a 17-slide deck QA session (5 cycles, 6 issues → 0 issues).
|
||||
|
||||
## The Loop
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 1. GENERATE → node main.js │
|
||||
│ 2. RENDER → soffice → pdftoppm │
|
||||
│ 3. BATCH QA → batch_vision_qa.py │
|
||||
│ 4. TARGETED → vision_analyze (deep) │
|
||||
│ 5. FIX → patch source files │
|
||||
│ 6. REPEAT 2-5 until ALL CLEAN │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Cycle Progression (Real Example)
|
||||
|
||||
| Cycle | Issues Found | Action | Remaining |
|
||||
|-------|-------------|--------|-----------|
|
||||
| 1 | 1 (batch) + 6 (targeted) | Fixed all 6 | 3 remaining |
|
||||
| 2 | 3 (targeted) | Fixed all 3 | 1 remaining |
|
||||
| 3 | 1 (targeted) | Fixed | 1 remaining |
|
||||
| 4 | 1 (targeted) | Fixed | 0 |
|
||||
| 5 | 0 (full batch) | Done | 0 ✅ |
|
||||
|
||||
## Why Multiple Cycles Are Needed
|
||||
|
||||
Fixes introduce new problems:
|
||||
- Moving a subtitle down to fix overlap may push it into content below
|
||||
- Enlarging a badge circle may overlap neighboring elements
|
||||
- Changing line color for visibility may reduce contrast with another element
|
||||
- Shortening a label to prevent wrapping may lose meaning
|
||||
|
||||
**Never declare success after the first fix-and-verify.** Always re-render and re-QA.
|
||||
|
||||
## Batch + Targeted Prompt Division
|
||||
|
||||
### Batch Prompt (Tier 1 — Breadth)
|
||||
Generic, checks all slides quickly (~25s for 17 slides). Uses expanded 6-category prompt:
|
||||
overlap, overflow, contrast, layout, content, symbols.
|
||||
|
||||
### Targeted Prompts (Tier 2 — Depth)
|
||||
Element-specific questions for slides with:
|
||||
- **Diagrams**: "Are the connecting lines visible? What color?"
|
||||
- **Badges/icons**: "Do the letters inside circles render correctly?"
|
||||
- **Tables**: "Are all cells populated? Any placeholder text?"
|
||||
- **Examples**: "Does the example value match the description?"
|
||||
- **Arrows/connectors**: "Do arrows render properly between elements?"
|
||||
|
||||
## Efficiency Tips
|
||||
|
||||
- Only re-render and re-QA slides that were changed (not the entire deck)
|
||||
- But ALWAYS do a full batch scan at the end to confirm no regressions
|
||||
- Batch QA via direct API calls is 10-20x faster than sequential vision_analyze calls
|
||||
- Save QA results to JSON for comparison between cycles
|
||||
@@ -0,0 +1,56 @@
|
||||
# Vision Model Capabilities (vLLM / Noris)
|
||||
|
||||
Tested against `https://ai.noris.de/v1` — OpenAI-compatible vLLM endpoint.
|
||||
|
||||
## Multimodal (Accepts Images) ✅
|
||||
|
||||
| Model | Notes |
|
||||
|-------|-------|
|
||||
| `vllm/release/gemma-4-31b-it` | Works. Despite not being labeled "vision", accepts `image_url` content type and describes images accurately. Good for slide QA. |
|
||||
|
||||
## Text-Only (Rejects Images) ❌
|
||||
|
||||
| Model | Error | Notes |
|
||||
|-------|-------|-------|
|
||||
| `vllm/release/qwen3.6-27b` | `400: At most 0 image(s) may be provided in one prompt` | Explicitly rejects image input |
|
||||
| `vllm/release/qwen3.6-35b-a3b` | `400: Input should be a valid string` | Rejects multimodal content format |
|
||||
| `vllm/release/gpt-oss-120b` | Returns "I'm not able to view images" | Accepts the request but model itself can't process images |
|
||||
| `vllm/release/glm-5-2` | Not tested for images | Primary chat model — assumed text-only |
|
||||
|
||||
## Testing Procedure
|
||||
|
||||
Send a single image with a simple "describe this image" prompt:
|
||||
|
||||
```python
|
||||
import urllib.request, json, base64
|
||||
|
||||
with open("test.jpg", "rb") as f:
|
||||
img_b64 = base64.b64encode(f.read()).decode()
|
||||
|
||||
payload = {
|
||||
"model": "<model_to_test>",
|
||||
"messages": [{"role": "user", "content": [
|
||||
{"type": "text", "text": "Describe this image in 1 sentence."},
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}}
|
||||
]}],
|
||||
"max_tokens": 80
|
||||
}
|
||||
|
||||
req = urllib.request.Request(
|
||||
"https://ai.noris.de/v1/chat/completions",
|
||||
data=json.dumps(payload).encode(),
|
||||
headers={"Authorization": "Bearer <key>", "Content-Type": "application/json"}
|
||||
)
|
||||
resp = urllib.request.urlopen(req, timeout=30)
|
||||
print(json.loads(resp.read())["choices"][0]["message"]["content"])
|
||||
```
|
||||
|
||||
If the response describes the image content → multimodal. If it apologizes about not seeing images or returns HTTP 400 → text-only.
|
||||
|
||||
## Config Gotcha
|
||||
|
||||
The `auxiliary.vision` section in `~/.hermes/config.yaml` must have:
|
||||
- Correct model name WITH prefix (e.g. `vllm/release/gemma-4-31b-it`, not `vllm/gemma-4-31b-it`)
|
||||
- Non-empty `base_url` and `api_key`
|
||||
|
||||
If these are empty or wrong, `vision_analyze` fails with "no keys found that support model" — even if the top-level `vision.*` section is correct. Both sections must be consistent.
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Batch visual QA of rendered document pages/slides via direct vLLM API calls.
|
||||
|
||||
Bypasses the vision_analyze tool — useful when:
|
||||
- vision_analyze fails due to config caching / model resolution issues
|
||||
- You need to QA many images efficiently in one pass
|
||||
- The active chat model is text-only but a multimodal model is on the same endpoint
|
||||
|
||||
Usage:
|
||||
python batch_vision_qa.py /path/to/images/ <num_images> [options]
|
||||
|
||||
Environment variables:
|
||||
NORIS_API_KEY - API key for the vLLM endpoint (required)
|
||||
VISION_BASE_URL - Full chat completions URL (default: https://ai.noris.de/v1/chat/completions)
|
||||
VISION_MODEL - Model name (default: vllm/release/gemma-4-31b-it)
|
||||
|
||||
Example:
|
||||
NORIS_API_KEY=sk-bf-xxx python batch_vision_qa.py /home/debian/ai101/qa/ 17
|
||||
|
||||
Expects files named slide-01.jpg, slide-02.jpg, ..., slide-NN.jpg.
|
||||
Outputs per-slide status and a summary of any issues found.
|
||||
|
||||
Before using: verify the model accepts images (see references/model-capabilities.md).
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
API_KEY = os.environ.get("NORIS_API_KEY", "")
|
||||
BASE_URL = os.environ.get(
|
||||
"VISION_BASE_URL", "https://ai.noris.de/v1/chat/completions"
|
||||
)
|
||||
MODEL = os.environ.get("VISION_MODEL", "vllm/release/gemma-4-31b-it")
|
||||
|
||||
# Expanded 6-category prompt: overlap, overflow, contrast, layout, content, symbols
|
||||
QA_PROMPT_TEMPLATE = """You are a visual QA reviewer for a professional presentation slide.
|
||||
Analyze this slide carefully for ALL of these issues:
|
||||
1. TEXT OVERLAP: Any text overlapping other text or graphics?
|
||||
2. TEXT OVERFLOW: Any text extending beyond edges or getting cut off?
|
||||
3. CONTRAST: Any low contrast between text and background making text hard to read?
|
||||
4. LAYOUT: Misalignment, elements too close, insufficient margins, awkward spacing?
|
||||
5. CONTENT: Unclear messaging, missing information, placeholder text, spelling errors, logical inconsistencies?
|
||||
6. SYMBOLS: Any broken/missing Unicode characters showing as boxes or empty circles?
|
||||
|
||||
Respond in this exact JSON format:
|
||||
{{"slide": {slide_num}, "overlap": "none|<description>", "overflow": "none|<description>", "contrast": "ok|<description>", "layout": "ok|<description>", "content": "ok|<description>", "symbols": "ok|<description>", "overall": "clean|issues found"}}
|
||||
|
||||
Be precise and thorough. If no issues, say "clean". If issues exist, describe them specifically."""
|
||||
|
||||
|
||||
def qa_single_image(image_path: str, slide_num: int) -> dict:
|
||||
"""Send one image to the vision model and return parsed QA result."""
|
||||
with open(image_path, "rb") as f:
|
||||
img_b64 = base64.b64encode(f.read()).decode()
|
||||
|
||||
payload = {
|
||||
"model": MODEL,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": QA_PROMPT_TEMPLATE.format(slide_num=slide_num),
|
||||
},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:image/jpeg;base64,{img_b64}"
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
"max_tokens": 500,
|
||||
}
|
||||
|
||||
req = urllib.request.Request(
|
||||
BASE_URL,
|
||||
data=json.dumps(payload).encode(),
|
||||
headers={
|
||||
"Authorization": f"Bearer {API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
|
||||
resp = urllib.request.urlopen(req, timeout=60)
|
||||
data = json.loads(resp.read())
|
||||
content = data["choices"][0]["message"].get("content", "")
|
||||
|
||||
# Strip markdown code fences if present
|
||||
content = content.strip()
|
||||
if content.startswith("```"):
|
||||
content = content.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
|
||||
|
||||
try:
|
||||
return json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
return {
|
||||
"slide": slide_num,
|
||||
"overlap": "parse error",
|
||||
"overflow": "parse error",
|
||||
"contrast": "parse error",
|
||||
"layout": "parse error",
|
||||
"content": "parse error",
|
||||
"symbols": "parse error",
|
||||
"overall": content[:200],
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print(f"Usage: {sys.argv[0]} <images_dir> <num_images>")
|
||||
print(f"Example: {sys.argv[0]} /home/debian/ai101/qa/ 17")
|
||||
sys.exit(1)
|
||||
|
||||
images_dir = sys.argv[1]
|
||||
num_slides = int(sys.argv[2])
|
||||
|
||||
if not API_KEY:
|
||||
print("ERROR: Set NORIS_API_KEY environment variable.")
|
||||
sys.exit(1)
|
||||
|
||||
results = []
|
||||
issues_found = []
|
||||
|
||||
for i in range(1, num_slides + 1):
|
||||
slide_path = os.path.join(images_dir, f"slide-{i:02d}.jpg")
|
||||
if not os.path.exists(slide_path):
|
||||
print(f"Slide {i}: file not found — {slide_path}")
|
||||
continue
|
||||
|
||||
try:
|
||||
result = qa_single_image(slide_path, i)
|
||||
results.append(result)
|
||||
status = result.get("overall", "?")
|
||||
marker = "⚠️" if "issue" in status.lower() else "✅"
|
||||
print(f"Slide {i}/{num_slides} {marker} {status}")
|
||||
if "issue" in status.lower():
|
||||
issues_found.append(result)
|
||||
except Exception as e:
|
||||
print(f"Slide {i} FAILED: {e}")
|
||||
results.append({"slide": i, "error": str(e)})
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
if issues_found:
|
||||
print(f"ISSUES ON {len(issues_found)} SLIDE(S):")
|
||||
for r in issues_found:
|
||||
print(f" Slide {r['slide']}:")
|
||||
for key in ["overlap", "overflow", "contrast", "layout", "content", "symbols"]:
|
||||
val = r.get(key, "")
|
||||
if val and val not in ("none", "ok", ""):
|
||||
print(f" {key}: {val}")
|
||||
else:
|
||||
print("ALL SLIDES CLEAN ✅")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Validate a .pptx file for PowerPoint compatibility.
|
||||
|
||||
Checks for issues that LibreOffice tolerates but PowerPoint rejects:
|
||||
- Negative shape dimensions (cx, cy ≤ 0) in <a:ext> elements
|
||||
- Negative shape positions (x, y < 0) in <a:off> elements
|
||||
- Missing required OOXML parts ([Content_Types].xml, presentation.xml, etc.)
|
||||
- Malformed XML in any part
|
||||
- Broken relationship references (rId in slide XML not found in .rels)
|
||||
- Missing Content_Type overrides for slides
|
||||
|
||||
Usage:
|
||||
python3 validate_pptx_compat.py <file.pptx>
|
||||
|
||||
Exit codes:
|
||||
0 = all checks passed
|
||||
1 = issues found (printed to stderr)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
NS_P = "http://schemas.openxmlformats.org/presentationml/2006/main"
|
||||
NS_R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
NS_REL = "{http://schemas.openxmlformats.org/package/2006/relationships}"
|
||||
|
||||
|
||||
def validate(pptx_path: str) -> list[str]:
|
||||
"""Return a list of issue strings; empty list means clean."""
|
||||
issues = []
|
||||
|
||||
with zipfile.ZipFile(pptx_path, "r") as z:
|
||||
# 1. Zip integrity
|
||||
bad = z.testzip()
|
||||
if bad:
|
||||
issues.append(f"BAD ZIP ENTRY: {bad}")
|
||||
|
||||
# 2. XML parse check on all XML/rels parts
|
||||
for name in z.namelist():
|
||||
if name.endswith(".xml") or name.endswith(".rels"):
|
||||
try:
|
||||
ET.parse(z.open(name))
|
||||
except ET.ParseError as e:
|
||||
issues.append(f"XML PARSE ERROR in {name}: {e}")
|
||||
|
||||
# 3. Required parts
|
||||
required = [
|
||||
"[Content_Types].xml",
|
||||
"_rels/.rels",
|
||||
"ppt/presentation.xml",
|
||||
"ppt/_rels/presentation.xml.rels",
|
||||
"ppt/theme/theme1.xml",
|
||||
]
|
||||
for req in required:
|
||||
if req not in z.namelist():
|
||||
issues.append(f"MISSING REQUIRED PART: {req}")
|
||||
|
||||
# 4. Slide rels existence
|
||||
slides = [
|
||||
f for f in z.namelist()
|
||||
if f.startswith("ppt/slides/slide") and f.endswith(".xml")
|
||||
]
|
||||
for s in sorted(slides):
|
||||
rels = s.replace("ppt/slides/", "ppt/slides/_rels/") + ".rels"
|
||||
if rels not in z.namelist():
|
||||
issues.append(f"MISSING RELS for {s}")
|
||||
|
||||
# 5. Content_Type overrides for slides
|
||||
ct = ET.parse(z.open("[Content_Types].xml"))
|
||||
ct_ns = {"ct": "http://schemas.openxmlformats.org/package/2006/content-types"}
|
||||
overrides = [
|
||||
o.get("PartName") for o in ct.findall(".//ct:Override", ct_ns)
|
||||
]
|
||||
for s in sorted(slides):
|
||||
part_name = "/" + s
|
||||
if part_name not in overrides:
|
||||
issues.append(f"Missing Content_Type override for {s}")
|
||||
|
||||
# 6. Negative dimensions and positions (THE key check)
|
||||
for i in range(1, len(slides) + 1):
|
||||
slide_path = f"ppt/slides/slide{i}.xml"
|
||||
if slide_path not in z.namelist():
|
||||
continue
|
||||
root = ET.parse(z.open(slide_path)).getroot()
|
||||
|
||||
for sp in root.iter(f"{{{NS_P}}}sp"):
|
||||
spPr = sp.find(f".//{{{NS_P}}}spPr")
|
||||
if spPr is None:
|
||||
continue
|
||||
|
||||
# Get shape name for reporting
|
||||
cNvPr = sp.find(f".//{{{NS_P}}}cNvPr")
|
||||
name = cNvPr.get("name", "?") if cNvPr is not None else "?"
|
||||
|
||||
xfrm = spPr.find(f"{{{NS_A}}}xfrm")
|
||||
if xfrm is None:
|
||||
continue
|
||||
|
||||
off = xfrm.find(f"{{{NS_A}}}off")
|
||||
ext = xfrm.find(f"{{{NS_A}}}ext")
|
||||
|
||||
if ext is not None:
|
||||
cx = int(ext.get("cx", "0"))
|
||||
cy = int(ext.get("cy", "0"))
|
||||
if cx <= 0 or cy <= 0:
|
||||
issues.append(
|
||||
f"slide{i}: shape \"{name}\" has zero/negative extent: "
|
||||
f"cx={cx} cy={cy}"
|
||||
)
|
||||
|
||||
if off is not None:
|
||||
x = int(off.get("x", "0"))
|
||||
y = int(off.get("y", "0"))
|
||||
if x < 0 or y < 0:
|
||||
issues.append(
|
||||
f"slide{i}: shape \"{name}\" has negative position: "
|
||||
f"x={x} y={y}"
|
||||
)
|
||||
|
||||
# 7. Broken image embed references
|
||||
for i in range(1, len(slides) + 1):
|
||||
slide_path = f"ppt/slides/slide{i}.xml"
|
||||
if slide_path not in z.namelist():
|
||||
continue
|
||||
root = ET.parse(z.open(slide_path)).getroot()
|
||||
|
||||
rels_path = f"ppt/slides/_rels/slide{i}.xml.rels"
|
||||
if rels_path not in z.namelist():
|
||||
continue
|
||||
rels_tree = ET.parse(z.open(rels_path))
|
||||
rels_root = rels_tree.getroot()
|
||||
valid_rids = {
|
||||
rel.get("Id") for rel in rels_root.findall(f"{NS_REL}Relationship")
|
||||
}
|
||||
|
||||
for blip in root.iter(f"{{{NS_A}}}blip"):
|
||||
embed = blip.get(f"{{{NS_R}}}embed")
|
||||
if embed is not None and embed not in valid_rids:
|
||||
issues.append(
|
||||
f"slide{i}: blipFill embed '{embed}' not found in rels"
|
||||
)
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 2:
|
||||
print("Usage: validate_pptx_compat.py <file.pptx>", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
pptx_path = sys.argv[1]
|
||||
try:
|
||||
issues = validate(pptx_path)
|
||||
except FileNotFoundError:
|
||||
print(f"File not found: {pptx_path}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
except zipfile.BadZipFile:
|
||||
print(f"Not a valid ZIP/PPTX file: {pptx_path}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
if issues:
|
||||
print(f"❌ {len(issues)} issue(s) found:", file=sys.stderr)
|
||||
for iss in issues:
|
||||
print(f" • {iss}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"✅ {pptx_path}: all checks passed")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user