kaizen-agent 1.19.3 → 1.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/cli/install.ts +10 -4
- package/package.json +1 -1
- package/skills/story-deck/SKILL.md +63 -0
- package/skills/story-deck/examples/build-example.js +29 -0
- package/skills/story-deck/references/rules.md +62 -0
- package/skills/story-deck/references/story-arc.md +35 -0
- package/skills/story-deck/scripts/check.sh +12 -0
- package/skills/story-deck/scripts/install_fonts.py +42 -0
- package/skills/story-deck/scripts/kit.js +174 -0
- package/skills/story-deck/scripts/prep_images.py +70 -0
- package/skills/story-deck/scripts/qa.sh +48 -0
- package/skills/story-video/SKILL.md +108 -0
- package/skills/story-video/engine/package.json +12 -0
- package/skills/story-video/engine/src/Root.tsx +24 -0
- package/skills/story-video/engine/src/engine/Film.tsx +405 -0
- package/skills/story-video/engine/src/engine/presets.json +58 -0
- package/skills/story-video/engine/src/index.ts +3 -0
- package/skills/story-video/engine/tsconfig.json +1 -0
- package/skills/story-video/examples/sample-film/README.md +12 -0
- package/skills/story-video/examples/sample-film/film.json +16 -0
- package/skills/story-video/examples/sample-film/lines.json +7 -0
- package/skills/story-video/references/licensing.md +14 -0
- package/skills/story-video/references/presets.md +21 -0
- package/skills/story-video/references/production.md +29 -0
- package/skills/story-video/references/revisions.md +25 -0
- package/skills/story-video/references/script.md +30 -0
- package/skills/story-video/scripts/check.sh +19 -0
- package/skills/story-video/scripts/intake.py +73 -0
- package/skills/story-video/scripts/master.sh +26 -0
- package/skills/story-video/scripts/music.sh +10 -0
- package/skills/story-video/scripts/setup.sh +25 -0
- package/skills/story-video/scripts/sfx.py +64 -0
- package/skills/story-video/scripts/srt.py +30 -0
- package/skills/story-video/scripts/still.sh +10 -0
- package/skills/story-video/scripts/vo.py +99 -0
package/README.md
CHANGED
|
@@ -232,6 +232,17 @@ In a terminal: `kaizen` (dashboard), `kaizen web` (board), `kaizen settings`, `k
|
|
|
232
232
|
|
|
233
233
|
Stuck on 1.18.0 or older? If `kaizen upgrade` says *could not pull* and stays on the old version, run this once: `bunx kaizen-agent@latest`. Your old install is kept as `~/kaizen.old`; from then on `kaizen upgrade` works by itself.
|
|
234
234
|
|
|
235
|
+
## Bundled skills
|
|
236
|
+
|
|
237
|
+
Installed alongside kaizen; your agent uses them when a request fits. Everything they make lives in `~/.kaizen/`.
|
|
238
|
+
|
|
239
|
+
| Skill | What it makes |
|
|
240
|
+
|---|---|
|
|
241
|
+
| `story-deck` | A cinematic storytelling pitch deck (A5 `.pptx`): story first, the product last. Output in `~/.kaizen/decks/`. |
|
|
242
|
+
| `story-video` | A publish-ready video from a prompt, photos, screenshots, a deck or footage: narration, captions, sound design, music, mastering, 16:9 and 9:16, revisable. Output in `~/.kaizen/videos/`. Works with no accounts (free voice, synthesized sounds); an ElevenLabs key unlocks natural voices. |
|
|
243
|
+
|
|
244
|
+
Each skill has a `scripts/check.sh` that lists what it needs (Node, Python, ffmpeg, LibreOffice).
|
|
245
|
+
|
|
235
246
|
## Learn more
|
|
236
247
|
|
|
237
248
|
- **[The guide](https://github.com/hfadhlullah/kaizen/blob/main/docs/guide.md)**: the board, request sources, notebook, settings, installer flags, uninstall
|
package/cli/install.ts
CHANGED
|
@@ -46,6 +46,9 @@ const c = {
|
|
|
46
46
|
green: (s: string) => `\x1b[32m${s}\x1b[0m`,
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
+
// Skill folders shipped in skills/ and linked into every agent.
|
|
50
|
+
const SKILLS = ["kaizen", "kaizen-help", "story-deck", "story-video"];
|
|
51
|
+
|
|
49
52
|
// Where each agent reads skills from. The skill folders follow the open Agent
|
|
50
53
|
// Skills standard, so every one of these can run the workflow; agents/ and
|
|
51
54
|
// commands/ are Claude Code's own formats and go nowhere else.
|
|
@@ -177,7 +180,7 @@ async function removeEverything() {
|
|
|
177
180
|
|
|
178
181
|
for (const root of roots) {
|
|
179
182
|
for (const a of AGENTS) {
|
|
180
|
-
for (const n of
|
|
183
|
+
for (const n of SKILLS) drop(join(root, a.dir, "skills", n));
|
|
181
184
|
for (const sub of ["agents", "commands"]) {
|
|
182
185
|
const dir = join(root, a.dir, sub);
|
|
183
186
|
try {
|
|
@@ -386,7 +389,7 @@ if (interactive && !upgrade && Bun.argv.length === 2) {
|
|
|
386
389
|
if (!stale) {
|
|
387
390
|
const here = found(home);
|
|
388
391
|
const linked = here.length > 0 && here.every((a) =>
|
|
389
|
-
|
|
392
|
+
SKILLS.every((n) => existsSync(join(home, a.dir, "skills", n))));
|
|
390
393
|
if (linked) {
|
|
391
394
|
const { dashboard } = await import("./dashboard.ts");
|
|
392
395
|
await dashboard(repoDir, here.map((a) => a.name), async (action) => {
|
|
@@ -432,8 +435,11 @@ if (upgrade) {
|
|
|
432
435
|
// failure is printed rather than swallowed.
|
|
433
436
|
const r = await Bun.$`${process.execPath} x --force kaizen-agent@${latest} --yes`.quiet().nothrow();
|
|
434
437
|
const out = clean(r.stdout.toString() + r.stderr.toString()).filter((l) => l.trim());
|
|
435
|
-
for (const l of out) console.log(l.startsWith(" ") ? l : ` ${l}`);
|
|
436
438
|
const after = versionOf(dest);
|
|
439
|
+
// The new installer's banner and first-install "Next" steps are noise here: show
|
|
440
|
+
// its step lines, or everything when it failed so the error is not hidden.
|
|
441
|
+
const shown = after === latest ? out.filter((l) => /^\s*(\+|skip)\s/.test(l)) : out;
|
|
442
|
+
for (const l of shown) console.log(l.startsWith(" ") ? l : ` ${l}`);
|
|
437
443
|
// The installer exits 1 when it skipped a link (an agent file the user owns), which
|
|
438
444
|
// is not a failed upgrade: the version on disk is what decides.
|
|
439
445
|
if (after !== latest) {
|
|
@@ -676,7 +682,7 @@ function dirEntries(sub: string) {
|
|
|
676
682
|
}
|
|
677
683
|
|
|
678
684
|
const links = targets.flatMap((a) => [
|
|
679
|
-
...
|
|
685
|
+
...SKILLS.map((n) => [join(repo, "skills", n), join(a.dir, "skills")] as const),
|
|
680
686
|
...(a.full ? [
|
|
681
687
|
...dirEntries("agents").map((f) => [f, join(a.dir, "agents")] as const),
|
|
682
688
|
...dirEntries("commands").map((f) => [f, join(a.dir, "commands")] as const),
|
package/package.json
CHANGED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: story-deck
|
|
3
|
+
description: Build a cinematic, minimalist storytelling pitch deck (A5 landscape .pptx) in the style of a senior agency — story first, problem, a single "what if" question, then the product reveal and feature scenes, ending in a demo CTA. Dark ink + gold, Playfair Display + Inter, full-bleed graded photos. Use when the user asks for a pitch deck / sales deck / product showcase deck "that tells a story", "cinematic", or a new deck for a product or audience in that style. Do NOT use for editing an existing .pptx, plain bullet decks, or data reports — use the pptx skill for those.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Story deck
|
|
7
|
+
|
|
8
|
+
A deck that tells a story before it sells. Worked example: [`examples/build-example.js`](examples/build-example.js)
|
|
9
|
+
(every helper on a fictional product, no images needed).
|
|
10
|
+
|
|
11
|
+
Read before building:
|
|
12
|
+
- [`references/story-arc.md`](references/story-arc.md) — the acts, slide recipes, copy rules, layout facts.
|
|
13
|
+
- [`references/rules.md`](references/rules.md) — truth, images, fonts, files, QA. Every rule there cost a redo.
|
|
14
|
+
|
|
15
|
+
If the Anthropic `pptx` skill is available, load it once (pptxgenjs gotchas, validator); `qa.sh` works without it.
|
|
16
|
+
Run `scripts/check.sh` first on a new machine.
|
|
17
|
+
|
|
18
|
+
## Workflow
|
|
19
|
+
|
|
20
|
+
1. **Brainstorm first.** Propose 2–3 story concepts + an outline (one line per slide) and
|
|
21
|
+
ask: audience, language, data available, photos, the CTA. Build only after the outline is agreed.
|
|
22
|
+
2. **Deck folder:** `~/.kaizen/decks/<slug>/` — everything a deck needs and produces lives there
|
|
23
|
+
(never in the user's project folders). Shared dependencies live once in `~/.kaizen/decks/`:
|
|
24
|
+
`node_modules` (pptxgenjs, resolved upward by Node) and `.venv` (Pillow, defusedxml, lxml,
|
|
25
|
+
fonttools, qrcode). First time on a machine:
|
|
26
|
+
`cd ~/.kaizen/decks && npm init -y && npm i pptxgenjs && python3 -m venv .venv && .venv/bin/pip install pillow defusedxml lxml fonttools "qrcode[pil]"`.
|
|
27
|
+
Copy in `scripts/kit.js`, `scripts/prep_images.py`, `scripts/qa.sh`.
|
|
28
|
+
3. **Research the truth.** Features from the product repos (shipped only — one sub-agent per
|
|
29
|
+
repo when there are several, if your agent has sub-agents). Stats from official sources with citations.
|
|
30
|
+
4. **Photos.** Find on Unsplash with a web-fetch tool (or ask the user for photos), download to `img/`, contact-sheet, choose.
|
|
31
|
+
Write `plan.json` (backgrounds + panels), run `python3 prep_images.py plan.json` → `bg/`.
|
|
32
|
+
5. **Fonts.** Run `scripts/install_fonts.py` once per machine (static instances; see rules.md).
|
|
33
|
+
6. **Write `build-<name>.js`** with `kit.js`:
|
|
34
|
+
|
|
35
|
+
```js
|
|
36
|
+
const { makeDeck } = require("./kit");
|
|
37
|
+
const d = makeDeck("Deck title", { logo: "logo.png", logoRatio: 199 / 880 }); // branding optional
|
|
38
|
+
d.story("s01", { kick, title, sub, notes, credit, top }); // photo story beat
|
|
39
|
+
d.pause(line, sub, notes); // black silence slide
|
|
40
|
+
d.question(line, notes); // gold "what if" slide
|
|
41
|
+
d.reveal(name, tagline, line, notes); // product name reveal
|
|
42
|
+
d.scene("f03", { kick, title, features: [[name, desc]], notes, credit, shot, shotRatio }); // shot = UI screenshot, framed right
|
|
43
|
+
d.cards(kick, title, [[label, name, desc] x3], notes, footnote);
|
|
44
|
+
d.cta("f16", { title, sub, facts, notes, credit, contact, site, qr }); // qr: PNG path (make with python qrcode), else a [QR] box
|
|
45
|
+
d.pres.writeFile({ fileName: "Deck.pptx" }).then(console.log);
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Branding options on `makeDeck(title, brand)`: `INK, CREAM, MUTED, GOLD, GOLD_BG, cardBg`
|
|
49
|
+
(hex, no `#`), `SERIF, SANS` (font names), `logo` (light-on-dark PNG path; none = no logo),
|
|
50
|
+
`logoRatio` (height/width). Defaults are the neutral ink + gold look with no logo.
|
|
51
|
+
For anything the helpers do not cover (triptych, recap timeline, stat rows) use
|
|
52
|
+
`d.slide()`, `d.kicker()`, `d.headline()`, `d.body()` and see build.js / build-features.js
|
|
53
|
+
in the reference project.
|
|
54
|
+
7. **QA:** `./qa.sh build-<name>.js`, look at every printed sheet (or send them to the user), fix, repeat. Clean `qa/` after.
|
|
55
|
+
8. **Report:** file path, the story in one line per slide, every placeholder left, every
|
|
56
|
+
claim the user must verify, and what was deliberately left out.
|
|
57
|
+
|
|
58
|
+
## Agent notes (Claude Code, Codex, Gemini CLI, OpenCode, Cursor…)
|
|
59
|
+
Steps name capabilities, not tools. Where your agent lacks one:
|
|
60
|
+
- **web fetch** (finding stock photos, sources): ask the user for photos/links instead.
|
|
61
|
+
- **viewing images** (checking contact sheets / frames): send the image paths to the user and ask them to look.
|
|
62
|
+
- **sub-agents** (reading several repos at once): read them one after another.
|
|
63
|
+
- **structured questions**: ask in plain text with numbered options.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Worked example: a story deck for a fictional product ("Ledgerly", bookkeeping for small cafés).
|
|
2
|
+
// No photos needed — it uses the image-free helpers. Copy into ~/.kaizen/decks/<slug>/ next to kit.js
|
|
3
|
+
// and run: node build-example.js (then ./qa.sh build-example.js to check it).
|
|
4
|
+
// In a real deck most beats are photo stories: d.story("bg01", {...}) after prep_images.py made bg/bg01.jpg.
|
|
5
|
+
const { makeDeck } = require("./kit");
|
|
6
|
+
|
|
7
|
+
const d = makeDeck("Ledgerly — The Closing Hour", {
|
|
8
|
+
GOLD: "D9A441", GOLD_BG: "D9A441", // brand accent; colours are hex without '#'
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
d.pause("Every café closes at ten.", "The owner's real work starts after that.",
|
|
12
|
+
"Open on a feeling, not the product. Pause for two seconds.");
|
|
13
|
+
d.cards("Act II — The pieces", "Three notebooks, one business.", [
|
|
14
|
+
["Morning", "Supplier invoices", "Paid from the owner's phone, noted somewhere."],
|
|
15
|
+
["Afternoon", "Cash drawer", "Counted twice, written on the back of a receipt."],
|
|
16
|
+
["Night", "Spreadsheet", "Retyped from both, after closing."],
|
|
17
|
+
], "Speaker note: numbers only with a source on the slide.", "Each piece is true. None of them talk to each other.");
|
|
18
|
+
d.question("What if the books closed themselves?", "The turn. Say it slowly.");
|
|
19
|
+
d.reveal("Ledgerly", "Bookkeeping that closes with the café.", "Invoices · Cash · Reports",
|
|
20
|
+
"First time the product is named.");
|
|
21
|
+
d.cta(null, {
|
|
22
|
+
kick: "Next step", label: "Book a demo",
|
|
23
|
+
title: "See your own numbers in 20 minutes.",
|
|
24
|
+
sub: "A demo with a sample of your last month's receipts.",
|
|
25
|
+
facts: "Web and mobile · English and Bahasa Indonesia",
|
|
26
|
+
contact: "[Name] · [WhatsApp] · [Email]", site: "[website]",
|
|
27
|
+
notes: "Fill the contact placeholders before presenting.",
|
|
28
|
+
});
|
|
29
|
+
d.pres.writeFile({ fileName: "Ledgerly-example.pptx" }).then((f) => console.log(f));
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Rules learned the hard way
|
|
2
|
+
|
|
3
|
+
## Truth
|
|
4
|
+
- Features: only what has shipped. Verify in the product repo: CHANGELOG released
|
|
5
|
+
versions + existing pages/routes. "Unreleased", proposals, SPH/quotation docs = not shipped.
|
|
6
|
+
Note what is missing (e.g. no iOS, no payment gateway) and keep it out of the copy;
|
|
7
|
+
warn in speaker notes.
|
|
8
|
+
- A requirements/spec doc is not proof of shipping: confirm each feature in the CHANGELOG or in
|
|
9
|
+
the code itself (content files, pages). Re-read the top of every CHANGELOG right before
|
|
10
|
+
delivering — products ship while decks are being made (a product changed its login model
|
|
11
|
+
mid-project once and two slides were already wrong).
|
|
12
|
+
- Stats: never invent. Use official/scientific sources (e.g. Dapodik, BPS, peer-reviewed
|
|
13
|
+
journals); put a full `Sumber: …` line on the slide and details in notes. If no source
|
|
14
|
+
exists for a planned stat, swap the stat — do not leave a fake number.
|
|
15
|
+
- Narrative numbers ("140 pesan belum dibaca") are fine but tell the user they are story, not data.
|
|
16
|
+
|
|
17
|
+
## Images
|
|
18
|
+
- Unsplash blocks curl/napi (bot wall). Use a web-fetch tool on `https://unsplash.com/s/photos/<query>`
|
|
19
|
+
asking for non-premium image URLs, then download
|
|
20
|
+
`https://images.unsplash.com/photo-<id>?w=1600&q=80&fm=jpg` with curl (that CDN works).
|
|
21
|
+
- Prefer real local scenes (for Indonesia: query `indonesia-school`, `sekolah`, `pesantren`,
|
|
22
|
+
`indonesian-teacher`, `indonesia-mechanic`). Build a contact sheet and LOOK before choosing.
|
|
23
|
+
- Never use pptxgenjs `sizing: cover` — Google Slides ignores it and stretches. Crop each
|
|
24
|
+
image to the exact box ratio in `prep_images.py` (panels: `[w_px, h_px]` = box ratio).
|
|
25
|
+
- Every photo must show the slide's own sentence. Problem slides show the real-world pain
|
|
26
|
+
(the workshop, the office, the WhatsApp phone); product and feature slides show people
|
|
27
|
+
USING the kind of tech the product is (students at computers / circuit simulation for a
|
|
28
|
+
simulator, adults in a training room or on laptops for an LMS). A pretty but unrelated
|
|
29
|
+
photo (a camera crew, a sermon, kids on a teacher-training slide) gets swapped.
|
|
30
|
+
- Show modern equipment (flat screens, laptops) — never CRT monitors or dated labs when
|
|
31
|
+
selling software. Mix genders in group shots of students; avoid all-one-gender rooms.
|
|
32
|
+
- For product slides, the product's REAL interface beats any stock photo: look in the repo for
|
|
33
|
+
screenshots/renders (`public/landing/`, `.preview/`, docs) and use them — framed via
|
|
34
|
+
`scene({shot, shotRatio})`, or graded as a background if it is a dark render. Never stock
|
|
35
|
+
photos of program code on screen for a non-coding product.
|
|
36
|
+
- Candid only: people absorbed in what they are doing (teaching, reading, working). No posed
|
|
37
|
+
portraits — nobody looking into the camera and smiling; it reads as stock and breaks the film.
|
|
38
|
+
Hands, backs, profiles and wide scenes work best.
|
|
39
|
+
- Photos with readable foreign text (e.g. English survey paper) break the illusion — swap.
|
|
40
|
+
|
|
41
|
+
## Fonts
|
|
42
|
+
- Default Playfair Display + Inter (both in the Google Slides font menu). Install them locally
|
|
43
|
+
with `~/.kaizen/decks/.venv/bin/python scripts/install_fonts.py` — it makes STATIC instances. Never install the variable
|
|
44
|
+
`[wght]` files: some viewers (LibreOffice desktop) draw big headlines greyed out, as if
|
|
45
|
+
transparent, while the XML colour is correct. A missing font (e.g. Cambria) renders with
|
|
46
|
+
ugly letter spacing.
|
|
47
|
+
- If a user reports "text not seen": first check the slide XML colour (unzip, look for
|
|
48
|
+
`srgbClr`/`alpha` on that run). Solid colour there = viewer/font problem, not the deck.
|
|
49
|
+
- Playfair numbers are old-style (low digits); mention it, offer Lora if unwanted.
|
|
50
|
+
|
|
51
|
+
## Files and tools
|
|
52
|
+
- Output decks as local `.pptx` (user imports into Google Slides). No Artifacts.
|
|
53
|
+
- Everything the skill generates lives in `~/.kaizen/decks/<slug>/`; never write inside the user's
|
|
54
|
+
project folders. Reading assets (logos, screenshots) from repos is fine.
|
|
55
|
+
- zsh does not word-split `$var` in `for x in $ids` — wrap in `bash -c` or use arrays.
|
|
56
|
+
- `qa.sh` uses the shared `~/.kaizen/decks/.venv` and creates it when missing.
|
|
57
|
+
|
|
58
|
+
## QA loop
|
|
59
|
+
Run `qa.sh build-x.js`, look at every sheet, fix: text overflowing into the next block
|
|
60
|
+
(headline wrapped to 3 lines), kicker wrapping, labels wrapping in cards, low contrast on
|
|
61
|
+
bright photos (raise gradient strength), page number collisions. Re-run until clean.
|
|
62
|
+
Delete `qa/` and PDFs at the end.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Story arc and slide recipes
|
|
2
|
+
|
|
3
|
+
The deck sells a feeling first and the product last. Never open with features.
|
|
4
|
+
|
|
5
|
+
## The five acts (12–16 slides)
|
|
6
|
+
|
|
7
|
+
| Act | Slides | Helper | What it does |
|
|
8
|
+
|---|---|---|---|
|
|
9
|
+
| I. World | 1–2 | `story` | Cover line about the audience's world, not the product. Scale or context (sourced stat, or none). |
|
|
10
|
+
| II. One scene | 1–2 | `story`, `pause` | Shrink to one person at one time ("06.45 — Gerbang sekolah"). A black `pause` slide for silence. |
|
|
11
|
+
| III. The pieces | 2–4 | `story`, triptych | Time-stamped scenes of the problem from each viewpoint (admin, teacher, parent…). |
|
|
12
|
+
| IV. The cost | 1–2 | `story` + stats | What is lost (trust, time). Stats only with a source line on the slide. |
|
|
13
|
+
| V. The turn | 1 | `question` | Gold slide, one italic question: "Bagaimana jika …?" |
|
|
14
|
+
| Reveal | 1 | `reveal` | Product name appears for the first time. |
|
|
15
|
+
| Features | 3–6 | `scene`, `cards` | Each scene = kicker (time/place · product), headline, 2–4 feature rows. |
|
|
16
|
+
| CTA | 1 | `cta` | One action (demo), contact placeholders, QR placeholder. |
|
|
17
|
+
|
|
18
|
+
Follow-up decks (shown after a story deck) skip acts I–V: open with a callback
|
|
19
|
+
cover ("Hari yang sama. Cerita yang berbeda."), revisit the same scenes with a
|
|
20
|
+
`Dulu: "<old headline>"` line (see build-features.js in the reference project), end
|
|
21
|
+
with a recap timeline and the CTA.
|
|
22
|
+
|
|
23
|
+
## Copy rules
|
|
24
|
+
- One idea per slide. Headline ≤ 2 lines at its size; if it wraps to 3, shorten the words.
|
|
25
|
+
- Kicker = TIME — PLACE or ACT — NAME, uppercase via helper.
|
|
26
|
+
- Body line is the human detail, not a feature list.
|
|
27
|
+
- Name the product only from the reveal onwards.
|
|
28
|
+
- Bahasa Indonesia by default (audience: kepala sekolah/yayasan); English if asked.
|
|
29
|
+
- Speaker notes: delivery cue + caveats + `Foto: <name> / Unsplash.` credit.
|
|
30
|
+
- Placeholders in brackets `[__]`, `[Nama]`, `[QR]` — list them in the final report.
|
|
31
|
+
|
|
32
|
+
## Layout facts (A5 landscape 8.268 × 5.827 in, margin 0.6)
|
|
33
|
+
- Photo backgrounds: gradient side must match where the text sits (`left` for `scene`, `bottom` for `story`).
|
|
34
|
+
- `scene` rows: 0.7 in pitch, 4 rows max, column width 4.3 in.
|
|
35
|
+
- Source lines: 8 pt at `H - 0.75`, width ≤ 6.2 in (clear of the page number).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Requirements for story-deck. Exit 1 if anything required is missing.
|
|
3
|
+
miss=0
|
|
4
|
+
need(){ command -v "$1" >/dev/null 2>&1 && echo "ok $1" || { echo "MISSING $1 — $2"; miss=1; }; }
|
|
5
|
+
need node "Node.js 18+ (https://nodejs.org or your package manager)"
|
|
6
|
+
need npm "comes with Node.js"
|
|
7
|
+
need python3 "Python 3.9+"
|
|
8
|
+
need soffice "LibreOffice (renders slides for the visual check)"
|
|
9
|
+
need pdftoppm "poppler-utils (brew install poppler / apt install poppler-utils / pacman -S poppler)"
|
|
10
|
+
[ -d "$HOME/.kaizen/decks/node_modules/pptxgenjs" ] && echo "ok pptxgenjs" || echo "todo pptxgenjs — cd ~/.kaizen/decks && npm init -y && npm i pptxgenjs"
|
|
11
|
+
fc-list 2>/dev/null | grep -qi "Playfair Display" && echo "ok fonts" || echo "todo fonts — ~/.kaizen/decks/.venv/bin/python scripts/install_fonts.py"
|
|
12
|
+
exit $miss
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Install STATIC Playfair Display + Inter into ~/.local/share/fonts/story-deck/.
|
|
2
|
+
|
|
3
|
+
Variable-font builds (the [wght] files on Google Fonts) render large headlines greyed /
|
|
4
|
+
semi-transparent in some desktop viewers (LibreOffice). Static instances do not.
|
|
5
|
+
Needs: pip install fonttools (qa.sh's .venv works: .venv/bin/pip install fonttools).
|
|
6
|
+
Usage: python3 install_fonts.py
|
|
7
|
+
"""
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
import urllib.request
|
|
12
|
+
|
|
13
|
+
from fontTools.ttLib import TTFont
|
|
14
|
+
from fontTools.varLib.instancer import instantiateVariableFont
|
|
15
|
+
|
|
16
|
+
RAW = "https://github.com/google/fonts/raw/main/ofl/"
|
|
17
|
+
SRC = {
|
|
18
|
+
"pf": "playfairdisplay/PlayfairDisplay%5Bwght%5D.ttf",
|
|
19
|
+
"pfi": "playfairdisplay/PlayfairDisplay-Italic%5Bwght%5D.ttf",
|
|
20
|
+
"in": "inter/Inter%5Bopsz,wght%5D.ttf",
|
|
21
|
+
"ini": "inter/Inter-Italic%5Bopsz,wght%5D.ttf",
|
|
22
|
+
}
|
|
23
|
+
JOBS = [
|
|
24
|
+
("pf", "PlayfairDisplay-Regular", {"wght": 400}),
|
|
25
|
+
("pf", "PlayfairDisplay-Bold", {"wght": 700}),
|
|
26
|
+
("pfi", "PlayfairDisplay-Italic", {"wght": 400}),
|
|
27
|
+
("in", "Inter-Regular", {"wght": 400, "opsz": 14}),
|
|
28
|
+
("in", "Inter-Bold", {"wght": 700, "opsz": 14}),
|
|
29
|
+
("ini", "Inter-Italic", {"wght": 400, "opsz": 14}),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
dest = os.path.expanduser("~/.local/share/fonts/story-deck/")
|
|
33
|
+
os.makedirs(dest, exist_ok=True)
|
|
34
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
35
|
+
for key, path in SRC.items():
|
|
36
|
+
urllib.request.urlretrieve(RAW + path, f"{tmp}/{key}.ttf")
|
|
37
|
+
for key, out, loc in JOBS:
|
|
38
|
+
font = instantiateVariableFont(TTFont(f"{tmp}/{key}.ttf"), loc, updateFontNames=True, static=True)
|
|
39
|
+
assert "fvar" not in font
|
|
40
|
+
font.save(dest + out + ".ttf")
|
|
41
|
+
print("installed", out)
|
|
42
|
+
subprocess.run(["fc-cache", "-f"], check=False)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Cinematic story deck helpers: A5 landscape, dark ink + one accent, serif display + sans body.
|
|
2
|
+
// Branding is adjustable: makeDeck(title, { INK, CREAM, MUTED, GOLD, GOLD_BG, SERIF, SANS, logo, logoRatio, cardBg }).
|
|
3
|
+
const pptxgen = require("pptxgenjs");
|
|
4
|
+
|
|
5
|
+
const W = 8.268, H = 5.827, M = 0.6;
|
|
6
|
+
const DEFAULTS = {
|
|
7
|
+
INK: "14110F", CREAM: "F3EDE3", MUTED: "B8AFA3", GOLD: "C9A24E", GOLD_BG: "C9A96E", cardBg: "1E1A17",
|
|
8
|
+
SERIF: "Playfair Display", SANS: "Inter",
|
|
9
|
+
logo: null, // path to a light-on-dark logo PNG; null = no logo drawn
|
|
10
|
+
logoRatio: 0.226, // logo height / width
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function makeDeck(title, brand = {}) {
|
|
14
|
+
const { INK, CREAM, MUTED, GOLD, GOLD_BG, SERIF, SANS, logo: logoPath, logoRatio, cardBg } = { ...DEFAULTS, ...brand };
|
|
15
|
+
const pres = new pptxgen();
|
|
16
|
+
pres.defineLayout({ name: "A5L", width: W, height: H });
|
|
17
|
+
pres.layout = "A5L";
|
|
18
|
+
pres.title = title;
|
|
19
|
+
let page = 0;
|
|
20
|
+
|
|
21
|
+
const notesWith = (notes, credit) => notes + (credit ? `\n\nFoto: ${credit} / Unsplash.` : "");
|
|
22
|
+
|
|
23
|
+
function slide(bgFile, notes, dark = true) {
|
|
24
|
+
const s = pres.addSlide();
|
|
25
|
+
page++;
|
|
26
|
+
s.background = { color: dark ? INK : GOLD_BG };
|
|
27
|
+
if (bgFile) s.addImage({ path: `bg/${bgFile}.jpg`, x: 0, y: 0, w: W, h: H });
|
|
28
|
+
s.addText(String(page).padStart(2, "0"), {
|
|
29
|
+
x: W - M - 0.6, y: H - 0.45, w: 0.6, h: 0.25, align: "right", margin: 0,
|
|
30
|
+
fontFace: SANS, fontSize: 8, color: dark ? MUTED : INK, charSpacing: 2, isTextBox: true,
|
|
31
|
+
});
|
|
32
|
+
s.addNotes(notes);
|
|
33
|
+
return s;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function kicker(s, text, y, x = M) {
|
|
37
|
+
s.addText(text.toUpperCase(), {
|
|
38
|
+
x, y, w: W - x - M, h: 0.3, margin: 0, fontFace: SANS, fontSize: 9, bold: true,
|
|
39
|
+
color: GOLD, charSpacing: 4, isTextBox: true,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function headline(s, text, y, h, opts = {}) {
|
|
44
|
+
s.addText(text, {
|
|
45
|
+
x: opts.x || M, y, w: opts.w || 5.6, h, margin: 0, fontFace: SERIF, fontSize: opts.size || 30,
|
|
46
|
+
color: opts.color || CREAM, valign: "top", lineSpacingMultiple: 0.95, isTextBox: true, ...opts.extra,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function body(s, text, y, h, opts = {}) {
|
|
51
|
+
s.addText(text, {
|
|
52
|
+
x: opts.x || M, y, w: opts.w || 4.6, h, margin: 0, fontFace: SANS, fontSize: opts.size || 12,
|
|
53
|
+
color: opts.color || MUTED, valign: "top", lineSpacingMultiple: 1.2, isTextBox: true,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function logo(s, x, y, w) {
|
|
58
|
+
if (logoPath) s.addImage({ path: logoPath, x, y, w, h: w * logoRatio });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Photo story beat: text sits low on the slide (photo gradient is at the bottom or left).
|
|
62
|
+
function story(bgFile, { kick, title, sub, notes, credit, top = 3.2 }) {
|
|
63
|
+
const s = slide(bgFile, notesWith(notes, credit));
|
|
64
|
+
kicker(s, kick, top);
|
|
65
|
+
headline(s, title, top + 0.35, 1.1, { size: 28, w: 6.2 });
|
|
66
|
+
if (sub) body(s, sub, top + 1.6, 0.6, { w: 5.2 });
|
|
67
|
+
return s;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Black pause slide.
|
|
71
|
+
function pause(line, sub, notes) {
|
|
72
|
+
const s = slide(null, notes);
|
|
73
|
+
s.addText(line, {
|
|
74
|
+
x: 0.8, y: 2.1, w: W - 1.6, h: 0.9, align: "center", valign: "middle", margin: 0,
|
|
75
|
+
fontFace: SERIF, fontSize: 28, italic: true, color: CREAM, isTextBox: true,
|
|
76
|
+
});
|
|
77
|
+
if (sub) s.addText(sub, {
|
|
78
|
+
x: 0.8, y: 3.1, w: W - 1.6, h: 0.5, align: "center", margin: 0, fontFace: SANS, fontSize: 12,
|
|
79
|
+
color: MUTED, isTextBox: true,
|
|
80
|
+
});
|
|
81
|
+
return s;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Gold statement slide.
|
|
85
|
+
function question(line, notes) {
|
|
86
|
+
const s = slide(null, notes, false);
|
|
87
|
+
s.addText(line, {
|
|
88
|
+
x: 1.0, y: 1.9, w: W - 2.0, h: 1.8, align: "center", valign: "middle", margin: 0,
|
|
89
|
+
fontFace: SERIF, fontSize: 32, italic: true, color: INK, isTextBox: true,
|
|
90
|
+
});
|
|
91
|
+
return s;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Product name reveal.
|
|
95
|
+
function reveal(name, tagline, line, notes) {
|
|
96
|
+
const s = slide(null, notes);
|
|
97
|
+
s.addText(name, {
|
|
98
|
+
x: 0, y: 1.75, w: W, h: 1.0, align: "center", margin: 0, fontFace: SERIF, fontSize: 54,
|
|
99
|
+
color: CREAM, isTextBox: true,
|
|
100
|
+
});
|
|
101
|
+
s.addText(tagline, {
|
|
102
|
+
x: 0, y: 2.85, w: W, h: 0.5, align: "center", margin: 0, fontFace: SERIF, fontSize: 17,
|
|
103
|
+
italic: true, color: CREAM, isTextBox: true,
|
|
104
|
+
});
|
|
105
|
+
s.addText(line.toUpperCase(), {
|
|
106
|
+
x: 0, y: 3.45, w: W, h: 0.3, align: "center", margin: 0, fontFace: SANS, fontSize: 9,
|
|
107
|
+
bold: true, color: GOLD, charSpacing: 4, isTextBox: true,
|
|
108
|
+
});
|
|
109
|
+
return s;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Feature slide: kicker, headline, feature rows in a left text column.
|
|
113
|
+
// shot: product screenshot path shown framed on the right (bgFile may be null); shotRatio = w/h.
|
|
114
|
+
function scene(bgFile, { kick, title, features, notes, credit, shot, shotRatio = 1.6 }) {
|
|
115
|
+
const s = slide(bgFile, notesWith(notes, credit));
|
|
116
|
+
if (shot) {
|
|
117
|
+
const sw = 3.0, sh = sw / shotRatio, sx = W - M - sw, sy = (H - sh) / 2;
|
|
118
|
+
s.addShape(pres.shapes.RECTANGLE, { x: sx - 0.06, y: sy - 0.06, w: sw + 0.12, h: sh + 0.12, fill: { color: cardBg }, line: { color: GOLD, width: 0.75 } });
|
|
119
|
+
s.addImage({ path: shot, x: sx, y: sy, w: sw, h: sh });
|
|
120
|
+
}
|
|
121
|
+
kicker(s, kick, M);
|
|
122
|
+
headline(s, title, 0.95, 1.0, { size: 24, w: 4.3 });
|
|
123
|
+
let y = 2.1;
|
|
124
|
+
features.forEach(([name, desc]) => {
|
|
125
|
+
s.addText([
|
|
126
|
+
{ text: name, options: { fontFace: SANS, fontSize: 11, bold: true, color: CREAM, breakLine: true } },
|
|
127
|
+
{ text: desc, options: { fontFace: SANS, fontSize: 9.5, color: MUTED } },
|
|
128
|
+
], { x: M, y, w: shot ? 3.85 : 4.3, h: 0.62, margin: 0, valign: "top", lineSpacingMultiple: 1.1, isTextBox: true });
|
|
129
|
+
y += 0.7;
|
|
130
|
+
});
|
|
131
|
+
return s;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Three dark cards.
|
|
135
|
+
function cards(kick, title, items, notes, footnote) {
|
|
136
|
+
const s = slide(null, notes);
|
|
137
|
+
kicker(s, kick, M);
|
|
138
|
+
headline(s, title, 0.95, 0.9, { size: 26, w: 6.8 });
|
|
139
|
+
const cw = 2.2, gap = (W - 2 * M - 3 * cw) / 2, cy = 2.3;
|
|
140
|
+
items.forEach(([label, name, desc], i) => {
|
|
141
|
+
const x = M + i * (cw + gap);
|
|
142
|
+
s.addShape(pres.shapes.RECTANGLE, { x, y: cy, w: cw, h: 2.0, fill: { color: cardBg } });
|
|
143
|
+
s.addText([
|
|
144
|
+
{ text: label.toUpperCase(), options: { fontFace: SANS, fontSize: 8, bold: true, color: GOLD, charSpacing: 1, breakLine: true } },
|
|
145
|
+
{ text: name, options: { fontFace: SERIF, fontSize: 17, color: CREAM, breakLine: true } },
|
|
146
|
+
{ text: desc, options: { fontFace: SANS, fontSize: 10, color: MUTED } },
|
|
147
|
+
], { x: x + 0.2, y: cy + 0.2, w: cw - 0.4, h: 1.6, margin: 0, valign: "top", paraSpaceAfter: 6, isTextBox: true });
|
|
148
|
+
});
|
|
149
|
+
if (footnote) body(s, footnote, 4.6, 0.3, { size: 10, w: 7.0 });
|
|
150
|
+
return s;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function cta(bgFile, { title, sub, facts, notes, credit, kick = "Langkah berikutnya", label = "Jadwalkan demo",
|
|
154
|
+
contact = "[Nama] · [WhatsApp] · [Email]", site = "[website]", qr = null }) {
|
|
155
|
+
const s = slide(bgFile, notesWith(notes, credit));
|
|
156
|
+
kicker(s, kick, 1.5);
|
|
157
|
+
headline(s, title, 1.85, 1.0, { size: 30, w: 6.2 }); // room for two lines
|
|
158
|
+
body(s, sub, 2.95, 0.5, { w: 4.4 });
|
|
159
|
+
s.addText([
|
|
160
|
+
{ text: label.toUpperCase(), options: { fontFace: SANS, fontSize: 9, bold: true, color: GOLD, charSpacing: 4, breakLine: true } },
|
|
161
|
+
{ text: contact, options: { fontFace: SANS, fontSize: 12, color: CREAM, breakLine: true } },
|
|
162
|
+
{ text: site, options: { fontFace: SANS, fontSize: 12, color: MUTED } },
|
|
163
|
+
], { x: M, y: 3.55, w: 4.4, h: 0.9, margin: 0, valign: "top", paraSpaceAfter: 4, isTextBox: true });
|
|
164
|
+
if (qr) s.addImage({ path: qr, x: W - M - 1.3, y: 3.2, w: 1.3, h: 1.3 });
|
|
165
|
+
else { s.addShape(pres.shapes.RECTANGLE, { x: W - M - 1.3, y: 3.2, w: 1.3, h: 1.3, fill: { color: CREAM } }); s.addText("[QR]", { x: W - M - 1.3, y: 3.2, w: 1.3, h: 1.3, align: "center", valign: "middle", margin: 0, fontFace: SANS, fontSize: 12, color: INK, isTextBox: true }); }
|
|
166
|
+
if (facts) body(s, facts, 4.45, 0.3, { size: 9 });
|
|
167
|
+
logo(s, M, H - 0.55, 1.6);
|
|
168
|
+
return s;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return { pres, slide, kicker, headline, body, logo, story, pause, question, reveal, scene, cards, cta };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = { makeDeck, W, H, M };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Crop photos to the slide and bake in the dark cinematic grade.
|
|
2
|
+
|
|
3
|
+
Usage: python3 prep_images.py plan.json (run from the deck folder; reads img/, writes bg/)
|
|
4
|
+
|
|
5
|
+
plan.json:
|
|
6
|
+
{"backgrounds": {"s01": ["<photo id>", focusY, base, "left|right|bottom", strength]},
|
|
7
|
+
"panels": {"p1": ["<photo id>", focusY, [w_px, h_px]]}}
|
|
8
|
+
photo id = file name in img/ without .jpg. focusY 0..1 = vertical crop focus.
|
|
9
|
+
base = overall darkening 0..1. strength = gradient darkness on the text side 0..1.
|
|
10
|
+
Panel size must have the SAME ratio as its box on the slide (w_in/h_in); pptxgenjs
|
|
11
|
+
"sizing" is ignored by Google Slides and stretches the photo.
|
|
12
|
+
|
|
13
|
+
pptxgenjs has no gradient fills, so the text-side shadow lives in the image.
|
|
14
|
+
"""
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from PIL import Image, ImageEnhance
|
|
20
|
+
|
|
21
|
+
W, H = 1800, 1273 # A5 landscape (210:148)
|
|
22
|
+
INK = (20, 17, 15)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def crop(im, w, h, focus_y):
|
|
26
|
+
r = w / h
|
|
27
|
+
if im.width / im.height > r: # too wide: crop sides
|
|
28
|
+
nw = int(im.height * r)
|
|
29
|
+
x = (im.width - nw) // 2
|
|
30
|
+
im = im.crop((x, 0, x + nw, im.height))
|
|
31
|
+
else: # too tall: crop around focus
|
|
32
|
+
nh = int(im.width / r)
|
|
33
|
+
y = int((im.height - nh) * focus_y)
|
|
34
|
+
im = im.crop((0, y, im.width, y + nh))
|
|
35
|
+
out = im.resize((w, h), Image.LANCZOS)
|
|
36
|
+
assert abs(out.width / out.height - r) < 0.01
|
|
37
|
+
return out
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def grade(im):
|
|
41
|
+
im = ImageEnhance.Color(im).enhance(0.75) # mute colours, film look
|
|
42
|
+
return Image.blend(im, Image.new("RGB", im.size, (60, 40, 20)), 0.08)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def overlay(im, base, side, strength):
|
|
46
|
+
ink = Image.new("RGB", im.size, INK)
|
|
47
|
+
im = Image.blend(im, ink, base)
|
|
48
|
+
g = Image.linear_gradient("L") # black top -> white bottom
|
|
49
|
+
if side == "left":
|
|
50
|
+
g = g.rotate(-90)
|
|
51
|
+
elif side == "right":
|
|
52
|
+
g = g.rotate(90)
|
|
53
|
+
g = g.resize(im.size).point(lambda v: int(v * strength))
|
|
54
|
+
return Image.composite(ink, im, g)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main(plan_path):
|
|
58
|
+
plan = json.loads(Path(plan_path).read_text())
|
|
59
|
+
Path("bg").mkdir(exist_ok=True)
|
|
60
|
+
for sid, (pid, fy, base, side, s) in plan.get("backgrounds", {}).items():
|
|
61
|
+
im = Image.open(f"img/{pid}.jpg").convert("RGB")
|
|
62
|
+
overlay(grade(crop(im, W, H, fy)), base, side, s).save(f"bg/{sid}.jpg", quality=85)
|
|
63
|
+
for sid, (pid, fy, (pw, ph)) in plan.get("panels", {}).items():
|
|
64
|
+
im = grade(crop(Image.open(f"img/{pid}.jpg").convert("RGB"), pw, ph, fy))
|
|
65
|
+
Image.blend(im, Image.new("RGB", im.size, INK), 0.35).save(f"bg/{sid}.jpg", quality=85)
|
|
66
|
+
print("ok")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
if __name__ == "__main__":
|
|
70
|
+
main(sys.argv[1] if len(sys.argv) > 1 else "plan.json")
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Build a deck, check the file, render every slide, and write 2x4 contact sheets to qa/.
|
|
3
|
+
# Usage (from the deck folder): qa.sh build-x.js -> prints the qa/sheet*.jpg paths to look at
|
|
4
|
+
# Uses the Anthropic pptx skill's validator when installed; otherwise a built-in structural check.
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
BUILD=${1:?usage: qa.sh build-script.js}
|
|
7
|
+
"$(dirname "$0")/check.sh" >/dev/null 2>&1 || { "$(dirname "$0")/check.sh" 2>/dev/null || true; }
|
|
8
|
+
V=$HOME/.kaizen/decks/.venv
|
|
9
|
+
[ -x "$V/bin/python" ] || { python3 -m venv "$V" && "$V/bin/pip" -q install defusedxml lxml pillow fonttools "qrcode[pil]"; }
|
|
10
|
+
PY=$V/bin/python
|
|
11
|
+
F=$(node "$BUILD")
|
|
12
|
+
PPTX_SKILL=$(ls -d ~/.claude/skills/*/pptx/scripts ~/.claude/skills/synced/*/pptx/scripts 2>/dev/null | head -1 || true)
|
|
13
|
+
if [ -n "$PPTX_SKILL" ] && [ -f "$PPTX_SKILL/office/validate.py" ]; then
|
|
14
|
+
$PY "$PPTX_SKILL/office/validate.py" "$F"
|
|
15
|
+
$PY "$PPTX_SKILL/office/soffice.py" --headless --convert-to pdf "$F" >/dev/null
|
|
16
|
+
else
|
|
17
|
+
# structural check: valid zip, every XML part parses, every slide relationship target exists
|
|
18
|
+
$PY - "$F" <<'PYEOF'
|
|
19
|
+
import sys, zipfile, posixpath
|
|
20
|
+
from lxml import etree
|
|
21
|
+
z = zipfile.ZipFile(sys.argv[1]); bad = z.testzip()
|
|
22
|
+
assert bad is None, f"corrupt part: {bad}"
|
|
23
|
+
names = {n for n in z.namelist() if not n.endswith("/")} # skip directory entries
|
|
24
|
+
for n in names:
|
|
25
|
+
if n.endswith(".xml") or n.endswith(".rels"):
|
|
26
|
+
etree.fromstring(z.read(n))
|
|
27
|
+
for n in names:
|
|
28
|
+
if n.startswith("ppt/slides/_rels/"):
|
|
29
|
+
for r in etree.fromstring(z.read(n)):
|
|
30
|
+
t = r.get("Target")
|
|
31
|
+
if r.get("TargetMode") == "External" or not t: continue
|
|
32
|
+
p = posixpath.normpath(posixpath.join("ppt/slides", t))
|
|
33
|
+
assert p in names, f"{n}: missing {p}"
|
|
34
|
+
print("structure OK:", sum(1 for n in names if n.startswith("ppt/slides/slide")), "slides")
|
|
35
|
+
PYEOF
|
|
36
|
+
soffice --headless --convert-to pdf --outdir "$(dirname "$F")" "$F" >/dev/null 2>&1
|
|
37
|
+
fi
|
|
38
|
+
rm -rf qa && mkdir qa
|
|
39
|
+
pdftoppm -jpeg -r 70 "${F%.pptx}.pdf" qa/s
|
|
40
|
+
rm -f "${F%.pptx}.pdf"
|
|
41
|
+
$PY - <<'PYEOF'
|
|
42
|
+
from PIL import Image; import glob, os
|
|
43
|
+
ims=[Image.open(f) for f in sorted(glob.glob('qa/s-*.jpg'))]; w,h=ims[0].size
|
|
44
|
+
for p in range(0,len(ims),8):
|
|
45
|
+
g=ims[p:p+8]; sh=Image.new('RGB',(w*2,h*4),'white')
|
|
46
|
+
for i,im in enumerate(g): sh.paste(im,((i%2)*w,(i//2)*h))
|
|
47
|
+
sh.save(f'qa/sheet{p//8}.jpg'); print(f'{os.getcwd()}/qa/sheet{p//8}.jpg')
|
|
48
|
+
PYEOF
|