From 4006c2661305ed221f957a08e1d3429cb525de67 Mon Sep 17 00:00:00 2001 From: akai Date: Sat, 21 Mar 2026 23:31:04 +0800 Subject: [PATCH] update: add minimax-pdf skill and reorganize pptx-generator references - Add minimax-pdf skill (CREATE / FILL / REFORMAT workflows) - Move pptx-generator reference docs into references/ subdirectory - Update README.md and README_zh.md with new skill entries --- README.md | 2 + README_zh.md | 2 + skills/minimax-pdf/README.md | 222 +++ skills/minimax-pdf/SKILL.md | 192 ++ skills/minimax-pdf/design/design.md | 381 ++++ skills/minimax-pdf/scripts/cover.py | 1579 +++++++++++++++++ skills/minimax-pdf/scripts/fill_inspect.py | 200 +++ skills/minimax-pdf/scripts/fill_write.py | 242 +++ skills/minimax-pdf/scripts/make.sh | 491 +++++ skills/minimax-pdf/scripts/merge.py | 112 ++ skills/minimax-pdf/scripts/palette.py | 521 ++++++ skills/minimax-pdf/scripts/reformat_parse.py | 374 ++++ skills/minimax-pdf/scripts/render_body.py | 1052 +++++++++++ skills/minimax-pdf/scripts/render_cover.js | 111 ++ skills/pptx-generator/SKILL.md | 33 +- skills/pptx-generator/design-system.md | 392 ---- .../references/design-system.md | 392 ++++ .../{ => references}/editing.md | 0 .../{ => references}/pitfalls.md | 0 .../{ => references}/pptxgenjs.md | 0 .../{ => references}/slide-types.md | 8 +- 21 files changed, 5897 insertions(+), 409 deletions(-) create mode 100644 skills/minimax-pdf/README.md create mode 100644 skills/minimax-pdf/SKILL.md create mode 100644 skills/minimax-pdf/design/design.md create mode 100644 skills/minimax-pdf/scripts/cover.py create mode 100644 skills/minimax-pdf/scripts/fill_inspect.py create mode 100644 skills/minimax-pdf/scripts/fill_write.py create mode 100644 skills/minimax-pdf/scripts/make.sh create mode 100644 skills/minimax-pdf/scripts/merge.py create mode 100644 skills/minimax-pdf/scripts/palette.py create mode 100644 skills/minimax-pdf/scripts/reformat_parse.py create mode 100644 skills/minimax-pdf/scripts/render_body.py create mode 100644 skills/minimax-pdf/scripts/render_cover.js delete mode 100644 skills/pptx-generator/design-system.md create mode 100644 skills/pptx-generator/references/design-system.md rename skills/pptx-generator/{ => references}/editing.md (100%) rename skills/pptx-generator/{ => references}/pitfalls.md (100%) rename skills/pptx-generator/{ => references}/pptxgenjs.md (100%) rename skills/pptx-generator/{ => references}/slide-types.md (98%) diff --git a/README.md b/README.md index 6dfeb48..56e41ba 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ Development skills for AI coding agents. Plug into your favorite AI coding tool | `ios-application-dev` | iOS application development guide covering UIKit, SnapKit, and SwiftUI. Touch targets, safe areas, navigation patterns, Dynamic Type, Dark Mode, accessibility, collection views, and Apple HIG compliance. | | `shader-dev` | Comprehensive GLSL shader techniques for creating stunning visual effects — ray marching, SDF modeling, fluid simulation, particle systems, procedural generation, lighting, post-processing, and more. ShaderToy-compatible. | | `gif-sticker-maker` | Convert photos (people, pets, objects, logos) into 4 animated GIF stickers with captions. Funko Pop / Pop Mart style, powered by MiniMax Image & Video Generation API. | +| `minimax-pdf` | Generate, fill, and reformat PDF documents with a token-based design system. CREATE polished PDFs from scratch (15 cover styles), FILL existing form fields, or REFORMAT documents into a new design. Print-ready output with typography and color derived from document type. | +| `pptx-generator` | Generate, edit, and read PowerPoint presentations. Create from scratch with PptxGenJS (cover, TOC, content, section divider, summary slides), edit existing PPTX via XML workflows, or extract text with markitdown. | ## Installation diff --git a/README_zh.md b/README_zh.md index eb5f64c..1a086a1 100644 --- a/README_zh.md +++ b/README_zh.md @@ -16,6 +16,8 @@ | `ios-application-dev` | iOS 应用开发指南,涵盖 UIKit、SnapKit 和 SwiftUI。触控目标、安全区域、导航模式、Dynamic Type、深色模式、无障碍、集合视图,符合 Apple HIG 规范。 | | `shader-dev` | 全面的 GLSL 着色器技术,用于创建惊艳的视觉效果 — 光线行进、SDF 建模、流体模拟、粒子系统、程序化生成、光照、后处理等。兼容 ShaderToy。 | | `gif-sticker-maker` | 将照片(人物、宠物、物品、Logo)转换为 4 张带字幕的动画 GIF 贴纸。Funko Pop / Pop Mart 盲盒风格,基于 MiniMax 图片与视频生成 API。 | +| `minimax-pdf` | 基于 token 化设计系统生成、填写和重排 PDF 文档。支持三种模式:CREATE(从零生成,15 种封面风格)、FILL(填写现有表单字段)、REFORMAT(将已有文档重排为新设计)。排版与配色由文档类型自动推导,输出即可打印。 | +| `pptx-generator` | 生成、编辑和读取 PowerPoint 演示文稿。支持用 PptxGenJS 从零创建(封面、目录、内容、分节页、总结页),通过 XML 工作流编辑现有 PPTX,或用 markitdown 提取文本。 | ## 安装 diff --git a/skills/minimax-pdf/README.md b/skills/minimax-pdf/README.md new file mode 100644 index 0000000..b2bc286 --- /dev/null +++ b/skills/minimax-pdf/README.md @@ -0,0 +1,222 @@ +# minimax-pdf + +A Claude skill for creating and editing visually polished PDFs. +Three routes. One design system. Tokens flow from content analysis through every renderer. + +## Quick start + +```bash +bash scripts/make.sh check # verify deps +bash scripts/make.sh fix # auto-install missing deps +bash scripts/make.sh demo # → demo.pdf +``` + +--- + +## Route A: CREATE — generate a new PDF + +```bash +bash scripts/make.sh run \ + --title "Q3 Strategy Review" \ + --type "proposal" \ + --author "Strategy Team" \ + --date "October 2025" \ + --content content.json \ + --out report.pdf +``` + +**`--type` options:** + +| Type | Palette | Cover pattern | Google Fonts (cover) | +|---|---|---|---| +| `report` | Deep ink, teal accent | `fullbleed` | Playfair Display / IBM Plex Sans | +| `proposal` | Near-black, amber accent | `split` | Syne / Nunito Sans | +| `resume` | White, navy accent | `typographic` | DM Serif Display / DM Sans | +| `portfolio` | Deep violet, coral accent | `atmospheric` | Fraunces / Inter | +| `academic` | Warm white, navy accent | `typographic` | EB Garamond / Source Sans 3 | +| `general` | Dark slate, blue accent | `fullbleed` | Outfit / Outfit | +| `minimal` | Near-white, red accent | `minimal` | Cormorant Garamond / Jost | +| `stripe` | Dark navy, amber accent | `stripe` | Barlow Condensed / Barlow | +| `diagonal` | Dark blue, teal accent | `diagonal` | Montserrat / Montserrat | +| `frame` | Warm cream, brown accent | `frame` | Cormorant / Crimson Pro | +| `editorial` | White, red accent | `editorial` | Bebas Neue / Libre Franklin | +| `magazine` | Warm linen, deep navy accent | `magazine` | Playfair Display / EB Garamond | +| `darkroom` | Deep navy, steel blue accent | `darkroom` | Playfair Display / EB Garamond | +| `terminal` | Near-black, neon green accent | `terminal` | Space Mono | +| `poster` | White, near-black accent | `poster` | Barlow Condensed / Courier Prime | + +**content.json block types:** + +```json +[ + {"type": "h1", "text": "Section Title"}, + {"type": "h2", "text": "Subsection"}, + {"type": "h3", "text": "Sub-subsection"}, + {"type": "body", "text": "Paragraph. Supports bold and italic."}, + {"type": "bullet", "text": "Unordered list item"}, + {"type": "numbered","text": "Ordered list item — counter auto-resets between lists"}, + {"type": "callout", "text": "Key insight or highlighted finding"}, + {"type": "table", + "headers": ["Col A", "Col B"], + "rows": [["a", "b"], ["c", "d"]] + }, + {"type": "image", "path": "chart.png", "caption": "Figure 1: optional caption"}, + {"type": "code", "text": "def hello():\n print('world')"}, + {"type": "math", "text": "\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}", "label": "(1)"}, + {"type": "divider"}, + {"type": "caption", "text": "Table 1: standalone caption label"}, + {"type": "pagebreak"}, + {"type": "spacer", "pt": 16} +] +``` + +--- + +## Route B: FILL — fill form fields in an existing PDF + +```bash +# See what fields the PDF has +bash scripts/make.sh fill --input form.pdf --inspect + +# Fill fields +bash scripts/make.sh fill \ + --input form.pdf \ + --out filled.pdf \ + --values '{"FirstName": "Jane", "Agree": "true", "Country": "US"}' + +# Or from a JSON file +bash scripts/make.sh fill --input form.pdf --out filled.pdf --data values.json +``` + +Field value rules: +- `text` → any string +- `checkbox` → `"true"` or `"false"` +- `dropdown` → must match a choice value shown by `--inspect` +- `radio` → must match a radio value shown by `--inspect` + +--- + +## Route C: REFORMAT — apply design to an existing document + +```bash +bash scripts/make.sh reformat \ + --input source.md \ + --title "Annual Report" \ + --type "report" \ + --author "Research Team" \ + --out output.pdf +``` + +Supported input: `.md` `.txt` `.pdf` `.json` + +--- + +## Architecture + +``` +SKILL.md ← Claude entry point, route table +design/design.md ← Aesthetic system (read before CREATE/REFORMAT) +scripts/ + make.sh ← Unified CLI + palette.py ← metadata → tokens.json [CREATE, REFORMAT] + cover.py ← tokens.json → cover.html [CREATE, REFORMAT] + render_cover.js ← cover.html → cover.pdf [CREATE, REFORMAT] + render_body.py ← tokens + content → body.pdf [CREATE, REFORMAT] + merge.py ← cover + body → final.pdf [CREATE, REFORMAT] + fill_inspect.py ← PDF → field list [FILL] + fill_write.py ← PDF + values → filled PDF [FILL] + reformat_parse.py ← doc → content.json [REFORMAT] +``` + +Design tokens (`tokens.json`) flow from `palette.py` to every renderer — cover and body are always visually consistent. + +## Dependencies + +| Tool | Used by | Install | +|---|---|---| +| Python 3.9+ | all `.py` scripts | system | +| `reportlab` | `render_body.py` | `pip install reportlab` | +| `pypdf` | fill, merge, reformat | `pip install pypdf` | +| Node.js 18+ | `render_cover.js` | system | +| `playwright` + Chromium | `render_cover.js` | `npm install -g playwright && npx playwright install chromium` | + +## License + +MIT + +## Document types + +| `--type` | Mood | Cover pattern | Cover fonts | +|---|---|---|---| +| `report` | Authoritative | `fullbleed` | Playfair Display / IBM Plex Sans | +| `proposal` | Confident | `split` | Syne / Nunito Sans | +| `resume` | Clean | `typographic` | DM Serif Display / DM Sans | +| `portfolio` | Expressive | `atmospheric` | Fraunces / Inter | +| `academic` | Scholarly | `typographic` | EB Garamond / Source Sans 3 | +| `general` | Neutral | `fullbleed` | Outfit | +| `minimal` | Restrained | `minimal` | Cormorant Garamond / Jost | +| `stripe` | Bold | `stripe` | Barlow Condensed / Barlow | +| `diagonal` | Dynamic | `diagonal` | Montserrat | +| `frame` | Classical | `frame` | Cormorant / Crimson Pro | +| `editorial` | Editorial | `editorial` | Bebas Neue / Libre Franklin | + +Cover fonts load via Google Fonts `@import` at render time — no local caching. +Body pages always use system fonts (Times / Helvetica) via ReportLab. + +## content.json schema + +```json +[ + {"type": "h1", "text": "Section Title"}, + {"type": "h2", "text": "Subsection"}, + {"type": "h3", "text": "Sub-subsection"}, + {"type": "body", "text": "Paragraph text. Supports bold and italic."}, + {"type": "bullet", "text": "Unordered list item"}, + {"type": "numbered","text": "Ordered list item — auto-numbered, counter resets between lists"}, + {"type": "callout", "text": "Highlighted insight or key finding"}, + {"type": "table", + "headers": ["Column A", "Column B", "Column C"], + "rows": [["row1a", "row1b", "row1c"], ["row2a", "row2b", "row2c"]] + }, + {"type": "image", "path": "chart.png", "caption": "Figure 1: Sales by quarter"}, + {"type": "code", "text": "SELECT * FROM users\nWHERE active = 1;"}, + {"type": "math", "text": "\\sigma = \\sqrt{\\frac{1}{N}\\sum_{i=1}^N (x_i - \\mu)^2}", "label": "(2)"}, + {"type": "divider"}, + {"type": "caption", "text": "Table 2: standalone label"}, + {"type": "pagebreak"}, + {"type": "spacer", "pt": 16} +] +``` + +## Architecture + +``` +SKILL.md ← Claude entry point, routing only +design/design.md ← Aesthetic system (read before any script) +scripts/ + make.sh ← Unified CLI: check / fix / run / demo + palette.py ← content metadata → tokens.json + cover.py ← tokens.json → cover.html + render_cover.js ← cover.html → cover.pdf (Playwright) + render_body.py ← tokens.json + content.json → body.pdf (ReportLab) + merge.py ← cover.pdf + body.pdf → final.pdf + QA report +``` + +Design tokens (color, typography, spacing) are written once by `palette.py` and consumed by every downstream script. This guarantees visual consistency between cover and body without any manual coordination. + +## Dependencies + +| Tool | Purpose | Install | +|---|---|---| +| Python 3.9+ | palette, cover, render_body, merge | system | +| `reportlab` | Body page rendering | `pip install reportlab` | +| `pypdf` | Merging PDFs | `pip install pypdf` | +| Node.js 18+ | Cover rendering | system | +| `playwright` | Headless Chromium for cover | `npm install -g playwright && npx playwright install chromium` | + +Run `bash scripts/make.sh check` to verify everything at once. +Run `bash scripts/make.sh fix` to auto-install what is missing. + +## License + +MIT diff --git a/skills/minimax-pdf/SKILL.md b/skills/minimax-pdf/SKILL.md new file mode 100644 index 0000000..35cfdb9 --- /dev/null +++ b/skills/minimax-pdf/SKILL.md @@ -0,0 +1,192 @@ +--- +name: minimax-pdf +description: > + Use this skill when visual quality and design identity matter for a PDF. + CREATE (generate from scratch): "make a PDF", "generate a report", "write a proposal", + "create a resume", "beautiful PDF", "professional document", "cover page", + "polished PDF", "client-ready document". + FILL (complete form fields): "fill in the form", "fill out this PDF", + "complete the form fields", "write values into PDF", "what fields does this PDF have". + REFORMAT (apply design to an existing doc): "reformat this document", "apply our style", + "convert this Markdown/text to PDF", "make this doc look good", "re-style this PDF". + This skill uses a token-based design system: color, typography, and spacing are derived + from the document type and flow through every page. The output is print-ready. + Prefer this skill when appearance matters, not just when any PDF output is needed. +license: MIT +metadata: + version: "1.0" + category: document-generation +--- + +# minimax-pdf + +Three tasks. One skill. + +## Read `design/design.md` before any CREATE or REFORMAT work. + +--- + +## Route table + +| User intent | Route | Scripts used | +|---|---|---| +| Generate a new PDF from scratch | **CREATE** | `palette.py` → `cover.py` → `render_cover.js` → `render_body.py` → `merge.py` | +| Fill / complete form fields in an existing PDF | **FILL** | `fill_inspect.py` → `fill_write.py` | +| Reformat / re-style an existing document | **REFORMAT** | `reformat_parse.py` → then full CREATE pipeline | + +**Rule:** when in doubt between CREATE and REFORMAT, ask whether the user has an existing document to start from. If yes → REFORMAT. If no → CREATE. + +--- + +## Route A: CREATE + +Full pipeline — content → design tokens → cover → body → merged PDF. + +```bash +bash scripts/make.sh run \ + --title "Q3 Strategy Review" --type proposal \ + --author "Strategy Team" --date "October 2025" \ + --accent "#2D5F8A" \ + --content content.json --out report.pdf +``` + +**Doc types:** `report` · `proposal` · `resume` · `portfolio` · `academic` · `general` · `minimal` · `stripe` · `diagonal` · `frame` · `editorial` · `magazine` · `darkroom` · `terminal` · `poster` + +| Type | Cover pattern | Visual identity | +|---|---|---| +| `report` | `fullbleed` | Dark bg, dot grid, Playfair Display | +| `proposal` | `split` | Left panel + right geometric, Syne | +| `resume` | `typographic` | Oversized first-word, DM Serif Display | +| `portfolio` | `atmospheric` | Near-black, radial glow, Fraunces | +| `academic` | `typographic` | Light bg, classical serif, EB Garamond | +| `general` | `fullbleed` | Dark slate, Outfit | +| `minimal` | `minimal` | White + single 8px accent bar, Cormorant Garamond | +| `stripe` | `stripe` | 3 bold horizontal color bands, Barlow Condensed | +| `diagonal` | `diagonal` | SVG angled cut, dark/light halves, Montserrat | +| `frame` | `frame` | Inset border, corner ornaments, Cormorant | +| `editorial` | `editorial` | Ghost letter, all-caps title, Bebas Neue | +| `magazine` | `magazine` | Warm cream bg, centered stack, hero image, Playfair Display | +| `darkroom` | `darkroom` | Navy bg, centered stack, grayscale image, Playfair Display | +| `terminal` | `terminal` | Near-black, grid lines, monospace, neon green | +| `poster` | `poster` | White bg, thick sidebar, oversized title, Barlow Condensed | + +Cover extras (inject into tokens via `--abstract`, `--cover-image`): +- `--abstract "text"` — abstract text block on the cover (magazine/darkroom) +- `--cover-image "url"` — hero image URL/path (magazine, darkroom, poster) + +**Color overrides — always choose these based on document content:** +- `--accent "#HEX"` — override the accent color; `accent_lt` is auto-derived by lightening toward white +- `--cover-bg "#HEX"` — override the cover background color + +**Accent color selection guidance:** + +You have creative authority over the accent color. Pick it from the document's semantic context — title, industry, purpose, audience — not from generic "safe" choices. The accent appears on section rules, callout bars, table headers, and the cover: it carries the document's visual identity. + +| Context | Suggested accent range | +|---|---| +| Legal / compliance / finance | Deep navy `#1C3A5E`, charcoal `#2E3440`, slate `#3D4C5E` | +| Healthcare / medical | Teal-green `#2A6B5A`, cool green `#3A7D6A` | +| Technology / engineering | Steel blue `#2D5F8A`, indigo `#3D4F8A` | +| Environmental / sustainability | Forest `#2E5E3A`, olive `#4A5E2A` | +| Creative / arts / culture | Burgundy `#6B2A35`, plum `#5A2A6B`, terracotta `#8A3A2A` | +| Academic / research | Deep teal `#2A5A6B`, library blue `#2A4A6B` | +| Corporate / neutral | Slate `#3D4A5A`, graphite `#444C56` | +| Luxury / premium | Warm black `#1A1208`, deep bronze `#4A3820` | + +**Rule:** choose a color that a thoughtful designer would select for this specific document — not the type's default. Muted, desaturated tones work best; avoid vivid primaries. When in doubt, go darker and more neutral. + +**content.json block types:** + +| Block | Usage | Key fields | +|---|---|---| +| `h1` | Section heading + accent rule | `text` | +| `h2` | Subsection heading | `text` | +| `h3` | Sub-subsection (bold) | `text` | +| `body` | Justified paragraph; supports `` `` markup | `text` | +| `bullet` | Unordered list item (• prefix) | `text` | +| `numbered` | Ordered list item — counter auto-resets on non-numbered blocks | `text` | +| `callout` | Highlighted insight box with accent left bar | `text` | +| `table` | Data table — accent header, alternating row tints | `headers`, `rows`, `col_widths`?, `caption`? | +| `image` | Embedded image scaled to column width | `path`/`src`, `caption`? | +| `figure` | Image with auto-numbered "Figure N:" caption | `path`/`src`, `caption`? | +| `code` | Monospace code block with accent left border | `text`, `language`? | +| `math` | Display math — LaTeX syntax via matplotlib mathtext | `text`, `label`?, `caption`? | +| `chart` | Bar / line / pie chart rendered with matplotlib | `chart_type`, `labels`, `datasets`, `title`?, `x_label`?, `y_label`?, `caption`?, `figure`? | +| `flowchart` | Process diagram with nodes + edges via matplotlib | `nodes`, `edges`, `caption`?, `figure`? | +| `bibliography` | Numbered reference list with hanging indent | `items` [{id, text}], `title`? | +| `divider` | Accent-colored full-width rule | — | +| `caption` | Small muted label | `text` | +| `pagebreak` | Force a new page | — | +| `spacer` | Vertical whitespace | `pt` (default 12) | + +**chart / flowchart schemas:** +```json +{"type":"chart","chart_type":"bar","labels":["Q1","Q2","Q3","Q4"], + "datasets":[{"label":"Revenue","values":[120,145,132,178]}],"caption":"Q results"} + +{"type":"flowchart", + "nodes":[{"id":"s","label":"Start","shape":"oval"}, + {"id":"p","label":"Process","shape":"rect"}, + {"id":"d","label":"Valid?","shape":"diamond"}, + {"id":"e","label":"End","shape":"oval"}], + "edges":[{"from":"s","to":"p"},{"from":"p","to":"d"}, + {"from":"d","to":"e","label":"Yes"},{"from":"d","to":"p","label":"No"}]} + +{"type":"bibliography","items":[ + {"id":"1","text":"Author (Year). Title. Publisher."}]} +``` + +--- + +## Route B: FILL + +Fill form fields in an existing PDF without altering layout or design. + +```bash +# Step 1: inspect +python3 scripts/fill_inspect.py --input form.pdf + +# Step 2: fill +python3 scripts/fill_write.py --input form.pdf --out filled.pdf \ + --values '{"FirstName": "Jane", "Agree": "true", "Country": "US"}' +``` + +| Field type | Value format | +|---|---| +| `text` | Any string | +| `checkbox` | `"true"` or `"false"` | +| `dropdown` | Must match a choice value from inspect output | +| `radio` | Must match a radio value (often starts with `/`) | + +Always run `fill_inspect.py` first to get exact field names. + +--- + +## Route C: REFORMAT + +Parse an existing document → content.json → CREATE pipeline. + +```bash +bash scripts/make.sh reformat \ + --input source.md --title "My Report" --type report --out output.pdf +``` + +**Supported input formats:** `.md` `.txt` `.pdf` `.json` + +--- + +## Environment + +```bash +bash scripts/make.sh check # verify all deps +bash scripts/make.sh fix # auto-install missing deps +bash scripts/make.sh demo # build a sample PDF +``` + +| Tool | Used by | Install | +|---|---|---| +| Python 3.9+ | all `.py` scripts | system | +| `reportlab` | `render_body.py` | `pip install reportlab` | +| `pypdf` | fill, merge, reformat | `pip install pypdf` | +| Node.js 18+ | `render_cover.js` | system | +| `playwright` + Chromium | `render_cover.js` | `npm install -g playwright && npx playwright install chromium` | diff --git a/skills/minimax-pdf/design/design.md b/skills/minimax-pdf/design/design.md new file mode 100644 index 0000000..2f28c5d --- /dev/null +++ b/skills/minimax-pdf/design/design.md @@ -0,0 +1,381 @@ +# Design System + +The aesthetic layer. Read this before touching any script. +This file answers "what should it look like and why." + +--- + +## The one rule + +Every design decision must be **rooted in the document's content and purpose**. +Dark teal + cream is not "professional". Serif + beige is not "elegant". +A color chosen because it fits the content will always outperform a color chosen +because it seems safe. + +--- + +## Palette logic + +`palette.py` takes a short content description and outputs `tokens.json`. +Here is the reasoning it applies: + +### Mood → base palette + +| Content signal | Mood | Background | Accent | Text | +|---|---|---|---|---| +| Research, science, analysis | Authoritative | `#0F1F2E` deep ink | `#00B4A6` teal | `#F0EDE6` warm white | +| Business, strategy, finance | Confident | `#1C1C2B` near-black | `#E8A020` amber | `#F5F2EC` cream | +| Creative, portfolio, design | Expressive | `#1A0A2E` deep violet | `#FF6B6B` coral | `#FAF5FF` lavender white | +| Education, academic paper | Scholarly | `#FAFAF7` warm white | `#2C4A7C` navy | `#1A1A2E` dark | +| Healthcare, wellness | Calm | `#F5F9F8` pale mint | `#2D8B72` forest | `#1E3830` deep green | +| Resume / personal | Clean | `#FFFFFF` white | pick from content | `#111111` near-black | +| General / unknown | Neutral | `#F8F6F1` warm off-white | `#3D3D3D` dark gray | `#1A1A1A` black | +| Formal publications, annual reports | Magazine | `#F2F0EC` warm linen | `#1C3557` deep navy | `#0D1A2B` near-black | +| Premium/dark reports, tech reviews | Darkroom | `#151C27` deep navy | `#4A6FA5` steel blue | `#F0EDE6` warm white | +| Technical docs, developer reports | Terminal | `#0D1117` near-black | `#39D353` neon green | `#E6EDF3` cool white | +| Portfolios, creative, photography | Poster | `#FFFFFF` white | `#0A0A0A` near-black | `#0A0A0A` near-black | + +### Accent selection rules + +- **One accent color only.** Using two accents splits visual energy. +- Accent appears on: cover geometric elements, section rules, callout left borders, + table header background, page header rule. Nowhere else. +- Accent must contrast with the cover background by at least 4.5:1 (WCAG AA). +- Do not default to blue. Blue is the most overused accent in AI-generated documents. + +### Color pairing anti-patterns (never use these) + +| ❌ Avoid | Why | +|---|---| +| Purple gradient on white | The default AI aesthetic — immediately signals "generated" | +| Navy + gold | Overused corporate cliché | +| All-black background | Prints badly, feels aggressive | +| More than 3 colors in the system | Visual noise | +| Accent on body text | Destroys readability | + +--- + +## Typography system + +### Font pairing logic + +Two typefaces maximum. Always. + +| Role | Criteria | Good choices (system-safe) | +|---|---|---| +| Display (cover title, H1) | Distinctive, strong contrast, high weight | Times New Roman, Georgia (serif) | +| Text (body, captions, UI) | Highly readable at 10–11pt | Helvetica, Arial (sans) | + +Cover fonts are loaded live via `@import url(...)` in the cover HTML — Playwright +fetches them at render time, no local caching. Body pages always use system fonts +(Times-Bold / Helvetica) via ReportLab — consistent and offline-safe. + +Pairs by mood (cover HTML only — body always uses system fonts): +- Authoritative: `Playfair Display` / `IBM Plex Sans` +- Confident: `Syne` / `Nunito Sans` +- Expressive: `Fraunces` / `Inter` +- Scholarly: `EB Garamond` / `Source Sans 3` +- Clean: `DM Serif Display` / `DM Sans` +- Restrained: `Cormorant Garamond` / `Jost` +- Bold: `Barlow Condensed` / `Barlow` +- Dynamic: `Montserrat` / `Montserrat` +- Classical: `Cormorant` / `Crimson Pro` +- Editorial: `Bebas Neue` / `Libre Franklin` +- Body fallback (always): `Times-Bold` / `Helvetica` (ReportLab system fonts) + +### Type scale + +All sizes in points. This scale is used by `palette.py` to populate `tokens.json`. + +| Token | Size | Leading | Usage | +|---|---|---|---| +| `display` | 54pt | 1.0 | Cover title | +| `h1` | 22pt | 1.3 | Section headings | +| `h2` | 15pt | 1.4 | Subsection headings | +| `h3` | 11.5pt | 1.5 | Sub-subsection | +| `body` | 10.5pt | 1.6 | Main prose | +| `caption` | 8.5pt | 1.4 | Figure/table captions | +| `meta` | 8pt | 1.3 | Header/footer text | + +### Spacing system + +Margins and rhythm are what separate "looks designed" from "looks printed". + +| Token | Value | Notes | +|---|---|---| +| `margin_outer` | 2.8cm | Left/right page margin | +| `margin_top` | 2.8cm | Top page margin | +| `margin_bottom` | 2.5cm | Bottom page margin | +| `section_gap` | 26pt | Space before H1 | +| `para_gap` | 8pt | Space after paragraph | +| `line_gap` | 17pt | Leading for body text | + +Never use ReportLab's default margins (too tight). Always set explicitly. + +--- + +## Cover design + +The cover is the most important page. It determines whether a reader trusts the document. + +### Thirteen cover patterns + +`cover.py` selects one based on `tokens.json["cover_pattern"]`. + +**1. `fullbleed`** — used for: `report`, `general` +- Deep background fills 100% of page +- Title: large, left-aligned, upper 60% of page +- Accent: thin horizontal rule + top-right corner strip +- Dot-grid background texture (subtle, 8–10% opacity) +- Footer band: author + date metadata +- Fonts: Playfair Display / IBM Plex Sans + +**2. `split`** — used for: `proposal` +- Left 42% panel: solid cover color, title + author +- Right 58%: off-white, dot-grid decoration +- Hard vertical dividing line in accent color +- No gradients — pure flat geometry +- Fonts: Syne / Nunito Sans + +**3. `typographic`** — used for: `resume`, `academic` +- White/off-white background +- Name or title as oversized display type (60–80pt), left-aligned +- First word in accent color, remainder in dark +- Thin rule below title block +- Fonts: DM Serif Display / DM Sans (resume) · EB Garamond / Source Sans 3 (academic) + +**4. `atmospheric`** — used for: `portfolio` +- Near-black background +- Soft radial glow in accent color (upper-right quadrant) +- Title centered-left, 2 lines max +- Short rule in accent below title +- Dot-grid texture at low opacity +- Fonts: Fraunces / Inter + +**5. `minimal`** — used for: `minimal` +- Near-white background, 8px left accent bar is the only color +- Title in very large, light-weight display type (300 weight) +- Hairline rule, author + date as single muted line +- Nothing else — the bar does all the visual work +- Fonts: Cormorant Garamond / Jost + +**6. `stripe`** — used for: `stripe` +- Page cut into three horizontal bands: accent / dark / light +- Top band: category label; middle: oversized title in white; bottom: metadata +- Hard edges, no gradients, no textures — newspaper / brand poster aesthetic +- Fonts: Barlow Condensed / Barlow + +**7. `diagonal`** — used for: `diagonal` +- SVG polygon cuts page diagonally: dark upper-left, light lower-right +- Accent-colored edge line traces the diagonal cut +- Title on dark area, metadata on light area +- Fonts: Montserrat / Montserrat + +**8. `frame`** — used for: `frame` +- White/cream background with an inset rectangular border (1.2px, 28px from edges) +- Accent strips inside top + bottom of frame; small accent corner squares +- Title centered in the frame space, centered alignment, classical weight +- Formal, timeless — annual reports, legal documents, academic papers +- Fonts: Cormorant / Crimson Pro + +**9. `editorial`** — used for: `editorial` +- Ghost first-letter of title fills upper-right at 5% opacity — visual texture +- 5px accent top bar; full-width uppercase title in condensed weight +- Title all-caps, very large (80px), flush-left +- Footer rule + author/date metadata +- Fonts: Bebas Neue / Libre Franklin + +**10. `magazine`** — used for: `magazine` +- Warm cream/linen background; fully centered, vertical stack layout +- Org/company name in small spaced caps + 2px accent rule beneath (top anchor) +- Large bold serif title (52px) centered; short accent rule under title +- Italic subtitle; optional `cover_image` URL renders as centered hero thumbnail +- Optional `abstract` field: justified text block with bold "Abstract:" label +- Author name in accent color (large, bold); date beneath +- Fonts: Playfair Display / EB Garamond + +**11. `darkroom`** — used for: `darkroom` +- Same centered stack layout as `magazine` but deep navy background, white text +- Org name + rules in semi-transparent white; accent rules desaturated +- Hero image (if provided) gets `grayscale(20%) brightness(0.9)` filter +- Fonts: Playfair Display / EB Garamond + +**12. `terminal`** — used for: `terminal` +- Near-black background; neon green accent; Space Mono monospace throughout +- Grid overlay: faint horizontal + vertical lines at 48px intervals (7% opacity) +- Status label top-left: green dot + `SYSTEM_REPORT // ` +- Title inside a bracket frame (border-left + border-top + pseudo-element corner) +- Subtitle prefixed with `>` in accent color +- Abstract text left; author block right; status bar at bottom (UTF-8 / Ln 1) +- Fonts: Space Mono / Space Mono + +**13. `poster`** — used for: `poster` +- White background; thick 52px left sidebar in accent (typically near-black) +- Title: 96px, 900-weight, all-caps, condensed — the dominant visual element +- Subtitle in typewriter font below title; thin 2px rule as separator +- Author + meta in Courier Prime monospace beneath rule +- Optional `cover_image` rendered as 260×340 grayscale thumbnail, right-aligned +- Accent square icon block (lower-right) with white horizontal lines +- Fonts: Barlow Condensed / Courier Prime + +### Optional token: `cover_image` + +Patterns `magazine`, `darkroom`, and `poster` accept an optional `cover_image` +token containing an absolute URL or `file://` path to an image. +The image renders via `` — Playwright fetches it at render time. +If omitted, the image area is simply skipped (layout adjusts gracefully). + +### Cover CSS requirements (critical for Playwright rendering) + +These three rules must appear in every cover HTML file or the output will have +white borders / incorrect dimensions: + +```css +body { margin: 0; padding: 0; } +html, body { width: 794px; height: 1123px; overflow: hidden; } +``` + +No `@page` rules needed — Playwright handles page size via the `pdf()` call. +Do NOT use CSS `background-image` for textures — use inline SVG or ``. +Always use `position: absolute` + `z-index` for layered elements. + +### What always kills a cover + +- Centered title on white background with a thin horizontal line underneath +- Gradient from one color to another (reads as PowerPoint, not print design) +- Drop shadows on text +- More than one accent color +- Emoji or icon fonts (fail silently on headless Chromium) + +--- + +## Inner page rules + +### What "restraint" means in practice + +Every design decision should remove something, not add something. +The page is done when there is nothing left to remove. + +- Accent color appears on section rules only — not on headings, not on bullets +- No card components (bordered boxes with colored headers) +- No rounded corners on anything except callout boxes (4px max) +- No shadows anywhere +- Tables: header row in accent, alternating row tint, no grid lines except outer box +- Callout boxes: left border in accent (4px), very light tint background, no icon + +### Page header / footer + +Header: document title (left, 7.5pt, muted) + accent rule (1.5pt, full width below) +Footer: author name (left, 7.5pt, muted) + page number (right, 7.5pt, muted) + light rule above + +--- + +## Quality bar + +A PDF passes if a designer would not be embarrassed to hand it to a client. +Concretely: + +- Cover has a clear visual identity that is not "generic AI output" +- Body text is readable at arm's length without squinting +- Every page looks like it belongs to the same document +- No element bleeds off the edge or overlaps another +- Page numbers are present and correct +- The accent color appears fewer than 8 times per page on average + +--- + +## Block type reference + +All body blocks use the same token system — colors and fonts come from `tokens.json`, never hardcoded. + +| Block | Rendering | Design notes | +|---|---|---| +| `h1` | 22pt heading + full-width accent rule below | KeepTogether with rule — heading never orphaned | +| `h2` | 15pt heading, dark text | No rule, no accent — visual hierarchy through size only | +| `h3` | 11.5pt bold, dark text | **No accent color** — accent on body headings violates the one-accent-location rule | +| `body` | 10.5pt justified, 17pt leading | Supports `` `` `` markup | +| `bullet` | Body size with `•` prefix, 14pt indent | Use for unordered lists | +| `numbered` | Body size with `N.` prefix, hanging indent | Counter auto-resets on any non-numbered block — no manual numbering needed | +| `callout` | Accent left-border (4px) + light tint background | Max one callout per section — overuse kills impact | +| `table` | Accent header row, alternating row tint, outer box only | Supports `col_widths` (fractions, e.g. `[0.3, 0.5, 0.2]`) for custom column widths | +| `image` | Scaled to column width, preserving aspect ratio | Use `path` or `src`; always provide a `caption` | +| `figure` | Same as image, but caption auto-prefixed "Figure N:" | Figure counter increments across all `figure`, `chart`, `flowchart` blocks | +| `code` | Courier 8.5pt, accent left-border, light tint background | Supports optional `language` label (rendered above block) | +| `math` | Formula centered, optional right-aligned equation label | LaTeX syntax; matplotlib mathtext renderer | +| `chart` | Bar / line / pie chart rendered via matplotlib | Color palette derived from document accent; figure auto-numbered | +| `flowchart` | Process diagram with labeled arrows | Supports 4 node shapes; back-edges drawn as curved arcs | +| `bibliography` | Numbered reference list with hanging indent | Heading rendered as h2 + accent rule; items as `[N] text` | +| `divider` | Accent-colored 1.2pt rule with padding | Use sparingly — only for major thematic breaks | +| `caption` | 8.5pt muted text, centered | Appears below images/tables via field or explicit block | +| `pagebreak` | Force page break | — | +| `spacer` | Vertical whitespace | `pt` field (default 12) | + +### Math formula guidance + +**Input syntax:** standard LaTeX math notation — `\frac{}{}`, `\int`, `\sum`, `\alpha`, `^`, `_`, etc. +**Rendering engine:** matplotlib mathtext — pure Python, no LaTeX compiler, no browser required. + +| Syntax example | Rendered as | +|---|---| +| `E = mc^2` | Inline expression | +| `\frac{\sqrt{\pi}}{2}` | Fraction | +| `\int_0^\infty e^{-x^2} dx` | Integral | +| `\sum_{i=1}^{n} x_i` | Summation | +| `\alpha + \beta = \gamma` | Greek letters | + +**Limitations:** matplotlib mathtext covers most common expressions but not advanced LaTeX environments (`align`, `cases`, `matrix`). Split complex multi-line proofs into multiple `math` blocks. + +**Fallback:** if matplotlib is not installed, renders as `expression` in code style. Run `make.sh fix` to install. + +**Equation labels:** `"label": "(1)"` — rendered right-aligned beside the formula. + +### Chart guidance + +**Rendered entirely in Python** — no external chart services, image files, or internet required. + +| chart_type | Use case | Required fields | +|---|---|---| +| `bar` | Comparing discrete categories | `labels`, `datasets` | +| `line` | Trends over time or ordered categories | `labels`, `datasets` | +| `pie` | Part-to-whole composition | `labels`, `datasets[0].values` | + +- Colors are derived from the document accent for visual consistency — do not set custom colors. +- Multi-series: add multiple objects to `datasets`, each with a `label` and `values` array. +- Figure auto-numbering: set `"figure": true` (default) or `"figure": false` to suppress. + +### Flowchart guidance + +**Node shapes:** + +| shape | Use for | +|---|---| +| `rect` (default) | Process step | +| `diamond` | Decision / condition | +| `oval` or `terminal` | Start / End | +| `parallelogram` | Input / Output | + +- Nodes are placed in input order (top to bottom). This controls the layout. +- Forward edges draw straight arrows; back-edges (to earlier nodes) draw curved arcs. +- Keep labels short (3–5 words max) — the diagram is A4-column-width at 78% scale. +- Figure auto-numbering applies same as chart. + +### Bibliography guidance + +- `id` field is the reference label — use numbers ("1", "2") or alphanumeric ("Smith23"). +- Text should be in a consistent citation style (APA, Chicago, etc.) — the renderer does not enforce style. +- The `title` field defaults to "References". Set `"title": ""` to suppress the heading. +- A `bibliography` block always starts with a new section heading + accent rule. + +### Image / figure guidance + +- Preferred formats: PNG, JPEG +- Scaled down if wider than the text column; never scaled up +- `figure` blocks auto-number; `image` blocks do not — use `figure` for numbered figures +- If the file does not exist at render time, a `[Image not found]` placeholder is substituted + +### Code block guidance + +- Preserves whitespace exactly — do not indent code in the JSON value +- Optional `language` field renders a small language label above the block (e.g., `"language": "python"`) +- No syntax highlighting (by design) — consistent with restraint principle +- Keep lines under ~90 characters for A4 column width diff --git a/skills/minimax-pdf/scripts/cover.py b/skills/minimax-pdf/scripts/cover.py new file mode 100644 index 0000000..0b01864 --- /dev/null +++ b/skills/minimax-pdf/scripts/cover.py @@ -0,0 +1,1579 @@ +#!/usr/bin/env python3 +""" +cover.py — Generate cover.html from tokens.json. + +Usage: + python3 cover.py --tokens tokens.json --out cover.html + +Reads tokens.json["cover_pattern"] and renders the matching HTML cover. +Cover fonts are loaded live via Google Fonts @import (no local caching). +Exit codes: 0 success, 1 bad args/missing file, 3 render error +""" + +import argparse +import json +import sys + + +# ── Google Fonts loader ──────────────────────────────────────────────────────── +def _gfonts_import(t: dict) -> str: + """Return a CSS @import for the document's Google Fonts, if available.""" + url = t.get("gfonts_import", "") + if url: + return f"@import url('{url}');" + return "" + + +# ── Shared CSS head (required by all patterns) ───────────────────────────────── +def _base_css(t: dict) -> str: + """Critical reset + shared variables. Never remove these rules.""" + return f""" +{_gfonts_import(t)} +* {{ margin: 0; padding: 0; box-sizing: border-box; }} +html, body {{ + width: 794px; height: 1123px; + overflow: hidden; + background: {t['cover_bg']}; + font-family: '{t['font_body']}', 'Helvetica Neue', Helvetica, Arial, sans-serif; +}} +.page {{ + position: relative; + width: 794px; height: 1123px; + background: {t['cover_bg']}; + overflow: hidden; +}} +""" + + +# ── Dot-grid SVG helper ───────────────────────────────────────────────────────── +def _dot_grid(x0, y0, cols, rows, *, gap, r, color, opacity) -> str: + """Render a dot-grid as an absolutely positioned SVG element.""" + dots = [] + for row in range(rows): + for col in range(cols): + cx = x0 + col * gap + cy = y0 + row * gap + dots.append(f'') + return ( + f'' + + "".join(dots) + "" + ) + + +# ── Cross-hatch SVG helper ────────────────────────────────────────────────────── +def _cross_hatch(color, opacity, spacing=32, stroke_w=0.5) -> str: + lines = [] + for i in range(-20, 60): + x = i * spacing + lines.append(f'') + return ( + f'' + + "".join(lines) + "" + ) + + +# ── Pattern 1: Full-bleed block ──────────────────────────────────────────────── +def _pattern_fullbleed(t: dict) -> str: + dot_grid = _dot_grid( + x0=500, y0=40, cols=10, rows=20, gap=24, r=1.8, + color=t["accent"], opacity=0.12 + ) + subtitle_block = "" + if t.get("subtitle"): + subtitle_block = f""" +
+ {t['subtitle']} +
""" + + return f""" + + + + + +
+ +
+ +
+ + {dot_grid} + +
+
{t.get('doc_type','Document').upper()}  ·  {t.get('date','')}
+
{t['title']}
+
+ {subtitle_block} +
+ + +
+""" + + +# ── Pattern 2: Split panel ───────────────────────────────────────────────────── +def _pattern_split(t: dict) -> str: + dot_grid = _dot_grid( + x0=360, y0=120, cols=10, rows=18, gap=22, r=2, + color="#CCCCCC", opacity=0.25 + ) + return f""" + + + + + +
+
+
+
{t['title']}
+
+ {'
' + t['subtitle'] + '
' if t.get('subtitle') else ''} +
{t.get('author','')}
+
{t.get('date','')}
+
+
+ {dot_grid} +
+
+
{t.get('doc_type','').upper()}
+
+""" + + +# ── Pattern 3: Typographic ───────────────────────────────────────────────────── +def _pattern_typographic(t: dict) -> str: + words = t['title'].split() + first = words[0] if words else "" + rest = " ".join(words[1:]) if len(words) > 1 else "" + return f""" + + + + + +
+
+
{first}
+ {'
' + rest + '
' if rest else ''} +
+
+
{t.get('author','')}
+
{t.get('date','')}
+
+ {'
' + t['subtitle'] + '
' if t.get('subtitle') else ''} +
+
+""" + + +# ── Pattern 4: Dark atmospheric ──────────────────────────────────────────────── +def _pattern_atmospheric(t: dict) -> str: + dot_grid = _dot_grid( + x0=60, y0=60, cols=16, rows=22, gap=20, r=1.5, + color=t["accent"], opacity=0.08 + ) + return f""" + + + + + +
+
+
+ {dot_grid} +
+
+
{t.get('doc_type','').upper()}  ·  {t.get('date','')}
+
{t['title']}
+
+ {'
' + t['subtitle'] + '
' if t.get('subtitle') else ''} +
+ +
+""" + + +# ── Pattern 5: Minimal — thick left bar, generous whitespace ─────────────────── +def _pattern_minimal(t: dict) -> str: + """ + Ultra-restrained: white background, 8px left accent bar, oversized light-weight + title, nothing else but a hairline rule and minimal metadata. The bar is the only + color on the page — everything else is black on white. + """ + # Pick text color for page (minimal uses page_bg which is near-white) + text_dark = t.get("dark", "#111111") + muted = t.get("muted", "#999999") + accent = t["accent"] + + subtitle_block = "" + if t.get("subtitle"): + subtitle_block = f'
{t["subtitle"]}
' + + return f""" + + + + + +
+
+
+
{t.get('doc_type','').upper()}
+
{t['title']}
+
+ {subtitle_block} +
{t.get('author','')}{(' · ' + t.get('date','')) if t.get('date') else ''}
+
+
+""" + + +# ── Pattern 6: Stripe — bold horizontal bands ────────────────────────────────── +def _pattern_stripe(t: dict) -> str: + """ + Page divided into three bold horizontal bands: + - Top band (accent, ~18%): document type label + - Middle band (dark, ~52%): large title in white + - Bottom band (page bg, ~30%): author / date / subtitle + Hard geometry, no gradients, no textures. Newspaper / brand poster aesthetic. + """ + top_h = 200 # accent band + mid_h = 580 # dark band + bot_y = top_h + mid_h # 780 + + accent = t["accent"] + dark = t.get("cover_bg", "#1A1A2E") + light = t.get("page_bg", "#FAFAF8") + text_l = t.get("text_light", "#FFFFFF") + muted = t.get("muted", "#888888") + + subtitle_block = "" + if t.get("subtitle"): + subtitle_block = f'
{t["subtitle"]}
' + + return f""" + + + + + +
+
+
{t.get('doc_type','').upper()}
+
+
+
{t['title']}
+
+
+
+
{t.get('author','')}
+
{t.get('date','')}
+ {subtitle_block} +
+
+""" + + +# ── Pattern 7: Diagonal — angled color split ─────────────────────────────────── +def _pattern_diagonal(t: dict) -> str: + """ + SVG polygon cuts the page diagonally: upper-left in dark cover color, + lower-right in light page bg. Title sits on the dark area, metadata on light. + One angled edge — no gradients, no curves. + """ + dark_bg = t.get("cover_bg", "#1B2A4A") + light_bg = t.get("page_bg", "#FAFCFF") + accent = t["accent"] + text_l = t.get("text_light", "#F8FAFF") + text_d = t.get("dark", "#0F1A2E") + muted = t.get("muted", "#7A8A99") + + # Polygon: full upper-left to ~60% down on right side + # Points: top-left, top-right, (794, 620), (0, 820) + poly = "0,0 794,0 794,620 0,820" + + subtitle_block = "" + if t.get("subtitle"): + subtitle_block = f'
{t["subtitle"]}
' + + return f""" + + + + + +
+ + + + + + + +
+
{t.get('doc_type','').upper()}  ·  {t.get('date','')}
+
{t['title']}
+
+
+ +
+
{t.get('author','')}
+ {subtitle_block} +
+
+""" + + +# ── Pattern 8: Frame — elegant inset border ──────────────────────────────────── +def _pattern_frame(t: dict) -> str: + """ + Classic formal layout: outer thin border line inset ~28px from page edges, + inner accent strip at top and bottom inside the frame. + Title centered in the frame space, classical serif typography. + Used for: academic papers, formal reports, legal docs, annual reports. + """ + bg = t.get("cover_bg", "#FAF8F3") + accent = t["accent"] + dark = t.get("dark", "#2A1A0A") + muted = t.get("muted", "#9A8A78") + + pad = 28 # frame inset from page edge + inner_w = 794 - 2 * pad + inner_h = 1123 - 2 * pad + + subtitle_block = "" + if t.get("subtitle"): + subtitle_block = f'
{t["subtitle"]}
' + + return f""" + + + + + +
+
+
+
+
+
+
+
+ +
+
{t.get('doc_type','').upper()}
+
+
{t['title']}
+
+ {subtitle_block} +
{t.get('author','')}{(' · ' + t.get('date','')) if t.get('date') else ''}
+
+
+""" + + +# ── Pattern 9: Editorial — oversized ghost letter + bold type ────────────────── +def _pattern_editorial(t: dict) -> str: + """ + Magazine / editorial feel: + - Oversized first-letter of title as a ghost background element (8–12% opacity) + - Bold category label at top in accent + - Title in very large condensed weight, flush-left + - Thin full-width rule separating title from metadata + - Author / date bottom-left, page type bottom-right + Designed for editorial reports, annual reviews, magazine-format content. + """ + bg = t.get("cover_bg", "#FFFFFF") + accent = t["accent"] + dark = t.get("dark", "#0A0A0A") + muted = t.get("muted", "#777777") + text_l = t.get("text_light", "#FFFFFF") + + # Ghost letter — first character of title + ghost = t['title'][0].upper() if t['title'] else "A" + + subtitle_block = "" + if t.get("subtitle"): + subtitle_block = f'
{t["subtitle"]}
' + + # Determine if background is dark (use light text) or light (use dark text) + is_dark_bg = ( + bg.startswith("#0") or bg.startswith("#1") or bg.startswith("#2") + ) + title_color = text_l if is_dark_bg else dark # noqa: F841 + body_color = text_l if is_dark_bg else dark + + return f""" + + + + + +
+
{ghost}
+
+
{t.get('doc_type','').upper()}
+ +
+
{t['title']}
+ {subtitle_block} +
+ + + +
+""" + + +# ── Pattern 10: Magazine — elegant centered with optional hero image ──────────── +def _pattern_magazine(t: dict) -> str: + """ + Upscale centered layout: company name + accent rule at top, large serif title, + decorative rule, italic subtitle, optional hero image, abstract block, author. + Used for: annual reports, strategic documents, formal publications. + """ + bg = t.get("cover_bg", "#F2F0EC") + accent = t["accent"] + dark = t.get("dark", "#0D1A2B") + muted = t.get("muted", "#888888") + org = t.get("doc_type", "").upper() + img_url = t.get("cover_image", "") + + subtitle_block = "" + if t.get("subtitle"): + subtitle_block = f'
{t["subtitle"]}
' + + image_block = "" + if img_url: + image_block = f""" +
+ +
""" + + abstract_block = "" + if t.get("abstract"): + abstract_block = f""" +
+ Abstract: + {t['abstract']} +
""" + + return f""" + + + + + +
+
{org}
+
+
{t['title']}
+
+ {subtitle_block} + {image_block} + {abstract_block} + {'
' if (t.get('abstract') or img_url) else '
'} +
{t.get('author','')}
+ +
+""" + + +# ── Pattern 11: Darkroom — dark magazine variant ──────────────────────────────── +def _pattern_darkroom(t: dict) -> str: + """ + Dark-background centered layout. Same structure as magazine but inverted: + deep navy page, white/silver text, accent rules in lighter tone. + Used for: premium reports, tech annual reviews, dark-themed documents. + """ + bg = t.get("cover_bg", "#151C27") + accent = t["accent"] + text_l = t.get("text_light", "#F0EDE6") + muted = t.get("muted", "#8A9AB0") + org = t.get("doc_type", "").upper() + img_url = t.get("cover_image", "") + + subtitle_block = "" + if t.get("subtitle"): + subtitle_block = f'
{t["subtitle"]}
' + + image_block = "" + if img_url: + image_block = f""" +
+ +
""" + + abstract_block = "" + if t.get("abstract"): + abstract_block = f""" +
+ Abstract: + {t['abstract']} +
""" + + return f""" + + + + + +
+
{org}
+
+
{t['title']}
+
+ {subtitle_block} + {image_block} + {abstract_block} + {'
' if (t.get('abstract') or img_url) else '
'} +
{t.get('author','')}
+ +
+""" + + +# ── Pattern 12: Terminal — cyber/hacker aesthetic ─────────────────────────────── +def _pattern_terminal(t: dict) -> str: + """ + Dark terminal/IDE aesthetic: grid overlay, monospace font, neon accent, + corner brackets around the title block, status bar at bottom. + Used for: tech reports, developer docs, security audits, system documentation. + """ + bg = t.get("cover_bg", "#0D1117") + accent = t["accent"] + text_l = t.get("text_light", "#E6EDF3") + muted = t.get("muted", "#48897C") + dark = t.get("dark", "#010409") + org = t.get("doc_type", "DOCUMENT").upper() + date_s = t.get("date", "") + author = t.get("author", "") + + subtitle_line = "" + if t.get("subtitle"): + subtitle_line = f'
> {t["subtitle"]}
' + + abstract_block = "" + if t.get("abstract"): + abstract_block = f""" +
{t['abstract']}
""" + + # grid overlay: horizontal + vertical lines + h_lines = "".join( + f'' + for y in range(0, 1124, 48) + ) + v_lines = "".join( + f'' + for x in range(0, 795, 48) + ) + grid_svg = ( + f'' + + h_lines + v_lines + "" + ) + + return f""" + + + + + +
+ {grid_svg} + +
+
+
SYSTEM_REPORT // {date_s}
+
+ +
+
{t['title']}
+ {subtitle_line} +
+ +
+ {abstract_block} +
+
AUTHOR_ID
+
{author}
+
{org}
+
+
+ +
+
+
Ln 1, Col 1
+
UTF-8
+
GENERATED_BY_COVERGENIUS
+
+
+""" + + +# ── Pattern 13: Poster — bold sidebar + oversized type ───────────────────────── +def _pattern_poster(t: dict) -> str: + """ + Bold minimalist poster: thick vertical sidebar on the left, oversized all-caps + title, typewriter-style metadata. Optional thumbnail on the right side. + Used for: portfolios, creative reports, journalism, photography books. + """ + bg = t.get("cover_bg", "#FFFFFF") + accent = t["accent"] # typically black or strong dark + dark = t.get("dark", "#0A0A0A") + muted = t.get("muted", "#888888") + text_l = t.get("text_light", "#FFFFFF") + img_url = t.get("cover_image", "") + + sidebar_w = 52 + + subtitle_block = "" + if t.get("subtitle"): + subtitle_block = f'
{t["subtitle"]}
' + + image_block = "" + if img_url: + image_block = f""" + """ + + meta_lines = [] + if t.get("author"): + meta_lines.append(f'
{t["author"]}
') + if t.get("subtitle"): + meta_lines.append(f'
{t["subtitle"]}
') + if t.get("date"): + meta_lines.append(f'
{t["date"]}
') + meta_block = "\n".join(meta_lines) + + return f""" + + + + + +
+ + +
+
{t['title']}
+ {subtitle_block} +
+
{meta_block}
+
+ +
+ {image_block} +
+
+
+
+
+
+
+
+
+""" + + +# ── Dispatch ─────────────────────────────────────────────────────────────────── +PATTERNS = { + "fullbleed": _pattern_fullbleed, + "split": _pattern_split, + "typographic": _pattern_typographic, + "atmospheric": _pattern_atmospheric, + "minimal": _pattern_minimal, + "stripe": _pattern_stripe, + "diagonal": _pattern_diagonal, + "frame": _pattern_frame, + "editorial": _pattern_editorial, + "magazine": _pattern_magazine, + "darkroom": _pattern_darkroom, + "terminal": _pattern_terminal, + "poster": _pattern_poster, +} + + +def render(tokens: dict) -> str: + """Dispatch to the cover pattern function and return the HTML string.""" + pattern = tokens.get("cover_pattern", "fullbleed") + fn = PATTERNS.get(pattern, _pattern_fullbleed) + return fn(tokens) + + +# ── CLI ─────────────────────────────────────────────────────────────────────── +def main(): + """CLI entry point.""" + parser = argparse.ArgumentParser(description="Render cover HTML from tokens.json") + parser.add_argument("--tokens", default="tokens.json") + parser.add_argument("--out", default="cover.html") + parser.add_argument("--subtitle", default="", help="Optional subtitle override") + args = parser.parse_args() + + try: + with open(args.tokens, encoding="utf-8") as f: + tokens = json.load(f) + except FileNotFoundError: + print(json.dumps({"status": "error", "error": f"tokens file not found: {args.tokens}"}), + file=sys.stderr) + sys.exit(1) + except json.JSONDecodeError as e: + print(json.dumps({"status": "error", "error": f"invalid JSON: {e}"}), file=sys.stderr) + sys.exit(1) + + if args.subtitle: + tokens["subtitle"] = args.subtitle + + html = render(tokens) + + try: + with open(args.out, "w", encoding="utf-8") as f: + f.write(html) + except OSError as e: + print(json.dumps({"status": "error", "error": str(e)}), file=sys.stderr) + sys.exit(3) + + print(json.dumps({ + "status": "ok", + "out": args.out, + "pattern": tokens.get("cover_pattern"), + })) + + +if __name__ == "__main__": + main() diff --git a/skills/minimax-pdf/scripts/fill_inspect.py b/skills/minimax-pdf/scripts/fill_inspect.py new file mode 100644 index 0000000..3090715 --- /dev/null +++ b/skills/minimax-pdf/scripts/fill_inspect.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +""" +fill_inspect.py — Inspect form fields in an existing PDF. + +Usage: + python3 fill_inspect.py --input form.pdf + python3 fill_inspect.py --input form.pdf --out fields.json + +Outputs a JSON summary of every fillable field: name, type, current value, +allowed values (for checkboxes / dropdowns), and page number. + +Exit codes: 0 success, 1 bad args / file not found, 2 dep missing, 3 read error +""" + +import argparse +import json +import sys +import importlib.util +import os + + + + +def ensure_deps(): + if importlib.util.find_spec("pypdf") is None: + import subprocess + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "--break-system-packages", "-q", "pypdf"] + ) + + +ensure_deps() +from pypdf import PdfReader +from pypdf.generic import ArrayObject, DictionaryObject, NameObject, TextStringObject + + +# ── Field type resolution ────────────────────────────────────────────────────── +def _field_type(field) -> str: + ft = field.get("/FT") + if ft is None: + return "unknown" + ft = str(ft) + if ft == "/Tx": + return "text" + if ft == "/Btn": + ff = int(field.get("/Ff", 0)) + return "radio" if ff & (1 << 15) else "checkbox" + if ft == "/Ch": + ff = int(field.get("/Ff", 0)) + return "dropdown" if ff & (1 << 17) else "listbox" + if ft == "/Sig": + return "signature" + return "unknown" + + +def _field_value(field) -> str | None: + v = field.get("/V") + return str(v) if v is not None else None + + +def _field_options(field, ftype: str) -> dict: + extra = {} + if ftype in ("checkbox",): + ap = field.get("/AP") + if ap and "/N" in ap: + states = [str(k) for k in ap["/N"]] + extra["states"] = states + checked = next((s for s in states if s != "/Off"), None) + if checked: + extra["checked_value"] = checked + if ftype in ("dropdown", "listbox"): + opt = field.get("/Opt") + if opt: + choices = [] + for item in opt: + if isinstance(item, (list, ArrayObject)) and len(item) >= 2: + choices.append({"value": str(item[0]), "label": str(item[1])}) + else: + choices.append({"value": str(item), "label": str(item)}) + extra["choices"] = choices + if ftype == "radio": + kids = field.get("/Kids") + if kids: + values = [] + for kid in kids: + ap = kid.get("/AP") + if ap and "/N" in ap: + for k in ap["/N"]: + if str(k) != "/Off": + values.append(str(k)) + extra["radio_values"] = values + return extra + + +def _walk_fields(fields, page_map: dict, parent_name: str = "") -> list: + """Recursively collect all leaf fields.""" + result = [] + for field in fields: + name = str(field.get("/T", "")) + full = f"{parent_name}.{name}" if parent_name else name + + kids = field.get("/Kids") + # Kids that have /T are sub-fields (groups), not widget annotations + if kids: + named_kids = [k for k in kids if "/T" in k] + if named_kids: + result.extend(_walk_fields(named_kids, page_map, full)) + continue + + ftype = _field_type(field) + if ftype == "unknown": + continue + + entry = { + "name": full, + "type": ftype, + "value": _field_value(field), + } + entry.update(_field_options(field, ftype)) + + # Page lookup via /P indirect reference + p_ref = field.get("/P") + if p_ref and hasattr(p_ref, "idnum"): + entry["page"] = page_map.get(p_ref.idnum, "?") + + result.append(entry) + return result + + +def inspect(pdf_path: str) -> dict: + try: + reader = PdfReader(pdf_path) + except Exception as e: + return {"status": "error", "error": str(e)} + + # Build page-number lookup: {object_id: 1-based page number} + page_map = {} + for i, page in enumerate(reader.pages): + if hasattr(page, "indirect_reference") and page.indirect_reference: + page_map[page.indirect_reference.idnum] = i + 1 + + acroform = reader.trailer.get("/Root", {}).get("/AcroForm") + if acroform is None or "/Fields" not in acroform: + return { + "status": "ok", + "has_fields": False, + "field_count": 0, + "fields": [], + "note": "This PDF has no fillable form fields.", + } + + fields = _walk_fields(list(acroform["/Fields"]), page_map) + + return { + "status": "ok", + "has_fields": bool(fields), + "field_count": len(fields), + "fields": fields, + } + + +def main(): + parser = argparse.ArgumentParser(description="Inspect PDF form fields") + parser.add_argument("--input", required=True, help="PDF file to inspect") + parser.add_argument("--out", default="", help="Write JSON to file (optional)") + args = parser.parse_args() + + if not os.path.exists(args.input): + print(json.dumps({"status": "error", "error": f"File not found: {args.input}"}), + file=sys.stderr) + sys.exit(1) + + result = inspect(args.input) + + output = json.dumps(result, indent=2, ensure_ascii=False) + + if args.out: + with open(args.out, "w") as f: + f.write(output) + + print(output) + + # Human-readable summary + if result["status"] == "ok" and result["has_fields"]: + print(f"\n── Fields in {args.input} ──────────────────────────────", + file=sys.stderr) + for f in result["fields"]: + pg = f" p.{f['page']}" if "page" in f else "" + val = f" = {f['value']}" if f.get("value") else "" + extra = "" + if "choices" in f: + extra = f" [{', '.join(c['value'] for c in f['choices'][:4])}{'…' if len(f['choices'])>4 else ''}]" + elif "states" in f: + extra = f" {f['states']}" + print(f" {f['type']:12} {f['name']}{pg}{val}{extra}", file=sys.stderr) + print("", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/skills/minimax-pdf/scripts/fill_write.py b/skills/minimax-pdf/scripts/fill_write.py new file mode 100644 index 0000000..3ce1523 --- /dev/null +++ b/skills/minimax-pdf/scripts/fill_write.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +""" +fill_write.py — Write values into PDF form fields. + +Usage: + # From a JSON data file + python3 fill_write.py --input form.pdf --data values.json --out filled.pdf + + # Inline JSON + python3 fill_write.py --input form.pdf --out filled.pdf \ + --values '{"FirstName": "Jane", "Agree": "true"}' + +values format: + { + "FieldName": "text value", # text field + "CheckBox1": "true", # checkbox (true / false) + "Dropdown1": "OptionValue", # dropdown (must match an existing choice value) + "Radio1": "/Choice2" # radio (must match a radio value) + } + +Exit codes: 0 success, 1 bad args, 2 dep missing, 3 read/write error, 4 validation error +""" + +import argparse +import json +import os +import sys +import importlib.util + + + + +def ensure_deps(): + if importlib.util.find_spec("pypdf") is None: + import subprocess + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "--break-system-packages", "-q", "pypdf"] + ) + + +ensure_deps() +from pypdf import PdfReader, PdfWriter +from pypdf.generic import NameObject, TextStringObject, BooleanObject + + +# ── Field helpers ───────────────────────────────────────────────────────────── +def _field_type(field) -> str: + ft = str(field.get("/FT", "")) + if ft == "/Tx": return "text" + if ft == "/Btn": + ff = int(field.get("/Ff", 0)) + return "radio" if ff & (1 << 15) else "checkbox" + if ft == "/Ch": + ff = int(field.get("/Ff", 0)) + return "dropdown" if ff & (1 << 17) else "listbox" + return "unknown" + + +def _get_checkbox_on_value(field) -> str: + """Return the /AP /N key that means 'checked' (anything except /Off).""" + ap = field.get("/AP") + if ap and "/N" in ap: + for k in ap["/N"]: + if str(k) != "/Off": + return str(k) + return "/Yes" + + +def _get_dropdown_values(field) -> list[str]: + opt = field.get("/Opt") + if not opt: + return [] + values = [] + for item in opt: + try: + from pypdf.generic import ArrayObject + if isinstance(item, (list, ArrayObject)) and len(item) >= 1: + values.append(str(item[0])) + else: + values.append(str(item)) + except Exception: + values.append(str(item)) + return values + + +# ── Walk + fill ─────────────────────────────────────────────────────────────── +def _walk_and_fill(fields, data: dict, filled: list, errors: list, parent: str = ""): + for field in fields: + name = str(field.get("/T", "")) + full = f"{parent}.{name}" if parent else name + + # Recurse into named groups + kids = field.get("/Kids") + if kids: + named = [k for k in kids if "/T" in k] + if named: + _walk_and_fill(named, data, filled, errors, full) + continue + + if full not in data: + continue + + value = data[full] + ftype = _field_type(field) + + if ftype == "text": + field.update({ + NameObject("/V"): TextStringObject(str(value)), + NameObject("/DV"): TextStringObject(str(value)), + }) + filled.append(full) + + elif ftype == "checkbox": + truthy = str(value).lower() in ("true", "1", "yes", "on") + on_val = _get_checkbox_on_value(field) + pdf_val = on_val if truthy else "/Off" + field.update({ + NameObject("/V"): NameObject(pdf_val), + NameObject("/AS"): NameObject(pdf_val), + }) + filled.append(full) + + elif ftype in ("dropdown", "listbox"): + allowed = _get_dropdown_values(field) + if allowed and str(value) not in allowed: + errors.append({ + "field": full, + "error": f"Value '{value}' not in allowed choices: {allowed}" + }) + continue + field.update({NameObject("/V"): TextStringObject(str(value))}) + filled.append(full) + + elif ftype == "radio": + # Radio value must start with / + pdf_val = str(value) if str(value).startswith("/") else f"/{value}" + field.update({ + NameObject("/V"): NameObject(pdf_val), + NameObject("/AS"): NameObject(pdf_val), + }) + filled.append(full) + + else: + errors.append({"field": full, "error": f"Unsupported field type: {ftype}"}) + + +def fill(pdf_path: str, out_path: str, data: dict) -> dict: + try: + reader = PdfReader(pdf_path) + except Exception as e: + return {"status": "error", "error": str(e)} + + writer = PdfWriter() + writer.clone_document_from_reader(reader) + + acroform = writer._root_object.get("/AcroForm") # type: ignore[attr-defined] + if acroform is None or "/Fields" not in acroform: + return { + "status": "error", + "error": "This PDF has no fillable form fields.", + "hint": "Run fill_inspect.py first to confirm the PDF has fields.", + } + + # Enable appearance regeneration so viewers show the new values + acroform.update({NameObject("/NeedAppearances"): BooleanObject(True)}) + + filled: list[str] = [] + errors: list[dict] = [] + _walk_and_fill(list(acroform["/Fields"]), data, filled, errors) + + # Warn about requested fields that were never found + not_found = [k for k in data if k not in filled and not any(e["field"] == k for e in errors)] + + try: + os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True) + with open(out_path, "wb") as f: + writer.write(f) + except Exception as e: + return {"status": "error", "error": f"Write failed: {e}"} + + result = { + "status": "ok", + "out": out_path, + "filled_count": len(filled), + "filled_fields": filled, + "size_kb": os.path.getsize(out_path) // 1024, + } + if errors: + result["validation_errors"] = errors + if not_found: + result["not_found"] = not_found + result["hint"] = "Run fill_inspect.py to see all available field names." + return result + + +def main(): + parser = argparse.ArgumentParser(description="Fill PDF form fields") + parser.add_argument("--input", required=True, help="Input PDF with form fields") + parser.add_argument("--out", required=True, help="Output PDF path") + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--data", help="Path to JSON file with field values") + group.add_argument("--values", help="Inline JSON string with field values") + args = parser.parse_args() + + if not os.path.exists(args.input): + print(json.dumps({"status": "error", "error": f"File not found: {args.input}"}), + file=sys.stderr) + sys.exit(1) + + # Load data + try: + if args.data: + with open(args.data) as f: + data = json.load(f) + else: + data = json.loads(args.values) + except Exception as e: + print(json.dumps({"status": "error", "error": f"JSON parse error: {e}"}), + file=sys.stderr) + sys.exit(1) + + result = fill(args.input, args.out, data) + print(json.dumps(result, indent=2, ensure_ascii=False)) + + if result["status"] == "ok": + print(f"\n── Fill complete ───────────────────────────────────────", + file=sys.stderr) + print(f" Output : {result['out']}", file=sys.stderr) + print(f" Filled : {result['filled_count']} field(s)", file=sys.stderr) + if result.get("validation_errors"): + print(f" Errors :", file=sys.stderr) + for e in result["validation_errors"]: + print(f" • {e['field']}: {e['error']}", file=sys.stderr) + if result.get("not_found"): + print(f" Not found: {result['not_found']}", file=sys.stderr) + print("", file=sys.stderr) + else: + sys.exit(3) + + +if __name__ == "__main__": + main() diff --git a/skills/minimax-pdf/scripts/make.sh b/skills/minimax-pdf/scripts/make.sh new file mode 100644 index 0000000..0b1730b --- /dev/null +++ b/skills/minimax-pdf/scripts/make.sh @@ -0,0 +1,491 @@ +#!/usr/bin/env bash +# make.sh — minimax-pdf unified CLI +# Usage: bash make.sh [options] +# +# Commands: +# check Verify all dependencies +# fix Auto-install missing dependencies +# run --title T --type TYPE Full pipeline → output.pdf +# --out FILE Output path (default: output.pdf) +# --author A --date D +# --subtitle S +# --abstract A Optional abstract text for cover +# --cover-image URL Optional cover image URL/path +# --content FILE Path to content.json (optional) +# demo Build a full-featured demo to demo.pdf +# +# Document types: +# report proposal resume portfolio academic general +# minimal stripe diagonal frame editorial +# magazine darkroom terminal poster +# +# Content block types: +# h1 h2 h3 body bullet numbered callout table +# image figure code math chart flowchart bibliography +# divider caption pagebreak spacer +# +# Exit codes: 0 success, 1 usage error, 2 dep missing, 3 runtime error + +set -euo pipefail +SCRIPTS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PY="python3" +NODE="node" + +# ── Colour helpers ───────────────────────────────────────────────────────────── +red() { printf '\033[0;31m%s\033[0m\n' "$*"; } +green() { printf '\033[0;32m%s\033[0m\n' "$*"; } +yellow() { printf '\033[0;33m%s\033[0m\n' "$*"; } +bold() { printf '\033[1m%s\033[0m\n' "$*"; } + +# ── check ────────────────────────────────────────────────────────────────────── +cmd_check() { + local ok=true + bold "Checking dependencies..." + + # Python + if command -v python3 &>/dev/null; then + green " ✓ python3 $(python3 --version 2>&1 | awk '{print $2}')" + else + red " ✗ python3 not found" + ok=false + fi + + # reportlab + if python3 -c "import reportlab" 2>/dev/null; then + green " ✓ reportlab" + else + yellow " ⚠ reportlab not installed (run: make.sh fix)" + ok=false + fi + + # pypdf + if python3 -c "import pypdf" 2>/dev/null; then + green " ✓ pypdf" + else + yellow " ⚠ pypdf not installed (run: make.sh fix)" + ok=false + fi + + # Node.js + if command -v node &>/dev/null; then + green " ✓ node $(node --version)" + else + red " ✗ node not found — cover rendering unavailable" + ok=false + fi + + # Playwright + if node -e "require('playwright')" 2>/dev/null || \ + node -e "require(require('child_process').execSync('npm root -g').toString().trim()+'/playwright')" 2>/dev/null; then + green " ✓ playwright" + else + yellow " ⚠ playwright not found (run: make.sh fix)" + ok=false + fi + + # matplotlib (optional — required for math/chart/flowchart; degrades gracefully) + if python3 -c "import matplotlib" 2>/dev/null; then + green " ✓ matplotlib (math, chart, flowchart blocks enabled)" + else + yellow " ⚠ matplotlib not installed — math/chart/flowchart blocks degrade to text (run: make.sh fix)" + fi + + if $ok; then + green "\nAll dependencies satisfied." + exit 0 + else + yellow "\nSome dependencies missing. Run: bash make.sh fix" + exit 2 + fi +} + +# ── fix ──────────────────────────────────────────────────────────────────────── +cmd_fix() { + bold "Installing missing dependencies..." + local rc=0 + + # Python packages + if command -v python3 &>/dev/null; then + python3 -m pip install --break-system-packages -q reportlab pypdf matplotlib 2>/dev/null \ + || python3 -m pip install -q reportlab pypdf matplotlib 2>/dev/null \ + || { yellow " pip install failed — try: pip install reportlab pypdf matplotlib"; rc=3; } + green " ✓ Python packages installed (reportlab, pypdf, matplotlib)" + fi + + # Playwright + if command -v npm &>/dev/null; then + npm install -g playwright --silent 2>/dev/null && \ + npx playwright install chromium --silent 2>/dev/null && \ + green " ✓ Playwright + Chromium installed" || \ + { yellow " playwright install failed — try manually"; rc=3; } + else + yellow " npm not found — cannot install Playwright automatically" + rc=2 + fi + + if [[ $rc -eq 0 ]]; then + green "\nAll dependencies installed. Run: bash make.sh check" + fi + exit $rc +} + +# ── run ──────────────────────────────────────────────────────────────────────── +cmd_run() { + local title="Untitled Document" + local type="general" + local author="" + local date="" + local subtitle="" + local abstract="" + local cover_image="" + local accent="" + local cover_bg="" + local content_file="" + local out="output.pdf" + local workdir + workdir="$(mktemp -d)" + + # Parse options + while [[ $# -gt 0 ]]; do + case "$1" in + --title) title="$2"; shift 2 ;; + --type) type="$2"; shift 2 ;; + --author) author="$2"; shift 2 ;; + --date) date="$2"; shift 2 ;; + --subtitle) subtitle="$2"; shift 2 ;; + --abstract) abstract="$2"; shift 2 ;; + --cover-image) cover_image="$2"; shift 2 ;; + --accent) accent="$2"; shift 2 ;; + --cover-bg) cover_bg="$2"; shift 2 ;; + --content) content_file="$2"; shift 2 ;; + --out) out="$2"; shift 2 ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac + done + + bold "Building: $title" + echo " Type : $type" + echo " Output : $out" + + # Step 1: tokens + echo "" + bold "Step 1/4 Generating design tokens..." + local accent_args=() + [[ -n "$accent" ]] && accent_args+=(--accent "$accent") + [[ -n "$cover_bg" ]] && accent_args+=(--cover-bg "$cover_bg") + $PY "$SCRIPTS/palette.py" \ + --title "$title" --type "$type" \ + --author "$author" --date "$date" \ + --out "$workdir/tokens.json" \ + "${accent_args[@]+"${accent_args[@]}"}" + + # Inject optional cover fields into tokens.json + if [[ -n "$abstract" || -n "$cover_image" ]]; then + PDF_ABSTRACT="$abstract" PDF_COVER_IMAGE="$cover_image" PDF_TOKENS="$workdir/tokens.json" \ + $PY - <<'PYEOF' +import json, os +with open(os.environ["PDF_TOKENS"]) as f: + t = json.load(f) +abstract = os.environ.get("PDF_ABSTRACT", "") +cover_image = os.environ.get("PDF_COVER_IMAGE", "") +if abstract: + t["abstract"] = abstract +if cover_image: + t["cover_image"] = cover_image +with open(os.environ["PDF_TOKENS"], "w") as f: + json.dump(t, f, indent=2) +PYEOF + fi + + cat "$workdir/tokens.json" | $PY -c " +import json,sys +t=json.load(sys.stdin) +print(f' Mood : {t[\"mood\"]}') +print(f' Pattern : {t[\"cover_pattern\"]}') +print(f' Fonts : {t[\"font_display\"]} / {t[\"font_body\"]}')" + + # Step 2: cover HTML + render + echo "" + bold "Step 2/4 Rendering cover..." + local subtitle_args=() + [[ -n "$subtitle" ]] && subtitle_args=(--subtitle "$subtitle") + $PY "$SCRIPTS/cover.py" \ + --tokens "$workdir/tokens.json" \ + --out "$workdir/cover.html" \ + "${subtitle_args[@]+"${subtitle_args[@]}"}" + + $NODE "$SCRIPTS/render_cover.js" \ + --input "$workdir/cover.html" \ + --out "$workdir/cover.pdf" + green " ✓ Cover rendered" + + # Step 3: body + echo "" + bold "Step 3/4 Rendering body pages..." + if [[ -z "$content_file" ]]; then + # Generate a minimal placeholder body + cat > "$workdir/content.json" <<'JSON' +[ + {"type":"h1", "text":"Document Body"}, + {"type":"body", "text":"Replace this with your content.json file using --content path/to/content.json"}, + {"type":"body", "text":"See the content.json schema in the skill README for the full list of supported block types: h1, h2, h3, body, bullet, callout, table, pagebreak, spacer."} +] +JSON + content_file="$workdir/content.json" + yellow " No content file provided — using placeholder body." + fi + + $PY "$SCRIPTS/render_body.py" \ + --tokens "$workdir/tokens.json" \ + --content "$content_file" \ + --out "$workdir/body.pdf" + green " ✓ Body rendered" + + # Step 4: merge + echo "" + bold "Step 4/4 Merging and QA..." + $PY "$SCRIPTS/merge.py" \ + --cover "$workdir/cover.pdf" \ + --body "$workdir/body.pdf" \ + --out "$out" \ + --title "$title" + + # Cleanup + rm -rf "$workdir" +} + +# ── fill ────────────────────────────────────────────────────────────────────── +cmd_fill() { + local input="" out="" values="" data_file="" inspect_only=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --input) input="$2"; shift 2 ;; + --out) out="$2"; shift 2 ;; + --values) values="$2"; shift 2 ;; + --data) data_file="$2"; shift 2 ;; + --inspect) inspect_only=true; shift ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac + done + + if [[ -z "$input" ]]; then + echo "Usage: make.sh fill --input form.pdf [--out filled.pdf] [--values '{...}'] [--data values.json] [--inspect]" + exit 1 + fi + + if $inspect_only || [[ -z "$out" && -z "$values" && -z "$data_file" ]]; then + bold "Inspecting form fields in: $input" + $PY "$SCRIPTS/fill_inspect.py" --input "$input" + return + fi + + bold "Filling form: $input → $out" + + local val_args="" + if [[ -n "$values" ]]; then val_args="--values $values"; fi + if [[ -n "$data_file" ]]; then val_args="--data $data_file"; fi + + $PY "$SCRIPTS/fill_write.py" --input "$input" --out "$out" $val_args +} + +# ── reformat ─────────────────────────────────────────────────────────────────── +cmd_reformat() { + local input="" title="Reformatted Document" type="general" + local author="" date="" out="output.pdf" subtitle="" + local tmpdir + tmpdir="$(mktemp -d)" + + while [[ $# -gt 0 ]]; do + case "$1" in + --input) input="$2"; shift 2 ;; + --title) title="$2"; shift 2 ;; + --type) type="$2"; shift 2 ;; + --author) author="$2"; shift 2 ;; + --date) date="$2"; shift 2 ;; + --subtitle) subtitle="$2"; shift 2 ;; + --out) out="$2"; shift 2 ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac + done + + if [[ -z "$input" ]]; then + echo "Usage: make.sh reformat --input source.md --title T --type TYPE --out output.pdf" + exit 1 + fi + + bold "Parsing: $input" + $PY "$SCRIPTS/reformat_parse.py" --input "$input" --out "$tmpdir/content.json" + green " ✓ Parsed to content.json" + + bold "Applying design and building PDF..." + local sub_args=() + [[ -n "$subtitle" ]] && sub_args=(--subtitle "$subtitle") + + cmd_run \ + --title "$title" --type "$type" \ + --author "$author" --date "$date" \ + --content "$tmpdir/content.json" \ + --out "$out" \ + "${sub_args[@]+"${sub_args[@]}"}" + + rm -rf "$tmpdir" +} + +# ── demo ────────────────────────────────────────────────────────────────────── +cmd_demo() { + local tmpdir + tmpdir="$(mktemp -d)" + + cat > "$tmpdir/content.json" <<'JSON' +[ + {"type":"h1", "text":"Executive Summary"}, + {"type":"body", "text":"This document was generated by minimax-pdf — a skill for creating visually polished PDFs. Every design decision is rooted in the document type and content, not a generic template."}, + {"type":"callout", "text":"Key insight: design tokens flow from palette.py through every renderer, keeping cover and body visually consistent."}, + + {"type":"h1", "text":"How It Works"}, + {"type":"h2", "text":"The Token Pipeline"}, + {"type":"body", "text":"The palette.py script infers a color palette and typography pair from the document type. These tokens are written to tokens.json and consumed by every downstream script."}, + {"type":"numbered","text":"palette.py generates color tokens, font selection, and the cover pattern"}, + {"type":"numbered","text":"cover.py renders the cover HTML using the selected pattern"}, + {"type":"numbered","text":"render_cover.js uses Playwright to convert the HTML cover to PDF"}, + {"type":"numbered","text":"render_body.py builds inner pages from content.json using ReportLab"}, + {"type":"numbered","text":"merge.py combines cover + body and runs final QA checks"}, + + {"type":"h2", "text":"Cover Patterns"}, + {"type":"table", + "headers": ["Pattern", "Document type", "Visual character"], + "rows": [ + ["fullbleed", "report, general", "Deep background · dot-grid texture"], + ["split", "proposal", "Left dark panel · right dot-grid"], + ["typographic", "resume, academic", "Oversized display type · first-word accent"], + ["atmospheric", "portfolio", "Dark bg · radial glow · dot-grid"], + ["magazine", "magazine", "Cream bg · centered · hero image"], + ["darkroom", "darkroom", "Navy bg · centered · grayscale image"], + ["terminal", "terminal", "Near-black · grid lines · monospace"], + ["poster", "poster", "White · thick sidebar · oversized title"] + ] + }, + + {"type":"h1", "text":"Data Visualisation"}, + {"type":"h2", "text":"Performance Metrics (Chart)"}, + {"type":"body", "text":"Charts are rendered natively using matplotlib with a color palette derived from the document accent. No external chart services or image files required."}, + {"type":"chart", + "chart_type": "bar", + "title": "Quarterly Performance", + "labels": ["Q1", "Q2", "Q3", "Q4"], + "datasets": [ + {"label": "Revenue", "values": [120, 145, 132, 178]}, + {"label": "Expenses", "values": [95, 108, 99, 122]} + ], + "y_label": "USD (thousands)", + "caption": "Quarterly revenue vs. expenses" + }, + + {"type":"h2", "text":"Market Share (Pie Chart)"}, + {"type":"chart", + "chart_type": "pie", + "labels": ["Product A", "Product B", "Product C", "Other"], + "datasets": [{"values": [42, 28, 18, 12]}], + "caption": "Annual market share by product line" + }, + + {"type":"pagebreak"}, + + {"type":"h1", "text":"Mathematics"}, + {"type":"body", "text":"Display math is rendered via matplotlib mathtext — no LaTeX binary installation required. Inline references use standard [N] notation in body text."}, + {"type":"math", "text":"E = mc^2", "label":"(1)"}, + {"type":"math", "text":"\\int_0^\\infty e^{-x^2}\\,dx = \\frac{\\sqrt{\\pi}}{2}", "label":"(2)"}, + {"type":"math", "text":"\\sum_{n=1}^{\\infty} \\frac{1}{n^2} = \\frac{\\pi^2}{6}", "caption":"Basel problem (Euler, 1734)"}, + + {"type":"h1", "text":"Process Flow"}, + {"type":"body", "text":"Flowcharts are drawn directly using matplotlib patches — no Graphviz or external tools needed. Supported node shapes: rect, diamond, oval, parallelogram."}, + {"type":"flowchart", + "nodes": [ + {"id":"start", "label":"Start", "shape":"oval"}, + {"id":"input", "label":"Receive Input", "shape":"parallelogram"}, + {"id":"valid", "label":"Valid?", "shape":"diamond"}, + {"id":"proc", "label":"Process Data", "shape":"rect"}, + {"id":"err", "label":"Return Error", "shape":"rect"}, + {"id":"out", "label":"Return Result", "shape":"parallelogram"}, + {"id":"end", "label":"End", "shape":"oval"} + ], + "edges": [ + {"from":"start", "to":"input"}, + {"from":"input", "to":"valid"}, + {"from":"valid", "to":"proc", "label":"Yes"}, + {"from":"valid", "to":"err", "label":"No"}, + {"from":"proc", "to":"out"}, + {"from":"err", "to":"end"}, + {"from":"out", "to":"end"} + ], + "caption": "Data validation and processing flow" + }, + + {"type":"h1", "text":"Code Example"}, + {"type":"code", "language":"python", + "text":"# Design token pipeline\ntokens = palette.build_tokens(\n title=\"Annual Report\",\n doc_type=\"report\",\n author=\"J. Smith\",\n date=\"March 2026\",\n)\nhtml = cover.render(tokens)\npdf = render_cover(html)"}, + + {"type":"h1", "text":"Design Principles"}, + {"type":"body", "text":"The aesthetic system is documented in design/design.md. The core rule: every design decision must be rooted in the document content and purpose. A color chosen because it fits the content will always outperform a color chosen because it seems safe."}, + {"type":"h2", "text":"Restraint over decoration"}, + {"type":"body", "text":"The page is done when there is nothing left to remove. Accent color appears on section rules only — not on headings, not on bullets. No card components, no drop shadows."}, + {"type":"callout", "text":"A PDF passes the quality bar when a designer would not be embarrassed to hand it to a client."}, + + {"type":"pagebreak"}, + {"type":"bibliography", + "title": "References", + "items": [ + {"id":"1","text":"Bringhurst, R. (2004). The Elements of Typographic Style (3rd ed.). Hartley & Marks."}, + {"id":"2","text":"Cairo, A. (2016). The Truthful Art: Data, Charts, and Maps for Communication. New Riders."}, + {"id":"3","text":"Hochuli, J. & Kinross, R. (1996). Designing Books: Practice and Theory. Hyphen Press."} + ] + } +] +JSON + + cmd_run \ + --title "minimax-pdf demo" \ + --type "report" \ + --author "minimax-pdf skill" \ + --date "$(date '+%B %Y')" \ + --subtitle "A demonstration of the token-based design pipeline" \ + --content "$tmpdir/content.json" \ + --out "demo.pdf" + + rm -rf "$tmpdir" +} + +# ── dispatch ─────────────────────────────────────────────────────────────────── +main() { + if [[ $# -lt 1 ]]; then + bold "minimax-pdf — make.sh" + echo "" + echo "Usage: bash make.sh [options]" + echo "" + echo "Commands:" + echo " check Verify all dependencies" + echo " fix Auto-install missing deps" + echo " run --title T --type TYPE CREATE: full pipeline → PDF" + echo " [--author A] [--date D] [--subtitle S]" + echo " [--abstract A] [--cover-image URL]" + echo " [--accent #HEX] [--cover-bg #HEX]" + echo " [--content content.json] [--out output.pdf]" + echo " fill --input f.pdf FILL: inspect or fill form fields" + echo " reformat --input doc.md REFORMAT: parse doc → apply design → PDF" + echo " demo Build a full-featured demo PDF" + exit 0 + fi + + case "$1" in + check) cmd_check ;; + fix) cmd_fix ;; + run) shift; cmd_run "$@" ;; + fill) shift; cmd_fill "$@" ;; + reformat) shift; cmd_reformat "$@" ;; + demo) cmd_demo ;; + *) echo "Unknown command: $1"; exit 1 ;; + esac +} + +main "$@" diff --git a/skills/minimax-pdf/scripts/merge.py b/skills/minimax-pdf/scripts/merge.py new file mode 100644 index 0000000..7bf68ee --- /dev/null +++ b/skills/minimax-pdf/scripts/merge.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +""" +merge.py — Merge cover.pdf + body.pdf → final.pdf and print a QA report. + +Usage: + python3 merge.py --cover cover.pdf --body body.pdf --out final.pdf + python3 merge.py --cover cover.pdf --body body.pdf --out final.pdf --title "My Report" + +Exit codes: 0 success, 1 bad args/missing file, 2 missing dep, 3 merge error +""" + +import argparse +import importlib.util +import json +import os +import sys + +def ensure_deps(): + if importlib.util.find_spec("pypdf") is None: + import subprocess + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "--break-system-packages", "-q", "pypdf"] + ) + + +ensure_deps() + +from pypdf import PdfWriter, PdfReader + + +def merge(cover_path: str, body_path: str, out_path: str, title: str = "") -> dict: + writer = PdfWriter() + + for fpath, label in [(cover_path, "cover"), (body_path, "body")]: + if not os.path.exists(fpath): + return {"status": "error", "error": f"{label} file not found: {fpath}"} + reader = PdfReader(fpath) + for page in reader.pages: + writer.add_page(page) + + # Set PDF metadata + if title: + writer.add_metadata({"/Title": title}) + + os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True) + with open(out_path, "wb") as f: + writer.write(f) + + size_kb = os.path.getsize(out_path) // 1024 + total_pages = len(writer.pages) + + # ── QA checks ───────────────────────────────────────────────────────────── + warnings = [] + + # Page count sanity + cover_pages = len(PdfReader(cover_path).pages) + body_pages = len(PdfReader(body_path).pages) + if cover_pages != 1: + warnings.append(f"Cover PDF has {cover_pages} pages (expected 1)") + + # File size sanity + if size_kb < 20: + warnings.append(f"Output is very small ({size_kb} KB) — may have blank pages") + if size_kb > 50_000: + warnings.append(f"Output is very large ({size_kb} KB) — consider compressing images") + + report = { + "status": "ok", + "out": out_path, + "total_pages": total_pages, + "cover_pages": cover_pages, + "body_pages": body_pages, + "size_kb": size_kb, + } + if warnings: + report["warnings"] = warnings + + return report + + +def main(): + parser = argparse.ArgumentParser(description="Merge cover + body PDFs") + parser.add_argument("--cover", required=True) + parser.add_argument("--body", required=True) + parser.add_argument("--out", required=True) + parser.add_argument("--title", default="") + args = parser.parse_args() + + result = merge(args.cover, args.body, args.out, args.title) + + if result["status"] == "error": + print(json.dumps(result), file=sys.stderr) + sys.exit(3) + + print(json.dumps(result)) + + # Human-readable QA summary + print(f"\n── Build complete ──────────────────────────────────────") + print(f" Output : {result['out']}") + print(f" Pages : {result['total_pages']} total (1 cover + {result['body_pages']} body)") + print(f" Size : {result['size_kb']} KB") + if result.get("warnings"): + print(f" ⚠ Warnings:") + for w in result["warnings"]: + print(f" • {w}") + else: + print(f" ✓ No issues detected") + print(f"────────────────────────────────────────────────────────\n") + + +if __name__ == "__main__": + main() diff --git a/skills/minimax-pdf/scripts/palette.py b/skills/minimax-pdf/scripts/palette.py new file mode 100644 index 0000000..9988aff --- /dev/null +++ b/skills/minimax-pdf/scripts/palette.py @@ -0,0 +1,521 @@ +#!/usr/bin/env python3 +""" +palette.py — Infer design tokens from document metadata. + +Usage: + python3 palette.py --title "AI Trends 2025" --type report --out tokens.json + python3 palette.py --title "John Doe Resume" --type resume --out tokens.json + python3 palette.py --meta meta.json --out tokens.json + +Outputs tokens.json consumed by all downstream scripts. +Cover fonts are loaded via Google Fonts @import in the cover HTML (no local caching). +Body fonts always use ReportLab system fonts (Times-Bold / Helvetica). +Exit codes: 0 success, 1 bad args, 3 write error +""" + +import argparse +import json +import sys + +# ── Palette library ──────────────────────────────────────────────────────────── +# Each entry: cover colors + cover_pattern + mood +PALETTES = { + "report": { + # Charcoal blue-grey cover; muted steel blue accent — authoritative, not flashy + "cover_bg": "#1B2A38", + "accent": "#3B6D8A", + "accent_lt": "#E6EFF5", + "text_light": "#EDE9E2", + "page_bg": "#FAFAF8", + "dark": "#1A1E24", + "body_text": "#2C2C30", + "muted": "#7A7A84", + "cover_pattern": "fullbleed", + "mood": "authoritative", + }, + "proposal": { + # Dark charcoal cover; slate grey-blue accent — confident, understated + "cover_bg": "#22272E", + "accent": "#4E6070", + "accent_lt": "#EAECEE", + "text_light": "#EDE9E2", + "page_bg": "#FAFAF7", + "dark": "#18191E", + "body_text": "#28282E", + "muted": "#7A7870", + "cover_pattern": "split", + "mood": "confident", + }, + "resume": { + # White; deep navy accent — clean and unambiguous + "cover_bg": "#FFFFFF", + "accent": "#1C3557", + "accent_lt": "#E8EEF5", + "text_light": "#FFFFFF", + "page_bg": "#FFFFFF", + "dark": "#111111", + "body_text": "#222222", + "muted": "#888888", + "cover_pattern": "typographic", + "mood": "clean", + }, + "portfolio": { + # Near-black charcoal; cool slate grey accent — subdued professional + "cover_bg": "#191C20", + "accent": "#6A7A88", + "accent_lt": "#EAECEE", + "text_light": "#EDE9E4", + "page_bg": "#F8F8F8", + "dark": "#18191E", + "body_text": "#28282E", + "muted": "#8A8A96", + "cover_pattern": "atmospheric", + "mood": "expressive", + }, + "academic": { + # Warm white; classic navy accent — scholarly standard + "cover_bg": "#F5F4F0", + "accent": "#2A436A", + "accent_lt": "#E6EBF4", + "text_light": "#FFFFFF", + "page_bg": "#F5F4F0", + "dark": "#1A1A28", + "body_text": "#1E1E2A", + "muted": "#686877", + "cover_pattern": "typographic", + "mood": "scholarly", + }, + "general": { + # Dark slate; muted steel accent — neutral, no-nonsense + "cover_bg": "#1F2329", + "accent": "#4A6070", + "accent_lt": "#E6EAEC", + "text_light": "#EEEBE5", + "page_bg": "#F8F6F2", + "dark": "#1A1A1A", + "body_text": "#2C2C2C", + "muted": "#888888", + "cover_pattern": "fullbleed", + "mood": "neutral", + }, + # ── Extended types — each uses a distinct new cover pattern ───────────────── + "minimal": { + # Warm off-white; dark neutral grey — truly restrained, no color signal + "cover_bg": "#F7F6F4", + "accent": "#4A4A4A", + "accent_lt": "#EBEBEA", + "text_light": "#F7F6F4", + "page_bg": "#F7F6F4", + "dark": "#111111", + "body_text": "#222222", + "muted": "#999999", + "cover_pattern": "minimal", + "mood": "restrained", + }, + "stripe": { + # Near-black; charcoal slate accent — structured, no-nonsense + "cover_bg": "#1E222A", + "accent": "#4A5568", + "accent_lt": "#EAECEE", + "text_light": "#FFFFFF", + "page_bg": "#F8F8F7", + "dark": "#0E1117", + "body_text": "#262630", + "muted": "#888898", + "cover_pattern": "stripe", + "mood": "bold", + }, + "diagonal": { + # Deep navy; muted slate-blue accent — dignified, controlled + "cover_bg": "#1A2535", + "accent": "#3D5A72", + "accent_lt": "#E4EBF0", + "text_light": "#EEF0F5", + "page_bg": "#F8FAFC", + "dark": "#0F1A2A", + "body_text": "#1E2C3A", + "muted": "#7A8A96", + "cover_pattern": "diagonal", + "mood": "dynamic", + }, + "frame": { + # Warm parchment; dark muted brown — classical, formal + "cover_bg": "#F5F2EC", + "accent": "#5C4A38", + "accent_lt": "#EAE5DE", + "text_light": "#F5F2EC", + "page_bg": "#F5F2EC", + "dark": "#2A1E14", + "body_text": "#2C2018", + "muted": "#9A8A78", + "cover_pattern": "frame", + "mood": "classical", + }, + "editorial": { + # White; deep burgundy accent — editorial weight without the shout + "cover_bg": "#FFFFFF", + "accent": "#7A2B36", + "accent_lt": "#EEE4E5", + "text_light": "#FFFFFF", + "page_bg": "#FFFFFF", + "dark": "#0A0A0A", + "body_text": "#1A1A1A", + "muted": "#777777", + "cover_pattern": "editorial", + "mood": "editorial", + }, + # ── New patterns (v2) ──────────────────────────────────────────────────────── + "magazine": { + # Warm linen; deep navy accent — formal publication standard + "cover_bg": "#F0EEE9", + "accent": "#1C3557", + "accent_lt": "#E4EBF3", + "text_light": "#FFFFFF", + "page_bg": "#F0EEE9", + "dark": "#0D1A2B", + "body_text": "#2A2A2A", + "muted": "#888888", + "cover_pattern": "magazine", + "mood": "magazine", + }, + "darkroom": { + # Deep navy; muted steel-blue accent — premium, controlled + "cover_bg": "#151C27", + "accent": "#3D5A7A", + "accent_lt": "#E2EBF2", + "text_light": "#EDE9E2", + "page_bg": "#F7F7F5", + "dark": "#0A1018", + "body_text": "#2C2C2C", + "muted": "#8A9AB0", + "cover_pattern": "darkroom", + "mood": "darkroom", + }, + "terminal": { + # Near-black; forest green accent — technical, serious (not neon) + "cover_bg": "#0D1117", + "accent": "#3D7A5C", + "accent_lt": "#E2EEE8", + "text_light": "#E6EDF3", + "page_bg": "#F8F8F6", + "dark": "#010409", + "body_text": "#2C2C2C", + "muted": "#5A7A6A", + "cover_pattern": "terminal", + "mood": "terminal", + }, + "poster": { + # White; near-black accent sidebar — stark, unambiguous + "cover_bg": "#FFFFFF", + "accent": "#0A0A0A", + "accent_lt": "#EBEBEA", + "text_light": "#FFFFFF", + "page_bg": "#FFFFFF", + "dark": "#0A0A0A", + "body_text": "#1A1A1A", + "muted": "#888888", + "cover_pattern": "poster", + "mood": "poster", + }, +} + +# ── Font pairs — CSS names for cover HTML, ReportLab names for body ───────────── +# cover uses Google Fonts via @import (no local disk caching needed) +# body always uses system fonts via ReportLab +FONT_PAIRS = { + "authoritative": { + "display_css": "Playfair Display", + "body_css": "IBM Plex Sans", + "gfonts_import": "https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=IBM+Plex+Sans:ital,wght@0,400;0,600;1,400&display=swap", + "display_rl": "Times-Bold", + "body_rl": "Helvetica", + "body_b_rl": "Helvetica-Bold", + }, + "confident": { + "display_css": "Syne", + "body_css": "Nunito Sans", + "gfonts_import": "https://fonts.googleapis.com/css2?family=Syne:wght@600;800&family=Nunito+Sans:wght@400;600;700&display=swap", + "display_rl": "Times-Bold", + "body_rl": "Helvetica", + "body_b_rl": "Helvetica-Bold", + }, + "clean": { + "display_css": "DM Serif Display", + "body_css": "DM Sans", + "gfonts_import": "https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:wght@300;400;500&display=swap", + "display_rl": "Times-Bold", + "body_rl": "Helvetica", + "body_b_rl": "Helvetica-Bold", + }, + "expressive": { + "display_css": "Fraunces", + "body_css": "Inter", + "gfonts_import": "https://fonts.googleapis.com/css2?family=Fraunces:ital,wght@0,700;0,900;1,900&family=Inter:wght@300;400;500&display=swap", + "display_rl": "Times-Bold", + "body_rl": "Helvetica", + "body_b_rl": "Helvetica-Bold", + }, + "scholarly": { + "display_css": "EB Garamond", + "body_css": "Source Sans 3", + "gfonts_import": "https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400;0,700;1,400&family=Source+Sans+3:wght@400;600&display=swap", + "display_rl": "Times-Bold", + "body_rl": "Helvetica", + "body_b_rl": "Helvetica-Bold", + }, + "neutral": { + "display_css": "Outfit", + "body_css": "Outfit", + "gfonts_import": "https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;700;900&display=swap", + "display_rl": "Times-Bold", + "body_rl": "Helvetica", + "body_b_rl": "Helvetica-Bold", + }, + "restrained": { + "display_css": "Cormorant Garamond", + "body_css": "Jost", + "gfonts_import": "https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,600;1,300&family=Jost:wght@300;400;500&display=swap", + "display_rl": "Times-Bold", + "body_rl": "Helvetica", + "body_b_rl": "Helvetica-Bold", + }, + "bold": { + "display_css": "Barlow Condensed", + "body_css": "Barlow", + "gfonts_import": "https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@700;900&family=Barlow:wght@400;500;600&display=swap", + "display_rl": "Times-Bold", + "body_rl": "Helvetica", + "body_b_rl": "Helvetica-Bold", + }, + "dynamic": { + "display_css": "Montserrat", + "body_css": "Montserrat", + "gfonts_import": "https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,300;0,700;0,900;1,400&display=swap", + "display_rl": "Times-Bold", + "body_rl": "Helvetica", + "body_b_rl": "Helvetica-Bold", + }, + "classical": { + "display_css": "Cormorant", + "body_css": "Crimson Pro", + "gfonts_import": "https://fonts.googleapis.com/css2?family=Cormorant:ital,wght@0,400;0,700;1,400&family=Crimson+Pro:wght@400;600&display=swap", + "display_rl": "Times-Bold", + "body_rl": "Helvetica", + "body_b_rl": "Helvetica-Bold", + }, + "editorial": { + "display_css": "Bebas Neue", + "body_css": "Libre Franklin", + "gfonts_import": ( + "https://fonts.googleapis.com/css2?family=Bebas+Neue" + "&family=Libre+Franklin:ital,wght@0,400;0,700;1,400&display=swap" + ), + "display_rl": "Times-Bold", + "body_rl": "Helvetica", + "body_b_rl": "Helvetica-Bold", + }, + # ── New moods (v2) ─────────────────────────────────────────────────────────── + "magazine": { + "display_css": "Playfair Display", + "body_css": "EB Garamond", + "gfonts_import": ( + "https://fonts.googleapis.com/css2?family=Playfair+Display" + ":ital,wght@0,700;0,900;1,700" + "&family=EB+Garamond:ital,wght@0,400;0,600;1,400&display=swap" + ), + "display_rl": "Times-Bold", + "body_rl": "Helvetica", + "body_b_rl": "Helvetica-Bold", + }, + "darkroom": { + "display_css": "Playfair Display", + "body_css": "EB Garamond", + "gfonts_import": ( + "https://fonts.googleapis.com/css2?family=Playfair+Display" + ":ital,wght@0,700;0,900;1,700" + "&family=EB+Garamond:ital,wght@0,400;0,600;1,400&display=swap" + ), + "display_rl": "Times-Bold", + "body_rl": "Helvetica", + "body_b_rl": "Helvetica-Bold", + }, + "terminal": { + "display_css": "Space Mono", + "body_css": "Space Mono", + "gfonts_import": ( + "https://fonts.googleapis.com/css2?family=Space+Mono" + ":ital,wght@0,400;0,700;1,400&display=swap" + ), + "display_rl": "Courier-Bold", + "body_rl": "Courier", + "body_b_rl": "Courier-Bold", + }, + "poster": { + "display_css": "Barlow Condensed", + "body_css": "Courier Prime", + "gfonts_import": ( + "https://fonts.googleapis.com/css2?family=Barlow+Condensed" + ":wght@700;900" + "&family=Courier+Prime:ital,wght@0,400;0,700;1,400&display=swap" + ), + "display_rl": "Times-Bold", + "body_rl": "Courier", + "body_b_rl": "Courier-Bold", + }, +} + +SYSTEM_FALLBACK = { + "display_css": "Georgia", + "body_css": "Arial", + "gfonts_import": "", + "display_rl": "Times-Bold", + "body_rl": "Helvetica", + "body_b_rl": "Helvetica-Bold", +} + + +# ── Colour helpers ────────────────────────────────────────────────────────────── +def _hex_to_rgb(h: str) -> tuple: + h = h.lstrip("#") + return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) + + +def _lighten(hex_color: str, factor: float = 0.09) -> str: + """Blend hex_color toward white (factor = accent weight, 0=white, 1=full color).""" + r, g, b = _hex_to_rgb(hex_color) + return "#{:02X}{:02X}{:02X}".format( + round(r * factor + 255 * (1 - factor)), + round(g * factor + 255 * (1 - factor)), + round(b * factor + 255 * (1 - factor)), + ) + + +# ── Token assembly ───────────────────────────────────────────────────────────── +def build_tokens( + title: str, + doc_type: str, + author: str = "", + date: str = "", + accent_override: str = "", + cover_bg_override: str = "", +) -> dict: + palette = PALETTES.get(doc_type, PALETTES["general"]).copy() + mood = palette["mood"] + font_pair = FONT_PAIRS.get(mood, SYSTEM_FALLBACK) + + # Apply caller-supplied overrides before token assembly + if accent_override: + palette["accent"] = accent_override + palette["accent_lt"] = _lighten(accent_override, 0.09) + if cover_bg_override: + palette["cover_bg"] = cover_bg_override + + tokens = { + # Identity + "title": title, + "author": author, + "date": date, + "doc_type": doc_type, + + # Palette + "cover_bg": palette["cover_bg"], + "accent": palette["accent"], + "accent_lt": palette["accent_lt"], + "text_light": palette["text_light"], + "page_bg": palette["page_bg"], + "dark": palette["dark"], + "body_text": palette["body_text"], + "muted": palette["muted"], + "cover_pattern": palette["cover_pattern"], + "mood": mood, + + # Typography — CSS names for cover HTML (loaded via Google Fonts @import) + "font_display": font_pair["display_css"], + "font_body": font_pair["body_css"], + "gfonts_import": font_pair["gfonts_import"], + + # Typography — ReportLab system font names for body pages + "font_display_rl": font_pair["display_rl"], + "font_body_rl": font_pair["body_rl"], + "font_body_b_rl": font_pair["body_b_rl"], + + # Legacy keys (kept so render_body.py's register_fonts is a no-op) + "font_heading": font_pair["display_rl"], + "font_body_b": font_pair["body_b_rl"], + "font_paths": {}, + + # Type scale (pt) + "size_display": 54, + "size_h1": 22, + "size_h2": 15, + "size_h3": 11.5, + "size_body": 10.5, + "size_caption": 8.5, + "size_meta": 8, + + # Layout (pt, 1cm ≈ 28.35pt) + "margin_left": 79, # 2.8cm + "margin_right": 79, + "margin_top": 79, + "margin_bottom": 71, # 2.5cm + "section_gap": 26, + "para_gap": 8, + "line_gap": 17, + } + return tokens + + +# ── CLI ─────────────────────────────────────────────────────────────────────── +def main(): + parser = argparse.ArgumentParser(description="Generate design tokens from document metadata") + parser.add_argument("--title", default="Untitled Document") + parser.add_argument("--type", default="general", + choices=list(PALETTES.keys()), + help="Document type: " + ", ".join(PALETTES.keys())) + parser.add_argument("--author", default="") + parser.add_argument("--date", default="") + parser.add_argument("--meta", help="JSON file with title/type/author/date keys") + parser.add_argument("--accent", default="", + help="Override accent colour (hex, e.g. #2D6A8F). " + "accent_lt is auto-derived by lightening toward white.") + parser.add_argument("--cover-bg", default="", + help="Override cover background colour (hex).") + parser.add_argument("--out", default="tokens.json") + args = parser.parse_args() + + if args.meta: + try: + with open(args.meta) as f: + meta = json.load(f) + args.title = meta.get("title", args.title) + args.type = meta.get("type", args.type) + args.author = meta.get("author", args.author) + args.date = meta.get("date", args.date) + except Exception as e: + print(json.dumps({"status": "error", "error": str(e)}), file=sys.stderr) + sys.exit(1) + + tokens = build_tokens( + args.title, args.type, args.author, args.date, + accent_override=args.accent, + cover_bg_override=getattr(args, "cover_bg", ""), + ) + + try: + with open(args.out, "w") as f: + json.dump(tokens, f, indent=2) + except Exception as e: + print(json.dumps({"status": "error", "error": str(e)}), file=sys.stderr) + sys.exit(3) + + print(json.dumps({ + "status": "ok", + "out": args.out, + "mood": tokens["mood"], + "pattern": tokens["cover_pattern"], + "fonts": f'{tokens["font_display"]} / {tokens["font_body"]}', + })) + + +if __name__ == "__main__": + main() diff --git a/skills/minimax-pdf/scripts/reformat_parse.py b/skills/minimax-pdf/scripts/reformat_parse.py new file mode 100644 index 0000000..be125d5 --- /dev/null +++ b/skills/minimax-pdf/scripts/reformat_parse.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 +""" +reformat_parse.py — Convert an existing document into content.json, +then hand off to the CREATE pipeline (render_body.py). + +Supported input formats: + .md / .txt — Markdown / plain text + .pdf — Extract text from existing PDF (layout preserved as best-effort) + .json — Pass-through if already content.json format + +Usage: + python3 reformat_parse.py --input doc.md --out content.json + python3 reformat_parse.py --input old.pdf --out content.json + python3 reformat_parse.py --input data.json --out content.json + +Then pipe into the CREATE pipeline: + python3 render_body.py --tokens tokens.json --content content.json --out body.pdf + +Or use make.sh reformat which does both steps: + bash make.sh reformat --input doc.md --type report --title "My Report" --out output.pdf + +Exit codes: 0 success, 1 bad args / unsupported format, 2 dep missing, 3 parse error +""" + +import argparse +import json +import os +import re +import sys +import importlib.util +from pathlib import Path + + + + +def ensure_deps(): + missing = [] + if importlib.util.find_spec("pypdf") is None: + missing.append("pypdf") + if missing: + import subprocess + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "--break-system-packages", "-q"] + missing + ) + + +ensure_deps() + + +# ── Markdown / plain text parser ─────────────────────────────────────────────── +def parse_markdown(text: str) -> list: + """ + Convert Markdown to content.json blocks. + Supports: # headings, **bold**, bullet lists, > blockquotes (→ callout), + | tables |, plain paragraphs. + """ + blocks = [] + lines = text.splitlines() + i = 0 + + def flush_para(buf: list): + t = " ".join(buf).strip() + if t: + blocks.append({"type": "body", "text": _md_inline(t)}) + + para_buf = [] + + while i < len(lines): + line = lines[i] + stripped = line.strip() + + # Blank line — flush paragraph buffer + if not stripped: + flush_para(para_buf) + para_buf = [] + i += 1 + continue + + # ATX Headings: # ## ### + m = re.match(r'^(#{1,3})\s+(.*)', stripped) + if m: + flush_para(para_buf) + para_buf = [] + level = len(m.group(1)) + htype = {1: "h1", 2: "h2", 3: "h3"}.get(level, "h3") + blocks.append({"type": htype, "text": _md_inline(m.group(2))}) + i += 1 + continue + + # Display math block: $$expr$$ on one line, or opening $$ ... closing $$ + if stripped.startswith("$$"): + flush_para(para_buf) + para_buf = [] + inline_expr = stripped[2:].rstrip("$").strip() + if inline_expr: + # Single-line: $$E = mc^2$$ + blocks.append({"type": "math", "text": inline_expr}) + i += 1 + else: + # Multi-line: opening $$ alone, then expression lines, then closing $$ + math_lines = [] + i += 1 + while i < len(lines) and lines[i].strip() != "$$": + math_lines.append(lines[i]) + i += 1 + if i < len(lines): + i += 1 # skip closing $$ + blocks.append({"type": "math", "text": "\n".join(math_lines).strip()}) + continue + + # Fenced code block: ``` or ~~~ + if stripped.startswith("```") or stripped.startswith("~~~"): + flush_para(para_buf) + para_buf = [] + fence = stripped[:3] + code_lines = [] + i += 1 + while i < len(lines) and not lines[i].strip().startswith(fence): + code_lines.append(lines[i]) + i += 1 + if i < len(lines): + i += 1 # skip closing fence + blocks.append({"type": "code", "text": "\n".join(code_lines)}) + continue + + # Blockquote → callout + if stripped.startswith(">"): + flush_para(para_buf) + para_buf = [] + qt = re.sub(r'^>\s*', '', stripped) + blocks.append({"type": "callout", "text": _md_inline(qt)}) + i += 1 + continue + + # Unordered bullet: -, *, + + if re.match(r'^[-*+]\s+', stripped): + flush_para(para_buf) + para_buf = [] + text_part = re.sub(r'^[-*+]\s+', '', stripped) + blocks.append({"type": "bullet", "text": _md_inline(text_part)}) + i += 1 + continue + + # Ordered list: 1. 2. etc. → numbered (preserves counter in render_body) + if re.match(r'^\d+\.\s+', stripped): + flush_para(para_buf) + para_buf = [] + text_part = re.sub(r'^\d+\.\s+', '', stripped) + blocks.append({"type": "numbered", "text": _md_inline(text_part)}) + i += 1 + continue + + # Table: | col | col | + if stripped.startswith("|"): + flush_para(para_buf) + para_buf = [] + table_lines = [] + while i < len(lines) and lines[i].strip().startswith("|"): + table_lines.append(lines[i].strip()) + i += 1 + # Remove separator rows (|---|---|) + data_rows = [r for r in table_lines if not re.match(r'^\|[-:| ]+\|$', r)] + parsed = [] + for row in data_rows: + cells = [c.strip() for c in row.strip("|").split("|")] + parsed.append(cells) + if len(parsed) >= 2: + blocks.append({ + "type": "table", + "headers": parsed[0], + "rows": parsed[1:], + }) + elif len(parsed) == 1: + # Single row — treat as paragraph + blocks.append({"type": "body", "text": " | ".join(parsed[0])}) + continue + + # Horizontal rule → spacer + if re.match(r'^[-*_]{3,}$', stripped): + flush_para(para_buf) + para_buf = [] + blocks.append({"type": "spacer", "pt": 16}) + i += 1 + continue + + # Plain text → accumulate into paragraph + para_buf.append(stripped) + i += 1 + + flush_para(para_buf) + return blocks + + +def _md_inline(text: str) -> str: + """Convert inline Markdown to ReportLab XML markup.""" + # Bold: **text** or __text__ + text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) + text = re.sub(r'__(.+?)__', r'\1', text) + # Italic: *text* or _text_ + text = re.sub(r'\*(.+?)\*', r'\1', text) + text = re.sub(r'_(.+?)_', r'\1', text) + # Inline code: `code` + text = re.sub(r'`(.+?)`', r'\1', text) + # Strip markdown links, keep text + text = re.sub(r'\[(.+?)\]\(.+?\)', r'\1', text) + return text + + +# ── PDF text extractor ───────────────────────────────────────────────────────── +def parse_pdf(pdf_path: str) -> list: + """ + Extract text from an existing PDF and convert to content.json blocks. + Best-effort: detects headings by font size heuristics if available, + otherwise falls back to paragraph splitting. + """ + from pypdf import PdfReader + + reader = PdfReader(pdf_path) + all_text = [] + + for page in reader.pages: + text = page.extract_text() + if text: + all_text.append(text.strip()) + + full_text = "\n\n".join(all_text) + + # Treat extracted PDF text as plain text / light markdown + # (most PDFs lose formatting — we do our best) + return parse_plain(full_text) + + +def parse_plain(text: str) -> list: + """ + Heuristic plain-text parser. + Short ALL-CAPS or title-case lines → headings. + Everything else → paragraphs. + """ + blocks = [] + paragraphs = re.split(r'\n{2,}', text.strip()) + + for para in paragraphs: + para = para.strip() + if not para: + continue + + lines = para.splitlines() + + # Single short line that looks like a heading + if len(lines) == 1 and len(para) < 80: + if para.isupper() or re.match(r'^[A-Z][^.!?]*$', para): + blocks.append({"type": "h1", "text": para.title()}) + continue + + # Bullet lists + if lines[0].startswith(("- ", "• ", "* ")): + for line in lines: + text_part = re.sub(r'^[-•*]\s+', '', line.strip()) + if text_part: + blocks.append({"type": "bullet", "text": text_part}) + continue + + # Regular paragraph + blocks.append({"type": "body", "text": " ".join(lines)}) + + return blocks + + +# ── Pass-through validator ───────────────────────────────────────────────────── +VALID_TYPES = {"h1","h2","h3","body","bullet","numbered","callout","table", + "image","code","math","divider","caption","pagebreak","spacer"} + +def validate_content_json(data: list) -> tuple[list, list]: + """Return (valid_blocks, warnings).""" + valid, warnings = [], [] + for i, block in enumerate(data): + if not isinstance(block, dict): + warnings.append(f"Block {i}: not a dict, skipped") + continue + btype = block.get("type") + if btype not in VALID_TYPES: + warnings.append(f"Block {i}: unknown type '{btype}', kept as-is") + valid.append(block) + return valid, warnings + + +# ── Dispatcher ───────────────────────────────────────────────────────────────── +def parse_file(input_path: str) -> tuple[list, list]: + """Return (blocks, warnings).""" + ext = Path(input_path).suffix.lower() + + if ext in (".md", ".txt", ".markdown"): + with open(input_path, encoding="utf-8", errors="replace") as f: + text = f.read() + blocks = parse_markdown(text) + return blocks, [] + + if ext == ".pdf": + blocks = parse_pdf(input_path) + return blocks, ["PDF text extraction is best-effort — review content.json before rendering"] + + if ext == ".json": + with open(input_path) as f: + data = json.load(f) + if isinstance(data, list): + return validate_content_json(data) + # Maybe it's a meta-wrapper {"content": [...]} + if isinstance(data, dict) and "content" in data: + return validate_content_json(data["content"]) + return [], [f"JSON file does not contain a list of content blocks"] + + return [], [f"Unsupported file type: {ext}. Supported: .md .txt .pdf .json"] + + +# ── CLI ──────────────────────────────────────────────────────────────────────── +def main(): + parser = argparse.ArgumentParser(description="Parse a document into content.json") + parser.add_argument("--input", required=True, help="Input file (.md, .txt, .pdf, .json)") + parser.add_argument("--out", default="content.json", help="Output content.json path") + args = parser.parse_args() + + if not os.path.exists(args.input): + print(json.dumps({"status": "error", "error": f"File not found: {args.input}"}), + file=sys.stderr) + sys.exit(1) + + try: + blocks, warnings = parse_file(args.input) + except Exception as e: + import traceback + print(json.dumps({"status": "error", "error": str(e), + "trace": traceback.format_exc()}), file=sys.stderr) + sys.exit(3) + + if not blocks: + print(json.dumps({ + "status": "error", + "error": "No content blocks extracted", + "warnings": warnings, + }), file=sys.stderr) + sys.exit(3) + + with open(args.out, "w", encoding="utf-8") as f: + json.dump(blocks, f, indent=2, ensure_ascii=False) + + result = { + "status": "ok", + "out": args.out, + "block_count": len(blocks), + "warnings": warnings, + } + print(json.dumps(result, indent=2)) + + print(f"\n── Parsed {args.input} ─────────────────────────────────────", + file=sys.stderr) + print(f" Blocks : {len(blocks)}", file=sys.stderr) + + type_counts: dict = {} + for b in blocks: + type_counts[b.get("type","?")] = type_counts.get(b.get("type","?"), 0) + 1 + for t, n in sorted(type_counts.items()): + print(f" {t:12} × {n}", file=sys.stderr) + + if warnings: + print(f" Warnings:", file=sys.stderr) + for w in warnings: + print(f" ⚠ {w}", file=sys.stderr) + print(f"\n Next: bash make.sh run --content {args.out} --title '...' --type ...", + file=sys.stderr) + print("", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/skills/minimax-pdf/scripts/render_body.py b/skills/minimax-pdf/scripts/render_body.py new file mode 100644 index 0000000..ef81de8 --- /dev/null +++ b/skills/minimax-pdf/scripts/render_body.py @@ -0,0 +1,1052 @@ +#!/usr/bin/env python3 +""" +render_body.py — Build the inner-page PDF from tokens.json + content.json. + +Usage: + python3 render_body.py --tokens tokens.json --content content.json --out body.pdf + +Block types: + h1 h2 h3 Headings (h1 adds a full-width accent rule below) + body Justified prose paragraph + bullet Bullet list item (• prefix) + numbered Auto-numbered list item (resets when interrupted) + callout Highlighted insight box with left accent bar + table Data table with accent header + alternating rows + image Inline image from file path + figure Image with auto-numbered "Figure N:" caption + code Monospace code block with accent left border + math Display math formula via matplotlib mathtext + chart Bar / line / pie chart rendered via matplotlib + flowchart Process diagram rendered via matplotlib + bibliography Numbered reference list + divider Full-width accent rule + caption Small muted text (e.g., under a figure) + pagebreak Force a new page + spacer Vertical whitespace (pt field, default 12) + +Exit codes: 0 success, 1 bad args/missing file, 2 missing dep, 3 render error +""" + +import argparse +import io +import json +import os +import sys +import importlib.util + + +# ── Dependency bootstrap ─────────────────────────────────────────────────────── +def ensure_deps(): + missing = [p for p in ("reportlab", "pypdf") + if importlib.util.find_spec(p) is None] + if missing: + import subprocess + subprocess.check_call( + [sys.executable, "-m", "pip", "install", + "--break-system-packages", "-q"] + missing + ) + + +ensure_deps() + +from reportlab.platypus import ( + BaseDocTemplate, PageTemplate, Frame, + Paragraph, Spacer, Table, TableStyle, + HRFlowable, PageBreak, Flowable, KeepTogether, + Preformatted, Image as RLImage, +) +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import ParagraphStyle +from reportlab.lib.colors import HexColor +from reportlab.lib.enums import TA_JUSTIFY, TA_CENTER +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont + + +# ── Font registration ────────────────────────────────────────────────────────── +def register_fonts(tokens: dict): + """Register TTF fonts from token font_paths if present.""" + for name, fpath in tokens.get("font_paths", {}).items(): + if os.path.exists(fpath): + try: + pdfmetrics.registerFont(TTFont(name, fpath)) + except Exception: + pass + + +# ══════════════════════════════════════════════════════════════════════════════ +# Custom Flowables +# ══════════════════════════════════════════════════════════════════════════════ + +class CalloutBox(Flowable): + """Highlighted insight box: coloured background + 4px left accent bar.""" + + def __init__(self, text: str, style, accent: str, bg: str): + super().__init__() + self._para = Paragraph(text, style) + self._accent = HexColor(accent) + self._bg = HexColor(bg) + + def wrap(self, aw, ah): + self._w = aw + _, ph = self._para.wrap(aw - 36, ah) + self._h = ph + 22 + return aw, self._h + + def draw(self): + c = self.canv + c.setFillColor(self._bg) + c.roundRect(0, 0, self._w, self._h, 5, fill=1, stroke=0) + c.setFillColor(self._accent) + c.rect(0, 0, 4, self._h, fill=1, stroke=0) + self._para.drawOn(c, 18, 11) + + +class BibliographyItem(Flowable): + """Single hanging-indent bibliography entry rendered as [N] text.""" + + LABEL_W = 28 + + def __init__(self, ref_id: str, text: str, style, dark: str): + super().__init__() + self._id = ref_id + self._text = text + self._style = style + self._dark = HexColor(dark) + + def wrap(self, aw, ah): + self._w = aw + self._para = Paragraph(self._text, self._style) + _, ph = self._para.wrap(aw - self.LABEL_W, ah) + self._h = ph + 4 + return aw, self._h + + def draw(self): + c = self.canv + c.setFillColor(self._dark) + c.setFont("Helvetica-Bold", 8.5) + c.drawString(0, self._h - 12, f"[{self._id}]") + self._para.drawOn(c, self.LABEL_W, 2) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Page template (header + footer) +# ══════════════════════════════════════════════════════════════════════════════ + +class BeautifulDoc(BaseDocTemplate): + def __init__(self, path: str, tokens: dict, **kw): + self._t = tokens + super().__init__(path, **kw) + fr = Frame( + self.leftMargin, self.bottomMargin, + self.width, self.height, id="body", + ) + tmpl = PageTemplate(id="main", frames=fr, onPage=self._decorate) + self.addPageTemplates([tmpl]) + + def _decorate(self, canv, doc): + t = self._t + lm = doc.leftMargin + rm = doc.rightMargin + pw = doc.pagesize[0] + ph = doc.pagesize[1] + top = ph - doc.topMargin + + canv.saveState() + + # Header accent rule + canv.setStrokeColor(HexColor(t["accent"])) + canv.setLineWidth(1.5) + canv.line(lm, top + 12, pw - rm, top + 12) + + # Header: title (left) + date (right) + canv.setFillColor(HexColor(t["muted"])) + canv.setFont(t["font_body_rl"], t["size_meta"]) + canv.drawString(lm, top + 16, t["title"].upper()) + canv.drawRightString(pw - rm, top + 16, t.get("date", "")) + + # Footer rule + canv.setStrokeColor(HexColor("#DDDDDD")) + canv.setLineWidth(0.5) + canv.line(lm, doc.bottomMargin - 12, pw - rm, doc.bottomMargin - 12) + + # Footer: author (left) + page number (right) + canv.setFillColor(HexColor(t["muted"])) + canv.setFont(t["font_body_rl"], t["size_meta"]) + canv.drawString(lm, doc.bottomMargin - 22, t.get("author", "")) + canv.drawRightString(pw - rm, doc.bottomMargin - 22, str(doc.page)) + + canv.restoreState() + + +# ══════════════════════════════════════════════════════════════════════════════ +# Style factory +# ══════════════════════════════════════════════════════════════════════════════ + +def make_styles(t: dict) -> dict: + hf = t["font_display_rl"] + bf = t["font_body_rl"] + bfb = t["font_body_b_rl"] + dk = t["body_text"] + d = t["dark"] + mu = t["muted"] + + return { + "h1": ParagraphStyle("H1", + fontName=hf, fontSize=t["size_h1"], + leading=t["size_h1"] * 1.3, + textColor=HexColor(d), + spaceBefore=t["section_gap"], spaceAfter=4, + ), + "h2": ParagraphStyle("H2", + fontName=hf, fontSize=t["size_h2"], + leading=t["size_h2"] * 1.4, + textColor=HexColor(d), + spaceBefore=18, spaceAfter=5, + ), + "h3": ParagraphStyle("H3", + fontName=bfb, fontSize=t["size_h3"], + leading=t["size_h3"] * 1.5, + textColor=HexColor(d), + spaceBefore=12, spaceAfter=3, + ), + "body": ParagraphStyle("Body", + fontName=bf, fontSize=t["size_body"], + leading=t["line_gap"], + textColor=HexColor(dk), + spaceAfter=t["para_gap"], alignment=TA_JUSTIFY, + ), + "bullet": ParagraphStyle("Bullet", + fontName=bf, fontSize=t["size_body"], + leading=t["line_gap"] - 1, + textColor=HexColor(dk), + spaceAfter=4, leftIndent=14, + ), + "numbered": ParagraphStyle("Numbered", + fontName=bf, fontSize=t["size_body"], + leading=t["line_gap"] - 1, + textColor=HexColor(dk), + spaceAfter=4, leftIndent=22, firstLineIndent=-22, + ), + "callout": ParagraphStyle("Callout", + fontName=bfb, fontSize=t["size_body"] + 0.5, leading=16, + textColor=HexColor(d), + ), + "caption": ParagraphStyle("Caption", + fontName=bf, fontSize=t["size_caption"], leading=13, + textColor=HexColor(mu), spaceAfter=6, + alignment=TA_CENTER, + ), + "table_header": ParagraphStyle("TblH", + fontName=bfb, fontSize=9.5, leading=13, + textColor=HexColor("#FFFFFF"), + ), + "table_cell": ParagraphStyle("TblC", + fontName=bf, fontSize=9.5, leading=13, + textColor=HexColor(dk), + ), + "code": ParagraphStyle("Code", + fontName="Courier", fontSize=8.5, leading=12.5, + textColor=HexColor(dk), + ), + "code_lang": ParagraphStyle("CodeLang", + fontName="Courier", fontSize=7, leading=10, + textColor=HexColor(mu), + ), + "bib": ParagraphStyle("Bib", + fontName=bf, fontSize=9, leading=14, + textColor=HexColor(dk), + ), + "bib_title": ParagraphStyle("BibTitle", + fontName=hf, fontSize=t["size_h2"], + leading=t["size_h2"] * 1.4, + textColor=HexColor(d), + spaceBefore=t["section_gap"], spaceAfter=8, + ), + "math_fallback": ParagraphStyle("MathFb", + fontName="Courier", fontSize=9, leading=13, + textColor=HexColor(dk), + ), + "eq_label": ParagraphStyle("EqLabel", + fontName="Helvetica", fontSize=9, leading=12, + textColor=HexColor(mu), + ), + } + + +# ══════════════════════════════════════════════════════════════════════════════ +# Shared helpers +# ══════════════════════════════════════════════════════════════════════════════ + +def _divider(accent: str) -> HRFlowable: + return HRFlowable( + width="100%", thickness=1.2, + color=HexColor(accent), + spaceBefore=14, spaceAfter=14, + ) + + +def _image_from_bytes(png_bytes: bytes, usable_w: float, + max_frac: float = 0.88) -> RLImage: + """Create a scaled RLImage from PNG bytes, bounded to max_frac of usable_w.""" + img = RLImage(io.BytesIO(png_bytes)) + max_w = usable_w * max_frac + if img.drawWidth > max_w: + scale = max_w / img.drawWidth + img.drawWidth = max_w + img.drawHeight = img.drawHeight * scale + return img + + +# ══════════════════════════════════════════════════════════════════════════════ +# PNG renderers (matplotlib) +# ══════════════════════════════════════════════════════════════════════════════ + +def _render_math_png(expr: str, dpi: int = 180) -> bytes | None: + """ + Render a LaTeX math expression via matplotlib mathtext. + No LaTeX binary required — uses matplotlib's built-in math parser. + Supports: fractions (\\frac), integrals (\\int), sums (\\sum), + Greek letters, sub/superscripts, etc. + """ + try: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + fig = plt.figure(figsize=(8, 1.2)) + fig.patch.set_facecolor("white") + ax = fig.add_axes([0, 0, 1, 1]) + ax.set_axis_off() + ax.set_facecolor("white") + ax.text(0.5, 0.5, f"${expr}$", + fontsize=16, ha="center", va="center", + transform=ax.transAxes) + buf = io.BytesIO() + fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight", + facecolor="white", pad_inches=0.1) + plt.close(fig) + buf.seek(0) + return buf.read() + except Exception: + return None + + +def _render_chart_png(item: dict, accent: str, dpi: int = 150) -> bytes | None: + """ + Render bar / line / pie chart to PNG using matplotlib. + + Required fields: + chart_type "bar" | "line" | "pie" (default "bar") + labels list of category strings + datasets list of {label?, values: list[number]} + + Optional fields: + title chart title + x_label X-axis label + y_label Y-axis label + """ + try: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + import matplotlib.colors as mcolors + import colorsys + import numpy as np + + chart_type = item.get("chart_type", "bar") + title_text = item.get("title", "") + labels = item.get("labels", []) + datasets = item.get("datasets", []) + + # Derive a consistent palette from the document accent color + r, g, b = mcolors.to_rgb(accent) + h, s, v = colorsys.rgb_to_hsv(r, g, b) + palette = [ + colorsys.hsv_to_rgb( + (h + i * 0.13) % 1.0, + max(0.35, s - i * 0.08), + min(0.92, v + i * 0.04), + ) + for i in range(max(len(datasets), 1)) + ] + + fig, ax = plt.subplots(figsize=(7, 3.6), dpi=dpi) + fig.patch.set_facecolor("white") + ax.set_facecolor("white") + + if chart_type == "bar": + x = np.arange(len(labels)) + n = max(len(datasets), 1) + width = 0.68 / n + for i, ds in enumerate(datasets): + offset = (i - (n - 1) / 2) * width + ax.bar(x + offset, ds.get("values", []), width * 0.88, + label=ds.get("label", f"Series {i+1}"), + color=palette[i % len(palette)], edgecolor="none") + ax.set_xticks(x) + ax.set_xticklabels(labels, fontsize=8.5) + ax.yaxis.grid(True, alpha=0.25, color="#CCCCCC", linewidth=0.7) + ax.set_axisbelow(True) + if item.get("x_label"): + ax.set_xlabel(item["x_label"], fontsize=8.5) + if item.get("y_label"): + ax.set_ylabel(item["y_label"], fontsize=8.5) + + elif chart_type == "line": + x = np.arange(len(labels)) + for i, ds in enumerate(datasets): + ax.plot(x, ds.get("values", []), marker="o", markersize=3.5, + label=ds.get("label", f"Series {i+1}"), + color=palette[i % len(palette)], linewidth=1.8) + ax.set_xticks(x) + ax.set_xticklabels(labels, fontsize=8.5) + ax.yaxis.grid(True, alpha=0.25, color="#CCCCCC", linewidth=0.7) + ax.set_axisbelow(True) + if item.get("x_label"): + ax.set_xlabel(item["x_label"], fontsize=8.5) + if item.get("y_label"): + ax.set_ylabel(item["y_label"], fontsize=8.5) + + elif chart_type == "pie": + vals = datasets[0].get("values", []) if datasets else [] + colors = [ + colorsys.hsv_to_rgb( + (h + i * 0.11) % 1.0, + max(0.30, s - i * 0.06), + min(0.92, v + i * 0.03), + ) + for i in range(len(vals)) + ] + ax.pie(vals, labels=labels, colors=colors, + autopct="%1.1f%%", pctdistance=0.82, + wedgeprops=dict(edgecolor="white", linewidth=1.4), + textprops=dict(fontsize=8.5)) + + # Shared styling + for spine in ax.spines.values(): + spine.set_linewidth(0.5) + spine.set_color("#CCCCCC") + ax.tick_params(axis="both", length=0, labelsize=8.5) + if title_text: + ax.set_title(title_text, fontsize=10, pad=8, + color="#333333", fontweight="bold") + if len(datasets) > 1 and chart_type != "pie": + ax.legend(frameon=False, fontsize=8, loc="upper right") + + plt.tight_layout(pad=0.4) + buf = io.BytesIO() + fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight", + facecolor="white", pad_inches=0.06) + plt.close(fig) + buf.seek(0) + return buf.read() + except Exception: + return None + + +def _render_flowchart_png(item: dict, accent: str, dark: str, + muted: str, dpi: int = 130) -> bytes | None: + """ + Render a top-to-bottom flowchart using matplotlib patches and arrows. + + Node schema: {id, label, shape?} + shape: "rect" (default) | "diamond" | "oval" | "parallelogram" + + Edge schema: {from, to, label?} + Forward edges (to a later node) draw straight arrows. + Back edges (to an earlier node) draw a curved arc to the right. + """ + try: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + import matplotlib.patches as mpatch + from matplotlib.patches import FancyBboxPatch + import matplotlib.colors as mcolors + + nodes_list = item.get("nodes", []) + edges = item.get("edges", []) + if not nodes_list: + return None + + nodes = {n["id"]: n for n in nodes_list} + order = {n["id"]: i for i, n in enumerate(nodes_list)} + + n_nodes = len(nodes_list) + BOX_W = 4.2 + BOX_H = 0.58 + STEP_Y = 1.25 + CX = 5.0 + + fig_h = max(3.5, n_nodes * STEP_Y + 0.8) + fig, ax = plt.subplots(figsize=(6, fig_h), dpi=dpi) + fig.patch.set_facecolor("white") + ax.set_facecolor("white") + ax.set_xlim(0, 10) + ax.set_ylim(-0.6, n_nodes * STEP_Y + 0.2) + ax.invert_yaxis() + ax.axis("off") + + acc_rgb = mcolors.to_rgb(accent) + dark_rgb = mcolors.to_rgb(dark) + muted_rgb = mcolors.to_rgb(muted) + + # Node positions (cx, cy) — preserves input order + pos = {nid: (CX, i * STEP_Y) for nid, i in order.items()} + + # ── Draw edges (behind nodes) ────────────────────────────────────────── + for edge in edges: + src, dst = edge.get("from"), edge.get("to") + if src not in pos or dst not in pos: + continue + x1, y1 = pos[src] + x2, y2 = pos[dst] + lbl = edge.get("label", "") + + src_shape = nodes.get(src, {}).get("shape", "rect") + dst_shape = nodes.get(dst, {}).get("shape", "rect") + dy_src = BOX_H * (0.80 if src_shape == "diamond" else 0.50) + dy_dst = BOX_H * (0.80 if dst_shape == "diamond" else 0.50) + + y_start = y1 + dy_src + y_end = y2 - dy_dst + + # Forward edge: straight; back-edge: curved arc + conn = "arc3,rad=0.0" if y_end > y_start + 0.01 else "arc3,rad=0.42" + + ax.annotate("", + xy=(x2, y_end), xytext=(x1, y_start), + arrowprops=dict( + arrowstyle="-|>", color=muted_rgb, + lw=1.0, mutation_scale=10, + connectionstyle=conn, + ), + ) + if lbl: + mid_x = (x1 + x2) / 2 + 0.28 + mid_y = (y_start + y_end) / 2 + ax.text(mid_x, mid_y, lbl, fontsize=7.5, + color=muted_rgb, ha="left", va="center") + + # ── Draw nodes (in front of edges) ──────────────────────────────────── + for nid, (cx, cy) in pos.items(): + node = nodes[nid] + shape = node.get("shape", "rect") + label = node.get("label", nid) + left = cx - BOX_W / 2 + bot = cy - BOX_H / 2 + + if shape in ("oval", "terminal"): + el = mpatch.Ellipse( + (cx, cy), BOX_W * 0.78, BOX_H * 1.15, + facecolor=acc_rgb, edgecolor=acc_rgb, linewidth=0, + ) + ax.add_patch(el) + ax.text(cx, cy, label, ha="center", va="center", + fontsize=8.5, fontweight="bold", color="white") + + elif shape == "diamond": + d = BOX_W * 0.44 + diamond = plt.Polygon( + [(cx, cy - d * 0.72), (cx + d, cy), + (cx, cy + d * 0.72), (cx - d, cy)], + facecolor="#FFFCF0", + edgecolor=accent, linewidth=1.2, + ) + ax.add_patch(diamond) + ax.text(cx, cy, label, ha="center", va="center", + fontsize=8, color=dark_rgb) + + elif shape == "parallelogram": + skew = 0.30 + para = plt.Polygon( + [(left + skew, bot), (left + BOX_W + skew, bot), + (left + BOX_W, bot + BOX_H), (left, bot + BOX_H)], + facecolor="white", + edgecolor=accent, linewidth=1.2, + ) + ax.add_patch(para) + ax.text(cx, cy, label, ha="center", va="center", + fontsize=8.5, color=dark_rgb) + + else: # rect (default) + rect = FancyBboxPatch( + (left, bot), BOX_W, BOX_H, + boxstyle="round,pad=0.04", + facecolor="white", + edgecolor=accent, linewidth=1.2, + ) + ax.add_patch(rect) + ax.text(cx, cy, label, ha="center", va="center", + fontsize=8.5, color=dark_rgb) + + plt.tight_layout(pad=0.2) + buf = io.BytesIO() + fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight", + facecolor="white", pad_inches=0.08) + plt.close(fig) + buf.seek(0) + return buf.read() + except Exception: + return None + + +# ══════════════════════════════════════════════════════════════════════════════ +# Block renderers +# +# All functions share the same signature: +# _add_XXX(story: list, item: dict, ctx: dict) +# +# ctx keys: +# tokens dict design tokens from palette.py +# styles dict ParagraphStyle objects from make_styles() +# usable_w float usable page width in points +# acc str accent hex color +# acc_lt str light accent hex color +# mu str muted hex color +# dark str dark hex color +# figure_n int auto-incrementing figure counter (mutable) +# numbered_n int auto-incrementing list counter (mutable) +# ══════════════════════════════════════════════════════════════════════════════ + +def _add_heading(story: list, item: dict, ctx: dict, level: int): + key = f"h{level}" + para = Paragraph(item["text"], ctx["styles"][key]) + if level == 1: + story.append(KeepTogether([para, _divider(ctx["acc"])])) + else: + story.append(para) + + +def _add_body(story: list, item: dict, ctx: dict): + story.append(Paragraph(item["text"], ctx["styles"]["body"])) + + +def _add_bullet(story: list, item: dict, ctx: dict): + story.append(Paragraph( + f"\u2022\u2002{item['text']}", ctx["styles"]["bullet"] + )) + + +def _add_numbered(story: list, item: dict, ctx: dict): + ctx["numbered_n"] += 1 + story.append(Paragraph( + f"{ctx['numbered_n']}.\u2002{item['text']}", + ctx["styles"]["numbered"], + )) + + +def _add_callout(story: list, item: dict, ctx: dict): + story.append(Spacer(1, 8)) + story.append(CalloutBox( + item["text"], ctx["styles"]["callout"], ctx["acc"], ctx["acc_lt"] + )) + story.append(Spacer(1, 8)) + + +def _add_table(story: list, item: dict, ctx: dict): + t = ctx["tokens"] + styles = ctx["styles"] + usable_w = ctx["usable_w"] + acc = ctx["acc"] + acc_lt = ctx["acc_lt"] + + headers = [Paragraph(h, styles["table_header"]) for h in item["headers"]] + rows = [ + [Paragraph(str(c), styles["table_cell"]) for c in row] + for row in item.get("rows", []) + ] + n_cols = len(item["headers"]) + + # Optional col_widths as fractions summing to 1.0 + if "col_widths" in item and len(item["col_widths"]) == n_cols: + col_w = [usable_w * f for f in item["col_widths"]] + else: + col_w = [usable_w / n_cols] * n_cols + + tbl = Table([headers] + rows, colWidths=col_w) + tbl.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), HexColor(acc)), + ("TEXTCOLOR", (0, 0), (-1, 0), HexColor("#FFFFFF")), + ("FONTNAME", (0, 0), (-1, 0), t["font_body_b_rl"]), + ("FONTSIZE", (0, 0), (-1, 0), 9.5), + ("TOPPADDING", (0, 0), (-1, 0), 7), + ("BOTTOMPADDING", (0, 0), (-1, 0), 7), + ("ROWBACKGROUNDS", (0, 1), (-1, -1), + [HexColor("#FFFFFF"), HexColor(acc_lt)]), + ("FONTNAME", (0, 1), (-1, -1), t["font_body_rl"]), + ("FONTSIZE", (0, 1), (-1, -1), 9.5), + ("TOPPADDING", (0, 1), (-1, -1), 6), + ("BOTTOMPADDING", (0, 1), (-1, -1), 6), + ("LEFTPADDING", (0, 0), (-1, -1), 10), + ("RIGHTPADDING", (0, 0), (-1, -1), 10), + ("BOX", (0, 0), (-1, -1), 0.5, HexColor("#CCCCCC")), + ("LINEBELOW", (0, 0), (-1, 0), 1.2, HexColor(acc)), + ("TEXTCOLOR", (0, 1), (-1, -1), HexColor(t["body_text"])), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ])) + story.append(tbl) + if item.get("caption"): + story.append(Spacer(1, 4)) + story.append(Paragraph(item["caption"], styles["caption"])) + story.append(Spacer(1, 12)) + + +def _add_image(story: list, item: dict, ctx: dict): + path = str(item.get("path", item.get("src", ""))) + if not os.path.exists(path): + story.append(Paragraph( + f"[Image not found: {path}]", ctx["styles"]["caption"] + )) + return + try: + img = RLImage(path) + uw = ctx["usable_w"] + if img.drawWidth > uw: + scale = uw / img.drawWidth + img.drawWidth = uw + img.drawHeight = img.drawHeight * scale + story.append(img) + except Exception as e: + story.append(Paragraph(f"[Image error: {e}]", ctx["styles"]["caption"])) + return + if item.get("caption"): + story.append(Spacer(1, 4)) + story.append(Paragraph(item["caption"], ctx["styles"]["caption"])) + story.append(Spacer(1, 8)) + + +def _add_figure(story: list, item: dict, ctx: dict): + """Like image but auto-numbers the caption as 'Figure N: ...'.""" + ctx["figure_n"] += 1 + raw_cap = item.get("caption", "") + caption = f"Figure {ctx['figure_n']}: {raw_cap}" if raw_cap \ + else f"Figure {ctx['figure_n']}" + _add_image(story, {**item, "caption": caption}, ctx) + + +def _add_code(story: list, item: dict, ctx: dict): + acc = ctx["acc"] + acc_lt = ctx["acc_lt"] + mu = ctx["mu"] + uw = ctx["usable_w"] + lang = item.get("language", "") + + pre = Preformatted(item.get("text", ""), ctx["styles"]["code"]) + tbl = Table([[pre]], colWidths=[uw]) + tbl.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, -1), HexColor(acc_lt)), + ("LINEBEFORE", (0, 0), ( 0, -1), 3, HexColor(acc)), + ("BOX", (0, 0), (-1, -1), 0.5, HexColor(mu)), + ("LEFTPADDING", (0, 0), (-1, -1), 14), + ("RIGHTPADDING", (0, 0), (-1, -1), 10), + ("TOPPADDING", (0, 0), (-1, -1), 8), + ("BOTTOMPADDING", (0, 0), (-1, -1), 8), + ])) + story.append(Spacer(1, 6)) + if lang: + story.append(Paragraph(lang.upper(), ctx["styles"]["code_lang"])) + story.append(tbl) + story.append(Spacer(1, 6)) + + +def _add_math(story: list, item: dict, ctx: dict): + """ + Display math block. + + Fields: + text LaTeX math expression (without enclosing $) + label optional equation label, e.g. "(1)" — displayed right-aligned + caption optional caption below the formula + + Example: + {"type": "math", "text": "E = mc^2", "label": "(1)"} + {"type": "math", "text": "\\\\int_0^\\\\infty e^{-x^2}\\\\,dx = \\\\frac{\\\\sqrt{\\\\pi}}{2}"} + """ + acc = ctx["acc"] + acc_lt = ctx["acc_lt"] + uw = ctx["usable_w"] + expr = item.get("text", "").strip() + label = item.get("label", "").strip() + + png = _render_math_png(expr) + + if png is None: + # Graceful text fallback if matplotlib unavailable + story.append(Spacer(1, 6)) + pre = Preformatted(f" {expr}", ctx["styles"]["math_fallback"]) + tbl = Table([[pre]], colWidths=[uw]) + tbl.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, -1), HexColor(acc_lt)), + ("LEFTPADDING", (0, 0), (-1, -1), 14), + ("RIGHTPADDING", (0, 0), (-1, -1), 14), + ("TOPPADDING", (0, 0), (-1, -1), 8), + ("BOTTOMPADDING", (0, 0), (-1, -1), 8), + ])) + story.append(tbl) + story.append(Spacer(1, 6)) + return + + img = _image_from_bytes(png, uw, max_frac=0.72) + story.append(Spacer(1, 10)) + + if label: + label_w = 44 + formula_w = uw - label_w + lbl_para = Paragraph(label, ctx["styles"]["eq_label"]) + row_tbl = Table([[img, lbl_para]], colWidths=[formula_w, label_w]) + row_tbl.setStyle(TableStyle([ + ("ALIGN", (0, 0), (0, 0), "CENTER"), + ("ALIGN", (1, 0), (1, 0), "RIGHT"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ])) + story.append(row_tbl) + else: + row_tbl = Table([[img]], colWidths=[uw]) + row_tbl.setStyle(TableStyle([ + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ])) + story.append(row_tbl) + + if item.get("caption"): + story.append(Spacer(1, 4)) + story.append(Paragraph(item["caption"], ctx["styles"]["caption"])) + story.append(Spacer(1, 10)) + + +def _add_chart(story: list, item: dict, ctx: dict): + """ + Render a chart (bar / line / pie) via matplotlib. + + Fields: + chart_type "bar" | "line" | "pie" (default "bar") + title chart title + labels list of category strings + datasets list of {label?, values: list[number]} + x_label X-axis label (bar/line) + y_label Y-axis label (bar/line) + caption caption text below chart + figure bool (default true) — prefix caption with "Figure N:" + """ + uw = ctx["usable_w"] + png = _render_chart_png(item, ctx["acc"]) + + if png is None: + story.append(Paragraph( + "[Chart: install matplotlib to render — pip install matplotlib]", + ctx["styles"]["caption"], + )) + return + + img = _image_from_bytes(png, uw, max_frac=0.95) + story.append(Spacer(1, 8)) + row_tbl = Table([[img]], colWidths=[uw]) + row_tbl.setStyle(TableStyle([("ALIGN", (0, 0), (-1, -1), "CENTER")])) + story.append(row_tbl) + + raw_cap = item.get("caption", "") + use_fig = item.get("figure", True) + if raw_cap or use_fig: + ctx["figure_n"] += 1 + prefix = f"Figure {ctx['figure_n']}: " if use_fig else "" + story.append(Spacer(1, 4)) + story.append(Paragraph(prefix + raw_cap, ctx["styles"]["caption"])) + story.append(Spacer(1, 10)) + + +def _add_flowchart(story: list, item: dict, ctx: dict): + """ + Render a flowchart via matplotlib. + + Fields: + nodes list of {id, label, shape?} + shape: "rect" (default) | "diamond" | "oval" | "parallelogram" + edges list of {from, to, label?} + caption caption below the diagram + figure bool (default true) — prefix caption with "Figure N:" + """ + uw = ctx["usable_w"] + png = _render_flowchart_png(item, ctx["acc"], ctx["dark"], ctx["mu"]) + + if png is None: + story.append(Paragraph( + "[Flowchart: install matplotlib to render — pip install matplotlib]", + ctx["styles"]["caption"], + )) + return + + img = _image_from_bytes(png, uw, max_frac=0.78) + story.append(Spacer(1, 8)) + row_tbl = Table([[img]], colWidths=[uw]) + row_tbl.setStyle(TableStyle([("ALIGN", (0, 0), (-1, -1), "CENTER")])) + story.append(row_tbl) + + raw_cap = item.get("caption", "") + use_fig = item.get("figure", True) + if raw_cap or use_fig: + ctx["figure_n"] += 1 + prefix = f"Figure {ctx['figure_n']}: " if use_fig else "" + story.append(Spacer(1, 4)) + story.append(Paragraph(prefix + raw_cap, ctx["styles"]["caption"])) + story.append(Spacer(1, 10)) + + +def _add_bibliography(story: list, item: dict, ctx: dict): + """ + Numbered reference list with hanging indent. + + Fields: + title section heading (default "References"); set "" to suppress + items list of {id, text} + + Example: + {"type": "bibliography", + "items": [ + {"id": "1", "text": "Smith, J. (2023). Title. Journal, 10(2), 1–15."}, + {"id": "2", "text": "Doe, A. (2022). Another title. Publisher."} + ]} + """ + heading = item.get("title", "References") + if heading: + story.append(KeepTogether([ + Paragraph(heading, ctx["styles"]["bib_title"]), + _divider(ctx["acc"]), + ])) + + for ref in item.get("items", []): + story.append(Spacer(1, 4)) + story.append(BibliographyItem( + str(ref.get("id", "")), + ref.get("text", ""), + ctx["styles"]["bib"], + ctx["dark"], + )) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Story builder +# ══════════════════════════════════════════════════════════════════════════════ + +# Block types that break a numbered list sequence +_RESETS_NUMBERED = frozenset({ + "h1", "h2", "h3", "body", "bullet", "callout", "table", + "image", "figure", "code", "math", "chart", "flowchart", + "bibliography", "divider", "caption", "pagebreak", "spacer", +}) + + +def build_story(content: list, tokens: dict, styles: dict) -> list: + usable_w = A4[0] - tokens["margin_left"] - tokens["margin_right"] + + ctx: dict = { + "tokens": tokens, + "styles": styles, + "usable_w": usable_w, + "acc": tokens["accent"], + "acc_lt": tokens["accent_lt"], + "mu": tokens["muted"], + "dark": tokens["dark"], + "figure_n": 0, + "numbered_n": 0, + } + + story: list = [] + + for item in content: + kind = item.get("type", "body") + + if kind in _RESETS_NUMBERED: + ctx["numbered_n"] = 0 + + if kind == "h1": _add_heading(story, item, ctx, 1) + elif kind == "h2": _add_heading(story, item, ctx, 2) + elif kind == "h3": _add_heading(story, item, ctx, 3) + elif kind == "body": _add_body(story, item, ctx) + elif kind == "bullet": _add_bullet(story, item, ctx) + elif kind == "numbered": _add_numbered(story, item, ctx) + elif kind == "callout": _add_callout(story, item, ctx) + elif kind == "table": _add_table(story, item, ctx) + elif kind == "image": _add_image(story, item, ctx) + elif kind == "figure": _add_figure(story, item, ctx) + elif kind == "code": _add_code(story, item, ctx) + elif kind == "math": _add_math(story, item, ctx) + elif kind == "chart": _add_chart(story, item, ctx) + elif kind == "flowchart": _add_flowchart(story, item, ctx) + elif kind == "bibliography": _add_bibliography(story, item, ctx) + elif kind == "divider": story.append(_divider(ctx["acc"])) + elif kind == "caption": + story.append(Paragraph(item["text"], styles["caption"])) + elif kind == "pagebreak": story.append(PageBreak()) + elif kind == "spacer": story.append(Spacer(1, item.get("pt", 12))) + + return story + + +# ══════════════════════════════════════════════════════════════════════════════ +# Main build +# ══════════════════════════════════════════════════════════════════════════════ + +def build(tokens: dict, content: list, out_path: str) -> dict: + register_fonts(tokens) + styles = make_styles(tokens) + + doc = BeautifulDoc( + out_path, tokens, + pagesize=A4, + leftMargin=tokens["margin_left"], + rightMargin=tokens["margin_right"], + topMargin=tokens["margin_top"], + bottomMargin=tokens["margin_bottom"], + ) + doc.build(build_story(content, tokens, styles)) + + size = os.path.getsize(out_path) + return {"status": "ok", "out": out_path, "size_kb": size // 1024} + + +# ══════════════════════════════════════════════════════════════════════════════ +# CLI +# ══════════════════════════════════════════════════════════════════════════════ + +def main(): + parser = argparse.ArgumentParser( + description="Render body PDF from tokens.json + content.json" + ) + parser.add_argument("--tokens", default="tokens.json") + parser.add_argument("--content", default="content.json") + parser.add_argument("--out", default="body.pdf") + args = parser.parse_args() + + for fpath in (args.tokens, args.content): + if not os.path.exists(fpath): + print( + json.dumps({"status": "error", + "error": f"File not found: {fpath}"}), + file=sys.stderr, + ) + sys.exit(1) + + with open(args.tokens, encoding="utf-8") as f: + tokens = json.load(f) + with open(args.content, encoding="utf-8") as f: + content = json.load(f) + + try: + result = build(tokens, content, args.out) + print(json.dumps(result)) + except Exception as e: + import traceback + print( + json.dumps({ + "status": "error", + "error": str(e), + "trace": traceback.format_exc(), + }), + file=sys.stderr, + ) + sys.exit(3) + + +if __name__ == "__main__": + main() diff --git a/skills/minimax-pdf/scripts/render_cover.js b/skills/minimax-pdf/scripts/render_cover.js new file mode 100644 index 0000000..8a29692 --- /dev/null +++ b/skills/minimax-pdf/scripts/render_cover.js @@ -0,0 +1,111 @@ +#!/usr/bin/env node +/** + * render_cover.js — Render cover.html → cover.pdf via Playwright. + * + * Usage: + * node render_cover.js --input cover.html --out cover.pdf + * node render_cover.js --input cover.html --out cover.pdf --wait 1200 + * + * Exit codes: 0 success, 1 bad args, 2 dependency missing, 3 render error + */ + +const path = require("path"); +const fs = require("fs"); + +function usage() { + console.error("Usage: node render_cover.js --input --out [--wait ]"); + process.exit(1); +} + +// ── Arg parsing ──────────────────────────────────────────────────────────────── +const args = process.argv.slice(2); +let inputFile = null, outFile = null, waitMs = 800; + +for (let i = 0; i < args.length; i++) { + if (args[i] === "--input" && args[i + 1]) { inputFile = args[++i]; } + else if (args[i] === "--out" && args[i + 1]) { outFile = args[++i]; } + else if (args[i] === "--wait" && args[i + 1]) { waitMs = parseInt(args[++i], 10); } +} + +if (!inputFile || !outFile) usage(); +if (!fs.existsSync(inputFile)) { + console.error(JSON.stringify({ status: "error", error: `File not found: ${inputFile}` })); + process.exit(1); +} + +// ── Playwright loader (tolerates global npm installs) ───────────────────────── +function loadPlaywright() { + const { execSync } = require("child_process"); + try { return require("playwright"); } catch (_) {} + try { + const root = execSync("npm root -g", { stdio: ["ignore","pipe","ignore"] }).toString().trim(); + return require(path.join(root, "playwright")); + } catch (_) {} + console.error(JSON.stringify({ + status: "error", + error: "playwright not found", + hint: "Run: npm install -g playwright && npx playwright install chromium" + })); + process.exit(2); +} + +// ── Main ─────────────────────────────────────────────────────────────────────── +(async () => { + const { chromium } = loadPlaywright(); + + let browser; + try { + browser = await chromium.launch(); + } catch (e) { + // Chromium binary missing — try installing + const { spawnSync } = require("child_process"); + const r = spawnSync("npx", ["playwright", "install", "chromium"], { stdio: "inherit", shell: true }); + if (r.status !== 0) { + console.error(JSON.stringify({ + status: "error", + error: "Chromium not installed and auto-install failed", + hint: "Run: npx playwright install chromium" + })); + process.exit(2); + } + browser = await chromium.launch(); + } + + try { + const page = await browser.newPage(); + const fileUrl = "file://" + path.resolve(inputFile); + await page.goto(fileUrl); + await page.waitForTimeout(waitMs); // let CSS + any JS settle + + await page.pdf({ + path: outFile, + width: "794px", + height: "1123px", + printBackground: true, + }); + + await browser.close(); + + // Basic sanity: output file must exist and be > 5 KB + const stat = fs.statSync(outFile); + if (stat.size < 5000) { + console.error(JSON.stringify({ + status: "error", + error: "Output PDF is suspiciously small — cover may be blank", + hint: "Check cover.html for render errors" + })); + process.exit(3); + } + + console.log(JSON.stringify({ + status: "ok", + out: outFile, + size_kb: Math.round(stat.size / 1024), + })); + + } catch (e) { + if (browser) await browser.close().catch(() => {}); + console.error(JSON.stringify({ status: "error", error: String(e) })); + process.exit(3); + } +})(); diff --git a/skills/pptx-generator/SKILL.md b/skills/pptx-generator/SKILL.md index 1739a22..995486c 100644 --- a/skills/pptx-generator/SKILL.md +++ b/skills/pptx-generator/SKILL.md @@ -1,6 +1,13 @@ --- name: pptx-generator -description: "Generate, edit, and read PowerPoint presentations. Create from scratch with PptxGenJS (cover, TOC, content, section divider, summary slides), edit existing PPTX via XML workflows, or extract text with markitdown. Triggers: PPT, PPTX, PowerPoint, presentation, slide, 幻灯片, 演示文稿." +description: "Generate, edit, and read PowerPoint presentations. Create from scratch with PptxGenJS (cover, TOC, content, section divider, summary slides), edit existing PPTX via XML workflows, or extract text with markitdown. Triggers: PPT, PPTX, PowerPoint, presentation, slide, deck, slides." +license: MIT +metadata: + version: "1.0" + category: productivity + sources: + - https://gitbrent.github.io/PptxGenJS/ + - https://github.com/microsoft/markitdown --- # PPTX Generator & Editor @@ -14,7 +21,7 @@ This skill handles all PowerPoint tasks: reading/analyzing existing presentation | Task | Approach | |------|----------| | Read/analyze content | `python -m markitdown presentation.pptx` | -| Edit or create from template | See [Editing Presentations](editing.md) | +| Edit or create from template | See [Editing Presentations](references/editing.md) | | Create from scratch | See [Creating from Scratch](#creating-from-scratch-workflow) below | | Item | Value | @@ -32,11 +39,11 @@ This skill handles all PowerPoint tasks: reading/analyzing existing presentation | File | Contents | |------|----------| -| [slide-types.md](slide-types.md) | 5 slide page types (Cover, TOC, Section Divider, Content, Summary) + additional layout patterns | -| [design-system.md](design-system.md) | Color palettes, font reference, style recipes (Sharp/Soft/Rounded/Pill), typography & spacing | -| [editing.md](editing.md) | Template-based editing workflow, XML manipulation, formatting rules, common pitfalls | -| [pitfalls.md](pitfalls.md) | QA process, common mistakes, critical PptxGenJS pitfalls | -| [pptxgenjs.md](pptxgenjs.md) | Complete PptxGenJS API reference | +| [slide-types.md](references/slide-types.md) | 5 slide page types (Cover, TOC, Section Divider, Content, Summary) + additional layout patterns | +| [design-system.md](references/design-system.md) | Color palettes, font reference, style recipes (Sharp/Soft/Rounded/Pill), typography & spacing | +| [editing.md](references/editing.md) | Template-based editing workflow, XML manipulation, formatting rules, common pitfalls | +| [pitfalls.md](references/pitfalls.md) | QA process, common mistakes, critical PptxGenJS pitfalls | +| [pptxgenjs.md](references/pptxgenjs.md) | Complete PptxGenJS API reference | --- @@ -59,19 +66,19 @@ Search to understand user requirements — topic, audience, purpose, tone, conte ### Step 2: Select Color Palette & Fonts -Use the [Color Palette Reference](design-system.md#color-palette-reference) to select a palette matching the topic and audience. Use the [Font Reference](design-system.md#font-reference) to choose a font pairing. +Use the [Color Palette Reference](references/design-system.md#color-palette-reference) to select a palette matching the topic and audience. Use the [Font Reference](references/design-system.md#font-reference) to choose a font pairing. ### Step 3: Select Design Style -Use the [Style Recipes](design-system.md#style-recipes--视觉风格系统) to choose a visual style (Sharp, Soft, Rounded, or Pill) matching the presentation tone. +Use the [Style Recipes](references/design-system.md#style-recipes) to choose a visual style (Sharp, Soft, Rounded, or Pill) matching the presentation tone. ### Step 4: Plan Slide Outline -Classify **every slide** as exactly one of the [5 page types](slide-types.md). Plan the content and layout for each slide. Ensure visual variety — do NOT repeat the same layout across slides. +Classify **every slide** as exactly one of the [5 page types](references/slide-types.md). Plan the content and layout for each slide. Ensure visual variety — do NOT repeat the same layout across slides. ### Step 5: Generate Slide JS Files -Create one JS file per slide in `slides/` directory. Each file must export a synchronous `createSlide(pres, theme)` function. Follow the [Slide Output Format](#slide-output-format) and the type-specific guidance in [slide-types.md](slide-types.md). Generate up to 5 slides concurrently using subagents if available. +Create one JS file per slide in `slides/` directory. Each file must export a synchronous `createSlide(pres, theme)` function. Follow the [Slide Output Format](#slide-output-format) and the type-specific guidance in [slide-types.md](references/slide-types.md). Generate up to 5 slides concurrently using subagents if available. **Tell each subagent:** 1. File naming: `slides/slide-01.js`, `slides/slide-02.js`, etc. @@ -81,7 +88,7 @@ Create one JS file per slide in `slides/` directory. Each file must export a syn 5. Fonts: Chinese = Microsoft YaHei, English = Arial (or approved alternative) 6. Colors: 6-char hex without # (e.g. `"FF0000"`) 7. Must use the theme object contract (see [Theme Object Contract](#theme-object-contract)) -8. Must follow the [PptxGenJS API reference](pptxgenjs.md) +8. Must follow the [PptxGenJS API reference](references/pptxgenjs.md) ### Step 6: Compile into Final PPTX @@ -114,7 +121,7 @@ Run with: `cd slides && node compile.js` ### Step 7: QA (Required) -See [QA Process](pitfalls.md#qa-process). +See [QA Process](references/pitfalls.md#qa-process). ### Output Structure diff --git a/skills/pptx-generator/design-system.md b/skills/pptx-generator/design-system.md deleted file mode 100644 index 15bb557..0000000 --- a/skills/pptx-generator/design-system.md +++ /dev/null @@ -1,392 +0,0 @@ -# Design System - -## Color Palette Reference - -| # | 名称 | 色值 | 风格 | 适用场景 | 建议 | -|---|------|------|------|----------|------| -| 1 | 现代与健康 | `#006d77` `#83c5be` `#edf6f9` `#ffddd2` `#e29578` | 清新、治愈 | 医疗健康、心理咨询、护肤品、瑜伽Spa | 深青做标题,浅粉做背景 | -| 2 | 商务与权威 | `#2b2d42` `#8d99ae` `#edf2f4` `#ef233c` `#d90429` | 严谨、经典 | 年度汇报、金融分析、企业介绍、政务报告 | 深蓝显专业,亮红强调数据 | -| 3 | 自然与户外 | `#606c38` `#283618` `#fefae0` `#dda15e` `#bc6c25` | 沉稳、大地色 | 户外用品、环境保护、农业项目、历史文化 | 深绿为底,米色为字 | -| 4 | 复古与学院 | `#780000` `#c1121f` `#fdf0d5` `#003049` `#669bbc` | 经典、书卷气 | 学术讲座、历史回顾、博物馆、复古品牌 | 深红与深蓝对比强烈 | -| 5 | 柔美与创意 | `#cdb4db` `#ffc8dd` `#ffafcc` `#bde0fe` `#a2d2ff` | 梦幻、糖果色 | 母婴产品、甜品店、女性时尚、幼儿园 | 文字用深灰或黑色 | -| 6 | 波西米亚 | `#ccd5ae` `#e9edc9` `#fefae0` `#faedcd` `#d4a373` | 温柔、低饱和 | 婚礼策划、家居软装、有机食品、慢生活 | 米色背景,绿棕点缀 | -| 7 | 活力与科技 | `#8ecae6` `#219ebc` `#023047` `#ffb703` `#fb8500` | 高能量、运动 | 体育赛事、健身房、创业路演、少儿教育 | 深蓝稳重心,橙色做焦点 | -| 8 | 匠心与手作 | `#7f5539` `#a68a64` `#ede0d4` `#656d4a` `#414833` | 质朴、咖啡调 | 咖啡店、手工艺品、传统文化、烘焙教学 | 适合纸质/皮革质感 | -| 9 | 科技与夜景 | `#000814` `#001d3d` `#003566` `#ffc300` `#ffd60a` | 深邃、高亮 | 科技发布会、星空天文、夜间经济、高端汽车 | 必须用深色模式 | -| 10 | 教育与图表 | `#264653` `#2a9d8f` `#e9c46a` `#f4a261` `#e76f51` | 清晰、逻辑强 | 统计报告、教育培训、市场分析、通用商务 | 完美的图表配色 | -| 11 | 森林与环保 | `#dad7cd` `#a3b18a` `#588157` `#3a5a40` `#344e41` | 单色渐变、森系 | 园林设计、ESG报告、环保公益、植物研究 | 单色系安全不会乱 | -| 12 | 优雅与时尚 | `#edafb8` `#f7e1d7` `#dedbd2` `#b0c4b1` `#4a5759` | 低饱和、莫兰迪 | 高定服装、艺术画廊、美妆品牌、杂志风 | 留白是关键 | -| 13 | 艺术与美食 | `#335c67` `#fff3b0` `#e09f3e` `#9e2a2b` `#540b0e` | 浓郁、复古画报 | 美食纪录片、艺术展、民族风情、复古餐厅 | 适合大色块拼接 | -| 14 | 轻奢与神秘 | `#22223b` `#4a4e69` `#9a8c98` `#c9ada7` `#f2e9e4` | 冷艳、紫调 | 珠宝展示、酒店管理、高端咨询、心理学 | 紫色营造高端氛围 | -| 15 | 纯净科技蓝 | `#03045e` `#0077b6` `#00b4d8` `#90e0ef` `#caf0f8` | 未来感、纯净 | 云计算/AI、水利海洋、医院医疗、洁净能源 | 从深海到天空的渐变 | -| 16 | 海岸珊瑚 | `#0081a7` `#00afb9` `#fdfcdc` `#fed9b7` `#f07167` | 清爽、夏日感 | 旅游度假、夏季活动、饮品品牌、海洋主题 | 青色与珊瑚色互补亮眼 | -| 17 | 活力橙薄荷 | `#ff9f1c` `#ffbf69` `#ffffff` `#cbf3f0` `#2ec4b6` | 明亮、欢快 | 儿童活动、促销海报、快消品、社交媒体 | 橙色吸睛,薄荷绿清爽 | -| 18 | 铂金白金 | `#0a0a0a` `#0070F3` `#D4AF37` `#f5f5f5` `#ffffff` | 高端、专业 | Agent产品、企业官网、金融科技、高端品牌 | 白金主调,蓝色行动,金色强调 | - ---- - -### Agent Design System — 完整色板 - -基于 tokens.css/ts 的 Platinum White-Gold Theme,提供完整色阶供精细设计使用。 - -#### White 白色系(背景与浅色表面) - -| 色阶 | 色值 | 用途 | -|------|------|------| -| white-0 | `#ffffff` | 主背景 | -| white-50 | `#fefefe` | 略带暖调的白 | -| white-75 | `#fcfcfc` | 微灰白 | -| white-100 | `#fafafa` | 次级背景 | -| white-200 | `#f7f7f7` | 卡片背景 | -| white-300 | `#f5f5f5` | 三级背景 | -| white-400 | `#f0f0f0` | 分隔区域 | -| white-500 | `#ebebeb` | 边框浅色 | -| white-600 | `#e5e5e5` | 禁用态背景 | -| white-700 | `#e0e0e0` | 深灰白 | -| white-800 | `#d9d9d9` | 占位符 | -| white-900 | `#d4d4d4` | 分隔线 | -| white-1000 | `#cccccc` | 最深白 | - -#### Gold 金色系(铂金商务强调色) - -| 色阶 | 色值 | 用途 | -|------|------|------| -| gold-25 | `#FFFDF5` | 极浅金背景 | -| gold-50 | `#FEF9E7` | 浅金背景 | -| gold-75 | `#FCF3D0` | 淡金高亮 | -| gold-100 | `#FAECB8` | 金色 hover 态 | -| gold-200 | `#F5DC8A` | 亮金强调 | -| gold-300 | `#E8C860` | 金色悬停 | -| gold-400 | `#D4AF37` | **主金色(核心)** | -| gold-500 | `#B8972E` | 金色文字 | -| gold-600 | `#9A7E26` | 深金强调 | -| gold-700 | `#7C651E` | 暗金边框 | -| gold-800 | `#5E4C16` | 深金背景 | -| gold-900 | `#40330F` | 极深金 | -| gold-1000 | `#221A08` | 黑金 | - -#### Blue 蓝色系(主操作色) - -| 色阶 | 色值 | 用途 | -|------|------|------| -| blue-25 | `#F0F7FF` | 极浅蓝背景 | -| blue-50 | `#E0EFFF` | 信息提示背景 | -| blue-75 | `#C2DFFF` | 浅蓝高亮 | -| blue-100 | `#A3CFFF` | 禁用态蓝 | -| blue-200 | `#66AFFF` | 亮蓝 | -| blue-300 | `#338FFF` | 蓝色悬停 | -| blue-400 | `#0070F3` | **主蓝色(核心)** | -| blue-500 | `#005FCC` | 蓝色文字 | -| blue-600 | `#004FA6` | 深蓝强调 | -| blue-700 | `#003F80` | 暗蓝边框 | -| blue-800 | `#002F5A` | 深蓝背景 | -| blue-900 | `#001F3D` | 极深蓝 | -| blue-1000 | `#001026` | 黑蓝 | - -#### Gray 灰色系(文字与中性色) - -| 色阶 | 色值 | 用途 | -|------|------|------| -| gray-0 | `#ffffff` | 白色 | -| gray-50 | `#fafafa` | 极浅灰 | -| gray-75 | `#f5f5f5` | 浅灰背景 | -| gray-100 | `#ededed` | 分隔线浅 | -| gray-200 | `#d4d4d4` | 边框浅 | -| gray-300 | `#a3a3a3` | 四级文字 | -| gray-400 | `#737373` | 三级文字 | -| gray-500 | `#525252` | 二级文字 | -| gray-600 | `#404040` | 深灰 | -| gray-700 | `#2e2e2e` | 暗色背景 | -| gray-800 | `#1f1f1f` | 深色背景 | -| gray-900 | `#141414` | 极深背景 | -| gray-1000 | `#0a0a0a` | **主文字色(核心)** | - -#### 透明度色值 - -##### Opacity Black(黑色透明) - -| 透明度 | 色值 | 用途 | -|--------|------|------| -| 0% | `#0a0a0a00` | 全透明 | -| 2% | `#0a0a0a05` | 微弱遮罩 | -| 4% | `#0a0a0a0a` | 次级交互背景 | -| 8% | `#0a0a0a14` | 边框/分隔 | -| 15% | `#0a0a0a26` | 按压态 | -| 20% | `#0a0a0a33` | 浅遮罩 | -| 25% | `#0a0a0a40` | 中遮罩 | -| 50% | `#0a0a0a80` | 半透明 | -| 70% | `#0a0a0ab2` | 深遮罩 | -| 80% | `#0a0a0acc` | 悬停态 | -| 90% | `#0a0a0ae5` | tooltip | -| 95% | `#0a0a0af2` | 弹窗 | - -##### Opacity White(白色透明) - -| 透明度 | 色值 | 用途 | -|--------|------|------| -| 0% | `#ffffff00` | 全透明 | -| 2% | `#ffffff05` | 微弱遮罩 | -| 4% | `#ffffff0a` | 次级交互背景 | -| 8% | `#ffffff12` | 边框/分隔 | -| 15% | `#ffffff26` | 按压态 | -| 20% | `#ffffff33` | 浅遮罩 | -| 25% | `#ffffff40` | 中遮罩 | -| 50% | `#ffffff80` | 半透明 | -| 70% | `#ffffffb2` | 深遮罩 | -| 80% | `#ffffffcc` | 悬停态 | -| 90% | `#ffffffe5` | tooltip | -| 95% | `#fffffff2` | 弹窗 | - ---- - -## Color Palette Rules (MANDATORY) - -### Strict Palette Adherence - -**Use ONLY the provided color palette. Do NOT create or modify colors.** - -- All colors must come from the user-provided palette -- Do NOT use colors outside the palette -- Do NOT modify palette colors (brightness, saturation, mixing) -- **Only exception**: Add transparency using the `transparency` property (0-100) - -```javascript -// Correct: Using palette colors -slide.addShape(pres.shapes.RECTANGLE, { fill: { color: theme.primary } }); -slide.addText("Title", { color: theme.accent }); - -// Wrong: Colors outside palette -slide.addShape(pres.shapes.RECTANGLE, { fill: { color: "1a1a2e" } }); -``` - -### No Gradients - -**Gradients are prohibited. Use solid colors only.** - -### No Animations - -**Animations and transitions are prohibited.** All slides must be static. - ---- - -## Font Reference - -### 推荐字体 - -| 语言 | 默认字体 | 可选字体 | -|------|----------|----------| -| **中文** | 微软雅黑 (Microsoft YaHei) | — | -| **英文** | Arial | Georgia, Calibri, Cambria, Trebuchet MS | - -- 中英文混排时:中文使用微软雅黑,英文使用所选字体 -- 优先使用系统自带字体,确保跨平台兼容 -- 标题和正文可使用不同字体配对(如 Georgia + Calibri) - -### Recommended Font Pairings - -| Header Font | Body Font | -|-------------|-----------| -| Georgia | Calibri | -| Arial Black | Arial | -| Calibri | Calibri Light | -| Cambria | Calibri | -| Trebuchet MS | Calibri | -| Impact | Arial | -| Palatino | Garamond | -| Consolas | Calibri | - -**Choose an interesting font pairing** — don't default to Arial for everything. Pick a header font with personality and pair it with a clean body font. - -### No Bold for Body Text - -**Plain body text and caption/legend text must NOT use bold.** - -- Body paragraphs, descriptions → normal weight -- Captions, legends, footnotes → normal weight -- Reserve bold for titles and headings only - -```javascript -// Correct -slide.addText("Main Title", { bold: true, fontSize: 36, fontFace: "Arial" }); -slide.addText("Body text here.", { bold: false, fontSize: 14, fontFace: "Arial" }); - -// Wrong -slide.addText("Body text here.", { bold: true, fontSize: 14 }); -``` - ---- - -## Style Recipes — 视觉风格系统 - -同一套设计可通过调整圆角(rectRadius)和间距呈现4种不同风格。根据场景选择合适的风格配方。 - -> **单位说明**: PptxGenJS 使用英寸(inch)作为单位。幻灯片尺寸为 10" x 5.625" (LAYOUT_16x9) - -### 风格概览 - -| 风格 | 圆角范围 | 间距范围 | 适合场景 | -|---|---|---|---| -| **Sharp & Compact** | 0 ~ 0.05" | 紧凑 | 数据密集型、表格、专业报告 | -| **Soft & Balanced** | 0.08" ~ 0.12" | 适中 | 企业汇报、商务演示、通用PPT | -| **Rounded & Spacious** | 0.15" ~ 0.25" | 宽松 | 产品介绍、营销演示、创意展示 | -| **Pill & Airy** | 0.3" ~ 0.5" | 通透 | 品牌展示、发布会、高端演示 | - -### Sharp & Compact(锐利紧凑) - -**视觉特征**: 方正、信息密度高、专业严肃感。 - -| 类别 | 值 (英寸) | 说明 | -|---|---|---| -| 圆角-小 | 0" | 完全直角 | -| 圆角-中 | 0.03" | 微圆角 | -| 圆角-大 | 0.05" | 小圆角 | -| 元素内边距 | 0.1" ~ 0.15" | 紧凑 | -| 元素间距 | 0.1" ~ 0.2" | 紧凑 | -| 页面边距 | 0.3" | 较窄 | -| 区块间距 | 0.25" ~ 0.35" | 紧凑 | - -### Soft & Balanced(柔和均衡) - -**视觉特征**: 适中的圆角、舒适的留白、专业又不失亲和。 - -| 类别 | 值 (英寸) | 说明 | -|---|---|---| -| 圆角-小 | 0.05" | 小圆角 | -| 圆角-中 | 0.08" | 中等圆角 | -| 圆角-大 | 0.12" | 较大圆角 | -| 元素内边距 | 0.15" ~ 0.2" | 适中 | -| 元素间距 | 0.15" ~ 0.25" | 适中 | -| 页面边距 | 0.4" | 标准 | -| 区块间距 | 0.35" ~ 0.5" | 适中 | - -### Rounded & Spacious(圆润宽松) - -**视觉特征**: 大圆角、充裕留白、友好亲切、现代感。 - -| 类别 | 值 (英寸) | 说明 | -|---|---|---| -| 圆角-小 | 0.1" | 中等圆角 | -| 圆角-中 | 0.15" | 大圆角 | -| 圆角-大 | 0.25" | 很大圆角 | -| 元素内边距 | 0.2" ~ 0.3" | 宽松 | -| 元素间距 | 0.25" ~ 0.4" | 宽松 | -| 页面边距 | 0.5" | 较宽 | -| 区块间距 | 0.5" ~ 0.7" | 宽松 | - -### Pill & Airy(胶囊通透) - -**视觉特征**: 全圆角胶囊形、大量留白、轻盈通透、品牌展示感强。 - -| 类别 | 值 (英寸) | 说明 | -|---|---|---| -| 圆角-小 | 0.2" | 大圆角 | -| 圆角-中 | 0.3" | 胶囊形 | -| 圆角-大 | 0.5" | 完全胶囊 | -| 元素内边距 | 0.25" ~ 0.4" | 通透 | -| 元素间距 | 0.3" ~ 0.5" | 通透 | -| 页面边距 | 0.6" | 宽 | -| 区块间距 | 0.6" ~ 0.9" | 通透 | - -### 组件级风格映射表 - -| 组件 | Sharp | Soft | Rounded | Pill | -|---|---|---|---|---| -| **按钮/标签** | rectRadius: 0 | rectRadius: 0.05 | rectRadius: 0.1 | rectRadius: 0.2 | -| **卡片/容器** | rectRadius: 0.03 | rectRadius: 0.1 | rectRadius: 0.2 | rectRadius: 0.3 | -| **图片容器** | rectRadius: 0 | rectRadius: 0.08 | rectRadius: 0.15 | rectRadius: 0.25 | -| **输入框形状** | rectRadius: 0 | rectRadius: 0.05 | rectRadius: 0.1 | rectRadius: 0.2 | -| **徽章/Badge** | rectRadius: 0.02 | rectRadius: 0.05 | rectRadius: 0.08 | rectRadius: 0.15 | -| **头像框** | rectRadius: 0 | rectRadius: 0.1 | rectRadius: 0.2 | rectRadius: 0.5 (圆形) | - -#### PptxGenJS 圆角示例 - -```javascript -// Sharp 风格卡片 -slide.addShape("rect", { - x: 0.5, y: 1, w: 4, h: 2.5, - fill: { color: "F5F5F5" }, - rectRadius: 0.03 -}); - -// Rounded 风格卡片 -slide.addShape("rect", { - x: 0.5, y: 1, w: 4, h: 2.5, - fill: { color: "F5F5F5" }, - rectRadius: 0.2 -}); - -// Pill 风格按钮 (高度0.4"时,rectRadius设为0.2"即为胶囊形) -slide.addShape("rect", { - x: 3, y: 4, w: 2, h: 0.4, - fill: { color: "4A90D9" }, - rectRadius: 0.2 -}); -``` - -### 混搭原则 - -#### 1. 外层容器 >= 内层圆角 - -```javascript -// 正确:外 > 内 -card: rectRadius: 0.2 -button: rectRadius: 0.1 - -// 错误:内 > 外 → 视觉溢出感 -card: rectRadius: 0.1 -button: rectRadius: 0.2 -``` - -#### 2. 信息密度决定间距 - -| 区域类型 | 推荐风格 | -|---|---| -| 数据展示区 | Sharp / Soft(紧凑间距) | -| 内容浏览区 | Rounded / Pill(宽松间距) | -| 标题区域 | Soft / Rounded(适中间距) | - -#### 3. 圆角与元素高度的关系 - -| 元素高度 | Sharp | Soft | Rounded | Pill | -|---|---|---|---|---| -| 小 (< 0.3") | 0" | 0.03" | 0.08" | 高度/2 | -| 中 (0.3" ~ 0.6") | 0.02" | 0.05" | 0.12" | 高度/2 | -| 大 (0.6" ~ 1.2") | 0.03" | 0.08" | 0.2" | 0.3" | -| 超大 (> 1.2") | 0.05" | 0.12" | 0.25" | 0.4" | - -> **Pill风格提示**: 要实现完美胶囊形,设置 `rectRadius = 元素高度 / 2` - -### Typography 排版规范 (PPT) - -| 用途 | 字号 (pt) | 说明 | -|---|---|---| -| 注释/来源 | 10 ~ 12 | 最小可读字号 | -| 正文/描述 | 14 ~ 16 | 标准正文 | -| 副标题 | 18 ~ 22 | 次要标题 | -| 标题 | 28 ~ 36 | 页面标题 | -| 大标题 | 44 ~ 60 | 封面/章节标题 | -| 数据高亮 | 60 ~ 96 | 关键数字展示 | - -### Spacing 间距规范 (PPT) - -基于10" x 5.625"幻灯片尺寸: - -| 用途 | 推荐值 (英寸) | -|---|---| -| 图标与文字间距 | 0.08" ~ 0.15" | -| 列表项间距 | 0.15" ~ 0.25" | -| 卡片内边距 | 0.2" ~ 0.4" | -| 元素组间距 | 0.3" ~ 0.5" | -| 页面安全边距 | 0.4" ~ 0.6" | -| 主要区块间距 | 0.5" ~ 0.8" | - -### 快速选择指南 - -| 演示类型 | 推荐风格 | 原因 | -|---|---|---| -| 财务/数据报告 | Sharp & Compact | 信息密度高,专业严谨 | -| 企业汇报/商务 | Soft & Balanced | 平衡专业与友好 | -| 产品介绍/营销 | Rounded & Spacious | 现代感,亲切感 | -| 发布会/品牌展示 | Pill & Airy | 高端感,视觉冲击 | -| 培训/教育 | Soft / Rounded | 清晰易读,友好 | -| 技术分享 | Sharp / Soft | 专业,信息清晰 | diff --git a/skills/pptx-generator/references/design-system.md b/skills/pptx-generator/references/design-system.md new file mode 100644 index 0000000..ea3fc75 --- /dev/null +++ b/skills/pptx-generator/references/design-system.md @@ -0,0 +1,392 @@ +# Design System + +## Color Palette Reference + +| # | Name | Colors | Style | Use Cases | Tips | +|---|------|--------|-------|-----------|------| +| 1 | Modern & Wellness | `#006d77` `#83c5be` `#edf6f9` `#ffddd2` `#e29578` | Fresh, soothing | Healthcare, counseling, skincare, yoga/spa | Deep teal for titles, light pink for background | +| 2 | Business & Authority | `#2b2d42` `#8d99ae` `#edf2f4` `#ef233c` `#d90429` | Formal, classic | Annual reports, financial analysis, corporate intro, government | Deep blue for professionalism, bright red to highlight data | +| 3 | Nature & Outdoors | `#606c38` `#283618` `#fefae0` `#dda15e` `#bc6c25` | Grounded, earthy | Outdoor gear, environmental, agriculture, historical culture | Dark green base, cream text | +| 4 | Vintage & Academic | `#780000` `#c1121f` `#fdf0d5` `#003049` `#669bbc` | Classic, scholarly | Academic lectures, history reviews, museums, heritage brands | Strong contrast between deep red and deep blue | +| 5 | Soft & Creative | `#cdb4db` `#ffc8dd` `#ffafcc` `#bde0fe` `#a2d2ff` | Dreamy, candy-toned | Mother & baby, desserts, women's fashion, kindergarten | Use dark gray or black for text | +| 6 | Bohemian | `#ccd5ae` `#e9edc9` `#fefae0` `#faedcd` `#d4a373` | Gentle, muted | Wedding planning, home decor, organic food, slow living | Cream background, green-brown accents | +| 7 | Vibrant & Tech | `#8ecae6` `#219ebc` `#023047` `#ffb703` `#fb8500` | High energy, sporty | Sports events, gyms, startup pitches, youth education | Deep blue for stability, orange as focal accent | +| 8 | Craft & Artisan | `#7f5539` `#a68a64` `#ede0d4` `#656d4a` `#414833` | Rustic, coffee-toned | Coffee shops, handicrafts, traditional culture, bakery | Suited for paper/leather textures | +| 9 | Tech & Night | `#000814` `#001d3d` `#003566` `#ffc300` `#ffd60a` | Deep, luminous | Tech launches, astronomy, night economy, luxury automobiles | Must use dark mode | +| 10 | Education & Charts | `#264653` `#2a9d8f` `#e9c46a` `#f4a261` `#e76f51` | Clear, logical | Statistical reports, education, market analysis, general business | Perfect chart color scheme | +| 11 | Forest & Eco | `#dad7cd` `#a3b18a` `#588157` `#3a5a40` `#344e41` | Monochrome gradient, forest | Landscape design, ESG reports, environmental causes, botanical | Monochrome palette is safe and cohesive | +| 12 | Elegant & Fashion | `#edafb8` `#f7e1d7` `#dedbd2` `#b0c4b1` `#4a5759` | Muted, Morandi tones | Haute couture, art galleries, beauty brands, magazine style | Negative space is key | +| 13 | Art & Food | `#335c67` `#fff3b0` `#e09f3e` `#9e2a2b` `#540b0e` | Rich, vintage-poster | Food documentaries, art exhibitions, ethnic themes, vintage restaurants | Works well with large color blocks | +| 14 | Luxury & Mysterious | `#22223b` `#4a4e69` `#9a8c98` `#c9ada7` `#f2e9e4` | Cool, purple-toned | Jewelry showcases, hotel management, high-end consulting, psychology | Purple evokes premium atmosphere | +| 15 | Pure Tech Blue | `#03045e` `#0077b6` `#00b4d8` `#90e0ef` `#caf0f8` | Futuristic, clean | Cloud/AI, water/ocean, hospitals, clean energy | Deep ocean to sky gradient | +| 16 | Coastal Coral | `#0081a7` `#00afb9` `#fdfcdc` `#fed9b7` `#f07167` | Refreshing, summery | Travel, summer events, beverage brands, ocean themes | Teal and coral as complementary focal colors | +| 17 | Vibrant Orange Mint | `#ff9f1c` `#ffbf69` `#ffffff` `#cbf3f0` `#2ec4b6` | Bright, cheerful | Children's events, promotional posters, FMCG, social media | Orange grabs attention, mint feels fresh | +| 18 | Platinum White Gold | `#0a0a0a` `#0070F3` `#D4AF37` `#f5f5f5` `#ffffff` | Premium, professional | Agent products, corporate websites, fintech, luxury brands | White-gold base, blue for action, gold for emphasis | + +--- + +### Agent Design System — Full Color Scale + +Based on the Platinum White-Gold Theme design tokens. Provides complete color scales for fine-grained design work. + +#### White Scale (Backgrounds & Light Surfaces) + +| Token | Value | Usage | +|-------|-------|-------| +| white-0 | `#ffffff` | Primary background | +| white-50 | `#fefefe` | Slightly warm white | +| white-75 | `#fcfcfc` | Near-white | +| white-100 | `#fafafa` | Secondary background | +| white-200 | `#f7f7f7` | Card background | +| white-300 | `#f5f5f5` | Tertiary background | +| white-400 | `#f0f0f0` | Separator zones | +| white-500 | `#ebebeb` | Light border | +| white-600 | `#e5e5e5` | Disabled background | +| white-700 | `#e0e0e0` | Deep white-gray | +| white-800 | `#d9d9d9` | Placeholder | +| white-900 | `#d4d4d4` | Divider lines | +| white-1000 | `#cccccc` | Deepest white | + +#### Gold Scale (Platinum Business Accent) + +| Token | Value | Usage | +|-------|-------|-------| +| gold-25 | `#FFFDF5` | Extremely light gold background | +| gold-50 | `#FEF9E7` | Light gold background | +| gold-75 | `#FCF3D0` | Pale gold highlight | +| gold-100 | `#FAECB8` | Gold hover state | +| gold-200 | `#F5DC8A` | Bright gold accent | +| gold-300 | `#E8C860` | Gold hover | +| gold-400 | `#D4AF37` | **Primary gold (core)** | +| gold-500 | `#B8972E` | Gold text | +| gold-600 | `#9A7E26` | Deep gold accent | +| gold-700 | `#7C651E` | Dark gold border | +| gold-800 | `#5E4C16` | Deep gold background | +| gold-900 | `#40330F` | Very deep gold | +| gold-1000 | `#221A08` | Black gold | + +#### Blue Scale (Primary Action Color) + +| Token | Value | Usage | +|-------|-------|-------| +| blue-25 | `#F0F7FF` | Extremely light blue background | +| blue-50 | `#E0EFFF` | Info alert background | +| blue-75 | `#C2DFFF` | Light blue highlight | +| blue-100 | `#A3CFFF` | Disabled blue | +| blue-200 | `#66AFFF` | Bright blue | +| blue-300 | `#338FFF` | Blue hover | +| blue-400 | `#0070F3` | **Primary blue (core)** | +| blue-500 | `#005FCC` | Blue text | +| blue-600 | `#004FA6` | Deep blue accent | +| blue-700 | `#003F80` | Dark blue border | +| blue-800 | `#002F5A` | Deep blue background | +| blue-900 | `#001F3D` | Very deep blue | +| blue-1000 | `#001026` | Black blue | + +#### Gray Scale (Text & Neutral Colors) + +| Token | Value | Usage | +|-------|-------|-------| +| gray-0 | `#ffffff` | White | +| gray-50 | `#fafafa` | Extremely light gray | +| gray-75 | `#f5f5f5` | Light gray background | +| gray-100 | `#ededed` | Light divider | +| gray-200 | `#d4d4d4` | Light border | +| gray-300 | `#a3a3a3` | Quaternary text | +| gray-400 | `#737373` | Tertiary text | +| gray-500 | `#525252` | Secondary text | +| gray-600 | `#404040` | Dark gray | +| gray-700 | `#2e2e2e` | Dark background | +| gray-800 | `#1f1f1f` | Deep background | +| gray-900 | `#141414` | Very deep background | +| gray-1000 | `#0a0a0a` | **Primary text (core)** | + +#### Opacity Values + +##### Opacity Black + +| Opacity | Value | Usage | +|---------|-------|-------| +| 0% | `#0a0a0a00` | Fully transparent | +| 2% | `#0a0a0a05` | Subtle overlay | +| 4% | `#0a0a0a0a` | Secondary interactive background | +| 8% | `#0a0a0a14` | Border / divider | +| 15% | `#0a0a0a26` | Pressed state | +| 20% | `#0a0a0a33` | Light overlay | +| 25% | `#0a0a0a40` | Medium overlay | +| 50% | `#0a0a0a80` | Semi-transparent | +| 70% | `#0a0a0ab2` | Deep overlay | +| 80% | `#0a0a0acc` | Hover state | +| 90% | `#0a0a0ae5` | Tooltip | +| 95% | `#0a0a0af2` | Modal | + +##### Opacity White + +| Opacity | Value | Usage | +|---------|-------|-------| +| 0% | `#ffffff00` | Fully transparent | +| 2% | `#ffffff05` | Subtle overlay | +| 4% | `#ffffff0a` | Secondary interactive background | +| 8% | `#ffffff12` | Border / divider | +| 15% | `#ffffff26` | Pressed state | +| 20% | `#ffffff33` | Light overlay | +| 25% | `#ffffff40` | Medium overlay | +| 50% | `#ffffff80` | Semi-transparent | +| 70% | `#ffffffb2` | Deep overlay | +| 80% | `#ffffffcc` | Hover state | +| 90% | `#ffffffe5` | Tooltip | +| 95% | `#fffffff2` | Modal | + +--- + +## Color Palette Rules (MANDATORY) + +### Strict Palette Adherence + +**Use ONLY the provided color palette. Do NOT create or modify colors.** + +- All colors must come from the user-provided palette +- Do NOT use colors outside the palette +- Do NOT modify palette colors (brightness, saturation, mixing) +- **Only exception**: Add transparency using the `transparency` property (0-100) + +```javascript +// Correct: Using palette colors +slide.addShape(pres.shapes.RECTANGLE, { fill: { color: theme.primary } }); +slide.addText("Title", { color: theme.accent }); + +// Wrong: Colors outside palette +slide.addShape(pres.shapes.RECTANGLE, { fill: { color: "1a1a2e" } }); +``` + +### No Gradients + +**Gradients are prohibited. Use solid colors only.** + +### No Animations + +**Animations and transitions are prohibited.** All slides must be static. + +--- + +## Font Reference + +### Recommended Fonts + +| Language | Default Font | Alternatives | +|----------|-------------|--------------| +| **Chinese** | Microsoft YaHei | — | +| **English** | Arial | Georgia, Calibri, Cambria, Trebuchet MS | + +- For mixed Chinese-English content: use Microsoft YaHei for Chinese, the chosen font for English +- Prefer system fonts for cross-platform compatibility +- Titles and body text can use different font pairings (e.g. Georgia + Calibri) + +### Recommended Font Pairings + +| Header Font | Body Font | +|-------------|-----------| +| Georgia | Calibri | +| Arial Black | Arial | +| Calibri | Calibri Light | +| Cambria | Calibri | +| Trebuchet MS | Calibri | +| Impact | Arial | +| Palatino | Garamond | +| Consolas | Calibri | + +**Choose an interesting font pairing** — don't default to Arial for everything. Pick a header font with personality and pair it with a clean body font. + +### No Bold for Body Text + +**Plain body text and caption/legend text must NOT use bold.** + +- Body paragraphs, descriptions → normal weight +- Captions, legends, footnotes → normal weight +- Reserve bold for titles and headings only + +```javascript +// Correct +slide.addText("Main Title", { bold: true, fontSize: 36, fontFace: "Arial" }); +slide.addText("Body text here.", { bold: false, fontSize: 14, fontFace: "Arial" }); + +// Wrong +slide.addText("Body text here.", { bold: true, fontSize: 14 }); +``` + +--- + +## Style Recipes + +The same design can be rendered in 4 distinct visual styles by adjusting corner radius (`rectRadius`) and spacing. Choose the style recipe that fits the presentation tone. + +> **Unit note**: PptxGenJS uses inches. Slide dimensions are 10" x 5.625" (LAYOUT_16x9). + +### Style Overview + +| Style | Corner Radius | Spacing | Best For | +|-------|--------------|---------|----------| +| **Sharp & Compact** | 0 ~ 0.05" | Tight | Data-dense, tables, professional reports | +| **Soft & Balanced** | 0.08" ~ 0.12" | Moderate | Corporate, business presentations, general use | +| **Rounded & Spacious** | 0.15" ~ 0.25" | Relaxed | Product intros, marketing, creative showcases | +| **Pill & Airy** | 0.3" ~ 0.5" | Open | Brand showcases, launch events, premium presentations | + +### Sharp & Compact + +**Visual character**: Geometric, high information density, formal and serious. + +| Category | Value (inches) | Notes | +|----------|---------------|-------| +| Corner radius — small | 0" | Full right angle | +| Corner radius — medium | 0.03" | Micro-rounded | +| Corner radius — large | 0.05" | Slight rounding | +| Element padding | 0.1" ~ 0.15" | Compact | +| Element gap | 0.1" ~ 0.2" | Compact | +| Page margin | 0.3" | Narrow | +| Block gap | 0.25" ~ 0.35" | Compact | + +### Soft & Balanced + +**Visual character**: Moderate rounding, comfortable whitespace, professional yet approachable. + +| Category | Value (inches) | Notes | +|----------|---------------|-------| +| Corner radius — small | 0.05" | Slight rounding | +| Corner radius — medium | 0.08" | Medium rounding | +| Corner radius — large | 0.12" | Larger rounding | +| Element padding | 0.15" ~ 0.2" | Moderate | +| Element gap | 0.15" ~ 0.25" | Moderate | +| Page margin | 0.4" | Standard | +| Block gap | 0.35" ~ 0.5" | Moderate | + +### Rounded & Spacious + +**Visual character**: Large corners, generous whitespace, friendly and modern. + +| Category | Value (inches) | Notes | +|----------|---------------|-------| +| Corner radius — small | 0.1" | Medium rounding | +| Corner radius — medium | 0.15" | Large rounding | +| Corner radius — large | 0.25" | Very large rounding | +| Element padding | 0.2" ~ 0.3" | Relaxed | +| Element gap | 0.25" ~ 0.4" | Relaxed | +| Page margin | 0.5" | Wide | +| Block gap | 0.5" ~ 0.7" | Relaxed | + +### Pill & Airy + +**Visual character**: Full pill-shaped corners, abundant whitespace, light and open feel, strong brand presence. + +| Category | Value (inches) | Notes | +|----------|---------------|-------| +| Corner radius — small | 0.2" | Large rounding | +| Corner radius — medium | 0.3" | Pill shape | +| Corner radius — large | 0.5" | Full pill | +| Element padding | 0.25" ~ 0.4" | Open | +| Element gap | 0.3" ~ 0.5" | Open | +| Page margin | 0.6" | Wide | +| Block gap | 0.6" ~ 0.9" | Open | + +### Component Style Mapping + +| Component | Sharp | Soft | Rounded | Pill | +|-----------|-------|------|---------|------| +| **Button / Tag** | rectRadius: 0 | rectRadius: 0.05 | rectRadius: 0.1 | rectRadius: 0.2 | +| **Card / Container** | rectRadius: 0.03 | rectRadius: 0.1 | rectRadius: 0.2 | rectRadius: 0.3 | +| **Image Container** | rectRadius: 0 | rectRadius: 0.08 | rectRadius: 0.15 | rectRadius: 0.25 | +| **Input Field** | rectRadius: 0 | rectRadius: 0.05 | rectRadius: 0.1 | rectRadius: 0.2 | +| **Badge** | rectRadius: 0.02 | rectRadius: 0.05 | rectRadius: 0.08 | rectRadius: 0.15 | +| **Avatar Frame** | rectRadius: 0 | rectRadius: 0.1 | rectRadius: 0.2 | rectRadius: 0.5 (circle) | + +#### PptxGenJS Corner Radius Examples + +```javascript +// Sharp style card +slide.addShape("rect", { + x: 0.5, y: 1, w: 4, h: 2.5, + fill: { color: "F5F5F5" }, + rectRadius: 0.03 +}); + +// Rounded style card +slide.addShape("rect", { + x: 0.5, y: 1, w: 4, h: 2.5, + fill: { color: "F5F5F5" }, + rectRadius: 0.2 +}); + +// Pill style button (height 0.4", rectRadius 0.2" = perfect pill) +slide.addShape("rect", { + x: 3, y: 4, w: 2, h: 0.4, + fill: { color: "4A90D9" }, + rectRadius: 0.2 +}); +``` + +### Mixing Rules + +#### 1. Outer container corner >= inner element corner + +```javascript +// Correct: outer > inner +card: rectRadius: 0.2 +button: rectRadius: 0.1 + +// Wrong: inner > outer → visual overflow effect +card: rectRadius: 0.1 +button: rectRadius: 0.2 +``` + +#### 2. Information density drives spacing + +| Zone Type | Recommended Style | +|-----------|------------------| +| Data display zone | Sharp / Soft (compact spacing) | +| Content browsing zone | Rounded / Pill (relaxed spacing) | +| Title zone | Soft / Rounded (moderate spacing) | + +#### 3. Corner radius vs element height + +| Element Height | Sharp | Soft | Rounded | Pill | +|---------------|-------|------|---------|------| +| Small (< 0.3") | 0" | 0.03" | 0.08" | height/2 | +| Medium (0.3" ~ 0.6") | 0.02" | 0.05" | 0.12" | height/2 | +| Large (0.6" ~ 1.2") | 0.03" | 0.08" | 0.2" | 0.3" | +| Extra large (> 1.2") | 0.05" | 0.12" | 0.25" | 0.4" | + +> **Pill tip**: For a perfect pill shape, set `rectRadius = element height / 2` + +### Typography Scale (PPT) + +| Usage | Size (pt) | Notes | +|-------|-----------|-------| +| Annotations / Sources | 10 ~ 12 | Minimum readable size | +| Body / Description | 14 ~ 16 | Standard body | +| Subtitle | 18 ~ 22 | Secondary heading | +| Title | 28 ~ 36 | Page title | +| Large Title | 44 ~ 60 | Cover / section title | +| Data Callout | 60 ~ 96 | Key number display | + +### Spacing Scale (PPT) + +Based on 10" x 5.625" slide dimensions: + +| Usage | Recommended (inches) | +|-------|---------------------| +| Icon-to-text gap | 0.08" ~ 0.15" | +| List item spacing | 0.15" ~ 0.25" | +| Card inner padding | 0.2" ~ 0.4" | +| Element group gap | 0.3" ~ 0.5" | +| Page safe margin | 0.4" ~ 0.6" | +| Major block gap | 0.5" ~ 0.8" | + +### Quick Selection Guide + +| Presentation Type | Recommended Style | Reason | +|------------------|------------------|--------| +| Finance / Data reports | Sharp & Compact | High density, serious and precise | +| Corporate / Business | Soft & Balanced | Balances professionalism and approachability | +| Product intro / Marketing | Rounded & Spacious | Modern feel, friendly | +| Launch events / Brand | Pill & Airy | Premium feel, visual impact | +| Training / Education | Soft / Rounded | Clear, readable, friendly | +| Tech sharing | Sharp / Soft | Professional, information-dense | diff --git a/skills/pptx-generator/editing.md b/skills/pptx-generator/references/editing.md similarity index 100% rename from skills/pptx-generator/editing.md rename to skills/pptx-generator/references/editing.md diff --git a/skills/pptx-generator/pitfalls.md b/skills/pptx-generator/references/pitfalls.md similarity index 100% rename from skills/pptx-generator/pitfalls.md rename to skills/pptx-generator/references/pitfalls.md diff --git a/skills/pptx-generator/pptxgenjs.md b/skills/pptx-generator/references/pptxgenjs.md similarity index 100% rename from skills/pptx-generator/pptxgenjs.md rename to skills/pptx-generator/references/pptxgenjs.md diff --git a/skills/pptx-generator/slide-types.md b/skills/pptx-generator/references/slide-types.md similarity index 98% rename from skills/pptx-generator/slide-types.md rename to skills/pptx-generator/references/slide-types.md index eb295ca..699560c 100644 --- a/skills/pptx-generator/slide-types.md +++ b/skills/pptx-generator/references/slide-types.md @@ -130,7 +130,7 @@ Consider: Purpose (corporate/educational/creative), Audience, Tone, Content Volu 4. **Section Descriptions** — Optional one-line summaries 5. **Visual Separators** — SVG dividers or spacing 6. **Decorative Elements** — Subtle accent shapes -7. **Page Number Badge (角标)** — **MANDATORY** +7. **Page Number Badge** — **MANDATORY** ### Design Decisions @@ -205,7 +205,7 @@ Consider: Purpose (corporate/educational/creative), Audience, Tone, Content Volu 2. **Section Title** — Always required. Clear, concise. 3. **Intro Text** — Optional 1-2 line description. 4. **Decorative Elements** — SVG accent shapes (bars, lines, geometric blocks). -5. **Page Number Badge (角标)** — **MANDATORY**. +5. **Page Number Badge** — **MANDATORY**. ### Design Decisions @@ -300,7 +300,7 @@ Pick a subtype based on the content. Each content slide belongs to exactly ONE s 2. **Body Content** — Text, bullets, data, or comparisons based on subtype 3. **Visual Element** — Image, chart, icon, or SVG shape — always required 4. **Source / Caption** — When showing data or external content -5. **Page Number Badge (角标)** — **MANDATORY** +5. **Page Number Badge** — **MANDATORY** ### Design Decisions @@ -379,7 +379,7 @@ Pick a subtype based on the content. Each content slide belongs to exactly ONE s 3. **Call to Action** — Clear next steps (if applicable) 4. **Contact Info** — Email, website, social handles (if provided) 5. **Decorative Elements** — SVG accents for visual consistency -6. **Page Number Badge (角标)** — **MANDATORY** +6. **Page Number Badge** — **MANDATORY** ### Design Decisions