
Minimax Pdf
- 8 installs
- 33 repo stars
- Updated April 26, 2026
- bighardperson/computer-science-skills-collection
This is a copy of minimax-pdf by minimax-ai - installs and ranking accrue to the original listing.
minimax-pdf is a Claude skill that generates designed print-ready PDFs from structured content and can also fill PDF form fields or reformat existing documents.
About
minimax-pdf is a skill that generates designed, print-ready PDFs from structured content, fills form fields in existing PDFs, and reformats existing documents into a styled PDF. It routes user intent into three paths (CREATE, FILL, REFORMAT) and derives color, typography, and spacing from the document type. A developer uses it when the visual quality of a PDF matters, not just producing any PDF.
- Generates print-ready PDFs from a content.json using a token-based design system
- Fills existing PDF form fields and reformats/re-styles existing documents
- 15 doc types (report, proposal, resume, portfolio, poster, etc.) each with its own cover pattern and typography
Minimax Pdf by the numbers
- 8 all-time installs (skills.sh)
- Data as of Jul 30, 2026 (Skillselion catalog sync)
minimax-pdf capabilities & compatibility
- Capabilities
- pdf generation · form fill · document reformat
- Use cases
- pdf parsing · documentation · resume
- Pricing
- Free
What minimax-pdf says it does
This skill uses a token-based design system: color, typography, and spacing are derived
Fill / complete form fields in an existing PDF
Reformat / re-style an existing document
npx skills add https://github.com/bighardperson/computer-science-skills-collection --skill minimax-pdfAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 8 |
|---|---|
| repo stars | ★ 33 |
| Last updated | April 26, 2026 |
| Repository | bighardperson/computer-science-skills-collection ↗ |
What it does
Produce a designed, print-ready PDF report, proposal, or resume from structured content, or fill and re-style existing PDFs.
Who is it for?
Producing polished, print-ready PDFs (reports, proposals, resumes, portfolios) where appearance matters.
Skip if: Cases where any plain PDF output is sufficient and design identity does not matter.
When should I use this skill?
The user asks to make a PDF, generate a report/proposal/resume, fill out a PDF form, or reformat/re-style an existing document.
What you get
A print-ready PDF whose color, typography, and spacing flow consistently from the document type across every page.
- Print-ready PDF
- Filled PDF form
- Re-styled/reformatted PDF
By the numbers
- 15 document types
- 3 routes (CREATE, FILL, REFORMAT)
Files
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 scripts/make.sh run \
--title "Q3 Strategy Review" --type proposal \
--author "Strategy Team" --date "October 2025" \
--accent "#2D5F8A" \
--content content.json --out report.pdfDoc 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_ltis 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 <b> <i> 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:
{"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.
# 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 scripts/make.sh reformat \
--input source.md --title "My Report" --type report --out output.pdfSupported input formats: .md .txt .pdf .json
---
Environment
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 |
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_imageURL renders as centered hero thumbnail - Optional
abstractfield: 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
magazinebut 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 // <date> - 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_imagerendered 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 <img src="..."> — 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:
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 <canvas>. 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 <b> <i> <font> 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 alabelandvaluesarray. - Figure auto-numbering: set
"figure": true(default) or"figure": falseto 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
idfield 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
titlefield defaults to "References". Set"title": ""to suppress the heading. - A
bibliographyblock 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
figureblocks auto-number;imageblocks do not — usefigurefor 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
languagefield 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
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 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 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:
[
{"type": "h1", "text": "Section Title"},
{"type": "h2", "text": "Subsection"},
{"type": "h3", "text": "Sub-subsection"},
{"type": "body", "text": "Paragraph. Supports <b>bold</b> and <i>italic</i>."},
{"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
# 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.jsonField value rules:
text→ any stringcheckbox→"true"or"false"dropdown→ must match a choice value shown by--inspectradio→ must match a radio value shown by--inspect
---
Route C: REFORMAT — apply design to an existing document
bash scripts/make.sh reformat \
--input source.md \
--title "Annual Report" \
--type "report" \
--author "Research Team" \
--out output.pdfSupported 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
[
{"type": "h1", "text": "Section Title"},
{"type": "h2", "text": "Subsection"},
{"type": "h3", "text": "Sub-subsection"},
{"type": "body", "text": "Paragraph text. Supports <b>bold</b> and <i>italic</i>."},
{"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 reportDesign 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
#!/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'<circle cx="{cx}" cy="{cy}" r="{r}" fill="{color}"/>')
return (
f'<svg style="position:absolute;top:0;left:0;width:794px;height:1123px;'
f'pointer-events:none;opacity:{opacity}" xmlns="http://www.w3.org/2000/svg">'
+ "".join(dots) + "</svg>"
)
# ── 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'<line x1="{x}" y1="0" x2="{x + 1200}" y2="1200" stroke="{color}" stroke-width="{stroke_w}"/>')
return (
f'<svg style="position:absolute;top:0;left:0;width:794px;height:1123px;'
f'pointer-events:none;opacity:{opacity};overflow:hidden" xmlns="http://www.w3.org/2000/svg">'
+ "".join(lines) + "</svg>"
)
# ── 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"""
<div style="font-size:14px;color:{t['muted']};letter-spacing:0.01em;
max-width:480px;line-height:1.5;margin-bottom:40px;">
{t['subtitle']}
</div>"""
return f"""<!DOCTYPE html>
<html>
<head><meta charset="UTF-8">
<style>
{_base_css(t)}
.label {{
font-size: 9px; font-weight: 500; letter-spacing: 0.22em;
color: {t['accent']}; text-transform: uppercase; margin-bottom: 28px;
}}
.title {{
font-family: '{t['font_display']}', 'Times New Roman', Georgia, serif;
font-weight: 900; font-size: 60px; line-height: 1.0;
color: {t['text_light']}; letter-spacing: -0.015em;
margin-bottom: 10px; max-width: 560px;
word-wrap: break-word;
}}
.rule {{
width: 52%; height: 1.5px;
background: linear-gradient(to right, {t['accent']}, transparent);
margin: 24px 0 20px;
}}
.content {{
position: absolute; left: 68px; right: 60px;
top: 0; bottom: 0;
display: flex; flex-direction: column; justify-content: center;
padding-top: 60px;
}}
.footer {{
position: absolute; bottom: 0; left: 0; right: 0;
height: 70px;
background: rgba(0,0,0,0.22);
display: flex; align-items: center;
justify-content: space-between;
padding: 0 68px;
}}
.footer-author {{ font-size: 11px; color: rgba(240,237,230,0.75); letter-spacing:0.04em; }}
.footer-date {{ font-size: 11px; color: {t['muted']}; letter-spacing: 0.04em; }}
</style>
</head>
<body>
<div class="page">
<!-- top-right accent strip -->
<div style="position:absolute;top:0;right:0;width:35%;height:4px;background:{t['accent']};"></div>
<!-- left vertical accent bar (gradient fade) -->
<div style="position:absolute;left:48px;top:18%;width:3px;height:60%;
background:linear-gradient(to bottom,{t['accent']},transparent);"></div>
<!-- dot grid background texture -->
{dot_grid}
<div class="content">
<div class="label">{t.get('doc_type','Document').upper()} · {t.get('date','')}</div>
<div class="title">{t['title']}</div>
<div class="rule"></div>
{subtitle_block}
</div>
<div class="footer">
<div class="footer-author">{t.get('author','')}</div>
<div class="footer-date">{t.get('date','')}</div>
</div>
</div>
</body></html>"""
# ── 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"""<!DOCTYPE html>
<html>
<head><meta charset="UTF-8">
<style>
{_base_css(t)}
.left-panel {{
position: absolute; top: 0; left: 0;
width: 330px; height: 1123px;
background: {t['cover_bg']};
display: flex; flex-direction: column;
justify-content: center;
padding: 0 44px;
}}
.right-panel {{
position: absolute; top: 0; left: 330px;
width: 464px; height: 1123px;
background: {t['page_bg']};
}}
.divider {{
position: absolute; top: 0; left: 329px;
width: 3px; height: 1123px;
background: {t['accent']};
}}
.left-top-bar {{
position: absolute; top: 0; left: 0;
width: 330px; height: 4px;
background: {t['accent']};
}}
.title {{
font-family: '{t['font_display']}', 'Times New Roman', serif;
font-weight: 900; font-size: 34px; line-height: 1.2;
color: {t['text_light']}; margin-bottom: 18px;
word-wrap: break-word;
}}
.rule {{
width: 55%; height: 1.5px;
background: {t['accent']};
margin-bottom: 14px;
}}
.subtitle {{
font-size: 12px; color: rgba(220,220,220,0.65);
line-height: 1.5; margin-bottom: 32px;
}}
.author {{
font-size: 11px; color: {t['text_light']}; margin-bottom: 4px;
}}
.date {{ font-size: 10px; color: {t['muted']}; }}
.right-label {{
position: absolute; bottom: 60px; right: 44px;
font-size: 9px; letter-spacing: 0.18em;
color: {t['muted']}; text-transform: uppercase;
}}
</style>
</head>
<body>
<div class="page">
<div class="left-top-bar"></div>
<div class="left-panel">
<div class="title">{t['title']}</div>
<div class="rule"></div>
{'<div class="subtitle">' + t['subtitle'] + '</div>' if t.get('subtitle') else ''}
<div class="author">{t.get('author','')}</div>
<div class="date">{t.get('date','')}</div>
</div>
<div class="right-panel">
{dot_grid}
</div>
<div class="divider"></div>
<div class="right-label">{t.get('doc_type','').upper()}</div>
</div>
</body></html>"""
# ── 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"""<!DOCTYPE html>
<html>
<head><meta charset="UTF-8">
<style>
{_base_css(t)}
html, body {{ background: {t['page_bg']}; }}
.page {{ background: {t['page_bg']}; }}
.content {{
position: absolute; left: 60px; top: 0; bottom: 0; right: 60px;
display: flex; flex-direction: column; justify-content: center;
}}
.first-word {{
font-family: '{t['font_display']}', 'Times New Roman', serif;
font-weight: 900; font-size: 72px; line-height: 1.0;
color: {t['accent']}; letter-spacing: -0.02em;
}}
.rest-words {{
font-family: '{t['font_display']}', 'Times New Roman', serif;
font-weight: 900; font-size: 72px; line-height: 1.0;
color: {t['dark']}; letter-spacing: -0.02em;
margin-bottom: 12px;
}}
.rule {{
width: 100%; height: 1.5px;
background: linear-gradient(to right, {t['accent']}, {t['accent']}40);
margin: 28px 0 20px;
}}
.meta-row {{
display: flex; justify-content: space-between; align-items: baseline;
}}
.author {{ font-size: 13px; color: {t['dark']}; letter-spacing: 0.02em; }}
.date {{ font-size: 12px; color: {t['muted']}; }}
.subtitle {{ font-size: 13px; color: {t['muted']}; margin-top: 8px; max-width: 500px; }}
</style>
</head>
<body>
<div class="page">
<div class="content">
<div class="first-word">{first}</div>
{'<div class="rest-words">' + rest + '</div>' if rest else ''}
<div class="rule"></div>
<div class="meta-row">
<div class="author">{t.get('author','')}</div>
<div class="date">{t.get('date','')}</div>
</div>
{'<div class="subtitle">' + t['subtitle'] + '</div>' if t.get('subtitle') else ''}
</div>
</div>
</body></html>"""
# ── 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"""<!DOCTYPE html>
<html>
<head><meta charset="UTF-8">
<style>
{_base_css(t)}
.glow {{
position: absolute;
top: -100px; right: -80px;
width: 500px; height: 500px;
background: radial-gradient(circle, {t['accent']}2E 0%, transparent 68%);
border-radius: 50%;
}}
.glow2 {{
position: absolute;
bottom: -40px; left: 10%;
width: 300px; height: 300px;
background: radial-gradient(circle, {t['accent']}14 0%, transparent 70%);
border-radius: 50%;
}}
.content {{
position: absolute; left: 64px; right: 80px;
top: 0; bottom: 0;
display: flex; flex-direction: column; justify-content: center;
}}
.label {{
font-size: 9px; letter-spacing: 0.22em;
color: {t['accent']}; text-transform: uppercase; margin-bottom: 32px;
}}
.title {{
font-family: '{t['font_display']}', 'Times New Roman', serif;
font-weight: 900; font-size: 50px; line-height: 1.05;
color: {t['text_light']}; max-width: 520px;
word-wrap: break-word; margin-bottom: 12px;
}}
.rule {{ width: 48px; height: 2px; background: {t['accent']}; margin: 24px 0 20px; }}
.subtitle {{
font-size: 13px; color: {t['muted']}; line-height: 1.6;
max-width: 400px; margin-bottom: 40px;
}}
.footer {{
position: absolute; bottom: 0; left: 0; right: 0; height: 64px;
border-top: 1px solid rgba(255,255,255,0.06);
display: flex; align-items: center; justify-content: space-between;
padding: 0 64px;
}}
.footer-l {{ font-size: 10.5px; color: rgba(240,237,230,0.6); }}
.footer-r {{ font-size: 10.5px; color: {t['muted']}; }}
</style>
</head>
<body>
<div class="page">
<div class="glow"></div>
<div class="glow2"></div>
{dot_grid}
<div style="position:absolute;top:0;right:0;width:30%;height:3px;background:{t['accent']};"></div>
<div class="content">
<div class="label">{t.get('doc_type','').upper()} · {t.get('date','')}</div>
<div class="title">{t['title']}</div>
<div class="rule"></div>
{'<div class="subtitle">' + t['subtitle'] + '</div>' if t.get('subtitle') else ''}
</div>
<div class="footer">
<div class="footer-l">{t.get('author','')}</div>
<div class="footer-r">{t.get('date','')}</div>
</div>
</div>
</body></html>"""
# ── 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'<div class="subtitle">{t["subtitle"]}</div>'
return f"""<!DOCTYPE html>
<html>
<head><meta charset="UTF-8">
<style>
{_base_css(t)}
html, body {{ background: {t['page_bg']}; }}
.page {{ background: {t['page_bg']}; }}
/* Left accent bar — the only color element */
.bar {{
position: absolute;
top: 0; left: 0;
width: 8px; height: 1123px;
background: {accent};
}}
/* Main content column — offset from bar */
.content {{
position: absolute;
left: 64px; right: 64px;
top: 0; bottom: 0;
display: flex;
flex-direction: column;
justify-content: center;
padding-bottom: 40px;
}}
.eyebrow {{
font-size: 9px;
font-weight: 500;
letter-spacing: 0.28em;
text-transform: uppercase;
color: {accent};
margin-bottom: 36px;
}}
.title {{
font-family: '{t['font_display']}', Georgia, 'Times New Roman', serif;
font-weight: 300;
font-size: 72px;
line-height: 1.0;
color: {text_dark};
letter-spacing: -0.02em;
max-width: 580px;
word-wrap: break-word;
margin-bottom: 0;
}}
.rule {{
width: 56px;
height: 1px;
background: {text_dark};
margin: 36px 0 24px;
opacity: 0.2;
}}
.subtitle {{
font-size: 13px;
font-weight: 300;
color: {muted};
line-height: 1.7;
max-width: 460px;
margin-bottom: 28px;
}}
.meta {{
font-size: 10px;
letter-spacing: 0.06em;
color: {muted};
margin-top: 4px;
}}
</style>
</head>
<body>
<div class="page">
<div class="bar"></div>
<div class="content">
<div class="eyebrow">{t.get('doc_type','').upper()}</div>
<div class="title">{t['title']}</div>
<div class="rule"></div>
{subtitle_block}
<div class="meta">{t.get('author','')}{(' · ' + t.get('date','')) if t.get('date') else ''}</div>
</div>
</div>
</body></html>"""
# ── 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'<div class="subtitle">{t["subtitle"]}</div>'
return f"""<!DOCTYPE html>
<html>
<head><meta charset="UTF-8">
<style>
{_base_css(t)}
html, body {{ background: {light}; }}
.page {{ background: {light}; }}
/* Three bands */
.band-top {{
position: absolute; top: 0; left: 0;
width: 794px; height: {top_h}px;
background: {accent};
display: flex; align-items: flex-end;
padding: 0 64px 24px;
}}
.band-mid {{
position: absolute; top: {top_h}px; left: 0;
width: 794px; height: {mid_h}px;
background: {dark};
display: flex; flex-direction: column; justify-content: center;
padding: 0 64px;
}}
.band-bot {{
position: absolute; top: {bot_y}px; left: 0;
width: 794px; height: {1123 - bot_y}px;
background: {light};
display: flex; flex-direction: column; justify-content: center;
padding: 0 64px;
}}
/* Top band — doc type in large caps */
.eyebrow {{
font-family: '{t['font_display']}', sans-serif;
font-size: 11px; font-weight: 700;
letter-spacing: 0.32em; text-transform: uppercase;
color: {dark}; opacity: 0.85;
}}
/* Mid band — title */
.title {{
font-family: '{t['font_display']}', 'Times New Roman', Georgia, serif;
font-weight: 900;
font-size: 62px;
line-height: 0.97;
color: {text_l};
letter-spacing: -0.02em;
max-width: 620px;
word-wrap: break-word;
}}
/* Thin horizontal separator between mid and bot */
.sep {{
position: absolute; top: {bot_y}px; left: 0;
width: 794px; height: 2px;
background: {accent};
}}
/* Bottom band */
.author {{
font-size: 13px; font-weight: 500;
color: {t.get('dark','#111')}; margin-bottom: 4px;
}}
.date {{ font-size: 11px; color: {muted}; margin-bottom: 12px; }}
.subtitle {{
font-size: 12px; color: {muted}; line-height: 1.6;
max-width: 540px;
}}
</style>
</head>
<body>
<div class="page">
<div class="band-top">
<div class="eyebrow">{t.get('doc_type','').upper()}</div>
</div>
<div class="band-mid">
<div class="title">{t['title']}</div>
</div>
<div class="sep"></div>
<div class="band-bot">
<div class="author">{t.get('author','')}</div>
<div class="date">{t.get('date','')}</div>
{subtitle_block}
</div>
</div>
</body></html>"""
# ── 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'<div class="subtitle-lt">{t["subtitle"]}</div>'
return f"""<!DOCTYPE html>
<html>
<head><meta charset="UTF-8">
<style>
{_base_css(t)}
html, body {{ background: {light_bg}; }}
.page {{ background: {light_bg}; overflow: hidden; }}
/* Title block — upper dark area */
.content-dark {{
position: absolute;
left: 64px; right: 64px;
top: 180px;
z-index: 2;
}}
.eyebrow {{
font-size: 9px; font-weight: 500;
letter-spacing: 0.26em; text-transform: uppercase;
color: {accent}; margin-bottom: 28px;
}}
.title {{
font-family: '{t['font_display']}', 'Helvetica Neue', sans-serif;
font-weight: 900;
font-size: 58px;
line-height: 1.0;
color: {text_l};
letter-spacing: -0.018em;
max-width: 560px;
word-wrap: break-word;
margin-bottom: 16px;
}}
.rule-accent {{
width: 52px; height: 3px;
background: {accent};
margin-top: 28px;
}}
/* Metadata — lower light area */
.content-light {{
position: absolute;
left: 64px; right: 64px;
bottom: 80px;
z-index: 2;
}}
.author {{
font-size: 12px; font-weight: 500;
color: {text_d}; margin-bottom: 4px;
}}
.date {{ font-size: 11px; color: {muted}; margin-bottom: 12px; }}
.subtitle-lt {{
font-size: 12px; color: {muted}; line-height: 1.6;
max-width: 480px;
}}
</style>
</head>
<body>
<div class="page">
<!-- Diagonal dark polygon -->
<svg style="position:absolute;top:0;left:0;width:794px;height:1123px;z-index:1"
xmlns="http://www.w3.org/2000/svg">
<polygon points="{poly}" fill="{dark_bg}"/>
<!-- Accent edge line along the diagonal -->
<line x1="0" y1="820" x2="794" y2="620"
stroke="{accent}" stroke-width="2.5"/>
</svg>
<div class="content-dark">
<div class="eyebrow">{t.get('doc_type','').upper()} · {t.get('date','')}</div>
<div class="title">{t['title']}</div>
<div class="rule-accent"></div>
</div>
<div class="content-light">
<div class="author">{t.get('author','')}</div>
{subtitle_block}
</div>
</div>
</body></html>"""
# ── 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'<div class="subtitle">{t["subtitle"]}</div>'
return f"""<!DOCTYPE html>
<html>
<head><meta charset="UTF-8">
<style>
{_base_css(t)}
html, body {{ background: {bg}; }}
.page {{ background: {bg}; }}
/* Outer frame rectangle */
.frame {{
position: absolute;
top: {pad}px; left: {pad}px;
width: {inner_w}px; height: {inner_h}px;
border: 1.2px solid {dark};
opacity: 0.35;
}}
/* Accent strips inside top and bottom of frame */
.frame-top-accent {{
position: absolute;
top: {pad + 10}px; left: {pad + 10}px;
width: {inner_w - 20}px; height: 3px;
background: {accent};
}}
.frame-bot-accent {{
position: absolute;
bottom: {pad + 10}px; left: {pad + 10}px;
width: {inner_w - 20}px; height: 3px;
background: {accent};
}}
/* Corner ornament squares */
.corner {{
position: absolute;
width: 8px; height: 8px;
background: {accent};
opacity: 0.6;
}}
.tl {{ top: {pad - 4}px; left: {pad - 4}px; }}
.tr {{ top: {pad - 4}px; right: {pad - 4}px; }}
.bl {{ bottom: {pad - 4}px; left: {pad - 4}px; }}
.br {{ bottom: {pad - 4}px; right: {pad - 4}px; }}
/* Main content centered in frame */
.content {{
position: absolute;
left: {pad + 56}px; right: {pad + 56}px;
top: 0; bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}}
.eyebrow {{
font-size: 8.5px;
font-weight: 500;
letter-spacing: 0.30em;
text-transform: uppercase;
color: {accent};
margin-bottom: 44px;
}}
.rule-top {{
width: 60px; height: 1px;
background: {dark};
opacity: 0.3;
margin-bottom: 28px;
}}
.title {{
font-family: '{t['font_display']}', Georgia, 'Times New Roman', serif;
font-weight: 400;
font-size: 44px;
line-height: 1.25;
color: {dark};
letter-spacing: 0.01em;
max-width: 540px;
word-wrap: break-word;
margin-bottom: 0;
}}
.rule-mid {{
width: 40px; height: 1.5px;
background: {accent};
margin: 28px 0 20px;
}}
.subtitle {{
font-size: 13px;
font-weight: 300;
font-style: italic;
color: {muted};
line-height: 1.6;
max-width: 400px;
margin-bottom: 20px;
}}
.meta {{
font-size: 10px;
letter-spacing: 0.08em;
color: {muted};
margin-top: 8px;
}}
</style>
</head>
<body>
<div class="page">
<div class="frame"></div>
<div class="frame-top-accent"></div>
<div class="frame-bot-accent"></div>
<div class="corner tl"></div>
<div class="corner tr"></div>
<div class="corner bl"></div>
<div class="corner br"></div>
<div class="content">
<div class="eyebrow">{t.get('doc_type','').upper()}</div>
<div class="rule-top"></div>
<div class="title">{t['title']}</div>
<div class="rule-mid"></div>
{subtitle_block}
<div class="meta">{t.get('author','')}{(' · ' + t.get('date','')) if t.get('date') else ''}</div>
</div>
</div>
</body></html>"""
# ── 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'<div class="subtitle">{t["subtitle"]}</div>'
# 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"""<!DOCTYPE html>
<html>
<head><meta charset="UTF-8">
<style>
{_base_css(t)}
html, body {{ background: {bg}; }}
.page {{ background: {bg}; }}
/* Ghost letter — background texture */
.ghost {{
position: absolute;
right: -60px; top: -40px;
font-family: '{t['font_display']}', 'Arial Black', sans-serif;
font-weight: 900;
font-size: 680px;
line-height: 1;
color: {dark};
opacity: 0.055;
user-select: none;
letter-spacing: -0.05em;
}}
/* Top bar: accent stripe */
.topbar {{
position: absolute;
top: 0; left: 0; right: 0;
height: 5px;
background: {accent};
}}
/* Category label */
.category {{
position: absolute;
top: 40px; left: 60px;
font-size: 9px; font-weight: 700;
letter-spacing: 0.30em; text-transform: uppercase;
color: {accent};
}}
/* Main title block */
.content {{
position: absolute;
left: 60px; right: 60px;
top: 0; bottom: 0;
display: flex;
flex-direction: column;
justify-content: center;
padding-bottom: 80px;
}}
.title {{
font-family: '{t['font_display']}', 'Arial Black', Impact, sans-serif;
font-weight: 900;
font-size: 80px;
line-height: 0.92;
color: {body_color};
letter-spacing: -0.03em;
max-width: 620px;
word-wrap: break-word;
text-transform: uppercase;
}}
.subtitle {{
font-size: 14px;
font-weight: 400;
color: {muted};
line-height: 1.6;
max-width: 500px;
margin-top: 20px;
}}
/* Full-width rule above footer */
.footer-rule {{
position: absolute;
bottom: 80px; left: 60px; right: 60px;
height: 1px;
background: {body_color};
opacity: 0.15;
}}
/* Footer row */
.footer {{
position: absolute;
bottom: 44px; left: 60px; right: 60px;
display: flex;
justify-content: space-between;
align-items: baseline;
}}
.footer-author {{ font-size: 11px; color: {muted}; letter-spacing: 0.04em; }}
.footer-date {{ font-size: 10px; color: {muted}; letter-spacing: 0.04em; }}
</style>
</head>
<body>
<div class="page">
<div class="ghost">{ghost}</div>
<div class="topbar"></div>
<div class="category">{t.get('doc_type','').upper()}</div>
<div class="content">
<div class="title">{t['title']}</div>
{subtitle_block}
</div>
<div class="footer-rule"></div>
<div class="footer">
<div class="footer-author">{t.get('author','')}</div>
<div class="footer-date">{t.get('date','')}</div>
</div>
</div>
</body></html>"""
# ── 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'<div class="subtitle">{t["subtitle"]}</div>'
image_block = ""
if img_url:
image_block = f"""
<div style="text-align:center;margin:32px 0 28px;">
<img src="{img_url}" style="max-width:340px;max-height:220px;
object-fit:cover;display:inline-block;"/>
</div>"""
abstract_block = ""
if t.get("abstract"):
abstract_block = f"""
<div style="font-size:11px;line-height:1.7;color:{muted};
text-align:justify;max-width:560px;margin:0 auto 0;">
<span style="font-weight:700;color:{accent};">Abstract:</span>
{t['abstract']}
</div>"""
return f"""<!DOCTYPE html>
<html>
<head><meta charset="UTF-8">
<style>
{_base_css(t)}
html, body {{ background: {bg}; }}
.page {{ background: {bg}; display:flex; flex-direction:column;
align-items:center; justify-content:center; padding:60px 80px; }}
.org-name {{
font-size: 9px; font-weight: 500; letter-spacing: 0.30em;
text-transform: uppercase; color: {dark}; text-align:center;
margin-bottom: 10px;
}}
.org-rule {{
width: 56px; height: 2px; background: {accent};
margin: 0 auto 52px;
}}
.title {{
font-family: '{t['font_display']}', Georgia, 'Times New Roman', serif;
font-weight: 700; font-size: 52px; line-height: 1.08;
color: {dark}; text-align: center; letter-spacing: -0.015em;
max-width: 560px; word-wrap: break-word; margin-bottom: 18px;
}}
.title-rule {{
width: 44px; height: 2.5px; background: {accent};
margin: 0 auto 20px;
}}
.subtitle {{
font-family: '{t['font_display']}', Georgia, serif;
font-style: italic; font-size: 14px; color: {muted};
text-align: center; line-height: 1.5; max-width: 440px;
margin: 0 auto;
}}
.separator {{
width: 100%; max-width: 620px; height: 1px;
background: {dark}; opacity: 0.12;
margin: 28px auto;
}}
.author-name {{
font-family: '{t['font_display']}', Georgia, serif;
font-size: 16px; font-weight: 700; color: {accent};
text-align: center; margin-bottom: 6px;
}}
.date-line {{
font-size: 11px; color: {muted}; text-align: center;
letter-spacing: 0.03em;
}}
</style>
</head>
<body>
<div class="page">
<div class="org-name">{org}</div>
<div class="org-rule"></div>
<div class="title">{t['title']}</div>
<div class="title-rule"></div>
{subtitle_block}
{image_block}
{abstract_block}
{'<div class="separator"></div>' if (t.get('abstract') or img_url) else '<div style="margin:28px 0;"></div>'}
<div class="author-name">{t.get('author','')}</div>
<div class="date-line">{t.get('date','')}</div>
</div>
</body></html>"""
# ── 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'<div class="subtitle">{t["subtitle"]}</div>'
image_block = ""
if img_url:
image_block = f"""
<div style="text-align:center;margin:32px 0 28px;">
<img src="{img_url}" style="max-width:340px;max-height:220px;
object-fit:cover;display:inline-block;
filter:grayscale(20%) brightness(0.9);"/>
</div>"""
abstract_block = ""
if t.get("abstract"):
abstract_block = f"""
<div style="font-size:11px;line-height:1.7;color:{muted};
text-align:justify;max-width:560px;margin:0 auto 0;">
<span style="font-weight:700;color:{accent};">Abstract:</span>
{t['abstract']}
</div>"""
return f"""<!DOCTYPE html>
<html>
<head><meta charset="UTF-8">
<style>
{_base_css(t)}
html, body {{ background: {bg}; }}
.page {{ background: {bg}; display:flex; flex-direction:column;
align-items:center; justify-content:center; padding:60px 80px; }}
.org-name {{
font-size: 9px; font-weight: 500; letter-spacing: 0.30em;
text-transform: uppercase; color: {text_l}; text-align:center;
opacity: 0.75; margin-bottom: 10px;
}}
.org-rule {{
width: 56px; height: 2px; background: {text_l};
opacity: 0.35; margin: 0 auto 52px;
}}
.title {{
font-family: '{t['font_display']}', Georgia, 'Times New Roman', serif;
font-weight: 700; font-size: 52px; line-height: 1.08;
color: {text_l}; text-align: center; letter-spacing: -0.015em;
max-width: 560px; word-wrap: break-word; margin-bottom: 18px;
}}
.title-rule {{
width: 44px; height: 2.5px; background: {text_l};
opacity: 0.35; margin: 0 auto 20px;
}}
.subtitle {{
font-family: '{t['font_display']}', Georgia, serif;
font-style: italic; font-size: 14px; color: {muted};
text-align: center; line-height: 1.5; max-width: 440px;
margin: 0 auto;
}}
.separator {{
width: 100%; max-width: 620px; height: 1px;
background: {text_l}; opacity: 0.12;
margin: 28px auto;
}}
.author-name {{
font-family: '{t['font_display']}', Georgia, serif;
font-size: 16px; font-weight: 700; color: {text_l};
text-align: center; margin-bottom: 6px;
}}
.date-line {{
font-size: 11px; color: {muted}; text-align: center;
letter-spacing: 0.03em;
}}
</style>
</head>
<body>
<div class="page">
<div class="org-name">{org}</div>
<div class="org-rule"></div>
<div class="title">{t['title']}</div>
<div class="title-rule"></div>
{subtitle_block}
{image_block}
{abstract_block}
{'<div class="separator"></div>' if (t.get('abstract') or img_url) else '<div style="margin:28px 0;"></div>'}
<div class="author-name">{t.get('author','')}</div>
<div class="date-line">{t.get('date','')}</div>
</div>
</body></html>"""
# ── 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'<div class="subtitle">> {t["subtitle"]}</div>'
abstract_block = ""
if t.get("abstract"):
abstract_block = f"""
<div class="abstract-text">{t['abstract']}</div>"""
# grid overlay: horizontal + vertical lines
h_lines = "".join(
f'<line x1="0" y1="{y}" x2="794" y2="{y}" stroke="{accent}" stroke-width="0.4"/>'
for y in range(0, 1124, 48)
)
v_lines = "".join(
f'<line x1="{x}" y1="0" x2="{x}" y2="1123" stroke="{accent}" stroke-width="0.4"/>'
for x in range(0, 795, 48)
)
grid_svg = (
f'<svg style="position:absolute;top:0;left:0;width:794px;height:1123px;'
f'pointer-events:none;opacity:0.07" xmlns="http://www.w3.org/2000/svg">'
+ h_lines + v_lines + "</svg>"
)
return f"""<!DOCTYPE html>
<html>
<head><meta charset="UTF-8">
<style>
{_base_css(t)}
html, body {{ background: {bg}; }}
.page {{ background: {bg}; }}
/* Terminal label — top */
.term-label {{
position: absolute; top: 44px; left: 56px; right: 56px;
display: flex; align-items: center; gap: 10px;
}}
.dot {{
width: 8px; height: 8px; border-radius: 50%;
background: {accent}; flex-shrink: 0;
}}
.term-meta {{
font-family: '{t['font_body']}', 'Courier New', monospace;
font-size: 10px; color: {accent}; letter-spacing: 0.08em;
text-transform: uppercase;
}}
/* Title bracket block */
.bracket-block {{
position: absolute;
top: 310px; left: 56px; right: 56px;
border-left: 2px solid {accent}; border-top: 2px solid {accent};
padding: 24px 28px 28px;
box-shadow: inset 0 0 0 0;
}}
.bracket-block::after {{
content: '';
position: absolute;
bottom: 0; right: 0;
width: 32px; height: 2px;
background: {accent};
}}
.bracket-block::before {{
content: '';
position: absolute;
bottom: 0; right: 0;
width: 2px; height: 32px;
background: {accent};
}}
.title {{
font-family: '{t['font_display']}', 'Courier New', monospace;
font-weight: 700; font-size: 46px; line-height: 1.05;
color: {text_l}; letter-spacing: 0.01em;
text-transform: uppercase;
word-wrap: break-word; margin-bottom: 16px;
}}
.subtitle {{
font-family: '{t['font_body']}', 'Courier New', monospace;
font-size: 13px; color: {accent};
line-height: 1.5; letter-spacing: 0.02em;
margin-top: 8px;
}}
/* Content block below brackets */
.content-lower {{
position: absolute;
top: 640px; left: 56px; right: 56px;
display: flex; gap: 40px; align-items: flex-start;
}}
.abstract-text {{
font-family: '{t['font_body']}', 'Courier New', monospace;
font-size: 10.5px; line-height: 1.8; color: {muted};
flex: 1;
}}
.author-block {{
text-align: right; flex-shrink: 0; min-width: 160px;
}}
.author-label {{
font-family: '{t['font_body']}', monospace;
font-size: 8px; letter-spacing: 0.20em; color: {muted};
text-transform: uppercase; margin-bottom: 6px;
}}
.author-name {{
font-family: '{t['font_body']}', monospace;
font-size: 14px; font-weight: 700; color: {text_l};
}}
.author-org {{
font-family: '{t['font_body']}', monospace;
font-size: 10px; color: {accent}; margin-top: 4px;
}}
/* Bottom status bar */
.statusbar {{
position: absolute; bottom: 0; left: 0; right: 0;
height: 36px; background: {accent}; opacity: 0.12;
}}
.statusbar-text {{
position: absolute; bottom: 0; left: 0; right: 0;
height: 36px; display: flex; align-items: center;
justify-content: space-between; padding: 0 56px;
}}
.sb-item {{
font-family: '{t['font_body']}', monospace;
font-size: 9px; color: {muted}; letter-spacing: 0.12em;
text-transform: uppercase;
}}
</style>
</head>
<body>
<div class="page">
{grid_svg}
<div class="term-label">
<div class="dot"></div>
<div class="term-meta">SYSTEM_REPORT // {date_s}</div>
</div>
<div class="bracket-block">
<div class="title">{t['title']}</div>
{subtitle_line}
</div>
<div class="content-lower">
{abstract_block}
<div class="author-block">
<div class="author-label">AUTHOR_ID</div>
<div class="author-name">{author}</div>
<div class="author-org">{org}</div>
</div>
</div>
<div class="statusbar"></div>
<div class="statusbar-text">
<div class="sb-item">Ln 1, Col 1</div>
<div class="sb-item">UTF-8</div>
<div class="sb-item">GENERATED_BY_COVERGENIUS</div>
</div>
</div>
</body></html>"""
# ── 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'<div class="subtitle">{t["subtitle"]}</div>'
image_block = ""
if img_url:
image_block = f"""
<img src="{img_url}" style="
width:260px;height:340px;object-fit:cover;
display:block;margin-top:32px;
filter:grayscale(100%) contrast(1.1);"/>"""
meta_lines = []
if t.get("author"):
meta_lines.append(f'<div class="meta-line">{t["author"]}</div>')
if t.get("subtitle"):
meta_lines.append(f'<div class="meta-line meta-role">{t["subtitle"]}</div>')
if t.get("date"):
meta_lines.append(f'<div class="meta-line meta-date">{t["date"]}</div>')
meta_block = "\n".join(meta_lines)
return f"""<!DOCTYPE html>
<html>
<head><meta charset="UTF-8">
<style>
{_base_css(t)}
html, body {{ background: {bg}; }}
.page {{ background: {bg}; }}
/* Left sidebar — the dominant color element */
.sidebar {{
position: absolute;
top: 0; left: 0;
width: {sidebar_w}px; height: 1123px;
background: {accent};
}}
/* Main content — offset from sidebar */
.content {{
position: absolute;
left: {sidebar_w + 52}px; right: 52px;
top: 100px; bottom: 80px;
}}
/* Oversized display title */
.title {{
font-family: '{t['font_display']}', 'Arial Black', Impact, sans-serif;
font-weight: 900;
font-size: 96px;
line-height: 0.92;
color: {dark};
letter-spacing: -0.03em;
text-transform: uppercase;
max-width: 620px;
word-wrap: break-word;
margin-bottom: 22px;
}}
.subtitle {{
font-family: '{t['font_body']}', 'Courier New', monospace;
font-size: 12px;
color: {muted};
letter-spacing: 0.05em;
margin-bottom: 0;
}}
/* Thin rule under title area */
.rule {{
width: 64px; height: 2px;
background: {dark};
margin: 24px 0 28px;
}}
/* Author / meta in typewriter font */
.meta-group {{
margin-top: 32px;
}}
.meta-line {{
font-family: '{t['font_body']}', 'Courier New', monospace;
font-size: 12px; color: {dark};
line-height: 1.8; letter-spacing: 0.02em;
}}
.meta-role {{
font-family: '{t['font_body']}', 'Courier New', monospace;
color: {muted};
}}
.meta-date {{
font-family: '{t['font_body']}', 'Courier New', monospace;
font-size: 12px; color: {dark};
margin-top: 8px;
}}
/* Right-side content area for thumbnail */
.right-col {{
position: absolute;
right: 52px;
top: 380px; bottom: 80px;
display: flex;
flex-direction: column;
align-items: flex-end;
}}
/* Small accent square icon */
.icon-block {{
width: 64px; height: 64px;
background: {accent};
margin-top: 28px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}}
.icon-lines {{
display: flex; flex-direction: column; gap: 6px;
}}
.icon-line {{
height: 2px; background: {text_l};
}}
</style>
</head>
<body>
<div class="page">
<div class="sidebar"></div>
<div class="content">
<div class="title">{t['title']}</div>
{subtitle_block}
<div class="rule"></div>
<div class="meta-group">{meta_block}</div>
</div>
<div class="right-col">
{image_block}
<div class="icon-block">
<div class="icon-lines">
<div class="icon-line" style="width:32px;"></div>
<div class="icon-line" style="width:24px;"></div>
<div class="icon-line" style="width:28px;"></div>
</div>
</div>
</div>
</div>
</body></html>"""
# ── 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()
#!/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()
#!/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()
#!/usr/bin/env bash
# make.sh — minimax-pdf unified CLI
# Usage: bash make.sh <command> [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 <command> [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 "$@"
#!/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()
#!/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 <file.html> --out <file.pdf> [--wait <ms>]");
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);
}
})();
Related skills
FAQ
What can minimax-pdf do besides create PDFs?
It has three routes: CREATE a new PDF from content, FILL form fields in an existing PDF, and REFORMAT an existing document into a styled PDF.
How does it decide the design?
It uses a token-based design system where color, typography, and spacing are derived from the document type and flow through every page.