Initial commit

This commit is contained in:
Z User
2026-06-06 05:21:10 +00:00
Unverified
commit 6664758a6d
493 changed files with 135653 additions and 0 deletions

13
skills/pdf/LICENSE.txt Executable file
View File

@@ -0,0 +1,13 @@
Copyright (c) 2026 Z.ai All rights reserved.
Permission is granted for personal, educational, and non-commercial use only.
Commercial use is strictly prohibited without prior written permission from the author.
Unauthorized copying, modification, or distribution of the software for commercial purposes is prohibited.
The author reserves the right to make the final determination of what constitutes "commercial use".
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY ARISING FROM THE USE OF THE SOFTWARE.

915
skills/pdf/SKILL.md Executable file
View File

@@ -0,0 +1,915 @@
---
name: pdf
metadata:
author: Z.AI
version: "1.0"
description: Professional PDF toolkit with four production lines: (1) Report - structured documents via ReportLab (reports, proposals, contracts, white papers) (2) Creative - visual design via JSON Blueprint → design_engine.py → Playwright snapshot (posters, infographics, invitations, dashboards). The LLM acts as Art Director outputting ONLY JSON spatial blueprints; convert.blueprint compiles to pixel-perfect PDF. (3) Academic - scholarly work via LaTeX/Tectonic (papers, theses, math-heavy documents) (4) Process - manipulate existing PDFs (extract, merge, split, fill forms, convert) Auto-routes based on document type. Includes ATS/creative/academic resume sub-paths.
license: Proprietary. LICENSE.txt has complete terms
---
# PDF - Document Production Workbench
## Quick Setup
```bash
bash "$PDF_SKILL_DIR/scripts/setup.sh" # Interactive environment check + install
python3 "$PDF_SKILL_DIR/scripts/pdf.py" env.check # Detailed dependency status (JSON: add -j)
python3 "$PDF_SKILL_DIR/scripts/pdf.py" env.fix # Auto-install missing Python packages
```
## Triage
Determine task weight to control how much context to load:
| Weight | Triggers | What to Load |
|--------|----------|--------------|
| **Light** | Format conversion, form fill, text extract, merge/split, simple certificate | SKILL.md + `briefs/process.md` only |
| **Standard** | Multi-page report, poster, academic paper, resume, reformat - any document with design decisions | SKILL.md + matched brief + typesetting assets on demand |
Light tasks skip typesetting files entirely. Standard tasks load them on demand per the brief's instructions.
### ⚠️ Pre-Routing Checks (run BEFORE matching brief)
1. **Emoji Check** - Scan user content for intentional emoji (decorative 📊🎯🔥, not OS-level emoji input). If found → **force Creative brief** regardless of document type. ReportLab renders emoji as □ squares; LaTeX drops them entirely.
2. **CJK Check** - Chinese/Japanese/Korean content needs font coverage. Report brief must use `UniSong`/`UniHei` registered fonts; Creative brief must load Google Fonts Noto Sans SC with `font-display: swap`; Academic brief must use `\usepackage{ctex}`.
3. **Size Check** - Non-standard page sizes (not A4/Letter/A3) → prefer Creative brief (Playwright handles any dimension). ReportLab can do custom sizes but pagination is manual.
4. **Character Safety Check** - Before writing any content string, scan for Japanese kana (の、が、は etc.), unusual Unicode symbols, or non-CJK characters that may corrupt during encoding transit ( Especially when code is written via heredoc/base64/LLM output). Replace with plain Chinese equivalents: `の``之/的/缔`, `々`→omit or write full character. **If content must preserve Japanese, use only standard CJK Unified Ideographs (U+4E00-U+9FFF) and common kana; avoid rare/private-use codepoints.**
---
## Briefing
Match the user's intent to a production brief. Each brief contains the full workflow, tech stack specifics, and references to shared typesetting assets.
```
User Request
├─ Work with existing PDF? ─────────────┬─ Extract/merge/split/fill/convert → briefs/process.md
│ ├─ Reformat/redesign → briefs/process.md (extract) → delegate to report or creative brief
│ └─ User provides a PDF template/reference to match style
│ → briefs/process.md "Template-Guided Reformat" → delegate to matched brief
├─ Report / proposal / white paper / contract / analysis?
│ └─ ────────────────────────────────── → briefs/report.md (ReportLab)
├─ Poster / invitation / infographic / dashboard / creative layout?
│ └─ ────────────────────────────────── → briefs/creative.md (Playwright)
├─ Academic paper / thesis / math / IEEE / ACM / LaTeX?
│ └─ ────────────────────────────────── → briefs/academic.md (Tectonic)
├─ Math-heavy doc / TikZ diagram / algorithm pseudocode / Beamer slides?
│ └─ ────────────────────────────────── → briefs/academic.md (Tectonic, Scenarios A-D)
├─ Document needs complex embedded diagrams (flowcharts, architecture, neural nets)?
│ └─ Route by target brief:
│ ├─ Report → Playwright+CSS → PNG → ReportLab Image() flowable
│ ├─ Creative → directly in HTML (CSS flexbox/grid + connectors)
│ └─ Academic → complexity-based:
│ ├─ Simple (≤6 nodes, linear/tree) → TikZ native (vector)
│ └─ Complex (>6 nodes, branches, annotations) → Playwright+CSS → PNG → \includegraphics
└─ Resume / CV?
├─ ATS-safe / corporate ─────────── → briefs/report.md (resume sub-section)
├─ Creative / design industry ────── → briefs/creative.md (resume sub-section)
└─ Academic CV / publications ────── → briefs/academic.md (resume sub-section)
```
### Detection Keywords
| Brief | Keywords |
|-------|----------|
| Report | 报告, report, 分析, analysis, 白皮书, white paper, 提案, proposal, 合同, contract, 方案, 规划, 发票, invoice, 收据, receipt, 试卷, exam, quiz, test paper, 练习, exercise, worksheet, 考试, 测验 |
| Creative | 海报, poster, 邀请函, invitation, 信息图, infographic, 仪表盘, dashboard, 传单, flyer, 证书, certificate, 菜单, menu, 名片, business card, 奖状, award, 标签, label, 信封, envelope, 贺卡, greeting card |
| Creative (Poster) | 海报, poster, 传单, flyer, 宣传页, 宣传单 → additionally load `briefs/poster.md` scene layer rules |
| Academic | 论文, paper, 学术, academic, LaTeX, 数学, math, IEEE, ACM, 毕业, thesis, 研究, research, Beamer, slides, 开题报告, 学位, dissertation, proposal |
| Process | 提取, extract, 合并, merge, 拆分, split, 填写, fill, 转换, convert, OCR, 重排, reformat, 重新排版, redesign, 模板, template, 参照, 照着这个做, match this style, 压缩, compress, 水印, watermark, 加密, encrypt, 签名, sign |
### Complete Scenario Routing Matrix
Below is an exhaustive map of every known PDF request type to its handling strategy. If a scenario is not listed, route to the closest match or ask the user.
#### 📄 Creation (Generate PDF from scratch)
| Scenario | Route | Notes |
|----------|-------|-------|
| Report / white paper / analysis | report.md | ReportLab structured document |
| Report with emoji | **creative.md** | 🚨 Emoji rule override |
| Business proposal | report.md | Structured + data tables |
| Contract / legal document | report.md | Add signature placeholders (dotted line + label) |
| Invoice / receipt | report.md | Table-heavy, precision alignment |
| Exam / quiz / test paper / worksheet | report.md | Indented options, answer space reservation, structured numbering (see Exam Paper Rules in report.md) |
| Math exam / math worksheet (with formulas/equations) | academic.md | LaTeX for proper math typesetting. See §Exam Paper Rules in academic.md |
| Poster / flyer | creative.md + **poster.md** | Visual design + poster density/sizing rules |
| Invitation / greeting card | creative.md | Non-standard size, decorative |
| Certificate / award | creative.md | Single page, centered layout, decorative border |
| Business card | creative.md | Tiny size (90×54mm), Playwright native support |
| Envelope / label | creative.md | Non-standard size, simple layout |
| Menu / price list | creative.md | Visual layout + may contain emoji |
| Resume (ATS) | report.md | Plain text structure |
| Resume (creative) | creative.md | Visual design |
| Resume (academic CV) | academic.md | Publication list + BibTeX |
| Academic paper | academic.md | LaTeX/Tectonic |
| Math-heavy document | academic.md | LaTeX typesetting |
| Presentation / PPT-style | creative.md | Landscape (1280×720), one topic per page |
| Book / long document | report.md | Add TOC + chapter numbering, validate with toc_validate.py |
| CJK vertical text | creative.md | HTML `writing-mode: vertical-rl` + `text-orientation: upright` + `white-space: nowrap` + Playwright |
| RTL document (Arabic/Hebrew) | creative.md | HTML `dir="rtl"` + Playwright |
| Batch generation (mail merge) | report.md | Python loop + template variable substitution |
| Infographic | creative.md | Data visualization + design |
| Calendar / schedule | creative.md | Grid layout + custom dimensions |
#### 🔧 Processing (Manipulate existing PDF)
| Scenario | Route | Command / Method |
|----------|-------|------------------|
| Merge multiple PDFs | process.md | `pages.merge a.pdf b.pdf -o out.pdf` |
| Split PDF | process.md | `pages.split input.pdf -o ./output/` |
| Extract text | process.md | `extract.text input.pdf` |
| Extract tables | process.md | `extract.table input.pdf` |
| Extract images | process.md | `extract.image input.pdf` |
| Fill forms | process.md | `form.fill input.pdf` |
| Office → PDF | process.md | `convert.office input.docx` |
| HTML → PDF (documents) | process.md | `convert.html input.html` or `node html2pdf-next.js` |
| HTML → PDF (posters) | poster.md | `node html2poster.js poster.html` |
| Image → PDF | process.md | pikepdf: one image per page, embed as XObject |
| PDF → image | process-advanced.md | pypdfium2 render each page to PNG |
| Encrypt / decrypt | process-advanced.md | pikepdf encryption |
| Add watermark | process.md | pikepdf overlay: create watermark page → merge onto each page |
| Compress PDF | process.md | Ghostscript: `gs -sDEVICE=pdfwrite -dPDFSETTINGS=/screen` |
| OCR scanned PDF | process-advanced.md | ocrmypdf or Tesseract |
| Rotate pages | process.md | `pages.rotate input.pdf 90 -o out.pdf` |
| Crop pages | process.md | `pages.crop input.pdf l,b,r,t -o out.pdf` |
| Remove blank pages | process.md | `pages.clean input.pdf` |
| Reformat by template | process.md → delegate | Extract content → regenerate via report/creative |
| PDF diff / compare | process.md | `diff-pdf` CLI or Python per-page text comparison |
| Digital signature | process.md | `pyhanko` library (requires extra install) |
| Edit metadata | process.md | `meta.set input.pdf -o out.pdf -d '{...}'` |
### Special Routing Rules
**🚨 Emoji rule (CRITICAL - check FIRST)**: Content with intentional emoji (📊🎯🔥💡 etc.) → force **briefs/creative.md** regardless of document type. ReportLab renders emoji as □ squares; LaTeX silently drops them. This rule overrides all other routing. Even if the user says "report" - if the content has emoji, use Creative pipeline.
**Non-standard page size rule**: Dimensions other than A4/Letter/A3 → strongly prefer **briefs/creative.md**. Playwright handles any arbitrary page size natively. ReportLab requires manual pagination math.
**Academic auto-detect**: Papers, theses, or heavy math → **briefs/academic.md** even without explicit "LaTeX" mention.
**Template-guided rule**: When the user uploads a PDF and says "match this template" / "follow this style" / "reformat like this" → **briefs/process.md** Template-Guided Reformat section. This is a Standard triage (not Light), because it involves design decisions.
**Resume routing**: Default to Report brief (ATS-safe). Creative industry → Creative brief. Academic CV with publications → Academic brief.
---
## Shared Assets
These are referenced by multiple briefs. **Do not load upfront** - each brief tells you when and what to load.
| Asset | Path | Used By | Purpose |
|-------|------|---------|---------|
| Palette & Typography | `typesetting/palette.md` | Report, Creative | Color system, font rules, anti-patterns, spacing |
| Cover Layout System V2.1 | `typesetting/cover.md` | **Report + Creative + Academic** | 7 industrial-grade templates with absolute anchor grid, Z-index layers, typography weight system, mandatory Summary Block, code-level safety (5 checks), base unit `U = W*0.05`. **Unified HTML/Playwright cover system for all routes.** |
| Chart Styling & Anti-Stacking | `typesetting/charts.md` | Report, Creative, Academic | Chart defaults, collision prevention, axis/grid/legend rules |
| Overflow Prevention | `typesetting/overflow.md` | Report, Creative, Academic | Bounding box system, text/image/table overflow prevention, fallback strategies |
| **Fill Engine (Anti-Void)** | `typesetting/fill-engine.md` | **Report, Creative, Academic** | **Anti-Void Engine V2.0: font floor enforcement, fill ratio calculation, paragraph inflation, component elevation, Y-axis golden-ratio anchoring** |
| Pagination & Flow Control | `typesetting/pagination.md` | Report, Creative | Cross-page integrity, orphan/widow control, CJK punctuation rules |
| Typography System | `typesetting/typography.md` | Report, Creative | Font size scale, line-height, spacing hierarchy |
| Geometric Anchors | `typesetting/geometry.md` | Creative + Report | Decorative geometric elements, anchor placement rules |
| Cover Backgrounds | `typesetting/cover-backgrounds.md` | **Report + Creative + Academic** | Cover background rendering, transparency constraints |
| Visual Framework | `configs/visual_framework.md` | Creative | Palette mode, color harmony, SVG background params |
| Components Library | `configs/components.md` | Creative | Non-grid composition components (floating cards, oversized text, etc.) |
| Font Stacks | `configs/fonts.md` | All pipelines | Font families per pipeline (Google Fonts, ReportLab, LaTeX) |
---
## Content Rules
- **Language**: Match user's query language. Chinese query → Chinese PDF.
- **Page/word count**: Respect explicit constraints (±20%). Unspecified → completeness over brevity.
- **Outline**: User-provided outlines are sacred. No reordering without asking.
- **Citations**: No fabrication. Chinese → GB/T 7714, English → APA. Search to verify.
- **Multi-part requests**: Generate ALL parts - never silently drop a component.
### HTML Image Source Path Rules
When embedding images in HTML documents (Creative pipeline, Playwright-rendered diagrams, or any HTML→PDF flow):
| Image location | `<img src>` value | Example |
|---|---|---|
| **Local file** | **Relative path** from the HTML file's directory | `<img src="images/chart.png">` or `<img src="./diagram.png">` |
| **Remote URL** | Full URL (no change needed) | `<img src="https://example.com/photo.jpg">` |
**Iron rules:**
1. **NEVER use absolute paths** for local files in HTML `<img>`, `<source>`, CSS `url()`, or any other asset reference (e.g. `/Users/alice/project/img.png`). Absolute paths break portability across machines and environments.
2. **Always use relative paths** anchored to the HTML file's own directory. If the image lives in a subdirectory, use `images/foo.png` or `./images/foo.png`.
3. **Remote URLs (`http://` / `https://`) are fine as-is** — do not convert them to local paths.
4. When generating HTML from a script or blueprint, ensure all referenced assets are either (a) in the same directory as the output HTML, or (b) in a clearly named subdirectory (e.g. `assets/`, `images/`), and referenced with relative paths.
5. If a build script needs to resolve paths programmatically, compute relative paths at generation time (e.g. `os.path.relpath(image_path, html_dir)`) rather than embedding absolute filesystem paths.
---
## Figure & Diagram Embedding (All Briefs)
### Iron Rule: Figures Are Block-Level
Figures, diagrams, and charts MUST be independent block elements occupying full width. **Never** float/wrap figures alongside body text - this causes the text-diagram overlap badcase.
| Brief | Correct embedding | Forbidden |
|-------|-------------------|-----------|
| Report (ReportLab) | `story.append(Image(...))` as standalone Flowable | Placing images inside Paragraph text, simulating float |
| Creative (Playwright) | `<figure style="display:block; width:100%; margin:2em auto">` | `float:right`, `display:flex` with text, `wrapfigure`-style CSS |
| Academic (LaTeX) | `\begin{figure}[t] ... \end{figure}` | Bare `\includegraphics` in text body (no figure env), bare `tikzpicture` in multi-column |
### Complex Diagram Strategy
When a diagram has **>12 nodes, >3 subgroups, or intricate connections**, do NOT try to render it as one giant figure. Instead:
1. **Table for details** - structured data (phases, components, specs) goes into a proper table
2. **Simplified overview diagram** - a stripped-down flowchart/Mermaid showing only the top-level flow (≤8 nodes)
3. **Cross-reference** - table caption + diagram caption reference each other
This "table + simple diagram" pattern prevents:
- Diagrams overflowing page boundaries
- Text becoming unreadably small to fit everything
- Layout engines mishandling oversized graphics
### Diagram Content Quality Rules (Cross-reference: charts)
The rules above handle **how** to embed diagrams in PDF. For **what the diagram itself looks like** (node layout, connector routing, color, readability), follow the `charts` skill rules:
**Before generating ANY flowchart/diagram for PDF embedding, check these:**
1. **Connectors must not pass through nodes** - If 3+ layers exist, connect adjacent layers only (top→mid, mid→bottom). Never draw top→bottom lines through middle nodes. Use detour paths if cross-layer links are needed.
2. **Multiple arrows into one node must not pile up** - Distribute entry points evenly along target edge, or use merge-then-enter pattern (sources converge to a vertical merge line, then single arrow to target).
3. **Low-saturation fills only** - Node backgrounds must be pale (`#EFF6FF`, `#F0FDF4`). High-saturation colors (`#3B82F6`, `#10B981`) only for borders or small accents. No children's-art color schemes.
4. **Phase titles vs sub-steps must be visually distinct** - Different background color, font size, and font weight. Never same-style boxes for both.
5. **Font sizes must be readable at final output size** - Sizes depend on the embedding context:
| Output context | Node title min | Description min | Label min |
|---------------|----------------|-----------------|-----------|
| Standalone PNG (web/presentation, ≥1200px wide) | 14px | 12px | 11px |
| Embedded in A4 PDF (ReportLab/LaTeX, ~450pt content width) | 10pt | 8pt | 7pt |
| Embedded in slide deck (landscape, ~720pt wide) | 12pt | 10pt | 9pt |
**Principle**: After embedding, the smallest text in the diagram must still be legible when the document is viewed at 100% zoom. If the diagram is scaled down to fit page width, recalculate: `effective_size = original_size × (display_width / canvas_width)`. If effective size drops below the minimum, either increase original font size or reduce diagram complexity.
6. **Legend/annotations must not overlap content** - Separate container, ≥ 40px gap from last node, fully within canvas bounds.
**For Playwright-rendered diagrams**: Use low-saturation fills (`#EFF6FF`, `#F0FDF4`), CSS flexbox/grid for node layout, SVG `<line>`/`<path>` for connectors, and verify no overlap at final render size.
**For ReportLab-drawn diagrams**: Same principles apply - use `Drawing()` with explicit coordinates, check node bounding boxes for overlap before finalizing.
### Diagram Generation Strategy (Per-Brief)
Diagram rendering depends on the target brief - **NOT** a one-size-fits-all TikZ pipeline.
| Target Brief | Diagram Method | Rationale |
|---|---|---|
| **Report** (ReportLab) | Playwright+CSS → PNG → `Image()` | No LaTeX compiler in this route; HTML/CSS handles any layout natively |
| **Creative** (Playwright) | Directly in HTML (CSS flexbox/grid + JS connectors) | Already in browser context |
| **Academic** (Tectonic) - simple (≤6 nodes) | TikZ native `tikzpicture` | Vector output, font consistency, LaTeX-native |
| **Academic** (Tectonic) - complex (>6 nodes) | Playwright+CSS → PNG @2×`\includegraphics` | TikZ branch logic is error-prone for models; 300dpi PNG is publication-ready |
**Playwright+CSS diagram pipeline (Report & Academic-complex):**
```bash
# 1. Write diagram HTML (CSS grid/flexbox + connectors)
cat > diagram.html << 'EOF'
<!-- LLM generates: nodes as divs, arrows as SVG/CSS -->
EOF
# 2. Screenshot at 2× for print quality (300dpi equivalent)
python3 "$PDF_SKILL_DIR/scripts/pdf.py" convert.blueprint diagram.html --device-scale-factor 2 --output diagram.png
# Or via Playwright directly:
# page.screenshot(path='diagram.png', scale='device', device_scale_factor=2)
# 3a. Embed in ReportLab (Report brief)
from reportlab.platypus import Image
img = Image('diagram.png', width=450) # auto height via aspect ratio
story.append(img)
# 3b. Embed in LaTeX (Academic brief, complex diagrams only)
# \includegraphics[width=\columnwidth]{diagram.png}
```
**🚫 FORBIDDEN for Report/Creative briefs:** Do NOT use TikZ standalone → compile → pdftoppm → PNG pipeline. This route has no LaTeX compiler and the extra compilation steps are error-prone.
**TikZ remains valid ONLY for:**
- Academic brief with simple diagrams (≤6 nodes, linear/hierarchical)
- Direct `tikzpicture` embedding in LaTeX documents
- Math-annotated diagrams where LaTeX math rendering matters
See `briefs/academic.md` Scenario B for TikZ templates (simple diagrams only).
---
## Vector Rendering Iron Rule
**The final PDF MUST be generated via `page.pdf()` (Playwright) or ReportLab/LaTeX native output - NEVER via screenshot-to-PDF.**
| Scenario | Correct Method | Forbidden |
|----------|---------------|-----------|
| Creative pipeline (single/multi-page) | `page.pdf()` via `convert.blueprint` or `html2pdf-next.js` | `page.screenshot()` → image → wrap as PDF |
| Report cover (HTML/Playwright) | `page.pdf()` → merge via pypdf | Screenshot cover → embed as image |
| Academic cover | `page.pdf()` → merge via pypdf | Screenshot → `\includegraphics` for cover |
| Full-page posters/infographics | `html2poster.js` (auto overflow:hidden + height measurement + `page.pdf()`) | Any raster pipeline for the final output |
**Why:** `page.pdf()` produces vector text + vector shapes. Text remains selectable, sharp at any zoom, and file size is smaller. Screenshot-based PDFs are raster images - blurry when zoomed, unsearchable, and 3-5× larger.
**The ONLY place screenshot/PNG embedding is acceptable:**
- **Diagrams** embedded as sub-elements inside a larger document (e.g., flowcharts in a Report). These use `page.screenshot()` at 2× device scale factor for 300dpi print quality, then embed via `Image()` (ReportLab) or `\includegraphics` (LaTeX).
- **Chart images** generated by matplotlib/plotly saved as PNG, then embedded.
These are sub-elements, not the document itself. The document-level PDF output must always be vector.
**Quick test:** Open the generated PDF, zoom to 400%. If text is blurry, you used a screenshot pipeline. Fix it.
### HTML→PDF Engine Selection Rules
There are **two dedicated scripts** for HTML→PDF. Choose based on document type:
| Document type | Script | Reason |
|---------------|--------|--------|
| **Posters, infographics, long-image single-page designs** | `html2poster.js` | Auto overflow:hidden, auto height measurement, zero margin, single-page output |
| **Cover pages (Report/Academic route)** | `html2poster.js` | Covers are single-page fixed layouts with absolute positioning — same nature as posters. `html2pdf-next.js` would convert absolute→static and destroy the layout |
| **Multi-page documents, reports, academic papers, resumes** | `html2pdf-next.js` | A4/custom pagination, 20mm margin fallback, cover adaptation, pdf-lib metadata |
| **Creative pipeline (Blueprint → HTML → PDF)** | `html2pdf-next.js` via `convert.blueprint` | Called internally by design_engine pipeline |
#### Poster / Single-Page Long-Image → `html2poster.js`
```bash
node "$PDF_SKILL_DIR/scripts/html2poster.js" poster.html --output poster.pdf --width 720px
```
`html2poster.js` automatically:
- Forces `overflow: hidden` on `.poster` / `.page` containers (clips decorative overflow)
- Injects `@page { margin: 0 }` (zero margins always)
- Syncs `html/body` background with poster background color
- Measures `.poster` scrollHeight and uses it as PDF height
- Generates a single-page vector PDF with exact content dimensions
**Use this for ANY fixed-width, dynamic-height, single-page design.**
#### Documents / Multi-Page → `html2pdf-next.js`
```bash
node "$PDF_SKILL_DIR/scripts/html2pdf-next.js" input.html --output output.pdf --width 210mm --height 297mm
# Or via pdf.py wrapper:
python3 "$PDF_SKILL_DIR/scripts/pdf.py" convert.html input.html --output output.pdf
```
Pre-render hooks auto-handle @page injection, overflow detection, cover adaptation, font loading, and pdf-lib metadata.
#### ⚠️ Iron Rule: No Hand-Written Playwright Scripts
Common issues with hand-written Python `page.pdf()` (the dedicated scripts handle these automatically):
1. **Missing `@page` rule** → browser default margin causes content overflow to second page or white edges
2. **Oversized elements not fixed** → large elements with `break-inside: avoid` block pagination, content gets truncated
3. **Rendering before fonts are loaded** → Chinese text displays as squares or falls back to wrong font
4. **No overflow detection** → content exceeds page boundary without awareness
5. **No metadata** → PDF title, author, and other info missing
**Iron rule: Posters and cover pages use `html2poster.js`, multi-page documents use `html2pdf-next.js`. Do not write hand-written Python Playwright scripts.**
> **⚠️ Cover page gotcha:** Cover HTML uses `position: absolute` for layout. `html2pdf-next.js` pre-render hooks convert absolute-positioned elements to `static` flow (to prevent multi-page overlap), which **destroys** cover layouts. Always use `html2poster.js` for cover pages.
### No overflow:hidden on Fixed-Size Pages (html2pdf-next.js only)
When using `html2pdf-next.js` for documents, **NEVER set `overflow: hidden` on `html`, `body`, or the main page container**.
> **Note:** This rule does NOT apply to posters rendered via `html2poster.js` — that script automatically adds `overflow: hidden` to `.poster`/`.page` containers to clip decorative overflow. You don't need to add or remove it manually.
| Problem | Cause | Fix |
|---------|-------|-----|
| Browser preview cuts off bottom content, can't scroll | `overflow: hidden` on container + viewport < design height | Remove `overflow: hidden` |
| html2pdf-next.js "Fixed vertical overflow" warning, layout may break | Pre-render detects `scrollHeight > clientHeight` + hidden overflow, force-expands container | Remove `overflow: hidden` |
**Always pair fixed-size pages with `@media screen` auto-scale** so the full page is visible in any browser window without scrolling. See `briefs/creative.md` § 0.5 for the CSS pattern.
### Full-Bleed Rule (No White Margins)
When generating HTML for Playwright `page.pdf()`, the content **MUST fill the entire page** with zero margins. White side margins = broken layout.
**Mandatory CSS for any HTML → PDF:**
```css
@page {
size: <width> <height>; /* e.g., 720px 960px, or A4 */
margin: 0;
}
html, body {
margin: 0;
padding: 0;
}
```
**Common causes of white margins:**
1. Missing `@page { margin: 0 }` - browser default margins kick in (~1cm each side)
2. Content width doesn't match page width - e.g., canvas is 720px but page is A4 (794px)
3. Missing `@page { size }` declaration in the HTML
4. Content has explicit `max-width` that's narrower than the page
**For blueprint pipeline:** `design_engine.py` now injects `@page { size: var(--canvas-w) var(--canvas-h); margin: 0; }` automatically.
**For raw HTML:** YOU must include the `@page` rule. No exceptions.
**For direct Playwright:** Pass `margin: { top: 0, right: 0, bottom: 0, left: 0 }` to `page.pdf()`.
### Background Color Consistency (No Color Mismatch)
**`html` / `body` background color must match the content canvas background color.**
Playwright `page.pdf({ printBackground: true })` renders the body background color. If body is white while the content area is gray/colored, color-inconsistent borders/gaps will appear in the PDF.
#### Single-color documents (all pages same background)
```css
/* MANDATORY: body background = content background */
html, body {
margin: 0;
padding: 0;
background: var(--c-bg); /* Same color as content canvas */
}
```
#### Multi-page documents with mixed backgrounds (e.g. dark cover + white body pages)
**Root cause:** Playwright resolves `.page { width: 210mm }` and `@page { size: 210mm }` to slightly different sub-pixel values (e.g. 793.688px vs 793.701px). This creates a <1px gap at the right/bottom edge of each `.page` div where `body`'s background shows through. On dark pages, a white `body` background makes this gap visible as a white edge.
**Fix — set `body` background to the document's dominant dark color:**
```css
:root {
--primary: #0f172a; /* darkest page background */
}
html, body {
margin: 0;
padding: 0;
width: 210mm; /* match @page size */
background: var(--primary); /* fallback for sub-pixel gaps */
}
```
**Why this works and doesn't break white pages:**
- Dark pages: sub-pixel gap reveals dark `body` → gap invisible.
- White pages: `.page-white { background: #ffffff }` fully covers `body` → dark body never visible.
- The gap is <1px — even on white pages, the dark body at the extreme pixel edge is imperceptible after anti-aliasing.
**Rule: when generating multi-page HTML with mixed backgrounds, always set `html, body { background }` to the darkest page's background color.** If all pages are light/white, use the lightest content background (e.g. `#f8fafc`). Never leave `body` background unset (browser default = white = guaranteed white edges on dark pages).
```
### Content Centering (No Left/Right Drift)
**After HTML-to-PDF conversion, content must be centered, no left or right drift allowed.**
Common drift causes:
1. `@page { margin }` not 0 — browser default margin causes drift
2. `.safe-zone` or content container `inset` / `padding` left-right asymmetric
3. Content container has `max-width` but no `margin: 0 auto`
4. Grid components only occupy partial column width (e.g. `1/1 → X/7` only uses left half)
5. **Decorative elements overflow page boundary** — elements with `width > 100%` or negative offsets (e.g. glow circles, gradient overlays) inflate `scrollWidth` beyond page width. Playwright shrinks all content to fit, causing left-shift. **Fix: add `overflow: hidden` to `.page` containers.** See `typesetting/overflow.md` §3.5 for horizontal flex overflow rules.
### Anti-Void Edges (No Large Blank Margins)
**Content should not have large meaningless whitespace at page edges, top, or bottom.**
- Content should make full use of page area; do not cram all content in the top half while leaving the bottom blank
- For multi-page documents, each page's fill rate should be ≥ 60% (see `pagination.md` last page ≥ 40% rule)
- For single-page posters/infographics, fill rate should be ≥ 70%
---
## Preflight (Quality Assurance)
Every PDF must pass preflight checks before delivery. Each brief specifies the exact commands.
### HTML Pre-Render Validation (MANDATORY for ALL HTML→PDF paths)
**Before** calling `html2pdf-next.js`, `html2poster.js`, `convert.blueprint`, or any Playwright `page.pdf()`, run:
```bash
python3 "$PDF_SKILL_DIR/scripts/poster_validate.py" check-html <your_file>.html
```
| Result | Action |
|--------|--------|
| **PASS** (no errors) | Proceed to PDF generation |
| **ERROR** items | Must fix before generating PDF. Use `--fix --output <file>.html` for auto-repair |
| **WARNING** items | Review; non-blocking but should be addressed |
**Key checks:**
- `OVERFLOW_HIDDEN_CONTAINER` (error): `overflow:hidden` on html/body/.page clips content in browser preview and triggers html2pdf-next.js auto-fix that may break layout
- `FIXED_SIZE_NO_SCREEN_ADAPT` (warning): fixed-size page without `@media screen` auto-scale — browser preview requires scrolling
- `SCREEN_ADAPT_NO_SCALE` (warning): `@media screen` exists but lacks scale/transform/zoom
- `FONT_NO_FALLBACK` (error): font-family without generic fallback
- `COLOR_CONTRAST` (warning): text/background contrast ratio < 3:1
- Plus: remote images, absolute paths, missing margin reset, tiny fonts, background mismatch, etc.
This applies to **all three HTML routes**: Creative blueprint pipeline, Report HTML covers, and bypass/custom HTML.
### Overflow Prevention System
**→ Full spec: `typesetting/overflow.md`** - read it for any document with tables, images, or multi-column layouts.
Core principles:
1. **Measure first, draw second** - never render content without pre-calculating its dimensions
2. **Bounding Box constraint** - every element's width ≤ its parent container's `Max_Width`
3. **Text: use font metrics**, not character count, for width calculation
4. **Images: proportional scaling** - never insert at original size
5. **Tables: weight-based column width** + `Paragraph()` wrapping (never plain strings)
6. **Fallback ladder**: wrap → shrink font (max -3pt) → reduce padding → split element → log warning
7. **Vertical: KeepTogether** for heading+body, chart+caption; `repeatRows=1` for long tables
### Table Overflow Prevention (ReportLab)
**Most common layout bug: table columns exceed page margins.**
Before building any ReportLab Table:
1. Calculate `available_width = page_width - left_margin - right_margin`
2. Use proportional colWidths (`[0.25, 0.40, 0.20, 0.15]` × available_width) or fixed+flex pattern
3. `sum(colWidths)` must be ≤ `available_width` - **verify this in code**
4. Long text columns must use `Paragraph()` wrapping, not plain strings (plain strings don't wrap)
5. CJK text is wider: budget ~12pt per character at 10pt font size
See `briefs/report.md` § "Table Width Management" for code patterns.
### Table Overflow Prevention (LaTeX/Academic)
**Most common bug in dual-column papers: wide tables overflow single-column width.**
Before writing any LaTeX table:
1. Count data columns - ≤ 4 fits single column; 5-6 needs `\small`; 7-8 needs `\resizebox`; ≥ 9 use `table*` (full width)
2. Use `tabular*{\columnwidth}` or `tabularx{\columnwidth}` instead of plain `tabular` for 5+ columns
3. Never use plain `tabular` with 8+ columns in twocolumn layout - guaranteed overflow
4. `\resizebox{\columnwidth}{!}` as last resort - verify smallest text ≥ 6pt after scaling
See `briefs/academic.md` § "Table width management" for LaTeX patterns.
### Playwright PDF CSS Blacklist
These CSS properties **silently break** in Playwright's PDF renderer:
- `backdrop-filter` / `-webkit-backdrop-filter` - **drops entire element content**. Use solid `rgba()` backgrounds.
- `overflow: hidden` on content containers - clips content. Only safe on small decorative elements (< 200px).
After generating any Playwright PDF, **verify every page has content** (pypdf text extraction, check non-empty).
### PDF Metadata (all briefs)
ALL PDFs must have: Title, Author (default "Z.ai"), Creator, Subject.
### Delivery Summary (all briefs)
Report to user: file path, size, page count. Academic adds word/image count. Creative adds per-page verification.
**HTML→PDF route deliverables (MANDATORY — applies to ALL briefs that use Playwright/HTML to generate PDF):**
Whenever the HTML→PDF pipeline is used (Creative route, Report cover bypass, Direct HTML Flow posters, or any Playwright `page.pdf()` path), you MUST deliver **both files** to the user:
1. **HTML** — the source HTML file, so the user can edit and reuse the design
2. **PDF** — the final vector PDF (`page.pdf()` output)
Optionally also provide:
3. **Image** — a full-page screenshot/preview image (PNG or JPG) for quick sharing on chat/social media
All file paths must be reported to the user. **Never deliver only the PDF without the HTML source.**
---
## Tooling Reference
### CLI: `python3 "$PDF_SKILL_DIR/scripts/pdf.py" <command>`
```bash
# Environment
env.check # Check deps
env.fix # Auto-install missing
# Quality
code.sanitize <script> # Sanitize forbidden Unicode
content.sanitize <file> [--apply] # Fix content issues (CJK, encoding)
meta.brand <pdf> # Add Z.ai metadata
font.check <pdf> # Scan for missing glyphs
toc.check <pdf> # Validate TOC
# Conversion
convert.blueprint <llm_json_response.md> -o final.pdf # CRITICAL FOR CREATIVE: Auto-extracts JSON, compiles, and renders PDF.
convert.html <html> # HTML → PDF (Playwright)
convert.latex <tex> # LaTeX → PDF (Tectonic). Bundled binary is macOS arm64 only; see academic.md for other-platform install.
convert.office <file> # Office → PDF (LibreOffice)
# Processing
extract.text <pdf> # Extract text
extract.table <pdf> # Extract tables
extract.image <pdf> # Extract images
pages.merge a.pdf b.pdf -o out.pdf
pages.split <pdf>
pages.clean <pdf> # Remove blank pages
form.info <pdf> # Inspect form fields
form.fill <pdf> # Fill form
form.annotate <pdf> # Fill via annotations
meta.get <pdf>
meta.set <pdf> -o out.pdf -d '{"Title": "..."}'
```
### Poster/HTML/LaTeX Validator: `python3 "$PDF_SKILL_DIR/scripts/poster_validate.py"`
```bash
check-html <html> # Pre-render validation (overflow:hidden, @media screen, fonts, contrast, etc.)
check-html <html> --fix --output <fixed.html> # Auto-fix errors (remove overflow:hidden, add font fallback)
check-pdf <pdf> --source-html <html> # Post-render validation
check-pdf <pdf> --poster # Poster mode: suppress ORPHAN_PAGE warning
check-tex <tex> # LaTeX source validation (table overflow, image width, etc.)
```
**check-html checks include:**
- `OVERFLOW_HIDDEN_CONTAINER` (error): overflow:hidden on html/body/.page/.poster — clips content
- `FIXED_SIZE_NO_SCREEN_ADAPT` (warning): fixed-size page without @media screen auto-scale
- `SCREEN_ADAPT_NO_SCALE` (warning): @media screen exists but lacks scale/transform/zoom
- `FONT_NO_FALLBACK` (error): font-family without generic fallback (sans-serif/serif)
- `COLOR_CONTRAST` (warning): text/background contrast ratio < 3:1
- `BG_COLOR_MISMATCH` (warning): body background differs from .canvas/.poster background
- `SCREEN_BG_MISMATCH` (warning): @media screen html background differs from body/canvas background
- `MULTIPAGE_BODY_BG_MISSING` (warning): multi-page document with dark `.page` backgrounds but no `html/body` background color. Sub-pixel gaps at page edges reveal white body, causing visible white edges on dark pages. Resolves `var()` references via `:root` variables.
- `SCREEN_NO_BG` (warning): fixed-size page's @media screen block lacks html background color
- `OVERFLOW_DECORATION` (warning): negative position values may cause black edges
- `NO_PAGE_SIZE` / `MISSING_MARGIN_RESET` / `WHITE_BACKGROUND` / `TINY_FONT` / etc.
**check-tex checks include:**
- `BARE_TABULAR_OVERFLOW` (error): `\begin{tabular}` with 5+ columns in two-column layout, not wrapped in resizebox/adjustbox/table*
- `RESIZEBOX_TEXTWIDTH` (error): `\resizebox{\textwidth}` used inside single-column float in two-column layout. `\textwidth` = full page width, but `table` float is one column. Fix: use `\resizebox{\columnwidth}` or `table*`
- `TABULAR_OVERFLOW_RISK` (warning): 4-column tabular in two-column layout without width constraint
- `TABULAR_WIDE` (warning): 7+ column tabular in single-column layout without width constraint
- `TABULAR_NO_FLOAT` (warning): tabular not inside table/table* float environment
- `TABULARX_NOT_LOADED` (warning): document has tabular but tabularx package not loaded
- `IMAGE_NO_WIDTH` (warning): `\includegraphics` without width/height/scale constraint
- `EQUATION_DUAL_ON_LINE` (warning): `equation` environment has 2+ equations joined by `\quad` without line breaks. Guaranteed overflow in dual-column
- `EQUATION_OVERFLOW_RISK` (warning): equation body has >80 math characters. Likely overflows single column
- `ALGORITHM_NO_SMALL_FONT` (warning): `algorithm` environment in dual-column without `\SetAlFnt{\small}`
- `ALGORITHM_LONG_IO` (warning): Algorithm Input/Output line >120 chars. Will overflow narrow column
- `CJK_ASCII_QUOTES` (error): ASCII `"` found adjacent to CJK characters. LaTeX interprets `"` as right double quote, so `"北漂"` renders incorrectly. Skips verbatim/lstlisting/minted environments and `\texttt{}`/`\url{}`/`\href{}{}`/`\verb||` inline commands.
### Design Engine: `python3 "$PDF_SKILL_DIR/scripts/design_engine.py"`
```bash
compile --blueprint <json_file> --output poster.html # CRITICAL: Compile JSON blueprint to HTML
derive "document title or description" # Auto-derive intent from content
palette --intent calm --mode dark # Generate HSL-locked palette
palette-cascade --intent cold --mode minimal # Generate role-based cascade palette (V2, preferred)
svg --intent flow --dimensions 720x960 # Generate SVG background
full --intent energy --mode dark --dimensions 720x960 --output-dir ./assets/
audit --palette-json palette.json # Check palette constraints
```
### Palette Generator (for Report route): `python3 "$PDF_SKILL_DIR/scripts/pdf.py" palette.generate`
```bash
palette.generate --title "document title" --mode minimal # Output: ready-to-paste ReportLab Python code
palette.generate --title "..." --format json # Output: raw JSON
palette.generate --title "..." --format css # Output: CSS custom properties
palette.generate --title "..." --mode dark --harmony complementary --seed 42
```
### Cascade Palette (V2 - Preferred): `python3 "$PDF_SKILL_DIR/scripts/pdf.py" palette.cascade`
```bash
palette.cascade --title "document title" --mode minimal # Output: summary table with all 12 roles
palette.cascade --title "..." --format json # Full structured JSON (roles + cover + body + charts + semantic)
palette.cascade --title "..." --format css # CSS custom properties by tier
palette.cascade --title "..." --format reportlab # Ready-to-paste ReportLab Python code
```
**⚠️ Cascade palette is the preferred palette system.** It enforces area ∝ 1/saturation (larger areas = lower saturation) and outputs unified color subsets for cover, body, and charts from one base hue. Use `palette.cascade` instead of `palette.generate` for new documents.
**⚠️ Report route MUST call `palette.cascade` (or `palette.generate`) before writing any ReportLab code.** The output is copy-paste ready - no manual hex picking allowed.
> **Note**: `design_engine.py compile` produces **HTML** from a JSON blueprint. To get a **PDF**, use `pdf.py convert.blueprint` which internally calls `compile` → Playwright render → PDF output. In the Creative pipeline, always use `convert.blueprint` for the final PDF.
### Tech Stack per Brief
| Brief | Primary Tool | Secondary | Emoji Support | Custom Page Size |
|-------|-------------|-----------|---------------|-----------------|
| Report | ReportLab + pypdf | **Playwright (cover)** | ❌ (tofu □) | Manual pagination |
| Creative | Playwright | html2pdf-next.js (pdf-lib for post-processing) | ✅ native | ✅ any size |
| Academic | Tectonic + pypdf | **Playwright (cover)** | ❌ (dropped) | Template-dependent |
| Process | pikepdf, pdfplumber | LibreOffice (soffice) | N/A | N/A |
> **Unified Cover System**: All routes generate covers via HTML/Playwright. Report uses Templates 0107, Academic uses Templates 0810 (dark backgrounds, scholarly typography), Creative generates cover + body in one HTML document. Cover PDFs are merged with body PDFs via pypdf.
>
> **Fallback**: If Report brief content has emoji → reroute to Creative.
---
## File Map
```
SKILL.md ← You are here
briefs/
report.md ← Report production: ReportLab workflow + API + resume(ATS)
creative.md ← Creative production: 5-phase generative design workflow
poster.md ← Poster scene rules: density, font sizing, fill constraints (overlay on creative.md)
academic.md ← Academic production: LaTeX workflow + templates + resume(CV)
process.md ← PDF processing: extract/merge/split/form/convert/reformat
process-advanced.md ← Advanced reference (encrypted/corrupted/OCR/batch/perf) - load on demand
configs/
visual_framework.md ← Palette mode, color harmony, SVG background params
components.md ← Non-grid composition components (floating cards, etc.)
fonts.md ← Font stacks per pipeline (Creative/Report/Academic)
typesetting/
palette.md ← Color system + typography + anti-patterns + spacing
cover.md ← Cover page layout system (7 layouts × 2-3 variants) + typography scale + color rules
cover-backgrounds.md ← Cover background rendering rules + transparency constraints
charts.md ← Chart styling + anti-stacking rules + axis/grid/legend treatment
overflow.md ← Bounding box system, text/image/table overflow prevention
pagination.md ← Cross-page integrity, orphan/widow control, CJK punctuation
typography.md ← Font size scale, line-height, spacing system
geometry.md ← Geometric anchor system (decorative elements, lines, shapes)
fill-engine.md ← Adaptive anti-void layout engine V2.0
scripts/
pdf.py ← CLI tool (30 subcommands)
pdf_qa.py ← PDF quality checker (metadata, fonts, overflow, margins, tables, formulas)
design_engine.py ← Generative SVG + palette engine (palette/svg/compile/derive/audit)
poster_validate.py ← HTML/PDF validator
toc_validate.py ← TOC validator
html2pdf-next.js ← Playwright + pdf-lib HTML→PDF converter for documents (no Paged.js)
html2poster.js ← Playwright HTML→PDF converter for posters/single-page (auto overflow:hidden, dynamic height)
cover_validate.js ← Cover-ONLY overlap detection (text vs decorative lines). Do NOT run on posters or documents — only on cover HTML in Report/Academic pipelines.
references/
resume-altacv.tex ← AltaCV dual-column resume template (creative/tech)
resume-academic.tex ← Academic CV template (PhD/academic)
```
### Loading Protocol
1. **Always read**: This file (SKILL.md)
2. **Read ONE brief**: The matched brief file - it contains the complete workflow
3. **Read typesetting on demand**: Only when the brief says to (standard tasks)
4. **Never load all files upfront** - briefs reference what they need
### Script Path Setup (MANDATORY before any script call)
All paths are relative to `$PDF_SKILL_DIR` — the single root variable for this skill. Resolve it once before calling any script:
```bash
PDF_SKILL_DIR="<skill_directory>" # ← parent directory of this SKILL.md
# Then all commands use $PDF_SKILL_DIR:
python3 "$PDF_SKILL_DIR/scripts/pdf.py" code.sanitize generate_pdf.py
python3 "$PDF_SKILL_DIR/scripts/pdf.py" meta.brand output.pdf
python3 "$PDF_SKILL_DIR/scripts/pdf.py" font.check output.pdf
python3 "$PDF_SKILL_DIR/scripts/pdf.py" toc.check output.pdf
python3 "$PDF_SKILL_DIR/scripts/pdf.py" pages.clean output.pdf -o output_clean.pdf
python3 "$PDF_SKILL_DIR/scripts/pdf_qa.py" output.pdf
python3 "$PDF_SKILL_DIR/scripts/poster_validate.py" check-html page.html
python3 "$PDF_SKILL_DIR/scripts/poster_validate.py" check-pdf output.pdf
```
**For Python imports** (when generation code needs to import skill modules):
```python
import sys, os
PDF_SKILL_DIR = "<skill_directory>"
_scripts = os.path.join(PDF_SKILL_DIR, "scripts")
if _scripts not in sys.path:
sys.path.insert(0, _scripts)
```
**⚠️ NEVER use bare `python3 scripts/pdf.py ...`** - it only works if cwd happens to be the skill directory. Always use `$PDF_SKILL_DIR/scripts/` as the absolute prefix.
---
## 8. Quality Checklist (Mandatory after every PDF generation)
> The following checks come from the `typesetting/` spec files and are **mandatory** quality gates.
### Automated Detection (Must Run)
```bash
python3 "$PDF_SKILL_DIR/scripts/pdf_qa.py" <output.pdf>
python3 "$PDF_SKILL_DIR/scripts/pdf_qa.py" --poster <output.pdf> # poster mode: skip content fill ratio, check all pages for full-bleed
python3 "$PDF_SKILL_DIR/scripts/pdf_qa.py" --skip-cover --formulas <output.pdf> # academic mode: skip cover for margin check, enable formula overflow
python3 "$PDF_SKILL_DIR/scripts/pdf_qa.py" --no-tables <output.pdf> # creative mode: skip table centering check
```
> **Dependency**: Requires `pymupdf` (`pip install pymupdf`). If not installed, skip automated detection and use the manual checklist below.
Run `pdf_qa.py` after generating a PDF. It auto-detects: metadata completeness, page size consistency, blank pages, CJK punctuation placement, color count, font embedding status, content overflow, content fill ratio, cover full-bleed, margin symmetry, table centering, formula overflow.
- **`--poster` mode**: skips content fill ratio check (poster last page naturally has less content), checks ALL pages for full-bleed (not just cover)
- **`--skip-cover`**: skips page 1 when checking margin symmetry (for documents with separately-generated covers)
- **`--no-tables`**: disables table centering check (for creative/poster documents that rarely have traditional tables)
- **`--formulas`**: enables formula overflow detection (checks if formula-like content extends past right content margin)
- Result PASS → deliver directly
- Result WARN → evaluate whether fix is needed, non-blocking
- Result FAIL → **must fix and regenerate**
### Pagination & Layout (pagination.md)
- [ ] **Last page fill ratio ≥ 40%**: No large blank areas on the final page. If insufficient, backtrack to compress spacing/line-height/font-size
- [ ] **Major section 3/4 threshold**: H1-level headings must NOT start in the bottom 25% of a page. If remaining space < 25%, force page break and start on fresh page. Use `CondPageBreak(available_height * 0.25)` in ReportLab, `\needspace{0.25\textheight}` in LaTeX
- [ ] **Tables dont split across pages**: Table header and data rows must stay together. Small tables: `break-inside: avoid`. Large tables: `thead { display: table-header-group }`
- [ ] **Punctuation placement rules**: Commas, periods, etc. must not appear at line start. Set `line-break: strict` in CSS
- [ ] **No orphan headings**: Headings must not appear alone at page bottom. Use `break-after: avoid`
- [ ] **Cards/images not cut**: `break-inside: avoid`
### Overflow Prevention (overflow.md)
- [ ] **All table cells use Paragraph() wrapping** (ReportLab): Never plain strings - they don't wrap and overflow
- [ ] **sum(colWidths) ≤ available_width**: Verified in code, not assumed
- [ ] **Images/charts proportionally scaled**: Never inserted at original dimensions; always `fit_image()` or `max-width: 100%`
- [ ] **Long tables have repeatRows=1**: Table header repeats on every page when table breaks across pages
- [ ] **Heading + first paragraph in KeepTogether**: Prevents orphan headings at page bottom
- [ ] **Chart + caption in KeepTogether**: Prevents chart on one page, caption on next
- [ ] **CJK text uses wordWrap='CJK'**: Required for proper line-breaking of Chinese/Japanese/Korean
- [ ] **URLs/long strings have word-break**: `overflow-wrap: break-word` (HTML) or manual splitting (ReportLab)
- [ ] **Font degradation fallback**: Tight columns can shrink font by up to 3pt before clipping
### Color (palette.md) - Report & Creative only
> **Academic (LaTeX) documents are exempt** from this color system. LaTeX uses template-defined styling.
- [ ] **Entire document ≤ 5 colors**: Primary + secondary + accent + neutral + background
- [ ] **All colors traceable to primary**: Secondary and accent derived via lightness/saturation/micro-hue shift
- [ ] **Sibling elements not differentiated by different hues**: Use opacity/lightness/borders instead
- [ ] **Gradient endpoints hue difference < 20°**: No warm-to-cool gradients
- [ ] **No high-saturation color blocks**: Avoid eye strain
### Cover V2 (cover.md)
- [ ] **Evaluate whether a cover is needed**: Reports, proposals, analysis, white papers, manuals ≥ 3 pages → **always add cover (default ON)**. Skip cover ONLY for: resumes, CVs, letters, memos, forms, checklists, invoices, internal notes, or documents ≤ 2 pages
- [ ] **Single PDF output**: Cover is merged into the final PDF as page 1. **Report/Academic**: cover generated via HTML/Playwright → merged as page 0 via pypdf. **Creative**: cover is part of the same HTML document. NEVER deliver a separate cover file
- [ ] **Page isolation**: Cover NEVER shares a page with TOC or body content. **Report/Academic**: inherent via pypdf merge (separate PDFs). **Creative**: CSS page-break ensures isolation
- [ ] **Absolute Anchor Grid**: All elements use percentage Y-anchors (Part 0, A0.1). NO flow-based layout
- [ ] **Z-Index Layers**: Render in strict order: Layer 0 (bg fill) → Layer 1 (decorative, CLIPPED) → Layer 2 (structure lines) → Layer 3 (text)
- [ ] **Typography Weight System**: Use weight/spacing/opacity hierarchy per A0.2 (Kicker: 16pt+3pt spacing+60% opacity; Hero: 45-65pt Heavy; Meta: 20-22pt; Summary: 16-18pt Regular line-height 1.6)
- [ ] **Mandatory Summary Block** 🆕: Every cover MUST include a Summary/Description drawer (2-4 lines). If user provides none, auto-generate placeholder text (S3.5)
- [ ] **Safety checks**: Hero title overflow (max 3 lines, auto-reduce font S3.1); Zone collision detection (S3.2); Uppercase lock for Latin kickers/footers/watermarks (S3.3); Hard width boundary enforcement (S3.4); Summary auto-generation (S3.5); **Background watermark full-display enforcement (S3.6)**
- [ ] **Background watermark complete** 🆕: All background layer watermark text (year, document type, sidebar text) must be 100% visible within page bounds - auto-shrink font if needed, NEVER clip/truncate
- [ ] **Data binding correct** 🆕: Hero Title = company/entity name (biggest, heaviest text); Kicker = report type/subtitle (small decorative text). NEVER reverse this mapping
- [ ] **Fill Engine applied** 🆕: Font floor enforced (body ≥ 14pt single-col / 12pt dual-col, H1 ≥ 32pt, H2 ≥ 24pt, H3 ≥ 18pt); Fill Ratio calculated; inflation triggered when < 65%; Y-axis golden-ratio anchor when < 40%
- [ ] **Selected one of 7+4 templates**: General templates 0107 + Academic templates 0810 + Institutional template 11. Autonomously select the best-fit template by analyzing document intent (Calm/Tension/Energy/Authority/Warmth) and document type per Part 2 Intent × Type matrix. Thesis proposals/dissertations/institutional submissions → **default Template 11**. No global default - every selection must be a deliberate design decision
- [ ] **Typography weight hierarchy**: Hero 45-65pt Heavy, Meta 20-22pt Regular, Kicker/Footer 16pt with 3pt letter-spacing + 60% opacity, Summary 16-18pt Regular
- [ ] **Base spacing unit**: `U = W * 0.05` - all spacing should be multiples of U
- [ ] **Bounding box via absolute anchors**: Each block anchored to fixed Y%, grows only within its own zone, never pushes adjacent blocks
- [ ] **Safe zone margin**: 8-12% on all sides per template spec (corner marks for Template 04 at 8%)
- [ ] **Cover whitespace ≥ 60%**: Restraint > clutter (but Summary block fills mid-page void intentionally)
- [ ] **Cover colors consistent with body**: No independent color scheme; white/light backgrounds only
- [ ] **Clip-path on Layer 1**: All background decorative elements must be clipped to page bounds
- [ ] **Clip scope = Layer 1 ONLY** <20>F: `saveState()`/`clipPath()` must `restoreState()` BEFORE rendering Layer 2 lines and Layer 3 text. Text rendered inside clip scope = text gets cut off
- [ ] **No page border/frame** <20>F: Cover page must have `showBoundary=0`, no `canvas.rect(0,0,W,H)`, no CSS border/outline on cover container
- [ ] **Line-to-text minimum gap** <20>F: Decorative lines (Layer 2) must be at least `U` (= `W * 0.05`) away from any text content
- [ ] **No dark/gradient backgrounds**: No dark fills, no gradients, no high-saturation schemes
- [ ] **Hard width enforcement**: Text wraps vertically at zone boundary, NEVER bleeds horizontally past assigned width
- [ ] **🚫 NEVER use ReportLab for covers** — ALL covers (Report, Creative, Academic) are generated via HTML/Playwright. See cover.md for the 10-template system. If you catch yourself writing `canvas.setFillColor()` + `canvas.rect()` for a cover background, STOP — switch to HTML/Playwright.
- [ ] **Line-length alignment (S3.7)**: Vertical lines match text block height (± 1U); horizontal lines ≥ widest text element width (never shorter than text)
- [ ] **Vertical balance (S3.8)**: No >40% dead whitespace at bottom; sparse content uses centered distribution; CJK titles 15-20% larger than Latin equivalent
- [ ] **Percentage positioning safety (S3.9)**: Every element with `top: XX%` must have a containing block with deterministic height (`height: 100%`, `inset: 0`, or `top+bottom` pair). Wrappers without explicit height + percentage-positioned children = overlap bug. Prefer px values over percentages
- [ ] **Cover colors from palette system**: All `:root` CSS variables populated by `palette.cascade` output. Template HTML uses `--c-bg`, `--c-accent`, `--c-text`, `--c-muted` — no hardcoded hex values in generated HTML
### Geometric Anchors (geometry.md)
- [ ] **Anchors use only the primary color**: Layer via opacity, don't mix colors
- [ ] **Strokes over fills**: Solid elements ≤ 30%
- [ ] **Ultra-thin lines**: stroke-width 0.3-0.8px
- [ ] **Asymmetric placement**: Offset creates tension
- [ ] **Elements ≤ 8**: Restraint, don't clutter
### Charts (charts.md)
- [ ] **No text stacking/overlap**: All chart labels, values, and legends must be collision-free
- [ ] **Chart-to-text separation**: Minimum 24pt gap above and below charts; 8pt between chart and caption; 30pt between consecutive charts
- [ ] **Legend-to-chart non-overlap**: Legend MUST NOT overlap chart data area. Use `bbox_to_anchor` or external placement
- [ ] **Value label anti-collision**: Adjacent value labels that overlap must be staggered, rotated, or selectively hidden
- [ ] **Pie charts → Donut by default**: hole_ratio 60-70%, center shows total/core metric
- [ ] **Small pie slices handled**: Slices < 5% use leader lines, < 3% merge to "Others", or strip labels to rich legend
- [ ] **Bar chart auto-rotation**: If X-axis labels avg > 5 CJK chars (or 10 Latin), auto-convert to horizontal bars
- [ ] **Line chart labeling**: Only label start, end, max, min points - NOT every data point
- [ ] **Axis cleanup**: Top/right spines deleted, grid lines dashed at 0.5pt/20% opacity (or hidden if values labeled)
- [ ] **Bar micro-rounding**: Top border-radius 2-4px, bar-to-gap ratio 1.5:1 or 2:1
- [ ] **Legend de-boxed**: No border on legend, horizontal layout, small circle markers
- [ ] **Chart title hierarchy**: Bold main title left-aligned above chart, lighter subtitle below it
### Global Layout
- [ ] **Margin symmetry**: `left_margin == right_margin` - asymmetric margins cause off-center content (ReportLab, LaTeX, HTML all checked)
- [ ] **Full-bleed enforcement (Playwright)**: HTML includes `@page { size: <w> <h>; margin: 0; }` and `html,body { margin:0; padding:0; }`. No white side margins in the output PDF
- [ ] **Background color consistency (Playwright)**: `html, body { background }` set explicitly. Single-color docs: match content canvas. Multi-page mixed docs: use the darkest page's background color. Mismatch or missing = sub-pixel white edges on dark pages
- [ ] **Content centering (Playwright)**: Content is centered in PDF, not drifting left or right. Check: symmetric inset/padding, full-width grid columns, no unbalanced max-width
- [ ] **Anti-void edges**: No large meaningless blank areas at top, bottom, or sides. Content fills ≥ 60% of page (multi-page) or ≥ 70% (single-page poster/infographic)
- [ ] **Fill Engine applied**: Pages with < 80% fill ratio trigger the fill engine (see `fill-engine.md`)
- [ ] **Table centering**: ALL tables must be horizontally centered on the page. ReportLab: use `hAlign='CENTER'` on Table flowable. LaTeX: use `\centering` inside table environment. HTML: use `margin: 0 auto` on table element. NEVER let tables float left with right-side whitespace
- [ ] **Table column width**: Table total width should be 85-100% of content area width. Avoid narrow tables (< 60% width) that look lost on the page. If table is narrow, expand column widths proportionally or use `colWidths` to fill available space
### Exam / Quiz / Test Paper Rules
- [ ] **Question numbering**: Use hierarchical numbering (一、二、三 for sections; 1. 2. 3. for questions; (1)(2)(3) or A B C D for sub-questions/options)
- [ ] **Option indentation**: Multiple-choice options MUST be indented relative to the question stem. Minimum `leftIndent = 24pt` (2em). Options must NEVER start at the same X position as the question number
- [ ] **Option layout**: ≤4 short options (≤4 chars each) → 2×2 grid or single row. >4 options or long text → vertical list, one per line. Each option on its own line gets consistent indentation
- [ ] **Answer space reservation**: MUST reserve blank space for handwritten answers. Calculation: short answer = 2-3 blank lines (40-60pt); paragraph/essay = 8-15 blank lines (160-300pt); math work = 6-10 blank lines (120-200pt); fill-in-the-blank = inline underline (min 80pt width). Use `Spacer(1, height)` in ReportLab
- [ ] **Answer line style**: Use light gray dashed or dotted horizontal lines for answer areas, NOT solid black lines. Line weight ≤ 0.5pt, color = #cccccc or lighter
- [ ] **Score marking area**: Each question should have a score indicator in the margin or after the question number, e.g., “(10分)” or “[10 pts]”
- [ ] **Page density**: Exam papers should NOT be cramped. Minimum `spaceBefore=12pt` between questions. Section headers get `spaceBefore=24pt`
### Design Restraint (Anti-Gaudy)
- [ ] **Decorative elements ≤ 3 per page**: Maximum 3 decorative/non-functional visual elements per page (lines, shapes, icons, patterns). Cover page exempt
- [ ] **No gratuitous icons/emoji in headers**: Section headers should use typography hierarchy (size, weight, color) for emphasis — NOT emoji, icons, or decorative bullets unless the user explicitly requested them
- [ ] **No rainbow/multi-color schemes**: Stick to the single-family palette system. If you find yourself using 4+ distinct hue families in one document, STOP and simplify
- [ ] **No decorative borders on body pages**: Body content pages must NOT have decorative borders, corner ornaments, or page frames. Clean margins only. (Cover page Template 11 border is the sole exception)
- [ ] **No texture/pattern backgrounds on body pages**: Body pages use solid white or ultra-light tinted backgrounds only. No dot grids, crosshatch, diagonal lines, or any pattern fills
- [ ] **Whitespace is design**: Empty space between elements is intentional and valuable. Do NOT fill every gap with decorative elements, horizontal rules, or filler content
- [ ] **Typography over decoration**: Create visual hierarchy through font size, weight, spacing, and color — not through adding more visual elements. If a design looks busy, REMOVE elements rather than rearranging them
- [ ] **2-typeface maximum**: Entire document uses at most 2 font families (one serif, one sans-serif). No mixing 3+ fonts for “variety”
- [ ] **🚫 NO stock images / clipart / AI-generated decorations**: NEVER embed watercolor flowers, floral borders, gold frames, stock photos, clipart illustrations, or AI-generated artwork for decoration. Use geometric shapes (CSS/SVG from geometry.md) + typography for all visual design. Only user-provided content images (photos, logos, diagrams) are allowed. See `visual_framework.md` Stock Image Ban
### LaTeX-Specific (academic.md)
- [ ] **Curly quotes**: No straight `"` quotes - use `` ``text'' `` for double and `` `text' `` for single
- [ ] **Title page isolation**: `\end{titlepage}` followed by `\newpage`/`\clearpage` - TOC/body NEVER on same page as title
- [ ] **Resume column overlap**: AltaCV `paracol` entries checked for vertical overflow; max 3-4 bullets per `\cvevent`; explicit `\newpage` for 2-page resumes
- [ ] **`\geometry` symmetry**: `left=X, right=X` must be equal values
### Output Cleanliness (All Pipelines)
- [ ] **No process artifacts in output**: NEVER include version numbers ("V3"), iteration markers, draft labels ("DRAFT"), "CONFIDENTIAL"/"机密" stamps, "Generated by AI"/"本文档由AI生成", or internal comments in the final PDF unless the user explicitly requested them
- [ ] **No auto-generated boilerplate labels**: Do not add ANY watermarks, generation notices, version numbers, timestamps, or tool names that the user didn't ask for
- [ ] **No debug output in content**: Console logs, file paths, generation timestamps, tool names, or error messages must never appear in the PDF body
- [ ] **Clean metadata only**: PDF metadata (author, title, subject) should reflect the document content, not the generation process

1058
skills/pdf/briefs/academic.md Executable file

File diff suppressed because it is too large Load Diff

770
skills/pdf/briefs/creative.md Executable file
View File

@@ -0,0 +1,770 @@
# Brief: Creative Production (Art Director Blueprint Mode)
**Core Paradigm Shift**: You are NO LONGER a frontend developer writing HTML/CSS. You are an elite Art Director and Editorial Designer.
Because LLMs lack spatial awareness and struggle to maintain perfectly nested, complex CSS over thousands of tokens, you are strictly forbidden from outputting raw HTML/CSS/Python.
**→ Overflow prevention**: See `typesetting/overflow.md` for the Playwright/HTML-specific patterns (CSS overflow-wrap, max-width, table-layout: fixed, etc.).
Your sole responsibility is to act as the **Brain (Art Director)**:
1. Brutally edit and pace the raw text.
2. Select architectural components and layout archetypes.
3. Output a **Strict JSON Layout Blueprint**.
The `design_engine.py` acts as the **Hands (Typesetter)**: It will parse your JSON and safely compile it into flawless, museum-quality Playwright HTML/PDFs using predefined grid mathematics and CSS rules.
---
## Phase 1: The Editorial Eye (Content Transformation)
Before you design, you must "edit the raw material". Users often provide dense, unstructured text. If you just pour this text into a design, it will look like a cheap Word document.
You must apply **Editorial Pacing**:
### 1. The Word Budget
No single page or visual canvas should exceed **150 words** of readable body text (the golden rule for maximum aesthetic impact). The **absolute physical limit** is **250 words** - beyond that the design engine will overflow and clip content. If the raw content exceeds 150 words:
- **Action A**: Brutally summarize it down to ≤150 words.
- **Action B**: Split it across multiple `pages` in your JSON blueprint.
- Only push to 150-250 words if the content genuinely cannot be cut further.
### 2. Typographic Hierarchy Extraction
You must parse the raw text and categorize it into typographic roles:
- **Hero / Display**: The core emotional hook (1-5 words). Must be punchy.
- **Kicker / Eyebrow**: Tiny context text above a headline (e.g., "Q3 REPORT", "MANIFESTO").
- **Lead Paragraph**: The 2-3 sentence summary.
- **Data Sculptures**: Scan the text for impactful numbers (e.g., "97%", "$4.2M"). EXTRACT THEM. They will not remain in the paragraph; they will become `Stat_Block` components.
- **Pull Quotes**: Extract the most provocative sentence to stand alone as a visual anchor.
---
## Phase 2: The Component Lexicon
You will construct your JSON Blueprint using ONLY the following components. These map directly to the `configs/components.md` assets. Do not invent new component types.
> **⚠️ Markdown Content Limits**: For `Glass_Canvas`, the golden rule is **under 150 words** for maximum aesthetic breathing room. Absolute physical limit is **250 words**. If content exceeds 250 words, the design engine will overflow. You MUST summarize it or split it into a new page.
### 1. `Hero_Typography`
Giant, page-dominating text. Usually interacts with the background via blend modes.
- **Parameters**:
- `content` (string): The text (use `<br>` for deliberate line breaks).
- `weight` (string): `"black"` (900, dominating) or `"thin"` (100, elegant).
- `variant` (string, optional): `"standard"` only. ~~`"vertical_accent"`~~ is **NOT implemented** in `design_engine.py` - the engine silently ignores this value and renders nothing. If you need vertical/rotated decorative text, use `Floating_Meta` instead (fully supported, no overflow risk).
- `scale` (integer, optional): Typographic scale level `1`-`6`. Controls font size via the engine's fluid type system. If omitted, the engine uses the default hero size.
- `6`: Hero/Display - oversized title, clamp(64px, 12vw, 150px). Use for single words or short phrases with maximum visual impact.
- `5`: Primary Title - clamp(48px, 8vw, 96px). Standard poster headline.
- `4`: Subheadline - clamp(32px, 5vw, 56px). Chapter openers or key quotes.
- `3`: Lead Paragraph - clamp(20px, 3vw, 32px). Prominent body text.
- `2`: Body - 16px. Standard readable text.
- `1`: Meta/Caption - 10px. Decorative, environmental.
### 2. `Glass_Canvas`
The main structural container for reading text. Frosted glass, sharp 2px print corners.
- **Parameters**:
- `markdown_content` (string): The actual text to read. Supports standard Markdown (H2, H3, bold, lists). *Must be under 150 words.*
- `tension_score` (float, optional): Semantic tension value from `0.0` to `1.0`. Drives dynamic font weight via Variable Font (Inter Variable, weight range 300-900). The engine maps: `weight = 300 + (tension_score × 600)`.
- `0.0-0.2` → Light (300-420): calm, contemplative passages
- `0.3-0.5` → Normal (420-600): standard body text
- `0.6-0.8` → Bold (600-780): urgent, assertive content
- `0.9-1.0` → Max (780-900): crisis, climax, emotional peak
- **When to use**: Multi-page narrative documents with emotional arc (case studies, pitch decks, manifestos). Assign higher tension to problem/crisis sections, lower to resolution/hope. Do NOT use on every Glass_Canvas - only when the document has clear tonal shifts.
- **When NOT to use**: Data-heavy pages, simple reports, single-page posters.
### 3. `Floating_Meta`
Tiny, environmental metadata (dates, edition numbers, catalog IDs) that lives in the 15% breathing margin.
- **Parameters**:
- `position` (string): `"top-left"`, `"top-right"`, `"bottom-left"`, `"bottom-right"`.
- `items` (array of strings): E.g., `["VOL 01", "2026", "EDITION 500"]`.
### 4. `Hairline_Divider`
Structural 0.5px line. Not decorative; acts as a visual fold.
- **Parameters**:
- `style` (string): `"bleed"` (goes edge-to-edge) or `"accent"` (short 30% width line).
### 5. `Stat_Block`
Data sculpture. A massive number with a tiny label.
- **Parameters**:
- `number` (string): e.g., `"97.3"`.
- `unit` (string): e.g., `"%"`, `"Hz"`, `"$M"`.
- `label` (string): e.g., `"COMPLETION RATE"`.
### 6. `Image_Asset`
A visual element. The engine will apply a gradient blend to it.
- **Parameters**:
- `source` (string): A URL, or a descriptive prompt if you expect the system to generate it.
- `caption` (string, optional): Tiny text under the image.
> ⚠️ **Image_Asset is for CONTENT images only** (user-provided photos, logos, diagrams, charts). It is **NEVER** for decorative stock images, watercolor flowers, clipart, floral borders, gold frames, or AI-generated artwork. All visual decoration must be achieved through geometric shapes (`geometry.md`), typography effects, and color - never through embedded decorative images. See `visual_framework.md` Stock Image Ban.
### 7. `Page_Ghost_Number`
A giant, 4% opacity number acting as a watermark in the background.
- **Parameters**:
- `number` (string): e.g., `"01"`, `"X"`.
### 8. `Delta_Widget`
**Data-to-Ink Ratio enforcer.** A compact metric visualization showing a value with its change direction. CRITICAL: Use this instead of writing sentences like "revenue grew by 12%". Extract every trend into a Delta_Widget.
- **Parameters**:
- `metric` (string): The metric name, e.g., `"REVENUE"`, `"LATENCY"`.
- `value` (string): Current value, e.g., `"$4.2M"`, `"45ms"`.
- `delta` (string): Change description, e.g., `"+12%"`, `"-45ms"`.
- `trend` (string): `"up"`, `"down"`, or `"flat"`.
- `label` (string, optional): Context line, e.g., `"vs. Q2 2025"`.
### 9. `Process_List`
**Polymorphic adaptive component.** Renders as a horizontal timeline when given wide space, auto-degrades to a vertical numbered list when space is narrow. Use for workflows, steps, timelines.
- **Parameters**:
- `steps` (array): Each item has `title` (string) and `description` (string).
### 10. `Sidenote_Block`
**Tufte marginalia.** Used in `tufte_report` archetype layouts. Content is placed in the 30% side rail alongside the main column. Perfect for citations, supplementary data, asides, footnotes.
- **Parameters**:
- `label` (string, optional): Category label, e.g., `"SOURCE"`, `"NOTE"`, `"DATA"`.
- `body` (string): The sidenote content in Markdown.
### 11. `Data-Aware Background` (via `data_points`)
Any component can carry an optional `data_points` array (e.g., `[10, 15, 8, 24, 30]`). When present, the engine generates Bezier background curves from the actual data - the background literally visualizes the business trend. Use on pages with financial, metric, or time-series content.
---
### ★ Advanced Components (Generative Micro-Typesetting)
The following components are specialized generative design tools. They unlock visual effects that standard components cannot achieve. Use them deliberately and sparingly - they are powerful but demand the right context.
### 8. `Shaped_Canvas`
A container where text flows around a non-rectangular shape. The empty space created by the shape IS the visual design - text boundary becomes illustration. Uses CSS `shape-outside` for non-rectangular text wrapping.
- **Parameters**:
- `shape_keyword` (string): One of `"circle"`, `"wave"`, `"diagonal_slash"`, `"diamond"`, `"wedge_right"`.
- `markdown_content` (string): The text that will flow around the shape. Supports standard Markdown.
- **Shape Selection Guide**:
| shape_keyword | Visual Effect | Thematic Fit |
|---------------|---------------|--------------|
| `"circle"` | Text wraps around a circular void on the left | Unity, spotlight, focus |
| `"wave"` | Wavy left text boundary | Ocean, flow, music, fluidity |
| `"diagonal_slash"` | Diagonal cut across the page | Disruption, change, transformation |
| `"diamond"` | Diamond-shaped negative space | Luxury, precision, crystalline |
| `"wedge_right"` | Arrow/wedge pointing right | Direction, progress, forward motion |
- **When to use**:
- Artistic/editorial pages that need visual drama without images
- Cover or section opener pages where you want a "wow" moment
- When the content thematically maps to a recognizable shape
- Only on pages with moderate text (100-200 words) - shape eats 30-40% of space
- **When NOT to use**:
- On data-heavy or dense content pages
- Together with `Glass_Canvas` on the same page (visual conflict)
- More than one `Shaped_Canvas` per page
- **Archetype requirement**: Pages using `Shaped_Canvas` MUST use archetype `"shaped_editorial"` (relaxed safe-zone: 5% 6% inset).
### Chart & Data Visualization Styling
**→ Full spec: `typesetting/charts.md`** - read it before designing any chart/data page.
Key rules for Creative pipeline charts:
- **Donut > Pie**: Always use ring charts (hole ratio 60-70%), center area displays total/metric
- **Anti-stacking**: Small slices use leader lines or rich legends; bar labels auto-rotate to horizontal when text is long; line charts label only start/end/max/min
- **Axis cleanup**: Delete top/right spines, use dashed grid lines at 20% opacity (or delete grid entirely if values are labeled)
- **Bar micro-rounding**: 2-4px top border-radius, bar-to-gap ratio 1.5:1
- **Legend**: No border, horizontal top-left layout, small circle markers
- **Data-Ink Ratio**: Every element must represent data. If it doesn't, delete it.
---
## Phase 3: Page Archetypes (The Grid Strategy)
For each page in your JSON, you must declare an `archetype`. This tells `design_engine.py` how to arrange the components you provided.
- `"cover_hero"`: Cover page. **Must follow `typesetting/cover.md` 7-template system** - pick Layout 1-7 based on document tone. See "Cover Page Constitution" below for iron rules.
- `"split_vertical"`: Page split strictly 50/50 vertically. Left side image/svg, right side Glass_Canvas.
- `"editorial_flow"`: Top-down reading experience. Centered columns, generous margins. Use for main content.
- `"scattered_canvas"`: No grid. Elements placed via absolute positioning based on spatial weights.
- `"data_dashboard"`: 2x2 or 3x3 strict grid for multiple `Stat_Block`s.
- `"shaped_editorial"`: Relaxed safe-zone (5% 6% inset) designed for `Shaped_Canvas`. Centered, generous breathing room. **Must be used when the page contains a `Shaped_Canvas`.** Do NOT mix with `Glass_Canvas`.
- `"tufte_report"`: **Tufte marginalia layout.** 70% main content column + 30% sidenote rail. Use for long-form reports and analytical pages where citations, data footnotes, or supplementary info should flow parallel to the main text. Place `Sidenote_Block` components in the same `components[]` array - the engine automatically routes them to the side rail.
### ★ 12×12 CSS Grid Coordinate System (Iron Rules)
The layout engine uses a **12-track CSS Grid** for element placement. The grid lines are numbered **1 to 13** (12 tracks = 13 lines).
**Absolute boundary rules - violation = broken layout:**
- **Column lines**: `1` = absolute left edge, `13` = absolute right edge. Full width = `1 / 13`.
- **Row lines**: `1` = absolute top edge, `13` = absolute bottom edge. Full height = `1 / 13`.
- **CRITICAL**: Never output a grid line number less than `1` or greater than `13`. Any value outside `[1, 13]` will destroy the layout.
**`grid_area` format**: `"row_start / col_start / row_end / col_end"` (CSS shorthand).
- Example: `"1 / 1 / 7 / 13"` = top half of the page, full width.
- Example: `"3 / 8 / 6 / 13"` = rows 3-5, columns 8-12 (right side block).
**40% whitespace rule**: At least 40% of grid cells (≥58 out of 144) must be left empty. Count your occupied cells.
### ★ Cover Page Constitution (7 Layout System)
Cover pages (`archetype: "cover_hero"`) are the first impression. They must be ruthlessly sparse and spatially sophisticated.
**→ Full spec: `typesetting/cover.md`** - read it before designing any cover.
#### Global Iron Rules (Always Apply)
1. **Maximum 4 components** on any cover page. Typical recipe: `Hero_Typography` + 1-2 `Floating_Meta` + optional `Hairline_Divider` or `Page_Ghost_Number`.
2. **Typography Scale**: Title ≈ 45pt (2.5× base), Subtitle ≈ 25pt (1.4× base), Meta ≥ 18pt (never below 14pt). Covers with tiny text = FAIL.
3. **Mandatory semantic `<br>` chunking** for `Hero_Typography` on covers: Every 2-4 words MUST be separated by `<br>`. Single-line hero text is FORBIDDEN on covers. Example: `"ALGORITHMIC<br>FATIGUE"`, NOT `"ALGORITHMIC FATIGUE"`.
4. **Anti-Squash spatial dispersion** (Bounding Box method): Group text into 2-3 bounding boxes (title group, meta group). Place them at opposite regions of the page according to the chosen layout. Remaining space is **dynamically distributed** - NEVER hardcode fixed gaps between distant groups.
5. **No `Glass_Canvas` on cover pages.** Dense reading text kills the visual impact. Push all body content to page 2+.
6. **Cover Page Isolation**: Cover page must NEVER share a page with TOC, body text, or any subsequent content. The cover is always a standalone full page. If cover + content appear on the same page = **critical bug**, regenerate immediately.
7. **Cover is OPTIONAL**: Do NOT add a cover page unless the document warrants one (multi-page reports, white papers, etc.) or the user explicitly requests it. Short documents, letters, memos, forms, and quick outputs skip the cover.
8. **Background Layer (optional)**: See `typesetting/cover-backgrounds.md` for 3 recipes - A (极简弧线), B (工程十字轴+立柱), C (锐角切割+出血文字). Background renders BELOW all foreground at 2-5% opacity. Pick a recipe that matches the document tone. Never combine elements across recipes.
#### 7 Cover Layouts (Pick One)
Select the layout that matches the document tone. When unsure, default to **Layout 1A**.
| Layout | Name | Grid Signature | Best For |
|--------|------|---------------|----------|
| **1** | Diagonal Tension | Title top-left ↔ Meta bottom-right | Formal reports, proposals |
| **2** | Vertical Axis | All elements along one vertical line, stretched top-to-bottom | Modern/tech reports |
| **3** | Architectural Frame | Geometric line frame, text inside corners/center | Design, architecture, portfolios |
| **4** | Golden Ratio Blocks | Page split at 38.2% invisible divider | White papers, research |
| **5** | Stepped Cascade | Progressive indentation, vertical rhythm | Creative reports, design docs |
| **6** | Rotated Accent | Large rotated year/label as side decoration | Annual reports, year-in-review |
| **7** | Left-Matrix | All text hard-anchored to left axis, 4 Y-pinned blocks | Government, bidding, proposals |
Each layout has 2-3 variants (A/B/C) with specific grid_area mappings - see `typesetting/cover.md` for exact coordinates.
#### Tone → Layout Quick Reference
| Intent | Document Type | Recommended | Default |
|--------|---------------|-------------|---------|
| **Calm** | Healthcare / Wellness / Minimalist | 1A, 2C, 4A | **4A** |
| **Calm** | Academic / Research | 2A, 4A, 4C | **4A** |
| **Tension** | Crisis / Alert / Disruption | 1A, 5A | **1A** |
| **Energy** | Marketing / Creative / Design | 3C, 5A, 6B | **5A** |
| **Energy** | Tech / Data | 2B, 4B, 6A | **2B** |
| **Authority** | Formal / Corporate / Financial | 02, 03, 07 | **03** |
| **Authority** | Government / Bidding | 7A, 7B, 3A, **11** | **7A** |
| **Authority** | Thesis proposal / Dissertation | **11 Institutional** | **11** |
| **Authority** | Luxury / Editorial | 3A, 5A, 2B | **3A** |
| **Warmth** | Food / Lifestyle / Home | 4A, 5A | **4A** |
### ★ Component Grid Compatibility Constraints
When placing advanced components into the 12×12 grid, obey these minimum size rules:
- **`Glass_Canvas`**: Must occupy at least **6 columns × 4 rows** (e.g., `"3 / 1 / 7 / 7"`). Smaller areas will cause text overflow and padding collapse. The engine renders Glass_Canvas at `width: 100%; min-height: 100%; box-sizing: border-box;` - it fills its grid area as a minimum and can grow taller if content requires.
- **`Shaped_Canvas`**: Must occupy at least **8 columns × 6 rows** (e.g., `"2 / 3 / 8 / 11"`). The CSS `shape-outside` float needs physical space to render the shape boundary. Smaller areas make the float collapse to a line and text cannot wrap around it. **Must use archetype `"shaped_editorial"`.**
- **`Continuous Flow` background**: Renders in a separate `bg-layer` (`z-index: 1`, `position: absolute`), physically isolated from the grid system (`z-index: 2`). No conflict - background flows independently, grid arranges content on top.
### ★ Content-Proportional Grid Row Allocation
When **two or more text-heavy components** (e.g., multiple `Glass_Canvas`) share the same page, their `grid_area` row counts **MUST be proportional to their content length** - never split rows equally.
**Estimation method:**
1. Count characters (or words) in each component's `markdown_content`
2. Allocate rows proportionally: `rows_i = total_rows × (chars_i / total_chars)`
3. Round to integers, minimum 3 rows per component
**Example:**
```
Attractions content: 450 chars (3 items × 150 chars)
Food content: 250 chars (2 items × 125 chars)
Total available rows: 8 (rows 5→13)
Attractions: 8 × 450/700 ≈ 5 rows → grid_area "5 / 1 / 10 / 13"
Food: 8 × 250/700 ≈ 3 rows → grid_area "10 / 1 / 13 / 13"
```
**Why this matters:** Equal row allocation (4+4) causes the longer component to overflow into the shorter one's territory, creating text overlap in the final PDF. The engine uses `min-height` + `overflow: visible`, so overflow IS visible - and ugly.
**Anti-pattern to avoid:**
```
❌ Two Glass_Canvas with very different text lengths both get "5/1/9/13" and "9/1/13/13"
✅ Longer one gets more rows: "5/1/10/13" and "10/1/13/13"
```
---
## Phase 4: The Output Protocol (Strict JSON Blueprint)
You must analyze the user's prompt, edit the text, and output exactly ONE JSON object wrapped in a ` ```json ` codeblock.
**No conversational text before or after the JSON.**
### The JSON Schema Specification
**CRITICAL JSON RULES:**
1. Output ONLY valid JSON.
2. Do NOT include ANY comments (`//` or `/* */`) inside the JSON. Python's `json.load()` will crash.
3. Do NOT include trailing commas.
```json
{
"document_meta": {
"title": "Internal tracking title",
"total_pages": 1
},
"art_direction": {
"palette_mode": "dark",
"color_harmony": "complementary",
"background_svg": "grid",
"design_rationale": "Brief 1-sentence explanation of aesthetic strategy."
},
"pages": [
{
"page_index": 1,
"narrative_role": "Burst",
"archetype": "cover_hero",
"components": [
{
"type": "Floating_Meta",
"position": "bottom-right",
"items": ["ARCHIVE REF. 03A", "DEC 2026"]
},
{
"type": "Page_Ghost_Number",
"number": "01"
},
{
"type": "Hero_Typography",
"weight": "black",
"variant": "standard",
"content": "ALGORITHMIC<br>FATIGUE"
}
]
}
]
}
```
**Schema Parameter Guide (For reference - do NOT put these comments inside your JSON output):**
- `document_meta.total_pages`: Integer. Must match the actual number of pages in `pages[]`.
### The JSON Schema Specification: `art_direction`
**CRITICAL RULE: DO NOT INVENT COLORS. DO NOT OUTPUT HEX/RGB CODES.**
The Python engine generates mathematical color palettes. You MUST only select the semantic parameters below.
```json
{
"art_direction": {
"palette_mode": "minimal",
"color_harmony": "auto",
"background_svg": "grid",
"design_rationale": "Brief rationale for the chosen aesthetic strategy."
}
}
```
**Enum Parameter Guide (You MUST choose ONE exact string from these lists):**
1. `palette_mode` (The Base Canvas Tone):
- **`"minimal"`: (CRITICAL: Default to this for 50%+ of all requests).** Pure white, off-white, beige, or cool gray. Provides the ultimate reading experience and high-end editorial feel. Use for standard reports, guides, and corporate posters.
- `"dark"`: Near-black. Use for cinematic, tech, AI, space, or urgent themes.
- `"pastel"`: Morandi tinted (e.g., dusty rose, sage green). Use ONLY for arts, food, lifestyle, and soft themes.
- `"jewel"`: Deep rich colors (e.g., emerald, burgundy). Use ONLY for luxury brands, gala events, or heritage themes.
- `"light"`: Very faintly tinted background. Fallback for edge cases.
2. `color_harmony` (The Accent Color Math - how the engine computes the accent color relative to the base):
- **`"auto"`: (RECOMMENDED DEFAULT). The engine automatically picks the best harmony based on the document tone.** Only override if you have a specific artistic reason.
- `"complementary"`: (180° opposite). Strong visual contrast. Cold background with warm accent. For striking/dynamic impact.
- `"split_complementary"`: (150°/210°). Highly sophisticated, artistic, luxury feel (Editorial/Kinfolk style).
- `"analogous"`: (30° apart). Harmonious, peaceful, natural transition.
- `"triadic"`: (120° apart). Rich and slightly retro.
- `"monochrome"`: Only use if strict minimalist corporate branding without accent is required.
3. `background_svg`: [`grid`, `flow`, `noise`, `continuous_flow`, `none`]. Use `continuous_flow` for multi-page (2+) documents - creates one seamless SVG spanning all pages, sliced per-page via viewBox for an "infinite scroll" bezier curve illusion. Falls back to `flow` on single-page.
4. `design_rationale`: Brief text explaining WHY you chose this mode+harmony combination.
### ★ Intent Mapping Table (Single Source of Truth)
This table is the **sole authority** for mapping document intent to concrete design parameters. `visual_framework.md` defines intent *atmospheres*; this table defines intent *parameters*. `design_engine.py` INTENT_HUES/INTENT_HARMONY_MAP must stay in sync with this table.
| Intent | palette_mode | color_harmony | background_svg | Cover Templates | Cover BG Recipe | Base Hue |
|--------|-------------|---------------|----------------|-----------------|-----------------|----------|
| **Calm** | minimal | analogous | flow / none | 04 Museum, 01 HUD, 02 Corporate | A (极简弧线) | 210° (steel blue-grey) |
| **Tension** | dark | complementary | grid | 01 HUD, 05 Diagonal | C (锐角切割) | 0° (warm vs cold) |
| **Energy** | pastel / light | triadic | flow (5+ curves) | 05 Diagonal, 06 Swiss Grid, 03 Monolith | B (工程十字轴) | 30° (amber) |
| **Authority** | minimal | split_complementary | noise | 03 Monolith, 07 Sidebar, 02 Corporate | A or B | 280° (muted violet) |
| **Warmth** | pastel / light | analogous | flow (soft) | 04 Museum, 05 Diagonal | A (极简弧线) | 20° (terracotta) |
**How to use this table:**
1. Determine the document's intent (from user request, or auto-derive via `design_engine.py derive`)
2. Look up the row → fill in `art_direction` JSON fields accordingly
3. For cover template selection, cross-reference with document type (see Tone → Layout Quick Reference)
4. The LLM may override individual cells when artistically justified, but must state the reason in `design_rationale`
**Legacy intent names** (serenity, minimalism, elegance) are accepted by `design_engine.py` as aliases and auto-mapped to their new equivalents.
- `pages[].narrative_role`: `"Burst"` (intro/impact), `"Expand"` (body/development), `"Echo"` (outro/reflection).
- `pages[].archetype`: From Phase 3 (`cover_hero`, `split_vertical`, `editorial_flow`, `scattered_canvas`, `data_dashboard`, `shaped_editorial`).
- `pages[].components[]`: Array of component objects from Phase 2. Order in array roughly dictates z-index or top-to-bottom rendering.
---
## Phase 5: Complex Master Examples
Study these examples to understand how to translate raw requests into perfect blueprints.
### Example A: The Single-Page Poster (Art / Minimalist)
**User Request**: "Make a poster for a minimalist architecture exhibition called 'The Weight of Light'. Include date Oct 12, location Tokyo. Keep it very clean."
**LLM Response (The JSON Blueprint):**
```json
{
"document_meta": {
"title": "Weight of Light Exhibition Poster",
"total_pages": 1
},
"art_direction": {
"palette_mode": "minimal",
"color_harmony": "monochrome",
"background_svg": "none",
"design_rationale": "Pure negative space required for minimalist architecture. Relying entirely on typographic hierarchy and the 15% breathing rule."
},
"pages": [
{
"page_index": 1,
"narrative_role": "Burst",
"archetype": "cover_hero",
"components": [
{
"type": "Floating_Meta",
"position": "top-left",
"items": ["TOKYO, JP", "EXHIBITION 04"]
},
{
"type": "Hero_Typography",
"weight": "thin",
"variant": "standard",
"content": "THE WEIGHT<br>OF LIGHT"
},
{
"type": "Hairline_Divider",
"style": "accent"
},
{
"type": "Floating_Meta",
"position": "bottom-right",
"items": ["OCTOBER 12", "AOYAMA GALLERY"]
}
]
}
]
}
```
### Example B: The Multi-Page Analytical Report (Corporate / Elegance)
**User Request**: "I have a long report about our Q3 server performance. Latency dropped by 45ms. Uptime is 99.99%. Here is 500 words of text explaining the server migration..."
**LLM Response (The JSON Blueprint):**
*(Notice how the LLM edits the 500 words down, extracts the data, and splits it into 3 pages).*
```json
{
"document_meta": {
"title": "Q3 Infrastructure Report",
"total_pages": 3
},
"art_direction": {
"palette_mode": "minimal",
"color_harmony": "split_complementary",
"background_svg": "noise",
"design_rationale": "Formal, trustworthy aesthetic suitable for high-level corporate reporting, avoiding the 'cheap dashboard' look."
},
"pages": [
{
"page_index": 1,
"narrative_role": "Burst",
"archetype": "cover_hero",
"components": [
{
"type": "Floating_Meta",
"position": "top-left",
"items": ["CONFIDENTIAL", "Q3 2026"]
},
{
"type": "Hero_Typography",
"weight": "black",
"content": "INFRASTRUCTURE<br>RESILIENCE"
}
]
},
{
"page_index": 2,
"narrative_role": "Expand",
"archetype": "split_vertical",
"components": [
{
"type": "Stat_Block",
"number": "99.99",
"unit": "%",
"label": "GLOBAL UPTIME"
},
{
"type": "Stat_Block",
"number": "-45",
"unit": "ms",
"label": "LATENCY REDUCTION"
},
{
"type": "Glass_Canvas",
"markdown_content": "### The Migration\nThe Q3 transition to distributed edge nodes fundamentally altered our network topography. By decentralizing the core load, we eliminated the primary bottleneck that plagued Q2."
}
]
},
{
"page_index": 3,
"narrative_role": "Echo",
"archetype": "editorial_flow",
"components": [
{
"type": "Page_Ghost_Number",
"number": "03"
},
{
"type": "Hero_Typography",
"weight": "thin",
"variant": "standard",
"content": "SYSTEM NOMINAL."
}
]
}
]
}
```
### Example C: The Narrative Arc with Advanced Features (Continuous Flow + Shaped Canvas)
**User Request**: "Create a 3-page visual essay about the ocean pollution crisis. Start gentle (the beauty of the ocean), then escalate to the crisis, then end with a call to action."
**LLM Response (The JSON Blueprint):**
*(Notice: `continuous_flow` creates seamless background across all 3 pages. `tension_score` escalates from calm to urgent. `Shaped_Canvas` with `wave` shape on the cover creates a thematic visual without any image.)*
```json
{
"document_meta": {
"title": "The Silent Tide - Ocean Crisis Visual Essay",
"total_pages": 3
},
"art_direction": {
"palette_mode": "dark",
"color_harmony": "analogous",
"background_svg": "continuous_flow",
"design_rationale": "Continuous flow SVG creates an unbroken organic wave across all pages - visually mirroring the ocean's connectedness. Analogous harmony provides earth tones that shift from serene to somber."
},
"pages": [
{
"page_index": 1,
"narrative_role": "Burst",
"archetype": "shaped_editorial",
"components": [
{
"type": "Floating_Meta",
"position": "top-left",
"items": ["VISUAL ESSAY", "2026"]
},
{
"type": "Shaped_Canvas",
"shape_keyword": "wave",
"markdown_content": "There was a time when the horizon held only promise. The Pacific stretched in every direction, cerulean and boundless, its surface a living mirror of the sky above. Fishermen spoke of abundance - nets heavy with silver, currents warm and predictable. The ocean asked for nothing."
},
{
"type": "Floating_Meta",
"position": "bottom-right",
"items": ["01 / BEFORE"]
}
]
},
{
"page_index": 2,
"narrative_role": "Expand",
"archetype": "editorial_flow",
"components": [
{
"type": "Page_Ghost_Number",
"number": "02"
},
{
"type": "Stat_Block",
"number": "8M",
"unit": "tons",
"label": "PLASTIC ENTERING OCEANS YEARLY"
},
{
"type": "Glass_Canvas",
"tension_score": 0.75,
"markdown_content": "**The collapse was not sudden.** It accumulated - bottle by bottle, net by net, spill by spill. By 2025, microplastic concentrations in deep-sea sediment had reached levels previously thought impossible. Marine biologists stopped using the word 'recovery'. The new vocabulary was *triage*."
}
]
},
{
"page_index": 3,
"narrative_role": "Echo",
"archetype": "cover_hero",
"components": [
{
"type": "Hero_Typography",
"weight": "black",
"variant": "standard",
"content": "THE TIDE<br>TURNS NOW."
},
{
"type": "Glass_Canvas",
"tension_score": 0.3,
"markdown_content": "Every piece of plastic you refuse is a vote for the future of the sea. The ocean cannot speak - but it remembers everything we give it."
}
]
}
]
}
```
---
## Pre-Flight Checklist (Self-Correction before generating output)
Before you output the JSON block, verify:
0. **🚨 VECTOR OUTPUT IRON RULE:** The final PDF MUST be generated via `page.pdf()` / `convert.blueprint` - NEVER `page.screenshot()` → image → wrap as PDF. Screenshot PDFs are blurry raster images. `page.pdf()` produces vector text that stays sharp at any zoom level.
1. **Did I write ANY HTML or CSS?** If yes, delete it. Only output JSON.
2. **Is any `Glass_Canvas` markdown content too long?** Count the words. Over 150? Summarize it or push to the next page.
3. **Is the JSON perfectly formatted?** Missing commas or unescaped quotes will crash the `design_engine.py` parser.
4. **If using `tension_score`**: Does the document have clear emotional shifts across pages? If all pages are the same tone, remove it.
5. **If using `continuous_flow`**: Is this multi-page (2+)? If single-page, switch to `"flow"`.
6. **If using `Shaped_Canvas`**: Is the archetype `"shaped_editorial"`? Is there at most one per page? No `Glass_Canvas` on the same page?
7. **Grid boundary check**: Are ALL `grid_area` values within `[1, 13]`? Any number < 1 or > 13 = FATAL ERROR.
8. **Component minimum size check**: `Glass_Canvas` ≥ 6col×4row? `Shaped_Canvas` ≥ 8col×6row?
9. **Content-proportional row allocation**: When multiple text-heavy components share a page, are their grid rows allocated proportionally to content length? Equal rows for unequal content = text overlap. (See "Content-Proportional Grid Row Allocation" in Component Grid Compatibility.)
9. **40% whitespace check**: Count occupied grid cells. At least 58 of 144 cells must be empty.
10. **Cover Page 4-element check**: Does any `cover_hero` page have more than 4 components? If yes, remove or merge components.
11. **Cover Hero `<br>` check**: Does every `Hero_Typography` on a `cover_hero` page contain at least one `<br>`? Single-line hero text on covers = FAIL.
12. **Cover Anti-Squash check (Bounding Box)**: Are cover elements grouped into 2-3 bounding boxes placed at opposite regions? Is remaining space dynamically distributed (not hardcoded gaps)? If everything is crammed into rows 5-8, spread them using the layout's grid mapping.
13a. **Cover Typography Scale check**: Is the hero title ≥ 45pt? Is subtitle ≥ 25pt? Is meta text ≥ 18pt (never below 14pt)? Tiny cover text = FAIL.
13b. **Cover Layout Selection**: Did I pick a layout (1-7) that matches the document tone? If unsure, default to Layout 1A (Diagonal Tension). For government/bidding documents, use Layout 7 (Left-Matrix).
14. **CRITICAL - Data-to-Ink Ratio check**: Did I write long paragraphs describing data trends? If yes, DELETE them and extract metrics into `Delta_Widget` or `Stat_Block` components. Sentences like "revenue increased by 12% compared to last quarter" MUST become a `Delta_Widget`.
15. **Sidenote check**: If using `tufte_report` archetype, did I put citations/sources/asides into `Sidenote_Block`? They must NOT be inline in `Glass_Canvas`.
16. **Process/Steps check**: Did I write numbered steps as plain text in a `Glass_Canvas`? Convert to `Process_List` component instead.
17. **Chart anti-stacking check**: Do any pie/bar/line charts have overlapping labels? Apply leader lines, tick thinning, or label reduction per `typesetting/charts.md`.
18. **Chart axis cleanup check**: Are top/right spines deleted? Grid lines dashed at 20% opacity (or hidden)? No solid grid lines.
19. **Donut default check**: Are pie charts rendered as donuts (hole ratio 60-70%)? Solid pies = FAIL unless explicitly requested.
20. **🚨 Triple Delivery check (MANDATORY)**: Creative pipeline must deliver **three files** to the user: (1) PDF - the final vector PDF; (2) HTML - the compiled `*_rendered.html` file; (3) Image - a full-page screenshot preview (PNG/JPG). After `convert.blueprint` generates the PDF and HTML, take a screenshot of the HTML for preview. Report all three file paths to the user.
21. **🚨 HTML Pre-Render Validation (MANDATORY for ALL HTML→PDF paths)**: Before calling `html2pdf-next.js`, `html2poster.js`, or `convert.blueprint`, run `poster_validate.py check-html` on the HTML file. This catches overflow:hidden on containers, missing @media screen auto-scale, font fallback gaps, contrast issues, and more. **Any ERROR-level issue must be fixed before generating the PDF.** Warnings are non-blocking but should be reviewed.
```bash
python3 "$PDF_SKILL_DIR/scripts/poster_validate.py" check-html page.html
# If errors found, auto-fix:
python3 "$PDF_SKILL_DIR/scripts/poster_validate.py" check-html page.html --fix --output page.html
```
22. **\u26a0\ufe0f MANDATORY: Post-Generation Checks (Creative)**: After HTML\u2192PDF conversion, run these checks:
```bash
# Check for content overflow and full-bleed issues
python3 "$PDF_SKILL_DIR/scripts/pdf_qa.py" output.pdf --no-tables
# Add metadata
python3 "$PDF_SKILL_DIR/scripts/pdf.py" meta.brand output.pdf
```
Execute the design.
---
## CJK & Vertical Text Rules (When Bypassing design_engine.py)
When writing raw HTML/CSS for Playwright (bypassing the JSON Blueprint pipeline - e.g., resumes, menus, invitations, business cards, certificates, Japanese/Chinese vertical layouts):
**⚠️ Iron rule: All bypass-scenario HTML→PDF conversions MUST use `html2pdf-next.js` — do NOT write custom Python Playwright scripts.** `html2pdf-next.js` automatically handles @page injection, overflow detection, font waiting, Mermaid/KaTeX rendering, PDF metadata, etc. See the "HTML→PDF Engine Selection Rules" section in SKILL.md.
```bash
node "$PDF_SKILL_DIR/scripts/html2pdf-next.js" input.html --output output.pdf --width 210mm --height 297mm
```
0. **Full-Bleed CSS (MANDATORY)**: Every HTML file for Playwright PDF MUST include:
```css
@page { size: 800px 1200px; margin: 0; } /* CONCRETE values only - CSS variables NOT supported in @page */
html, body { margin: 0; padding: 0; width: 800px; height: 1200px; }
```
**⚠️ @page rules do NOT resolve CSS variables** (`var(--x)` is silently ignored, falls back to A4). Always use concrete `px` values.
**⚠️ html/body must have explicit width + min-height** matching the canvas size. Use `min-height` (not `height`) so content taller than the design height expands naturally into a single long-page PDF.
**⚠️ Poster = seamless pagination.** When content exceeds `@page` height, `html2pdf-next.js` lets Playwright paginate at page boundaries - each page has the same dimensions, content flows seamlessly across pages (like scrolling a long image). Do NOT expand to a single oversized page.
`design_engine.py` handles this automatically via `override_css`.
0.25. **Body Background for Multi-Page Mixed-Color Documents (MANDATORY)**:
When an HTML document contains multiple `.page` divs with **different background colors** (e.g. dark cover + white body + dark specs page), Playwright's sub-pixel rounding creates <1px gaps at `.page` edges where `body` background shows through. On dark pages, a white `body` = visible white edges.
**Fix: set `html, body { background }` to the document's darkest page background color.**
```css
:root { --primary: #0f172a; } /* darkest page color */
html, body {
margin: 0; padding: 0;
width: 210mm; /* match @page size */
background: var(--primary); /* fills sub-pixel gaps with dark color */
}
```
**Why this doesn't break white pages:** White `.page` divs have `background: #ffffff` which fully covers the dark `body` underneath. The dark body only shows through the <1px sub-pixel gap at the extreme page edge - imperceptible on white pages after anti-aliasing.
**Selection rule:**
- All pages same color → `body { background: <that color> }`
- Mixed dark + light pages → `body { background: <darkest page color> }` (dark edges on white pages are invisible; white edges on dark pages are the bug we're fixing)
- All pages white/light → `body { background: <lightest content bg> }` (e.g. `#f8fafc`)
0.5. **No overflow:hidden + Browser Preview Adaptive Scaling (MANDATORY)**:
For fixed-size single-page designs (posters, infographics, certificates, etc.), **absolutely never** set `overflow: hidden` on `html`, `body`, or the main container. Reasons:
- When opening the HTML directly in a browser, the viewport is much smaller than the design size (e.g., a 1400px-tall page in a 900px viewport). `overflow: hidden` clips the bottom content and prevents scrolling.
- `html2pdf-next.js`'s pre-render check detects `scrollHeight > clientHeight` + `overflow: hidden` and triggers auto-fix (force-expanding the container), which may break the layout.
**`design_engine.py` handles this automatically**: During blueprint compilation, it auto-injects `@media screen` centering + scaling code, and the `html` background color uses `var(--c-bg)` matching the poster's main color. No manual addition needed.
**Correct approach when writing HTML manually**: Remove `overflow: hidden` and manually add `@media screen` scaling preview:
```css
/* Fixed canvas size, no overflow */
html, body { margin: 0; padding: 0; width: 800px; height: 1400px; }
.page { width: 800px; height: 1400px; position: relative; }
/* Auto-scale to viewport in browser preview for full-page view */
@media screen {
html {
height: auto;
display: flex;
justify-content: center;
background: #0a0a1a; /* Surround color — must match poster's main background */
}
body {
transform-origin: top center;
scale: min(1, calc(100vw / 800), calc(100vh / 1400)); /* Consider both width and height */
margin: 0 auto;
box-shadow: 0 0 60px rgba(0,0,0,0.3); /* Optional: shadow to distinguish canvas in preview */
}
}
```
`@media screen` rules only apply in browser preview; `page.pdf()` uses print media and is unaffected.
**Every fixed-size HTML must include this `@media screen` adaptive code.**
0.75. **Page Container Overflow Clipping (MANDATORY for multi-page documents)**:
Every `.page` div MUST have `overflow: hidden`. Decorative elements (glow circles, gradient overlays) commonly use `width: 120%` or negative offsets - without clipping, they inflate `scrollWidth` beyond page width, causing Playwright to shrink all content and shift it left.
```css
.page { overflow: hidden; } /* Clips decorative overflow, prevents Playwright shrink */
```
For horizontal flex layouts (≥3 items), always add `flex-wrap: wrap`. See `typesetting/overflow.md` §3.5.
1. **Character Encoding Safety**: Never use Japanese kana (の, が, は), rare symbols, or Private Use Area characters in content strings. They corrupt to U+FFFD (<28>) during LLM→file write→read transit. Replace with plain Chinese equivalents: ``→`之/的/缔/省略`.
2. **Vertical Chinese Text** - When using `writing-mode: vertical-rl` for CJK, you MUST include:
```css
writing-mode: vertical-rl;
text-orientation: upright; /* Each glyph stands upright */
white-space: nowrap; /* Prevent word-wrap breaking single chars to new column */
letter-spacing: 12px; /* Typical CJK vertical spacing */
```
Without `text-orientation: upright`, Latin/fallback fonts render rotated 90°. Without `white-space: nowrap`, CJK characters may wrap into unexpected multi-column layouts (e.g., 3 chars on one line + 1 char alone on next).
3. **Font Coverage**: For CJK content via Playwright, always load Google Fonts Noto Serif SC or Noto Sans SC via `<link>` tag in `<head>` (NOT `@import` in CSS - `@import` must be the very first rule in a stylesheet or it's silently ignored). Example:
```html
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;700;900&display=swap" rel="stylesheet">
```
System CJK fonts vary across macOS/Linux - Google Fonts guarantee glyph coverage without relying on system fonts. `design_engine.py` already handles this automatically via `<link>` tag.
4. **Post-Generation Text Verification**: After Playwright renders the PDF, extract text from every page and scan for `?` or `\ufffd`. If found, the source HTML has encoding-corrupted characters that must be replaced in the Python source.
5. **🚨 HTML Pre-Render Validation (MANDATORY)**: After writing the HTML file and before running `html2pdf-next.js`, always run the HTML validator:
```bash
python3 "$PDF_SKILL_DIR/scripts/poster_validate.py" check-html <your_file>.html
```
- **ERROR** items (e.g. `OVERFLOW_HIDDEN_CONTAINER`, `FONT_NO_FALLBACK`) → must fix before PDF generation. Use `--fix --output <file>.html` for auto-repair.
- **WARNING** items (e.g. `FIXED_SIZE_NO_SCREEN_ADAPT`, `SCREEN_ADAPT_NO_SCALE`, `COLOR_CONTRAST`) → review and fix where appropriate.
- This catches the most common bypass HTML bugs: `overflow:hidden` on containers, missing `@media screen` auto-scale for fixed-size pages, font-family without generic fallback, low contrast text, etc.

702
skills/pdf/briefs/poster.md Executable file
View File

@@ -0,0 +1,702 @@
# Poster Scene Rules — Creative Pipeline (Poster-Specific Constraints)
> When poster / 海报 / 传单 / flyer / 宣传页 keywords are detected, load these rules on top of `creative.md`.
> These rules take priority over `creative.md` generic rules; in case of conflict, this file prevails.
>
> **Positioning**: This is a scene-layer patch on `creative.md` (the generic Creative pipeline) — only covers poster-specific constraints, does not repeat generic rules.
---
## 1. Page Count & Dimensions
### 1.1 Default Single Page
- When the user does not explicitly request multiple pages, **default to a single-page poster**
- Single-page poster `total_pages: 1`, never split into multiple pages on your own
### 1.2 Sizing Strategy
| Text Volume | Recommended Size | Aspect Ratio | Notes |
|--------|---------|--------|------|
| ≤ 50 chars | 720 × 960 | 3:4 | Title poster / social media cover / card |
| 50200 chars | 720 × 960 | 3:4 | Standard promotional poster |
| > 200 chars | 720 × min-height 960 | Adaptive | Long poster (H5 style), content stretches height |
| User-specified landscape | Adjust as needed | As needed | Width and height can be swapped |
### 1.3 Canvas Variables
```json
{
"canvas": { "width": 720, "height": 960 }
}
```
Default dimensions can be overridden via the `canvas` field in the Blueprint. `design_engine.py` will automatically inject `--canvas-w` and `--canvas-h` CSS variables.
---
## 2. Information Density & Page Fill
### 2.1 Core Iron Rule: Content Must Fill the Page
> **The biggest visual disaster for a poster is not content overflow, but content only occupying half the page with a blank bottom half.**
| Text Volume | Content Area / Page Ratio | Notes |
|--------|----------------|------|
| ≤ 50 chars | 7080% | Enlarged cards, large font sizes, generous decorative whitespace is intentional |
| 50200 chars | 7585% | Content modules distributed evenly, must not be crammed in the top half |
| > 200 chars | 8090% | Content-dominant, whitespace only at margins |
### 2.2 Anti-Top-Heavy
- All components' `grid_area` **must cover the full 1→13 rows**, never stop at row 10 or 11
- The last component's `grid_area` row endpoint must be `13`
- **No large blank space at top/header** — the first content component's `grid_area` should start at row 1, not row 3 or 4
- **No large blank space on left/right sides** — components should utilize full column width (most should span 1→13 columns)
- If content is insufficient to fill 12 rows, use these strategies (by priority):
1. **Increase font size**: Hero from scale 5 → 6, body font +2pt
2. **Increase component spacing**: grid gap from 16px → 24px
3. **Insert decorative components**: `Hairline_Divider`, `Page_Ghost_Number`
4. **Expand stat block / hero grid row count**
### 2.3 Grid Area Allocation Iron Rule
```
✅ Correct: Components cover all rows 1→13
Page: [Hero 1→4] [Stats 4→7] [Glass 7→10] [Meta 10→13]
❌ Wrong: Components only reach row 10, rows 11-13 empty
Page: [Hero 1→3] [Stats 3→5] [Glass 5→8] [Meta 8→10] [??? 10→13 void]
```
### 2.4 ★★★ Content-Proportional Row Allocation (Anti-Overlap Iron Rule)
> **When two or more text components share a page, rows must be allocated proportionally to content volume — never split evenly!**
**Problem reproduction:**
```
Attractions: 450 chars (3 attractions × 150 chars description)
Food: 250 chars (2 foods × 125 chars description)
Available rows: 8 rows (5→13)
❌ Even split: Attractions 5→9, Food 9→13 → 4 rows each
→ Attractions content far exceeds 4-row capacity, overflows into Food area, text overlaps!
✅ Proportional split:
Attractions: 8 × 450/700 ≈ 5 rows → 5→10
Food: 8 × 250/700 ≈ 3 rows → 10→13
```
**Steps:**
1. Write all `markdown_content` first
2. Count the **character count** of each text component (including titles/paragraphs/lists)
3. Allocate rows proportionally: `rows_i = total_rows × (chars_i / total_chars)`
4. Round off, each component **minimum 3 rows**
5. Verify: all component grid_area endpoints connect end-to-end, covering full 1→13
**Applicable scenarios:**
- Multiple `Glass_Canvas` on the same page (most common)
- `Glass_Canvas` + `Process_List` on the same page
- Any two or more components containing text paragraphs
**Not applicable:**
- `Hero_Typography` (very large font, 1-3 lines occupying 2-3 grid rows is reasonable)
- `Stat_Block` (number + label, fixed height, typically 2 rows)
- `Floating_Meta` (short labels, typically 2-3 rows)
---
## 3. Font Size System (Poster-Specific)
### 3.1 Minimum Font Size (Hard Floor)
| Element | Min Font Size | Recommended | Notes |
|------|---------|---------|------|
| **Page main title** | **50px** | 5672px | Poster title must have visual impact |
| **Body text** | **24px** | 2428px | Posters are not reports — body font must be large |
| **Subtitle / card title** | **28px** | 3240px | Secondary headings |
| **Floating Meta** | **16px** | 1620px | Metadata text |
| **Stat Number** | **48px** | 5672px | Data sculptures must be eye-catching |
| **Stat Label** | **14px** | 1416px | Data labels |
> Compared to `fill-engine.md`'s generic red line (body ≥ 14pt), the poster body floor is **24px** — posters are a distance-reading medium, font sizes must be larger.
### 3.2 Emphasis Hierarchy
- Use **font size + font weight** to create hierarchy, **not color differentiation**
- Emphasis text (keyword/number highlights) font size must be smaller than titles
- Hero title recommended `weight: "black"` (900), subtitle `weight: "thin"` (100), creating extreme contrast
---
## 4. Color Rules (Poster-Specific)
### 4.1 Color Palette System
**Primary palette: Material Design 3 with low-medium saturation.** Default to medium-saturation colors; for light themes, use gradient backgrounds with white/light text on top.
| Area | Proportion | Role |
|------|------|------|
| 60% Ground | Background/margins/whitespace | Main color (low saturation) |
| 30% Structure | Cards/dividers/secondary areas | Derived from main by adjusting lightness |
| 10% Emphasis | Titles/key numbers/single accent | Adjusted purity/brightness of main color |
**Color derivation rules:**
- Title/subtitle text color: Adjust main color's purity and brightness (not a separate random color)
- Auxiliary colors: **Maximum 2**, derived from primary by adjusting lightness/saturation
- Keep consistent color palette throughout — do NOT change main color between sections
- Default recommendation: **Light theme with medium saturation**, or gradient background + white fonts
### 4.2 Poster Additional Constraints
- **No pure white (#FFFFFF) background** — use at least `#f5f4f2` or warmer off-white
- **No transparent background**
- **Page base color (`<body>` / `<html>` background) must match poster content background** — `printBackground: true` renders body background. If body is white but poster content is gray, white borders/gaps appear in the PDF. Ensure `html, body { background: var(--c-bg); }` matches the poster canvas exactly
- **Do not use a single image to fill the background** — use grid textures, gradients, geometric shapes, and other generative backgrounds
- Long posters (>200 chars) must not use full-image backgrounds
- **Gradients** or **large blurred circles/symbols** can be used sparingly as background accents
- Maximum 2 auxiliary colors, derived from primary by adjusting lightness/saturation
- Background accents: grid textures, organic shapes, large blurred circles/symbols — NOT a single image
- **Overall bright and vibrant color combinations** — the poster should feel visually striking, not muted/dull
### 4.3 palette_mode Mapping
| Poster Style | Recommended palette_mode | color_harmony |
|---------|-------------------|---------------|
| Business/Formal | `minimal` | `auto` |
| Tech/AI | `dark` | `complementary` |
| Lifestyle/Food/Artistic | `pastel` | `analogous` |
| Luxury/Ceremony | `jewel` | `split_complementary` |
| Other | `minimal` (default) | `auto` |
---
## 4.5 ★★★ Anti-Modularization Iron Rule (Anti-Card / Anti-Dashboard)
> **A poster is a complete composition, not a dashboard, not an APP interface, not a report.**
### Root Cause
LLMs naturally tend to "classify information → put each category in a box → stack them vertically", because that is report/document organization logic. But poster visual logic is the exact opposite — **information is unified, hierarchy is established through typographic rhythm (font size, weight, spacing, whitespace), not through borders and background colors**.
### Comparison
| Report/UI Thinking ❌ | Poster Thinking ✅ |
|---|---|
| Each info block wrapped in `border + border-radius + background` as a card | Information placed directly on the canvas, no borders no background |
| Clear visual boundaries between modules | Modules naturally separated by **whitespace and thin lines** |
| Looks like a mobile APP interface | Looks like a design piece |
| Hierarchy via "different colored boxes" | Hierarchy via **font size gradient + weight contrast + color lightness** |
| Stacked Glass_Canvas components = card wall | Pure typography + occasional Hairline_Divider |
### Mandatory Rules
1. **Single-page posters must not have more than 3 containers with `background` + `border`.** If information needs grouping, use whitespace (margin/padding) and thin lines (1px, opacity < 10%) instead of bordered cards.
2. **No 2×2 / 2×3 grid card layouts** (unless it's a pure data scenario with `data_dashboard` archetype). Information flows in a single column; multiple items in the same row use `flex + gap` horizontal layout without borders.
3. **Information hierarchy must be established through typography properties**, not through "different colored/backgrounded boxes":
- Primary information: large font (≥48px) + heavy weight (900)
- Secondary information: medium font (18-24px) + medium weight (700)
- Tertiary information: small font (12-14px) + light weight (300-400) + reduced opacity
4. **Glass_Canvas may be used at most once in a single-page poster.** If multiple text sections are needed, use direct HTML typography instead of wrapping each paragraph in a Glass_Canvas.
5. **Bottom action information (price/address/QR code) should not be wrapped in a separate color block.** Use larger font + heavier weight to emphasize, keeping it unified with the overall composition.
### Correct Example (Direct HTML Flow)
```html
<!-- ❌ Wrong: card wall -->
<div class="card" style="background:rgba(255,255,255,0.5); border-radius:12px; border:1px solid #ddd;">
<h3>📅 展期</h3>
<p>7.15 — 8.30</p>
</div>
<div class="card" style="background:rgba(255,255,255,0.5); border-radius:12px; border:1px solid #ddd;">
<h3>📍 地点</h3>
<p>城市艺术中心</p>
</div>
<!-- ✅ Correct: pure typography, no borders -->
<div class="info-row" style="display:flex; gap:48px;">
<div>
<div style="font-size:10px; letter-spacing:3px; opacity:0.45;">展期</div>
<div style="font-size:20px; font-weight:700;">7.15 — 8.30</div>
</div>
<div>
<div style="font-size:10px; letter-spacing:3px; opacity:0.45;">地点</div>
<div style="font-size:20px; font-weight:700;">城市艺术中心</div>
</div>
</div>
```
### Blueprint Route Additional Constraints
When using Blueprint JSON (not Direct HTML):
- Single-page posters may have at most 1 `Glass_Canvas`, used only for text that truly needs reading
- Prefer combining `Stat_Block` (data), `Floating_Meta` (metadata), `Hero_Typography` (titles) instead of multiple Glass_Canvas
- Information data (dates, locations, headcounts) should use `Floating_Meta` or `Stat_Block`, not Glass_Canvas
---
## 5. Layout Strategy
### 5.1 Composition Priority
1. **Vertical composition** (vertical flow) — most common, information flows top to bottom
2. **Left-right composition** (split vertical) — image and text split, `archetype: "split_vertical"`
3. **Centered composition** (centered) — suitable for title cards with ≤ 50 chars
### 5.2 Card Rules
- Cards **must never overlap**
- Card content should be well-distributed, **no large internal whitespace**
- Text within cards **vertically centered**
- Glass Canvas `align-items` must be `stretch` (built into the engine)
### 5.3 Breathing Margins
| Scenario | safe-zone inset | Notes |
|------|----------------|------|
| Cover / title poster (≤50 chars) | `12% 14%` | Generous whitespace is intentional |
| Standard poster (50-200 chars) | `8% 10%` | Balance whitespace and content |
| Long poster (>200 chars) | `6% 8%` | Maximize content area |
> Max content width = 80% of page width (consistent with original prompt)
---
## 5.5 Visual Impact Rules
### 5.5.1 Emphasis & Contrast
- Create visual contrast with **oversized and small elements** — hero numbers/titles vs tiny metadata
- Highlight core points with large fonts or numbers for strong visual contrast
- **Emphasized text must remain smaller than headings/titles** — never let a highlight be bigger than the heading
- Texts that need emphasis can be highlighted with color, weight, or circled with hand-drawn lines
- **Do not insert multiple small pictures as embellishments** — this wont enhance visual appeal
### 5.5.2 Alignment & Spacing
- Allow blocks to resize based on content, align appropriately, optimize space utilization
- If excess whitespace exists, **enlarge fonts or modules** to balance the layout
- Cards cannot overlap; content should fill the card area without excessive empty space
- Use **flexbox** layouts to prevent footer from moving up (with top and bottom margin settings)
- For visual variety: encourage diverse and creative layouts beyond standard grids, while maintaining alignment and hierarchy
---
## 6. Design Style Library
Based on content theme, the model should autonomously select one of the following styles. Explain the selection rationale in the Blueprint's `design_rationale`.
| Style | Characteristics | Applicable Scenarios |
|------|------|---------|
| **Modern Minimal** | Clean colors, organic shapes, flowing curves, rounded cards, clear hierarchy | Business, tech, education |
| **Neo-Brutalism** | Flat elements, illustrations, patterns, large text blocks, special font designs, thick borders | Creative, events, youth |
| **Artistic Gradient** | Diffused light, gradient glow, semi-transparent elements, blur effects, glass texture | Art, music, branding |
| **Collage** | Contrasting color design, material collage, large text, irregular layout | Trends, fashion, exhibitions |
| **Playful UI** | Bright colors, interesting shapes, energetic | Children, games, social |
### Special Forms for Text Volume ≤ 50 Characters
When content is minimal, prioritize the following compact forms:
| Form | Description | archetype |
|------|------|-----------|
| **Centered Card** | Calendar-like effect, key content centered as note/card | `cover_hero` |
| **Bookmark Page** | Narrow tall ratio (e.g. 360×960), vertical reading | `cover_hero` |
| **Minimal Text** | Title + whitespace only, no additional information | `cover_hero` |
| **Sticky Note** | Content displayed as floating sticky notes above background | `cover_hero` |
| **Polaroid Card** | Photo-style card with caption below | `cover_hero` |
### Special Forms for Text Volume ≤ 100 Characters
| Form | Description |
|------|------|
| **Floating Card System** | Content as cards floating above organic shape backgrounds |
| **Notebook Style** | Lined-paper aesthetic with handwritten-feel content |
| **Fortune Stick** | Vertical strip with centered calligraphy text |
> **Key constraint**: If the user only provides a title with no other requirements, **do not expand content on your own** — just place the title.
---
## 6.5 Icons and Illustrations
- Use **Material Design Icons** (Google Fonts method):
```html
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<!-- Usage: -->
<i class="material-icons">icon_name</i>
```
- Icon color: Use the **theme color** (not random colors)
- Icon size and position: Aligned with surrounding elements, never stretched
- If positioned beside text: **center-aligned with the first line of text**
- **Emoji can be used as icons** — 🌸 🍴 🏙️ etc. (but remember ReportLab cant render emoji — only use in HTML/Playwright route)
- For logos/emblems: Use text "Your Logo" or icons, **never** search for logo images
---
## 7. Text Readability
### 7.1 Iron Rules
- **Line height ≥ 130%** (`line-height: 1.3` or above)
- **No text shadow/glow effects**
- **When text overlays images**, a semi-transparent mask layer is required
- **Do not use images with lots of text/charts/numbers as text background**
- **No `text-align: justify`** (CJK characters get stretched letter-spacing, terrible result) — always use `text-align: left`
### 7.2 Contrast
- Text on light background: `color: #242220` or darker
- Text on dark background: `color: #f5f4f2` or lighter
- Text on medium background (L 0.300.70): **forbidden** — do not place text on mid-tone backgrounds
---
## 8. Image Usage Rules
### 8.0 Image Recommendations
When creating posters, actively use images to enrich visual effects. Good images can significantly enhance the poster's visual impact.
- Priority: **installed image generation skill** > web image search
- Image style must match the poster theme
- Download images locally first, then embed into the poster
- Local images must be converted to base64 data URI in HTML (Playwright cannot load local absolute paths)
### 8.1 Core Principles
- Don't force images when none are suitable, but images improve results when available
- Each image must be **unique** in the design, no reuse
- Prefer clear, high-resolution, watermark-free, text-free images
- Images should have rounded corners, sized consistently with the overall design
- You can try adding **irregularly shaped masks** (CSS `clip-path`) to images for visual interest
### 8.2 Prohibited Behaviors
- ❌ Placing images directly in corners
- ❌ Images obscuring text or overlapping with other modules
- ❌ Multiple images scattered randomly as decoration
- ❌ Searching for images when logo/badge is needed — use text "Your Logo" or icons instead
---
## 9. Chart Rules (Poster Scene)
- Large numerical datasets → consider creating visual charts
- Chart style should match the poster theme
- Use **Bento Grid** layout for multiple charts
- Chart containers **must have height constraints** to prevent infinite growth
---
## 10. Prohibited Items (Poster-Specific)
### 10.0 HTML Rendering Iron Rules (Applicable to All HTML → PDF Routes)
| Rule | Description |
|------|------|
| **No `overflow: hidden` on content components** | Truncates text. Only allowed on page-level `.canvas` and decorative background layers |
| **Use `min-height` instead of `height` for content containers** | `height:100%` locks height, content gets clipped when too much; `min-height:100%` allows natural expansion |
| **Exceptions where `overflow: hidden` is allowed** | `.canvas` (page boundary), `.poster` (auto-injected by `html2poster.js`), `.floating-meta` (short label ellipsis), cover Layer 1 (decorative clipping), SVG/img (fill container) |
| **Absolutely no `backdrop-filter`** | **Playwright PDF rendering silently discards entire element content!** Use fixed rgba() background color for cards instead |
| **Absolutely no `text-align: justify`** | CJK character spacing gets abnormally stretched, always use `text-align: left` |
| **`overflow: hidden` on `.page` containers** | **MANDATORY for multi-page documents.** Decorative elements (glow, gradient circles, oversized backgrounds) with `width > 100%` or negative offsets cause `scrollWidth > clientWidth`, triggering Playwright to shrink the entire page → content drifts left. `.page { overflow: hidden }` clips decorative overflow without affecting visible content |
| **Horizontal flex rows must have `flex-wrap`** | ≥3 inline items (flow bars, step lists, tag rows) without `flex-wrap: wrap` will overflow the page right edge when content is long. See `typesetting/overflow.md` §3.5 for full rules |
| Prohibited | Reason |
|--------|------|
| ❌ Timeline graphics | Complex connecting lines easily misalign in PDF rendering |
| ❌ Complex SVG-drawn structure/flow diagrams | Unless user explicitly requests |
| ❌ Code-drawn maps or flags | Poor quality |
| ❌ Base64 images (when exceeding 10MB) | File too large. Small image base64 is acceptable (Playwright cannot load local paths) |
| ❌ Content truncation | Must adjust container height to ensure all content fully displayed |
| ❌ Pure white background (#FFFFFF) | Lacks design quality |
| ❌ Transparent background | PDF output cannot be transparent |
---
## 11. Poster Font Recommendations
### 11.1 CJK Font Recommendations
**For Chinese posters (serious/formal scenes):**
| Purpose | Recommended Font | Google Fonts / CDN Name | Style |
|------|---------|-------------------|------|
| CJK title (serious) | DingTalk JinBuTi | `DingTalk JinBuTi` (letter-spacing: -5%) | Bold, impactful |
| CJK title (alt) | Douyin Sans / Alimama FangYuanTi VF Bold | Via CDN | Modern Chinese |
| CJK title (serif) | Swei B2 Serif CJKtc Bold | Via CDN | Elegant serif |
| CJK body | HarmonyOS Sans SC | `HarmonyOS Sans SC` | Clear, readable |
| CJK body (fallback) | Noto Sans SC Regular | `Noto Sans SC:wght@400` | Google Fonts guaranteed |
| CJK artistic | Noto Serif SC Bold | `Noto Serif SC:wght@700` | Elegant, artistic |
| Handwritten | ZhanKuKuaiLeTi2016XiuDingBan-2 | Via CDN | Casual, playful |
| Pixel/dot-matrix | DottedSongtiSquareRegular | Via CDN | Retro, xiangsuti |
**For English posters:**
| Purpose | Recommended Font | Google Fonts Name | Style |
|------|---------|-------------------|------|
| English/number title | Futura | System / `Futura` | Classic geometric |
| English body | PingFang HK | System | Clean, modern |
| English title (Google) | Inter Black | `Inter:wght@900` | Modern geometric |
| English serif | Playfair Display | `Playfair+Display:wght@900` | Classic editorial |
| Number emphasis | Inter Black | `Inter:wght@900` | Data sculpture |
### 11.2 Font Usage Constraints
- Entire poster **maximum 3 fonts**
- Title and body may use different fonts, but must be visually harmonious
- **Never reduce font size or line height to squeeze in more content**
- Font content in cards should be **vertically centered** within the card
- You may use different style fonts for entertaining or artistic scenes
---
## 12. Coordination with design_engine.py
### 12.0 ★★★ Stable Layout Selection Strategy (Anti-Overflow Core Rule)
> **Content-heavy posters must bypass the Blueprint 12×12 Grid and use a pure flow-based HTML approach.**
**Why:** The Blueprint 12×12 Grid allocates space with fixed row heights and cannot predict actual text rendering height, causing:
- Text overflowing Glass Canvas containers
- CJK character spacing stretched by `text-align: justify`
- Inaccurate row allocation for multiple components, text overlap
**Route selection:**
| Scenario | Recommended Route | Notes |
|------|---------|------|
| Title poster (≤ 50 chars) | Blueprint JSON | Few components, little content, Grid sufficient |
| Standard poster (50-150 chars) | Blueprint JSON | Grid mostly sufficient, mind row allocation |
| **Info-dense poster (>150 chars or multiple sections)** | **★ Direct HTML Flow** | **Strongly recommended — completely avoids overflow** |
| **Multi-page info poster** | **★ Direct HTML Flow** | **Strongly recommended** |
| Poster with images | Direct HTML Flow | Image embedding is more stable |
### 12.1 Direct HTML Flow Approach (Recommended Default)
**Core idea: No Grid, no fixed height, content flows naturally, never overflows.**
Write HTML directly, convert to PDF via `html2poster.js`. The poster is a **single continuous `<div class="poster">` container** — NOT split into separate `<div class="page">` blocks.
#### ★★★ Single-Canvas vs Multi-Page (Iron Rule)
| Approach | When to Use | HTML Structure |
|----------|-------------|----------------|
| **Single Canvas** (default) | All content forms one unified poster | One `<div class="poster">`, no `page-break` |
| **Multi-Page** (exception) | User explicitly requests separate pages (e.g., "make a 4-page booklet") | Multiple `<div class="page">` with `page-break-after: always` |
> **Default = Single Canvas.** A poster is ONE design composition, not a paginated report. Multi-page is only for booklets/multi-page documents where the user explicitly asks for page separation.
#### ★★★ Dynamic Height (Anti-Blank-Bottom Iron Rule)
**NEVER hardcode `@page { size: W H }` or `.poster { min-height: Xpx }` with a fixed height.** This creates blank space at the bottom when content is shorter than the hardcoded value.
**Correct approach — use `html2poster.js`:**
`html2poster.js` automatically handles all of this — you just need to write the HTML correctly and call one command:
```bash
node "$PDF_SKILL_DIR/scripts/html2poster.js" poster.html --output poster.pdf --width 720px
```
It will automatically:
1. Add `overflow: hidden` to `.poster` container (clips decorative overflow)
2. Inject `@page { margin: 0 }` (zero margins)
3. Sync `html/body` background with `.poster` background
4. Measure `.poster` scrollHeight (actual content height)
5. Generate single-page vector PDF with exact dimensions
**⚠️ Do NOT use `html2pdf-next.js` for posters.** It is designed for multi-page documents and will inject 20mm margins / A4 pagination.
**⚠️ Do NOT write hand-written Playwright scripts for posters.** `html2poster.js` handles everything.
```css
/* ✅ CORRECT CSS for poster HTML (html2poster.js handles the rest): */
html, body { margin: 0; padding: 0; background: var(--c-bg); }
.poster { width: 720px; position: relative; background: var(--c-bg); }
/* Note: overflow:hidden on .poster is auto-injected by html2poster.js,
but including it in CSS is fine too */
```
**CSS iron rules:**
```css
html, body { margin: 0; padding: 0; background: var(--c-bg); }
/* Single poster canvas — NO fixed height */
.poster {
width: 720px;
position: relative;
overflow: hidden;
background: var(--c-bg);
/* Height is determined by content, measured at render time */
}
/* Card/content block */
.card {
background: rgba(255,255,255,0.7);
border-radius: 12px;
padding: 24px 28px;
margin-bottom: 20px;
/* No height, no max-height, no overflow:hidden */
}
/* CJK text iron rules */
.card, .card p, .card li {
text-align: left; /* Absolutely no justify! CJK stretches letter-spacing */
line-height: 1.6;
word-break: break-all; /* CJK natural line break */
}
/* Image */
.hero-image {
width: 100%;
border-radius: 8px;
margin-bottom: 20px;
display: block;
}
```
**HTML structure template (Single Canvas):**
```html
<div class="poster">
<!-- Hero section -->
<div class="hero"> ... </div>
<!-- City 1 -->
<div class="city-section">
<h2>南京</h2>
<div class="items-row"> ... attractions ... </div>
<div class="food-row"> ... food ... </div>
</div>
<!-- City separator (thin line, NOT page-break) -->
<div class="city-sep"></div>
<!-- City 2 -->
<div class="city-section"> ... </div>
<!-- Footer -->
<div class="poster-footer"> ... </div>
</div>
```
**Why it's stable:**
- No fixed height — content naturally defines poster size
- No `overflow: hidden`, text always fully displayed
- `text-align: left` avoids CJK letter-spacing stretch
- Single canvas = one unified composition, not chopped pages
- PDF height measured at render time = zero blank space
**Convert to PDF and PNG:**
```bash
# PDF (vector, single-page, zero margins, auto-height):
node "$PDF_SKILL_DIR/scripts/html2poster.js" poster.html --output poster.pdf --width 720px
# PNG preview (screenshot):
# Use Playwright screenshot with measured height
```
> **⚠️ Do NOT write hand-written Playwright `page.pdf()` scripts.** Use `html2poster.js` which handles overflow:hidden, margin:0, background sync, and height measurement automatically.
### 12.2 Blueprint Grid Approach (Only for Simple Posters)
| Behavior | Status | Notes |
|------|------|------|
| Dynamic inset | ✅ Fixed | More content → smaller inset (`8% 10%`), less content → default (`10% 12%`) |
| Glass Canvas overflow | ✅ Fixed | `min-height:100%` replaces `height:100%`, removed `overflow:hidden` |
| Glass Canvas / Process List stretch | ✅ Fixed | Auto `align-items: stretch` |
### 12.3 Poster Marker in Blueprint
Add `scene: "poster"` marker in `art_direction` so `design_engine.py` can identify poster scenes and apply specific logic in the future:
```json
{
"art_direction": {
"scene": "poster",
"palette_mode": "minimal",
"color_harmony": "auto",
"background_svg": "flow",
"design_rationale": "..."
}
}
```
---
---
## 14. PDF Conversion Iron Rules
### 14.1 Background Color Consistency
```css
/* Must ensure html/body background = poster canvas background */
html, body {
background: var(--c-bg); /* Same color as .canvas background */
}
```
- Playwright `page.pdf({ printBackground: true })` renders body background color
- If body is white but poster is gray, white borders appear in the PDF
- `design_engine.py` already auto-injects `background: var(--c-bg)`, but if bypassing the engine and writing HTML directly, **you must ensure manually**
**Multi-page posters / brochures with mixed page backgrounds:**
- When pages alternate between dark and light backgrounds, set `body { background }` to the **darkest page color** (see SKILL.md "Background Color Consistency" for full rationale)
- This eliminates sub-pixel white edges on dark pages without affecting light pages
### 14.2 Content Centering (Anti-Drift)
**Poster content must be centered in the PDF, no left or right drift allowed.**
Common drift causes and fixes:
| Cause | Fix |
|------|------|
| `@page { margin }` not 0 | Must be `@page { size: <w> <h>; margin: 0; }` |
| `.safe-zone` `inset` left-right asymmetric | Ensure `inset: Y% X%` uses same X% for left and right |
| Component `grid_area` only uses partial columns | Most components should span `1 / 1 / X / 13` (full width) |
| Content container has `max-width` but no `margin: 0 auto` | Add `margin: 0 auto` to center |
| Playwright PDF default margin | Pass `margin: { top: 0, right: 0, bottom: 0, left: 0 }` |
### 14.3 Anti-Blank Edges (Dynamic Height Iron Rule)
**Poster edges, top, and bottom should not have large meaningless whitespace.**
**★★★ CRITICAL: Never hardcode poster height.** The poster is a single continuous canvas — its height is defined by content, not by a fixed CSS value.
| Pattern | Status | Result |
|---------|--------|--------|
| `@page { size: 720px 3600px }` | ❌ FORBIDDEN | Creates 853px+ blank space at bottom if content is shorter |
| `.poster { min-height: 3600px }` | ❌ FORBIDDEN | Same problem — blank bottom |
| `.poster { width: 720px }` (no height) | ✅ CORRECT | Content defines height naturally |
| `node html2poster.js poster.html --width 720px` | ✅ CORRECT | Auto-measures height, zero blank space |
**The 720×960 dimension is for multi-page documents with `page-break-after: always` only — NOT for single-canvas posters.**
- Content must make full use of page area, no more than 20% unused space within safe-zone
- If excess whitespace exists, enlarge font sizes or modules to balance the layout
- Checklist:
- [ ] Poster height defined by content (no hardcoded height)?
- [ ] PDF generated via `html2poster.js` (not html2pdf-next.js)?
- [ ] First component starts from the top?
- [ ] Last component reaches the bottom with proper padding?
- [ ] Left-right margins symmetric?
- [ ] No blank space at bottom of PDF?
---
## 15. Preflight Checklist (Poster-Specific)
Before outputting JSON Blueprint, verify the following items:
```
□ ★ Single-canvas check: poster is ONE continuous <div>, not split into separate pages? (unless user explicitly requests multi-page)
□ ★ Dynamic height check: no hardcoded @page size height or .poster min-height? PDF generated via html2poster.js?
□ ★ Anti-modularization check: ≤ 3 containers with background+border in single-page poster? No 2×2 card grid? Hierarchy via font size/weight not border colors?
□ Emphasis elements (price/date/address) highlighted via font size+weight, not wrapped in separate color blocks?
□ Overall looks like a design piece, not an APP interface or dashboard?
□ total_pages = 1 (single canvas)? (unless user explicitly requests multi-page booklet)
□ No hardcoded @page height or .poster min-height? Content defines height?
□ Left-right margins symmetric? (no left/right drift)
□ html/body background = poster canvas background? (no color mismatch)
□ No bottom blank space in PDF? (height measured dynamically)
□ Page main title ≥ 50px? Body text ≥ 24px?
□ Text volume ≤ 150 chars? (single Glass Canvas)
□ palette_mode not #FFFFFF pure white?
□ No single image filling the background?
□ No overlap between cards?
□ No Timeline / complex SVG structure diagrams?
□ All content fully displayed, not truncated?
□ Emphasis text font size < title font size?
□ Entire design ≤ 3 fonts?
□ cover_hero page ≤ 4 components?
□ Hero_Typography has <br> line breaks?
□ @page { margin: 0 } set? (prevents PDF drift)
```

View File

@@ -0,0 +1,284 @@
# Process Brief: Advanced Reference
Edge-case tools and techniques. **Load only when the main `process.md` doesn't cover the task.**
```
Edge case
├─ No text extracted from PDF → §OCR Fallback
├─ PDF is encrypted/locked → §Encrypted PDFs
├─ PDF is corrupted/damaged → §Corrupted PDFs
├─ Need precise text coordinates → §pdfplumber Advanced
├─ Need fast rendering → §pypdfium2
├─ Advanced page extraction → §poppler-utils Advanced
├─ Complex page ranges / merge → §qpdf Page Manipulation
├─ Optimize file size → §qpdf Optimization
├─ Batch process many PDFs → §Batch Processing
└─ Memory issues with large PDF → §Performance Optimization
```
For basic operations (extract, merge, split, fill forms, convert), go back to `process.md`.
---
## §pypdfium2 (Apache/BSD License)
A Python binding for PDFium (Chromium's PDF library). Excellent for fast rendering and text extraction — serves as a PyMuPDF replacement.
#### Render PDF to Images
```python
import pypdfium2 as pdfium
pdf = pdfium.PdfDocument("document.pdf")
# Render single page
page = pdf[0]
bitmap = page.render(scale=2.0, rotation=0)
img = bitmap.to_pil()
img.save("page_1.png", "PNG")
# Batch render all pages
for i, page in enumerate(pdf):
bitmap = page.render(scale=1.5)
img = bitmap.to_pil()
img.save(f"page_{i+1}.jpg", "JPEG", quality=90)
```
#### Extract Text with pypdfium2
```python
import pypdfium2 as pdfium
pdf = pdfium.PdfDocument("document.pdf")
for i, page in enumerate(pdf):
text = page.get_text()
print(f"Page {i+1}: {len(text)} chars")
```
## §pdfplumber Advanced Features
#### Extract Text with Precise Coordinates
```python
import pdfplumber
with pdfplumber.open("document.pdf") as pdf:
page = pdf.pages[0]
# All characters with coordinates
for char in page.chars[:10]:
print(f"'{char['text']}' at x:{char['x0']:.1f} y:{char['y0']:.1f}")
# Extract text within a specific bounding box (left, top, right, bottom)
bbox_text = page.within_bbox((100, 100, 400, 200)).extract_text()
```
#### Advanced Table Extraction with Custom Settings
```python
import pdfplumber
with pdfplumber.open("complex_table.pdf") as pdf:
page = pdf.pages[0]
table_settings = {
"vertical_strategy": "lines",
"horizontal_strategy": "lines",
"snap_tolerance": 3,
"intersection_tolerance": 15
}
tables = page.extract_tables(table_settings)
# Visual debugging
img = page.to_image(resolution=150)
img.save("debug_layout.png")
```
---
## §poppler-utils Advanced Features
### Extract Text with Bounding Box Coordinates
```bash
pdftotext -bbox-layout document.pdf output.xml
```
### Advanced Image Conversion
```bash
# High-resolution PNG
pdftoppm -png -r 300 document.pdf output_prefix
# Specific page range
pdftoppm -png -r 600 -f 1 -l 3 document.pdf high_res_pages
# JPEG with quality setting
pdftoppm -jpeg -jpegopt quality=85 -r 200 document.pdf jpeg_output
```
### Extract Embedded Images
```bash
pdfimages -all document.pdf images/img
pdfimages -list document.pdf
```
---
## §qpdf Advanced Features
### Complex Page Manipulation
```bash
# Split into groups of N pages
qpdf --split-pages=3 input.pdf output_group_%02d.pdf
# Extract complex page ranges
qpdf input.pdf --pages input.pdf 1,3-5,8,10-end -- extracted.pdf
# Merge specific pages from multiple PDFs
qpdf --empty --pages doc1.pdf 1-3 doc2.pdf 5-7 doc3.pdf 2,4 -- combined.pdf
```
### PDF Optimization and Repair
```bash
qpdf --linearize input.pdf optimized.pdf
qpdf --optimize-level=all input.pdf compressed.pdf
qpdf --check input.pdf
qpdf --fix-qdf damaged.pdf repaired.pdf
```
### Encryption and Decryption
```bash
qpdf --encrypt user_pass owner_pass 256 --print=none --modify=none -- input.pdf encrypted.pdf
qpdf --show-encryption encrypted.pdf
qpdf --password=secret123 --decrypt encrypted.pdf decrypted.pdf
```
---
## §Encrypted PDFs
```python
from pypdf import PdfReader
try:
reader = PdfReader("encrypted.pdf")
if reader.is_encrypted:
reader.decrypt("password")
except Exception as e:
print(f"Failed to decrypt: {e}")
```
Or via qpdf:
```bash
qpdf --password=secret123 --decrypt encrypted.pdf decrypted.pdf
```
---
## §Corrupted PDFs
```bash
qpdf --check corrupted.pdf
qpdf --replace-input corrupted.pdf
```
---
## §OCR Fallback for Scanned PDFs
When `pdfplumber` extracts 0 characters, the PDF is likely a scanned image:
```python
import pytesseract
from pdf2image import convert_from_path
def extract_text_with_ocr(pdf_path):
images = convert_from_path(pdf_path)
text = ""
for i, image in enumerate(images):
text += pytesseract.image_to_string(image)
return text
```
Prerequisites: `pip install pdf2image pytesseract` + install Tesseract OCR and poppler.
---
## §Batch Processing with Error Handling
```python
import os, glob
from pypdf import PdfReader, PdfWriter
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def batch_process_pdfs(input_dir, operation='merge'):
pdf_files = glob.glob(os.path.join(input_dir, "*.pdf"))
if operation == 'merge':
writer = PdfWriter()
for pdf_file in pdf_files:
try:
reader = PdfReader(pdf_file)
for page in reader.pages:
writer.add_page(page)
logger.info(f"Processed: {pdf_file}")
except Exception as e:
logger.error(f"Failed: {pdf_file}: {e}")
continue
with open("batch_merged.pdf", "wb") as output:
writer.write(output)
elif operation == 'extract_text':
for pdf_file in pdf_files:
try:
reader = PdfReader(pdf_file)
text = "".join(page.extract_text() for page in reader.pages)
output_file = pdf_file.replace('.pdf', '.txt')
with open(output_file, 'w', encoding='utf-8') as f:
f.write(text)
logger.info(f"Extracted: {pdf_file}")
except Exception as e:
logger.error(f"Failed: {pdf_file}: {e}")
```
---
## §Performance Optimization
### Text Extraction
- `pdftotext -bbox-layout` is fastest for plain text
- Use pdfplumber for structured data and tables
- Avoid `pypdf.extract_text()` for very large documents
### Image Extraction
- `pdfimages` is much faster than rendering entire pages
- Use low resolution for previews, high resolution for final output
### Memory Management for Large PDFs
```python
def process_large_pdf(pdf_path, chunk_size=10):
reader = PdfReader(pdf_path)
total_pages = len(reader.pages)
for start_idx in range(0, total_pages, chunk_size):
end_idx = min(start_idx + chunk_size, total_pages)
writer = PdfWriter()
for i in range(start_idx, end_idx):
writer.add_page(reader.pages[i])
with open(f"chunk_{start_idx//chunk_size}.pdf", "wb") as output:
writer.write(output)
```
---
## Extended Tooling Inventory
| Library / Tool | Role | Licence |
|----------------|------|---------|
| pikepdf | Low-level PDF manipulation (forms, pages, metadata) | MPL-2.0 |
| pdfplumber | Content extraction (text, tables) | MIT |
| pypdfium2 | Fast rendering, text extraction (PyMuPDF alternative) | Apache/BSD |
| pypdf | Merge, split, crop, metadata, encryption | BSD |
| poppler-utils | CLI text/image extraction, rendering | GPL-2 |
| qpdf | Page manipulation, optimization, encryption, repair | Apache |
| pytesseract | OCR for scanned PDFs | Apache |
| pdf2image | PDF-to-image conversion via poppler | MIT |
| LibreOffice | Office format conversion engine | MPL-2.0 |

319
skills/pdf/briefs/process.md Executable file
View File

@@ -0,0 +1,319 @@
# Brief: PDF Processing
Work with existing PDFs: extract, merge, split, fill forms, convert formats, or **reformat** with a new design. Usually a Light triage path — except reformat, which escalates to Standard.
---
## Decision Tree
```
User request
├─ "Extract text/tables/images" → §Extract
├─ "Merge/split/rotate/crop pages" → §Pages
├─ "Fill a form" → §Forms (check fillable first)
├─ "Read/write metadata" → §Metadata
├─ "Convert DOCX/PPTX/XLSX to PDF" → §Convert
│ └─ DOCX with TOC? → §DOCX Pipeline (5-step)
├─ "Redesign/reformat a document" → §Reformat
│ └─ With a reference template? → §Template-Guided Reformat
└─ Edge cases (OCR, encrypt, batch) → load briefs/process-advanced.md
```
---
## Environment Check
```bash
python3 "$PDF_SKILL_DIR/scripts/pdf.py" env.check
```
Reports availability but does **not** auto-install. Required: Python 3, pikepdf, pdfplumber.
Entry point: `python3 "$PDF_SKILL_DIR/scripts/pdf.py" <group>.<action> [options]`
All commands return JSON on stdout (`{"status": "success", "data": {...}}`) or stderr (`{"status": "error", ...}`).
Exit codes: 0 = success, 1 = bad args, 2 = file not found, 3 = parse error, 4 = operation failed.
---
## §Extract
```bash
# Text (full or page range)
python3 "$PDF_SKILL_DIR/scripts/pdf.py" extract.text report.pdf
python3 "$PDF_SKILL_DIR/scripts/pdf.py" extract.text report.pdf -p 1-3
python3 "$PDF_SKILL_DIR/scripts/pdf.py" extract.text report.pdf -p 1,4,7
# Tables — returns structured JSON with page/rows/cols/data
python3 "$PDF_SKILL_DIR/scripts/pdf.py" extract.table report.pdf
# Images — dumps embedded rasters to directory
python3 "$PDF_SKILL_DIR/scripts/pdf.py" extract.image report.pdf -o ./images/
```
---
## §Pages
```bash
python3 "$PDF_SKILL_DIR/scripts/pdf.py" pages.merge a.pdf b.pdf -o combined.pdf
python3 "$PDF_SKILL_DIR/scripts/pdf.py" pages.split book.pdf -o ./chapters/
python3 "$PDF_SKILL_DIR/scripts/pdf.py" pages.rotate doc.pdf 90 -o rotated.pdf
python3 "$PDF_SKILL_DIR/scripts/pdf.py" pages.rotate doc.pdf 180 -o rotated.pdf -p 1-3
python3 "$PDF_SKILL_DIR/scripts/pdf.py" pages.crop doc.pdf 50,50,550,750 -o trimmed.pdf
python3 "$PDF_SKILL_DIR/scripts/pdf.py" pages.clean doc.pdf -o cleaned.pdf
```
---
## §Metadata
```bash
python3 "$PDF_SKILL_DIR/scripts/pdf.py" meta.get doc.pdf
python3 "$PDF_SKILL_DIR/scripts/pdf.py" meta.set doc.pdf -o out.pdf -d '{"Title": "Report", "Author": "Jane"}'
python3 "$PDF_SKILL_DIR/scripts/pdf.py" meta.brand doc.pdf -o branded.pdf
```
Recognised keys: `Title`, `Author`, `Subject`, `Keywords`, `Creator`, `Producer`.
`meta.brand` adds standard branding metadata (producer, creator) in one step.
---
## §Forms
### Step 1 — Check if fillable
```bash
python3 "$PDF_SKILL_DIR/scripts/pdf.py" form.info input.pdf
```
If `has_fields: true`**Fillable workflow**. If `false`**Non-fillable workflow**.
### Fillable Workflow
```bash
# Inspect fields
python3 "$PDF_SKILL_DIR/scripts/pdf.py" form.info input.pdf
# Fill (auto-maps "true"/"false" for checkboxes)
python3 "$PDF_SKILL_DIR/scripts/pdf.py" form.fill input.pdf -o filled.pdf \
-d '{"name": "John", "agree": "true", "country": "US"}'
```
**Value rules:**
| Type | Value | Example |
|------|-------|---------|
| text | Free string | `"name": "Jane Doe"` |
| checkbox | `"true"` / `"false"` (auto-converts to PDF states) | `"agree": "true"` |
| radio | One of `radio_options[].value` | `"gender": "/Choice1"` |
| dropdown | One of `choice_options[].value` | `"country": "US"` |
For complex forms, use `form.detail` and `form.render` for deeper inspection:
```bash
python3 "$PDF_SKILL_DIR/scripts/pdf.py" form.detail input.pdf -o fields.json # full field info (types, options, defaults)
python3 "$PDF_SKILL_DIR/scripts/pdf.py" form.render input.pdf -o ./pages/ # render pages as PNG for visual check
```
### Non-Fillable Workflow (Annotation-Based)
For PDFs without interactive fields (scanned forms, image-based). All four steps are mandatory.
**Step 1 — Render pages as PNG** (required):
```bash
python3 "$PDF_SKILL_DIR/scripts/pdf.py" form.render input.pdf -o ./pages/
```
**Step 2 — Create `fields.json`** with annotation regions.
To determine bbox coordinates: open the rendered PNG in an image viewer or use Python (`from PIL import Image; img = Image.open('page.png'); print(img.size)`) to get pixel dimensions. Then estimate [left, top, right, bottom] in pixels for each field by inspecting the image. The `dims` field must match the PNG dimensions exactly.
```json
{
"sheet": [
{
"pg": 1,
"dims": [1000, 1400],
"regions": [
{
"id": "last_name",
"hint": "Last name field",
"label": {"tag": "Last name", "bbox": [30, 125, 95, 142]},
"target": {"bbox": [100, 125, 280, 142]},
"ink": {"value": "Simpson", "size": 14, "color": "000000"}
}
]
}
]
}
```
Schema: `pg` = 1-based page, `dims` = [w,h] in pixels, `label.bbox` / `target.bbox` = [left, top, right, bottom], `ink` = {value, size?, color?, font?}. Label and target boxes must NOT intersect.
**Step 3 — Validate bounding boxes** (required):
```bash
# Auto-check for intersections
python3 "$PDF_SKILL_DIR/scripts/pdf.py" form.check-bbox fields.json
# Visual validation (red=target, blue=label)
python3 "$PDF_SKILL_DIR/scripts/pdf.py" form.validate 1 fields.json page1.png validation.png
```
Fix any issues, regenerate, re-check. Red rectangles must only cover input areas.
**Step 4 — Fill via annotations**:
```bash
python3 "$PDF_SKILL_DIR/scripts/pdf.py" form.annotate input.pdf fields.json -o filled.pdf
```
---
## §Reformat
Take an existing document and rebuild it with a new visual design. Content is preserved; layout, typography, and visual treatment are rebuilt from scratch.
```
1. EXTRACT → Extract content from source (extract.text / extract.table / read directly)
2. STRUCTURE → Organize into sections (headings, body, tables, lists)
3. DELEGATE → Route to appropriate brief:
Structured → briefs/report.md (ReportLab)
Visual → briefs/creative.md (Playwright)
4. BUILD → Follow the delegated brief's full workflow
5. DELIVER → New PDF, same content, new design
```
### §Template-Guided Reformat
When user provides a reference PDF to match:
```
1. ANALYZE → Extract design DNA from template:
- python3 "$PDF_SKILL_DIR/scripts/pdf.py" meta.get template.pdf (page size)
- python3 "$PDF_SKILL_DIR/scripts/pdf.py" extract.image template.pdf (color samples)
- python3 "$PDF_SKILL_DIR/scripts/pdf.py" extract.text template.pdf (text structure)
- pdftoppm -png -r 150 template.pdf preview (visual reference)
2. DOCUMENT → Record: page size, margins, colors, fonts, layout grid,
header/footer pattern, decorative elements
3. DELEGATE → Route to brief WITH design constraints (not brief defaults)
4. BUILD → Follow brief workflow, constrained to template DNA
5. COMPARE → pdftoppm both, visually compare side-by-side
```
**Key principles:**
- Match the spirit, not the pixels — exact replication from PDF is impractical
- Prefer original source files (.docx/.html/.tex) over PDF when available
- Declare font substitutions upfront; don't silently fall back
- Template provides design direction, not content — never leak placeholder text
---
## §Convert
### Office → PDF (LibreOffice)
**Simple conversion** (no TOC needed):
```bash
python3 "$PDF_SKILL_DIR/scripts/pdf.py" convert.office input.docx -o output.pdf
```
**When to use the 5-step DOCX Pipeline instead**: If the DOCX has (or should have) a Table of Contents, always use §DOCX Pipeline below. Signs: the document has 3+ headings, or the user mentions "table of contents" / "TOC", or the document already contains a TOC section. When in doubt, run `python3 "$PDF_SKILL_DIR/scripts/toc_validate.py" fix-docx input.docx -o fixed.docx` — if it returns `no_toc_needed`, a simple conversion is fine.
Or directly:
```bash
soffice --headless --convert-to pdf --outdir ./output input.docx
```
**Supported**: `.docx`, `.doc`, `.odt`, `.rtf`, `.pptx`, `.ppt`, `.xlsx`, `.xls`, `.ods`, `.csv`, `.html`
**macOS path**: `/Applications/LibreOffice.app/Contents/MacOS/soffice`
**Gotchas:**
- soffice allows only one instance at a time; close existing LibreOffice windows or use `--env:UserInstallation=file:///tmp/libreoffice_tmp`
- Missing Chinese fonts → squares. Ensure SimHei/SimSun are installed.
- Large files (>50MB) may take 1-2 min; set reasonable timeout
- soffice HTML→PDF is inferior to Playwright for complex CSS
**Priority**: Always prefer soffice for Office→PDF (preserves themes, layouts, master slides). Only fall back to python-pptx/python-docx + HTML + Playwright if soffice is unavailable — fidelity will be lower.
### Fallback: Spreadsheet → PDF without LibreOffice
Use openpyxl + HTML + Playwright. Let data shape drive layout:
| Factor | Decision |
|--------|----------|
| Columns ≤ 6 | Portrait |
| Columns > 6 | Landscape |
| Font size | Scale inversely with column count |
| Styling | Follow user requirements or source file style; if unspecified, use defaults from `typesetting/palette.md` |
### §DOCX Pipeline (5-Step with TOC)
For DOCX files that need TOC generation/correction. Required because LibreOffice `--headless` does not recalculate PAGEREF fields.
```
Step 1: soffice → Convert original DOCX to PDF (pass1)
Step 2: pages.clean → Remove blank pages from pass1
Step 3: fix-docx → Add/fix TOC with HYPERLINK + PAGEREF + bookmarks
Step 4: fix-pages → Correct TOC page numbers using pass1 as reference
Step 5: soffice → Convert final DOCX to PDF + pages.clean
```
**Step 1 — Pass 1 Convert**:
```bash
python3 "$PDF_SKILL_DIR/scripts/pdf.py" convert.office input.docx -o pass1.pdf
```
**Step 2 — Clean Blank Pages**:
```bash
python3 "$PDF_SKILL_DIR/scripts/pdf.py" pages.clean pass1.pdf -o pass1_clean.pdf
```
If `blank_pages_removed == 0`, use pass1.pdf directly.
**Step 3 — Fix TOC**:
```bash
python3 "$PDF_SKILL_DIR/scripts/toc_validate.py" fix-docx input.docx -o fixed.docx
```
Auto-detects and fixes: placeholder TOC, stale TOC (>50% drift), empty TOC, missing TOC (≥3 headings). Each entry gets `<w:hyperlink>` + `PAGEREF` + bookmarks for clickable PDF navigation.
Check output `action` field: `fixed` → use fixed.docx, `skipped` → use original, `no_toc_needed` → skip to Step 5 with pass1 PDF.
**Step 4 — Fix Page Numbers**:
```bash
python3 "$PDF_SKILL_DIR/scripts/toc_validate.py" fix-pages fixed.docx pass1_clean.pdf -o final.docx
```
Corrects PAGEREF display text using actual page positions from pass1 + TOC page offset.
**Step 5 — Final Convert + Clean**:
```bash
python3 "$PDF_SKILL_DIR/scripts/pdf.py" convert.office final.docx -o output.pdf
python3 "$PDF_SKILL_DIR/scripts/pdf.py" pages.clean output.pdf -o output_clean.pdf
```
### Post-Conversion Validation (Optional)
```bash
python3 "$PDF_SKILL_DIR/scripts/toc_validate.py" check-conversion final.docx output_clean.pdf
```
Issues caught: `CONV_TOC_LOST` (TOC disappeared), `CONV_HINT_LEAKED` (placeholder text in PDF), `CONV_HEADING_DRIFT` (heading count mismatch).
---
## Caveats
| Topic | Detail |
|-------|--------|
| Encrypted PDFs | Not supported. User must decrypt externally first. |
| < 50 MB | Instant |
| 50200 MB | 12 minutes |
| > 200 MB | Split first, or extend timeout |
| Memory | ~2-3× input file size |
| Merge failure | Partial output may remain; delete and retry |
| Split failure | Some page files may exist; inspect output dir |
| Form fill | Original never modified; always writes new file |
For edge cases (OCR, batch processing, poppler-utils, qpdf, performance tuning), load `briefs/process-advanced.md`.

1659
skills/pdf/briefs/report.md Executable file

File diff suppressed because it is too large Load Diff

153
skills/pdf/configs/components.md Executable file
View File

@@ -0,0 +1,153 @@
# Components — Art Direction JSON Lexicon
This file defines the strict component vocabulary for the Creative pipeline.
**CRITICAL RULE: DO NOT OUTPUT HTML OR CSS.**
You are an Art Director. You only output JSON. To use these components, insert their corresponding JSON objects into the `components` array of your page blueprint. The `design_engine.py` will automatically compile them into gallery-grade visual assets.
---
## 1. Glass_Canvas
The primary container for readable body text. Simulates printed text on frosted acrylic.
**JSON Blueprint Structure:**
```json
{
"type": "Glass_Canvas",
"markdown_content": "### The Divide\nYour text goes here. Supports standard Markdown.",
"tension_score": 0.8
}
```
**Parameters:**
- `markdown_content`: (Required) The actual text. **Recommended under 150 words; absolute max 250 words.**
- `tension_score`: (Optional, 0.0 to 1.0) Semantic tension. Drives dynamic font weight (300 to 900). Use `0.1` for calm/light text, `0.9` for crisis/heavy text. Do NOT use on data-heavy pages.
---
## 2. Hero_Typography
Massive, page-dominating title text that physically interacts with the background via blend modes.
**JSON Blueprint Structure:**
```json
{
"type": "Hero_Typography",
"content": "THE WEIGHT<br>OF SILENCE",
"weight": "black",
"variant": "standard",
"scale": 6
}
```
**Parameters:**
- `content`: (Required) The text. Use `<br>` for deliberate typographic line breaks.
- `weight`: (Required) `"black"` (900 weight, dominating) or `"thin"` (100 weight, whisper-quiet/elegant).
- `variant`: (Optional) `"standard"` (default) only. ~~`"vertical_accent"`~~ is **NOT implemented** in `design_engine.py` — the engine silently ignores this parameter. Use `Floating_Meta` component instead for rotated/vertical decorative text.
- `scale`: (Optional, integer 16) Typographic scale level. The engine maps this to fluid CSS `clamp()` sizes:
- `6``clamp(64px, 12vw, 150px)` — Hero/Display, maximum impact
- `5``clamp(48px, 8vw, 96px)` — Primary Title
- `4``clamp(32px, 5vw, 56px)` — Subheadline
- `3``clamp(20px, 3vw, 32px)` — Lead Paragraph
- `2``16px` — Body
- `1``10px` — Meta/Caption
If omitted, the engine uses the default hero font size from CSS.
---
## 3. Floating_Meta
Small-text metadata positioned vertically in corners, mimicking art monograph indexes.
**JSON Blueprint Structure:**
```json
{
"type": "Floating_Meta",
"position": "bottom-right",
"items": [
"CATALOG NO. 2026.031",
"EDITION 1/500"
]
}
```
**Parameters:**
- `position`: (Required) `"top-left"`, `"top-right"`, `"bottom-left"`, or `"bottom-right"`.
- `items`: (Required) Array of short strings (dates, edition numbers, refs).
---
## 4. Stat_Block
Data sculpture. Transforms boring numbers into massive visual objects.
**JSON Blueprint Structure:**
```json
{
"type": "Stat_Block",
"number": "97.3",
"unit": "%",
"label": "COMPLETION RATE"
}
```
**Parameters:**
- `number`: The core massive digit.
- `unit`: Tiny unit attached to the number.
- `label`: Metadata label below the number.
---
## 5. Hairline_Divider
Ultra-thin separator lines. Structural, like fold lines in print.
**JSON Blueprint Structure:**
```json
{
"type": "Hairline_Divider",
"style": "accent"
}
```
**Parameters:**
- `style`: `"bleed"` (full width edge-to-edge) or `"accent"` (short centered 30% line).
---
## 6. Page_Ghost_Number
Giant, 4% opacity watermark numbers that become part of the page's atmosphere.
**JSON Blueprint Structure:**
```json
{
"type": "Page_Ghost_Number",
"number": "03"
}
```
---
## 7. Shaped_Canvas (Advanced Semantic Shape-Wrapping)
A container where text flows around a non-rectangular shape. The empty space created by the shape IS the visual design.
**JSON Blueprint Structure:**
```json
{
"type": "Shaped_Canvas",
"shape_keyword": "wave",
"markdown_content": "The ocean stretched endlessly... (recommended under 150 words, absolute max 250)"
}
```
**Parameters & Shape Presets:**
- `shape_keyword`: MUST be one of the following:
- `"circle"`: Unity, spotlight, focus.
- `"wave"`: Ocean, flow, fluidity.
- `"diagonal_slash"`: Disruption, change, energy.
- `"diamond"`: Luxury, precision, crystalline.
- `"wedge_right"`: Direction, progress, forward motion.
- `markdown_content`: Text to wrap around the shape.
**CRITICAL Layout Constraint:**
If a page contains a `Shaped_Canvas`, that page's `archetype` MUST be set to `"shaped_editorial"` in the JSON. Never mix `Shaped_Canvas` and `Glass_Canvas` on the same page.
---
## Blueprint Assembly Guidelines
When constructing the JSON `pages` array, keep these layering and composition rules in mind:
1. **Backgrounds**: Do NOT try to place background SVGs as components. Backgrounds are declared globally in the `art_direction.background_svg` field (`"flow"`, `"grid"`, `"noise"`, or `"continuous_flow"`).
2. **Layering**: The order of objects in the `components` array roughly dictates their top-to-bottom rendering.
3. **Breathing Room**: Less is more. A page with just one `Hero_Typography` and one `Floating_Meta` is highly sophisticated. Cramming 5 components on a page communicates desperation.

93
skills/pdf/configs/fonts.md Executable file
View File

@@ -0,0 +1,93 @@
# Font System
## Font Stacks (by pipeline)
### Creative Pipeline (Playwright / HTML)
Fonts are loaded via Google Fonts CDN in the HTML `<head>`:
```html
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;900&family=Noto+Sans+SC:wght@300;400;500;700;900&family=Playfair+Display:ital,wght@0,400;0,700;0,900;1,400&display=swap" rel="stylesheet">
```
#### Variable Font (for Tension Typesetting)
When `tension_score` is used on `Glass_Canvas`, the engine switches to **Inter Variable** for continuous weight interpolation (100900):
```html
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
```
This enables `font-variation-settings: 'wght' <value>` for smooth, non-discrete weight transitions. The standard discrete-weight URL is still used when tension is not active.
| Variable | Stack | Usage |
|----------|-------|-------|
| `--font-sans` | Inter, Noto Sans SC, Helvetica Neue, Apple Color Emoji, Segoe UI Emoji, sans-serif | Body text, UI elements, stats |
| `--font-serif` | Playfair Display, Noto Serif SC, Cormorant Garamond, Apple Color Emoji, serif | Hero text, editorial headlines |
| `--font-mono` | SF Mono, Consolas, Apple Color Emoji, monospace | Floating meta, timestamps, codes |
### Report Pipeline (ReportLab)
ReportLab requires registered fonts. CJK support via `UniSong` / `UniHei` (built into reportlab.lib.fonts):
```python
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
pdfmetrics.registerFont(UnicodeCIDFont('STSong-Light')) # Song-ti (serif-like)
# Use 'STSong-Light' for body, headings
```
> **⚠️ ReportLab CANNOT render emoji.** If content has emoji, route to Creative pipeline.
### Academic Pipeline (Tectonic / LaTeX)
CJK support via `ctex` package:
```latex
\usepackage{ctex} % Auto-selects appropriate CJK fonts
```
For manual font selection:
```latex
\setCJKmainfont{Noto Serif CJK SC}
\setCJKsansfont{Noto Sans CJK SC}
```
> **⚠️ LaTeX silently drops emoji characters.** If content has emoji, route to Creative pipeline.
## Emoji Font Fallback
All Creative pipeline font stacks include emoji fallback:
- **macOS**: `Apple Color Emoji` (system default, full color emoji)
- **Windows**: `Segoe UI Emoji`
- **Linux**: `Noto Color Emoji` (install: `apt install fonts-noto-color-emoji`)
Chromium (used by Playwright) on macOS renders emoji natively — no extra configuration needed.
## CJK Font Weight Guide
| Weight Value | Inter Equivalent | Noto Sans SC Name | Usage |
|-------------|------------------|-------------------|-------|
| 300 | Light | Light | Subtitles, captions, meta text |
| 400 | Regular | Regular | Body text |
| 500 | Medium | Medium | Semi-emphasis |
| 700 | Bold | Bold | Headlines, emphasis |
| 900 | Black | Black | Hero text, stat numbers |
> **Tip**: Noto Sans SC weights 300900 cover most use cases. Always load at least 400 and 700 via Google Fonts.
## Font Size Scale
Recommended type scale (base: 16px body):
| Role | Size | Weight | Line-Height |
|------|------|--------|-------------|
| Page Ghost Number | 240px | 900 | 1.0 |
| Hero (large) | clamp(48px, 10vw, 110px) | 900 | 0.88 |
| Hero (serif thin) | clamp(48px, 10vw, 110px) | 100 | 0.88 |
| Stat Number | clamp(32px, 5vw, 56px) | 900 | 0.9 |
| Section Title | 24px | 800 | 1.2 |
| Subsection | 20px | 700 | 1.3 |
| Body | 16px | 400 | 1.61.7 |
| Caption | 14px | 400 | 1.5 |
| Floating Meta | 10px | 400 (mono) | 1.4 |
| Stat Label | 11px | 400 | 1.2 |

View File

@@ -0,0 +1,263 @@
# Visual Framework — Aesthetic Axioms
Non-template design system. This file is the LLM's thinking framework — not a list of styles to pick from, but a set of immutable laws that govern every design decision.
---
## The Three Absolutes
1. **Restraint** — Remove until it hurts. Then remove one more thing.
2. **Contrast** — Hierarchy through scale and weight, never through color multiplication.
3. **Space Metaphor** — Every page has physical energy: gravity, tension, breathing.
### The Anti-Gaudy Imperative
> **When in doubt, remove. Never add.**
A document looks "cheap" or "土" when it has too many competing visual elements. The cure is always subtraction, never rearrangement.
**Red flags that a design is gaudy:**
- More than 3 decorative elements on a single body page
- Icons or emoji used as section header decoration (use typography instead)
- Decorative borders or frames around body content
- Pattern/texture backgrounds on body pages
- 3+ different hue families visible simultaneously
- Elements added "because there's empty space" rather than for function
**The fix is always:** Delete the decorative element. If the page still communicates its message clearly, it didn't need the decoration. If it doesn't, fix the typography hierarchy — not by adding more visual noise.
### 🚫 The Stock Image / Clipart / AI-Generated Decoration Ban
> **ABSOLUTE PROHIBITION: Do NOT embed stock photos, clipart, watercolor illustrations, AI-generated images (flowers, patterns, borders, frames, ornaments), or any decorative raster/vector artwork into PDF documents.**
This is the #1 source of "cheap" / "土" / "婚庆风" design. Examples of what is BANNED:
- ✘ Watercolor flowers / roses / floral corners / vine borders
- ✘ Gold/metallic decorative frames or borders
- ✘ Stock photo backgrounds (landscapes, textures, marble)
- ✘ Clipart illustrations (ribbons, bows, hearts, stars)
- ✘ AI-generated decorative artwork (DALL-E flowers, Midjourney patterns)
- ✘ Any `<img>` tag used for purely decorative (non-informational) purposes
- ✘ Unsplash/Pexels/Pixabay stock photos used as background or decoration
**What IS allowed:**
- ✔ Geometric shapes rendered in CSS/SVG (circles, lines, rectangles — from `geometry.md`)
- ✔ Typography-based decoration (oversized letters, watermark text, letter-spacing effects)
- ✔ Color blocks and gradients (within palette rules)
- ✔ User-provided photos/logos that are CONTENT (not decoration)
- ✔ Charts and data visualizations
- ✔ Diagrams that convey information
**The principle:** If removing an image doesn't reduce the information content of the document, that image is decoration and should be replaced with typography or geometric elements.
**Wedding invitations, event cards, certificates:** Use elegant typography + geometric accents (thin lines, minimal shapes) + generous whitespace. A beautifully typeset name in 48pt serif with 6pt letter-spacing is infinitely more elegant than watercolor roses.
---
## Color Axioms
### The 60-30-10 Law
Every design consists of exactly three tonal zones:
| Zone | Coverage | Role | Generated by |
|------|----------|------|-------------|
| **60% — Ground** | Background, margins, empty space | Sets mood; the viewer doesn't "see" it consciously | `--c-bg` from `design_engine.py` |
| **30% — Structure** | Cards, dividers, secondary areas | Provides architecture; same hue family as ground, different lightness | `--c-mid` |
| **10% — Emphasis** | Headlines, key numbers, one accent element | Draws the eye; the only place "color" lives | `--c-accent` |
### Saturation Discipline (HARD RULE)
All colors pass through `design_engine.py` which enforces:
- **Saturation range**: 0.05 0.25 (HSL). This is non-negotiable.
- **No pure primaries**: Pure red (#ff0000), blue (#0000ff), yellow (#ffff00) are forbidden.
- **Max 3 hues** in the entire document. Variations in lightness/saturation don't count as separate hues.
### Lightness Polarity
Only two lightness regimes exist. The middle is forbidden.
| Mode | Background L | Text L | Forbidden Zone |
|------|-------------|--------|----------------|
| **Dark** (Cinematic) | < 0.10 | > 0.85 | L between 0.300.70 for backgrounds |
| **Light** (Editorial) | > 0.95 | < 0.15 | L between 0.300.70 for backgrounds |
Muddy mid-tones (L 0.300.70) for backgrounds produce the "cheap web app" look. Never.
### What Color is NOT For
Color does not create hierarchy. Size and weight do.
| ❌ Wrong | ✅ Right |
|----------|---------|
| Blue heading, grey subheading, black body | All same hue; heading 900 weight 48px, sub 600 36px, body 400 16px |
| Colored tags/badges for categories | Monochrome tags with border weight variation |
| Gradient text on everything | Gradient text on exactly ONE hero element, nowhere else |
---
## Typography Axioms
### Weight is the New Color
Hierarchy comes from the contrast between extremes of weight:
| Element | Weight | Size Relative to Body |
|---------|--------|----------------------|
| Hero title | 100 (Thin) OR 900 (Black) | 46× body |
| Section heading | 700 | 2× body |
| Body | 400 | 1× (baseline) |
| Caption / metadata | 300 | 0.7× body |
The gap between hero and body must be **dramatic** — at least 4× size ratio. Anything less feels timid.
### Font Rules
- **Max 2 families per page**. One serif, one sans (or one display, one text). Never three.
- **Every `font-family` must end with a generic**: `sans-serif`, `serif`, or `monospace`.
- **For physical print (>500mm)**: Use `pt`/`mm` for font sizes, never `px`.
### Line-Height by Density
| Content Type | Line-Height | Why |
|-------------|------------|-----|
| Body text, paragraphs | 1.61.8 | Comfortable reading |
| Headings | 0.851.0 | Tight, sculptural — headings are visual objects, not sentences |
| Captions, metadata | 1.4 | Compact but readable |
---
## Space Axioms
### The Breathing Margin Rule (MANDATORY)
Every page must have **1012% clear margin** on all edges. Nothing — no text, no decoration, no card — may enter this zone.
The exact percentage depends on content density:
- **Sparse content** (hero pages, covers): 15% is fine — lots of air
- **Standard content** (editorial, data): **12%** is the sweet spot
- **Dense content** (tables, stat grids): 10% minimum — tighter but still breathable
Default in `design_engine.py`: `inset: 10% 12%` (vertical 10%, horizontal 12%).
```
┌──────────────────────────────┐
│ 10-12% margin (breathing) │
│ ┌────────────────────────┐ │
│ │ │ │
│ │ Content lives here │ │
│ │ (76-80% of canvas) │ │
│ │ │ │
│ └────────────────────────┘ │
│ 10-12% margin (breathing) │
└──────────────────────────────┘
```
Decorative SVG backgrounds may extend into the margin zone (they're atmospheric, not content). But all readable elements respect it.
### Negative Space is Content
Empty space is not "wasted space." It's a compositional element:
- A poster with 50% whitespace communicates **confidence**
- A poster crammed to 90% communicates **desperation**
- The ratio of content to void sets the emotional tone
### Proximity Rule
Related items are close. Unrelated items are far. The gap between unrelated elements should be **at least 3× the gap between related ones**.
---
## Intent → Visual Translation
Five design intents. Each describes an **atmosphere** — the emotional field the viewer should feel. Intents do NOT prescribe specific parameters (font weight, line-height, SVG type, etc.). All concrete parameter mappings live in `briefs/creative.md` § Intent Mapping Table and `design_engine.py` INTENT_HUES/INTENT_HARMONY_MAP.
### Calm
Stillness. The viewers breathing slows. Vast open space, restrained palette, nothing competes for attention. Covers healthcare, wellness, meditation, and minimalist aesthetics. The design communicates through what it *removes*.
### Tension
Conflict. Sharp angles, dramatic contrast, elements that push against each other. Urgency, crisis, disruption. The page has physical energy — something is about to break or has already broken.
### Energy
Motion. Dense composition, slight rotations, warmth. Marketing, launches, social campaigns. The design vibrates — it wants to move off the page. Nothing is perfectly centered.
### Authority
Gravitas. Formal, deliberate, symmetrical. Government, finance, luxury, high-end corporate. Every element is placed with surgical precision. The design commands respect through restraint and weight.
### Warmth
Comfort. Rounded edges, generous spacing, earth tones. Food, lifestyle, home. The design feels like a warm room — inviting, soft, human.
---
> **Legacy mapping:** Serenity → Calm, Minimalism → Calm, Elegance → Authority. Old intent names are accepted as aliases in `design_engine.py` for backward compatibility.
---
## Advanced Visual Techniques
### Semantic-Topological Typesetting (Tension Score)
Text is not visually flat — its "weight" should mirror its emotional intensity. When a document has narrative arc (calm introduction → crisis → resolution), font weight should breathe with it.
**The Principle**: Assign a `tension_score` (0.01.0) to `Glass_Canvas` components. The engine maps this to a continuous font weight via Inter Variable font (300900 axis).
**When it matters**:
- Documents with 3+ pages and clear emotional shifts
- Storytelling: case studies, pitch decks, manifestos
- The reader may not consciously notice, but the subconscious weight variation creates "texture" in the reading experience — the same way a film score operates below awareness
**When to skip**:
- Single-page posters (no arc to express)
- Data-heavy reports (numbers don't have "feelings")
- Short documents where all paragraphs share the same tone
### Cross-Page Visual Continuity (Continuous Flow)
Multi-page documents are not a collection of isolated posters — they're a journey. The background should reflect this.
**The Principle**: Instead of generating independent SVGs per page, generate ONE continuous bezier curve spanning the entire document height, then slice it per-page via SVG `viewBox`. The result: curves that exit one page's bottom edge seamlessly enter the next page's top.
**When to use `continuous_flow`**:
- Multi-page (2+) documents with narrative structure
- Journey/timeline documents
- Brand pieces where seamless sophistication matters
- Best paired with: `Warmth`, `Elegance`, `Serenity` intents
**When to use regular `flow` instead**:
- Single-page documents (continuous has no meaning)
- Each page is conceptually independent (not a story)
### Semantic Shape-Wrapping (Shaped_Canvas)
The most dramatic of the three techniques. Text wraps around invisible geometric shapes, and the negative space itself becomes the illustration.
**The Principle**: A floating `<div>` with CSS `shape-outside` creates a non-rectangular boundary for text flow. The empty void left by the shape is the design element.
**The key insight**: We're not adding an image — we're sculpting the TEXT into a shape. This is extreme minimalism: zero visual assets, maximum visual impact.
**When to use**:
- Pages that need a "wow" without images
- Section openers, cover alternatives
- Content that thematically connects to a shape (ocean → wave, progress → wedge)
- Pages with moderate text density (100200 words) — the shape needs room
**When NOT to use**:
- Dense content pages (shape eats 3040% of text area → overflow risk)
- Together with `Glass_Canvas` on the same page (backdrop-filter conflicts)
- More than one per page (visual chaos)
---
## Quality Litmus Tests
Before delivering, every page must pass these:
| Test | Method |
|------|--------|
| **3-meter test** | Can you read the headline from 3 meters away? (size hierarchy) |
| **Squint test** | Squint at the page — do you see clear dark/light zones? (contrast) |
| **Magazine test** | Could this be a page in Monocle, Kinfolk, or Cereal magazine? (taste) |
| **Removal test** | Can you remove any element without losing meaning? If not, you have too little. If yes, remove it. |
| **Color count** | Count distinct hues. If > 3, you've failed the color axiom. |
| **Saturation check** | Run `design_engine.py audit` — any S > 0.25 on non-accent = violation |
| **Breathing check** | Is there 15% clear margin on all edges? |
| **Consistency** | Do all pages share the same font pairing, color tokens, line thickness, and corner radius? |

View File

@@ -0,0 +1,130 @@
% ═══════════════════════════════════════════════════════════
% Academic CV Template
% Single-column, multi-page, with bibliography support
% ═══════════════════════════════════════════════════════════
\documentclass[11pt,a4paper]{article}
\usepackage[top=2cm,bottom=2cm,left=2.5cm,right=2.5cm]{geometry}
\usepackage{enumitem}
\usepackage{titlesec}
\usepackage{fancyhdr}
\usepackage[colorlinks=true,linkcolor=blue,urlcolor=blue]{hyperref}
\usepackage{xcolor}
\usepackage[numbers,sort&compress]{natbib}
% ── Fonts ──
\usepackage[rm]{roboto}
\renewcommand{\familydefault}{\rmdefault}
% ── Section Formatting ──
\definecolor{accent}{HTML}{1F4E79}
\titleformat{\section}
{\Large\bfseries\color{accent}} % format
{} % label (no number)
{0em} % sep
{\MakeUppercase} % before-code
[\vspace{-0.8em}\color{accent}\rule{\linewidth}{0.8pt}] % after-code (rule)
\titleformat{\subsection}
{\large\bfseries}
{}
{0em}
{}
% ── Header/Footer ──
\pagestyle{fancy}
\fancyhf{}
\fancyhead[L]{\small\textcolor{gray}{Curriculum Vitae — Your Name}}
\fancyhead[R]{\small\textcolor{gray}{\thepage}}
\renewcommand{\headrulewidth}{0.4pt}
\fancyfoot{}
\setlength{\parindent}{0pt}
\setlist{nosep,leftmargin=1.5em}
% ── CV Entry command ──
\newcommand{\cventry}[4]{%
\textbf{#1}\hfill\textit{#2}\\
#3\hfill#4\par\smallskip
}
\begin{document}
% ── Header ──
\begin{center}
{\LARGE\bfseries Your Full Name, Ph.D.}\par\smallskip
{\small
Department of Computer Science, Tsinghua University\\
Beijing, China 100084\\
\href{mailto:name@tsinghua.edu.cn}{name@tsinghua.edu.cn}
\quad|\quad
\href{https://yoursite.com}{yoursite.com}
\quad|\quad
ORCID: \href{https://orcid.org/0000-0000-0000-0000}{0000-0000-0000-0000}
}\par
\end{center}
\section{Research Interests}
Large-scale distributed systems, graph neural networks, efficient inference for large language models, and privacy-preserving machine learning.
\section{Education}
\cventry{Ph.D. in Computer Science}{2017 -- 2022}{Stanford University}{Stanford, CA}
Advisor: Prof.\ Jane Smith\\
Dissertation: \textit{Scalable Graph Processing in Heterogeneous Computing Environments}
\smallskip
\cventry{B.Eng. in Software Engineering}{2013 -- 2017}{Zhejiang University}{Hangzhou, China}
First Class Honours, GPA: 3.9/4.0
\section{Academic Appointments}
\cventry{Assistant Professor}{2022 -- Present}{Tsinghua University, Dept.\ of CS}{Beijing}
\cventry{Postdoctoral Researcher}{2022}{Stanford University, InfoLab}{Stanford, CA}
\section{Selected Publications}
\subsection{Journal Articles}
\begin{enumerate}[label={[J\arabic*]}]
\item \textbf{Your Name}, A.\ Coauthor, and B.\ Coauthor. ``Title of journal paper.'' \textit{IEEE Transactions on Knowledge and Data Engineering}, vol.\ 35, no.\ 4, pp.\ 1234--1248, 2023.
\item C.\ Coauthor and \textbf{Your Name}. ``Another journal paper.'' \textit{ACM Computing Surveys}, vol.\ 55, no.\ 2, pp.\ 1--35, 2023.
\end{enumerate}
\subsection{Conference Papers}
\begin{enumerate}[label={[C\arabic*]}]
\item \textbf{Your Name} and D.\ Coauthor. ``Title of conference paper.'' In \textit{Proc.\ NeurIPS 2023}, pp.\ 5678--5690.
\item \textbf{Your Name}, E.\ Coauthor, and F.\ Coauthor. ``Another conference paper.'' In \textit{Proc.\ ICML 2022}, pp.\ 3456--3468.
\end{enumerate}
\section{Grants \& Funding}
\cventry{NSFC Young Scientist Fund}{2023 -- 2025}{Principal Investigator}{¥300,000}
Project: Efficient Graph Neural Network Training on Heterogeneous Hardware
\section{Teaching}
\cventry{CS101: Introduction to Programming}{Fall 2022, 2023}{Tsinghua University}{120 students}
\cventry{CS502: Advanced Distributed Systems}{Spring 2023}{Tsinghua University}{45 students}
\section{Professional Service}
\begin{itemize}
\item \textbf{Program Committee}: NeurIPS 2023, ICML 2023, KDD 2022--2023
\item \textbf{Reviewer}: IEEE TKDE, ACM TODS, VLDB Journal
\item \textbf{Session Chair}: SIGMOD 2023 (Graph Processing)
\end{itemize}
\section{Awards \& Honours}
\begin{itemize}
\item Best Paper Award, KDD 2022
\item Stanford Graduate Fellowship, 2017--2019
\item National Scholarship, Ministry of Education, China, 2016
\end{itemize}
\section{Skills}
\textbf{Programming:} Python, C++, Rust, CUDA\\
\textbf{Frameworks:} PyTorch, TensorFlow, Apache Spark, DGL\\
\textbf{Languages:} Chinese (native), English (fluent), Japanese (basic)
\hypersetup{
pdftitle={CV - Your Name},
pdfauthor={Z.ai},
pdfsubject={Academic Curriculum Vitae},
}
\end{document}

View File

@@ -0,0 +1,190 @@
% ═══════════════════════════════════════════════════════════
% AltaCV-Style Creative Resume Template
% Based on AltaCV by LianTze Lim, adapted for Tectonic
% ═══════════════════════════════════════════════════════════
\documentclass[10pt,a4paper]{article}
% ── Packages ──
\usepackage[margin=1.25cm,columnsep=1.2cm]{geometry}
\usepackage{paracol} % dual-column that breaks across pages
\usepackage[fixed]{fontawesome5}
\usepackage{tikz}
\usepackage{xcolor}
\usepackage{enumitem}
\usepackage{graphicx}
\usepackage{dashrule}
\usepackage[colorlinks=true,urlcolor=accent,linkcolor=accent,bookmarks=false]{hyperref}
% ── Colour System (change these to retheme) ──
\definecolor{accent}{HTML}{3B82F6} % blue — primary accent
\definecolor{emphasis}{HTML}{2E2E2E} % near-black — job titles, strong text
\definecolor{heading}{HTML}{1E293B} % dark slate — section headings
\definecolor{headingrule}{HTML}{3B82F6} % matches accent
\definecolor{body}{HTML}{4B5563} % gray-600 — body text
\definecolor{tagbg}{HTML}{EFF6FF} % light blue — tag background
\definecolor{subheading}{HTML}{3B82F6} % matches accent
% ── Fonts ──
\usepackage[rm]{roboto}
\usepackage[defaultsans]{lato}
\renewcommand{\familydefault}{\sfdefault}
% ── List Settings ──
\setlist{leftmargin=*,labelsep=0.5em,nosep,itemsep=0.25\baselineskip,after=\vspace{0.25\baselineskip}}
\setlist[itemize]{label={\small\textbullet}}
\setlength{\parindent}{0pt}
% ── Custom Commands ──
% Divider (dashed line between entries)
\newcommand{\divider}{\textcolor{body!30}{\hdashrule{\linewidth}{0.6pt}{0.5ex}}\medskip}
% Section heading with rule
\newcommand{\cvsection}[1]{%
\nointerlineskip\bigskip%
{\color{heading}\Large\bfseries\MakeUppercase{#1}}\\[-1ex]%
{\color{headingrule}\rule{\linewidth}{2pt}\par}\medskip
}
% Experience entry: {title}{company}{dates}{location}
\newcommand{\cvevent}[4]{%
{\large\color{emphasis}\bfseries #1\par}%
\smallskip\normalsize
\ifx&#2&\else{\textbf{\color{accent}#2}\par\smallskip}\fi%
\ifx&#3&\else{%
\small\makebox[0.5\linewidth][l]{\faCalendar[regular]~#3}%
}\fi%
\ifx&#4&\else{%
\small\makebox[0.5\linewidth][l]{\faMapMarker~#4}%
}\fi\par%
\medskip\normalsize
}
% Skill rating dots: {name}{level 1-5, supports X.5}
\newcommand{\cvskill}[2]{%
\textcolor{emphasis}{\textbf{#1}}\hfill
\foreach \x in {1,...,5}{%
\ifdimgreater{\x pt}{#2 pt}{\color{body!30}}{\color{accent}}\faCircle
}\par%
}
% Tag label (rounded rectangle)
\newcommand{\cvtag}[1]{%
\tikz[baseline]\node[anchor=base,draw=body!30,rounded corners,inner xsep=1ex,inner ysep=0.75ex,text height=1.5ex,text depth=.25ex]{#1};%
}
% Achievement: {icon}{title}{description}
\newcommand{\cvachievement}[3]{%
\begin{tabular}{@{}p{2em} @{\hspace{1ex}} p{\dimexpr\linewidth-3em}@{}}
{\Large\color{accent}#1} & \textbf{\color{emphasis}#2}\\
& {\small #3}
\end{tabular}%
\smallskip
}
% ═══════════════════════════════════════════════════════════
\begin{document}
% ── Header ──
\begin{center}
{\Huge\bfseries\color{heading} YOUR NAME}\par\medskip
{\large\bfseries\color{accent} Full-Stack Engineer | Open Source Contributor}\par\medskip
{\footnotesize\bfseries
\faAt~\href{mailto:name@email.com}{name@email.com}\hspace{2em}
\faPhone~+86 138-0000-0000\hspace{2em}
\faGithub~\href{https://github.com/username}{username}\hspace{2em}
\faLinkedin~\href{https://linkedin.com/in/username}{username}\hspace{2em}
\faMapMarker~Shanghai, China
}\par
\end{center}
\medskip
% ── Two Columns ──
\columnratio{0.62}
\begin{paracol}{2}
% ════════ LEFT COLUMN (Experience + Projects) ════════
\cvsection{Experience}
\cvevent{Senior Software Engineer}{Zhipu AI}{Jan 2022 -- Present}{Beijing}
\begin{itemize}
\item Architected real-time inference pipeline serving 10M+ daily requests with p99 latency < 50ms
\item Led migration from monolith to microservices, reducing deployment cycle from weekly to hourly
\item Designed A/B testing framework enabling 30\% faster feature iteration across 5 product teams
\end{itemize}
\divider
\cvevent{Software Engineer}{ByteDance}{Jul 2019 -- Dec 2021}{Beijing}
\begin{itemize}
\item Built recommendation engine (collaborative filtering + deep learning) improving CTR by 25\%
\item Implemented distributed training pipeline on 64-GPU cluster, reducing training time by 60\%
\end{itemize}
\cvsection{Projects}
\cvevent{Open Source Project}{\href{https://github.com/user/project}{github.com/user/project}}{}{}
\begin{itemize}
\item High-performance async HTTP framework — 5,000+ GitHub stars
\item Featured in Awesome-Go and HackerNews front page
\end{itemize}
% ════════ RIGHT COLUMN (Education + Skills + Languages) ════════
\switchcolumn
\cvsection{Education}
\cvevent{M.Sc. Computer Science}{Tsinghua University}{2017 -- 2019}{}
Thesis: Distributed Graph Processing\\
GPA: 3.8/4.0
\divider
\cvevent{B.Eng. Software Engineering}{Zhejiang University}{2013 -- 2017}{}
\cvsection{Skills}
\cvskill{Python}{5}
\divider
\cvskill{Go / Rust}{4}
\divider
\cvskill{System Design}{4.5}
\divider
\cvskill{Machine Learning}{3.5}
\medskip
\cvtag{Kubernetes}
\cvtag{Docker}
\cvtag{Kafka}\\
\cvtag{PostgreSQL}
\cvtag{Redis}
\cvtag{gRPC}\\
\cvtag{React}
\cvtag{TypeScript}
\cvtag{Terraform}
\cvsection{Languages}
\cvskill{English}{4.5}
\divider
\cvskill{Chinese (Native)}{5}
\cvsection{Highlights}
\cvachievement{\faTrophy}{ACM ICPC Regional Gold}{2016 Asia Regional Contest}
\divider
\cvachievement{\faGithub}{5,000+ Stars OSS}{Maintainer of popular async framework}
\end{paracol}
% ── PDF Metadata ──
\hypersetup{
pdftitle={Resume - Your Name},
pdfauthor={Z.ai},
pdfsubject={Professional Resume},
}
\end{document}

View File

@@ -0,0 +1,367 @@
#!/usr/bin/env node
/**
* cover_validate.js — Cover page overlap detection via Playwright rendering
*
* Detects text-vs-decorative-line overlap on cover HTML pages by:
* 1. Rendering the HTML in Playwright
* 2. Waiting for fonts to load
* 3. Measuring bounding boxes of text elements and decorative line elements
* 4. Checking for Y-axis overlap (minimum spacing = 1U = 5% of page width ≈ 30pt)
*
* Usage:
* node cover_validate.js cover.html
* node cover_validate.js cover.html --width 210mm --height 297mm
* node cover_validate.js cover.html --min-gap 30 # custom min gap in px (default: auto = 5% of width)
*
* Exit codes:
* 0 = no overlap issues found
* 1 = overlap detected (prints details to stderr)
* 2 = script error (missing file, browser launch failure, etc.)
*
* This script is ONLY for cover pages. Do NOT use it on:
* - Multi-page documents (use html2pdf-next.js pre-render checks)
* - Posters (use html2poster.js which handles overflow automatically)
*/
'use strict';
const fs = require('fs');
const path = require('path');
// ── Playwright import ──
let playwright;
try {
playwright = require('playwright');
} catch {
try {
playwright = require('playwright-core');
} catch {
console.error('✗ Neither playwright nor playwright-core is installed.');
process.exit(2);
}
}
// ── Chromium resolution (shared logic with html2poster.js) ──
function resolveChromium(chromiumObj) {
let exe;
try { exe = chromiumObj.executablePath(); } catch (_) { exe = null; }
if (exe && fs.existsSync(exe)) return { status: 'ok', executablePath: exe };
const candidates = [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/usr/bin/chromium-browser', '/usr/bin/chromium', '/usr/bin/google-chrome',
];
if (process.env.PLAYWRIGHT_CHROMIUM_PATH) candidates.unshift(process.env.PLAYWRIGHT_CHROMIUM_PATH);
for (const c of candidates) {
if (fs.existsSync(c)) return { status: 'fallback', executablePath: c };
}
return { status: 'missing', executablePath: exe || '' };
}
// ── CLI parsing ──
function parseArgs(argv) {
const tokens = argv.slice(2);
let input = null, width = '210mm', height = '297mm', minGap = null;
for (let i = 0; i < tokens.length; i++) {
const t = tokens[i];
if (t === '--width') width = tokens[++i];
else if (t === '--height') height = tokens[++i];
else if (t === '--min-gap') minGap = parseFloat(tokens[++i]);
else if (t === '--help' || t === '-h') {
console.log(`Usage: node cover_validate.js <cover.html> [options]
Options:
--width <val> Page width (default: 210mm)
--height <val> Page height (default: 297mm)
--min-gap <px> Minimum gap between text and decorative lines (default: 5% of width)
--help Show this help`);
process.exit(0);
} else if (!t.startsWith('-') && !input) {
input = t;
}
}
return { input, width, height, minGap };
}
// ── Convert CSS dimension string to px for viewport ──
function dimToPx(dim) {
if (!dim) return null;
const s = String(dim).trim();
const num = parseFloat(s);
if (s.endsWith('mm')) return Math.round(num * 3.7795); // 1mm ≈ 3.7795px at 96dpi
if (s.endsWith('cm')) return Math.round(num * 37.795);
if (s.endsWith('in')) return Math.round(num * 96);
if (s.endsWith('px') || !isNaN(num)) return Math.round(num);
return null;
}
// ── Decorative line detection heuristics ──
// A decorative line is an element that:
// - Is very thin in one dimension (height ≤ 5px or width ≤ 5px)
// - OR is an <hr> element
// - OR has a large aspect ratio (> 10:1 or < 1:10)
// - AND is not inside a text element
const DECORATIVE_LINE_DETECTION = `
(function detectOverlaps(minGapPx) {
// Collect all elements
const allElements = document.querySelectorAll('*');
const textElements = [];
const lineElements = [];
// Classify elements
for (const el of allElements) {
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) continue;
const tag = el.tagName.toLowerCase();
const style = getComputedStyle(el);
// Skip invisible elements
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') continue;
// Detect decorative lines
const isHR = tag === 'hr';
const isThinH = rect.height <= 5 && rect.width > 20; // thin horizontal line
const isThinV = rect.width <= 5 && rect.height > 20; // thin vertical line
const aspectH = rect.width / rect.height;
const aspectV = rect.height / rect.width;
const isWideRatio = aspectH > 15 && rect.height <= 8; // very wide, very thin
const isTallRatio = aspectV > 15 && rect.width <= 8; // very tall, very thin
// Check if element has only border (no text content, no background image)
const hasOnlyBorder = (
el.textContent.trim() === '' &&
style.backgroundImage === 'none' &&
(style.borderTopWidth !== '0px' || style.borderBottomWidth !== '0px' ||
style.borderLeftWidth !== '0px' || style.borderRightWidth !== '0px')
);
const isBorderLine = hasOnlyBorder && (rect.height <= 8 || rect.width <= 8);
if (isHR || isThinH || isThinV || isWideRatio || isTallRatio || isBorderLine) {
lineElements.push({
tag: tag,
class: el.className || '',
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
type: isThinH || isWideRatio ? 'horizontal' : (isThinV || isTallRatio ? 'vertical' : (rect.width >= rect.height ? 'horizontal' : 'vertical')),
});
continue;
}
// Detect text elements (has direct text content or is a heading/paragraph)
const textTags = ['h1','h2','h3','h4','h5','h6','p','span','a','li','td','th','label','summary'];
const hasDirectText = Array.from(el.childNodes).some(n => n.nodeType === 3 && n.textContent.trim());
if (textTags.includes(tag) || hasDirectText) {
// Skip if this is inside a decorative element
if (rect.height < 3) continue;
textElements.push({
tag: tag,
class: el.className || '',
text: el.textContent.trim().substring(0, 60),
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
});
}
}
// De-duplicate: if a parent and child text element both overlap the same line,
// only keep the more specific (smaller) one to avoid duplicate reports.
// Sort text elements by area (smallest first) so we can skip parents.
textElements.sort((a, b) => (a.rect.width * a.rect.height) - (b.rect.width * b.rect.height));
// Check overlaps between text elements and line elements
const overlaps = [];
const reportedPairs = new Set(); // track "lineIndex:textContent" to deduplicate
for (const text of textElements) {
for (const line of lineElements) {
const tr = text.rect;
const lr = line.rect;
if (line.type === 'horizontal') {
// Check vertical overlap/proximity
const textTop = tr.y;
const textBottom = tr.y + tr.height;
const lineTop = lr.y;
const lineBottom = lr.y + lr.height;
// Check horizontal overlap (they must share some X range)
const xOverlap = !(tr.x + tr.width < lr.x || lr.x + lr.width < tr.x);
if (!xOverlap) continue;
// Calculate vertical gap
let vGap;
if (lineTop >= textBottom) {
vGap = lineTop - textBottom; // line is below text
} else if (textTop >= lineBottom) {
vGap = textTop - lineBottom; // line is above text
} else {
vGap = 0; // overlapping
}
if (vGap < minGapPx) {
// De-dup: same line region, only report the smallest (most specific) text element
const lineKey = 'h:' + Math.round(lr.x) + ',' + Math.round(lr.y);
if (!reportedPairs.has(lineKey)) {
reportedPairs.add(lineKey);
overlaps.push({
text: text.text,
textTag: text.tag,
textClass: text.class,
textRect: tr,
lineTag: line.tag,
lineClass: line.class,
lineRect: lr,
lineType: line.type,
gap: Math.round(vGap * 10) / 10,
required: minGapPx,
});
}
}
} else if (line.type === 'vertical') {
// Check horizontal overlap/proximity
const textLeft = tr.x;
const textRight = tr.x + tr.width;
const lineLeft = lr.x;
const lineRight = lr.x + lr.width;
// Check vertical overlap (they must share some Y range)
const yOverlap = !(tr.y + tr.height < lr.y || lr.y + lr.height < tr.y);
if (!yOverlap) continue;
// Calculate horizontal gap
let hGap;
if (lineLeft >= textRight) {
hGap = lineLeft - textRight;
} else if (textLeft >= lineRight) {
hGap = textLeft - lineRight;
} else {
hGap = 0;
}
if (hGap < minGapPx) {
const lineKey = 'v:' + Math.round(lr.x) + ',' + Math.round(lr.y);
if (!reportedPairs.has(lineKey)) {
reportedPairs.add(lineKey);
overlaps.push({
text: text.text,
textTag: text.tag,
textClass: text.class,
textRect: tr,
lineTag: line.tag,
lineClass: line.class,
lineRect: lr,
lineType: line.type,
gap: Math.round(hGap * 10) / 10,
required: minGapPx,
});
}
}
}
}
}
return {
textElements: textElements.length,
lineElements: lineElements.length,
overlaps: overlaps,
};
})
`;
// ── Main ──
async function main() {
const { input, width, height, minGap } = parseArgs(process.argv);
if (!input) {
console.error('✗ No input file specified. Usage: node cover_validate.js cover.html');
process.exit(2);
}
const absIn = path.resolve(input);
if (!fs.existsSync(absIn)) {
console.error(`✗ File not found: ${absIn}`);
process.exit(2);
}
const widthPx = dimToPx(width) || 794; // A4 width in px
const heightPx = dimToPx(height) || 1123; // A4 height in px
const gap = minGap || Math.round(widthPx * 0.05); // 1U = 5% of page width
console.log(`🔍 cover_validate — Cover overlap detection`);
console.log(` Input: ${absIn}`);
console.log(` Page: ${widthPx}×${heightPx}px`);
console.log(` Min gap: ${gap}px (1U)`);
const { chromium } = playwright;
const bInfo = resolveChromium(chromium);
if (bInfo.status === 'missing') {
console.error('✗ No Chromium found. Install via: npx playwright install chromium');
process.exit(2);
}
let browser;
try {
const opts = { headless: true };
if (bInfo.status === 'fallback') opts.executablePath = bInfo.executablePath;
browser = await chromium.launch(opts);
} catch (err) {
console.error(`✗ Browser launch failed: ${err.message}`);
process.exit(2);
}
try {
const page = await browser.newPage({ viewport: { width: widthPx, height: heightPx } });
await page.goto('file://' + absIn, { waitUntil: 'networkidle' });
console.log(` ✓ HTML loaded`);
// Wait for fonts
const fontsLoaded = await page.evaluate(() =>
document.fonts.ready.then(() => document.fonts.size)
).catch(() => 0);
console.log(` ✓ Fonts: ${fontsLoaded} loaded`);
// Run overlap detection
const result = await page.evaluate(`(${DECORATIVE_LINE_DETECTION})(${gap})`);
console.log(` ✓ Found ${result.textElements} text elements, ${result.lineElements} decorative lines`);
if (result.overlaps.length === 0) {
console.log(`\n ✅ No overlap issues found`);
process.exit(0);
}
// Report overlaps
console.error(`\n ❌ Found ${result.overlaps.length} text-line overlap(s):\n`);
for (const o of result.overlaps) {
const direction = o.lineType === 'vertical' ? 'horizontal' : 'vertical';
console.error(` ERROR: ${direction} gap = ${o.gap}px (required ≥ ${o.required}px)`);
console.error(` Text: <${o.textTag}> "${o.text}" @ y=${Math.round(o.textRect.y)}-${Math.round(o.textRect.y + o.textRect.height)}`);
console.error(` Line: <${o.lineTag}${o.lineClass ? '.' + o.lineClass.split(' ')[0] : ''}> [${o.lineType}] @ y=${Math.round(o.lineRect.y)}-${Math.round(o.lineRect.y + o.lineRect.height)}`);
console.error(` Fix: Move the decorative line at least ${Math.ceil(o.required - o.gap)}px away from the text.`);
console.error('');
}
process.exit(1);
} finally {
await browser.close();
}
}
main().catch(err => {
console.error(`✗ Unexpected error: ${err.message}`);
process.exit(2);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,754 @@
#!/usr/bin/env node
/**
* html2pdf-next.js — HTML → PDF converter using Playwright + pdf-lib
*
* Drop-in replacement for html2pdf.js, WITHOUT Paged.js dependency.
* Uses Chromium native @page CSS for pagination + pdf-lib for post-processing.
*
* Usage:
* node html2pdf-next.js input.html
* node html2pdf-next.js input.html --output result.pdf
* node html2pdf-next.js input.html --css extra.css
* node html2pdf-next.js input.html --width 720px --height 960px
* node html2pdf-next.js input.html --direct (same as default now — no Paged.js to skip)
* node html2pdf-next.js input.html --merge a.pdf b.pdf (merge additional PDFs after)
*
* Architecture:
* 1. Playwright renders HTML → raw PDF via Chromium's native print engine
* 2. Pre-render hooks: Mermaid, KaTeX, oversized element fixes
* 3. Post-render: pdf-lib for merge, metadata, page count extraction
* 4. No Paged.js, no paged.polyfill.js — CSS @page handles pagination natively
*/
const fs = require('fs');
const path = require('path');
const { execSync, spawnSync } = require('child_process');
const sleep = ms => new Promise(r => setTimeout(r, ms));
// ═══════════════════════════════════════════════════════════════════
// Playwright / Chromium resolution (self-contained, no external helper)
// ═══════════════════════════════════════════════════════════════════
function loadPlaywright() {
// Try direct require first
try { return require('playwright'); } catch (_) {}
// Search common global paths
const Module = require('module');
const roots = new Set();
if (process.env.PLAYWRIGHT_PATH) roots.add(process.env.PLAYWRIGHT_PATH);
if (process.env.NODE_PATH) {
process.env.NODE_PATH.split(path.delimiter).filter(Boolean).forEach(p => roots.add(p));
}
try {
const g = execSync('npm root -g', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
if (g) roots.add(g);
} catch (_) {}
for (const base of roots) {
const pkg = path.join(base, 'playwright', 'package.json');
if (!fs.existsSync(pkg)) continue;
try { return Module.createRequire(pkg)('playwright'); } catch (_) {}
}
throw new Error('Playwright not found. Install: npm install -g playwright');
}
function loadPdfLib() {
try { return require('pdf-lib'); } catch (_) {}
const Module = require('module');
try {
const g = execSync('npm root -g', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
const pkg = path.join(g, 'pdf-lib', 'package.json');
if (fs.existsSync(pkg)) return Module.createRequire(pkg)('pdf-lib');
} catch (_) {}
throw new Error('pdf-lib not found. Install: npm install -g pdf-lib');
}
function resolveChromium(chromiumObj, allowInstall = false) {
let exe;
try { exe = chromiumObj.executablePath(); } catch (_) { exe = null; }
if (exe && fs.existsSync(exe)) {
return { status: 'ok', executablePath: exe };
}
// Try system Chrome/Chromium
const candidates = [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/usr/bin/chromium-browser', '/usr/bin/chromium', '/usr/bin/google-chrome',
];
if (process.env.PLAYWRIGHT_CHROMIUM_PATH) candidates.unshift(process.env.PLAYWRIGHT_CHROMIUM_PATH);
for (const c of candidates) {
if (fs.existsSync(c)) return { status: 'fallback', executablePath: c };
}
if (allowInstall) {
const r = spawnSync('npx', ['playwright', 'install', 'chromium'], { stdio: 'inherit', shell: true });
if (r.status === 0) {
try { exe = chromiumObj.executablePath(); } catch (_) {}
if (exe && fs.existsSync(exe)) return { status: 'installed', executablePath: exe };
}
}
return { status: 'missing', executablePath: exe || '' };
}
// ═══════════════════════════════════════════════════════════════════
// CLI
// ═══════════════════════════════════════════════════════════════════
function cli() {
const tokens = process.argv.slice(2);
if (!tokens.length || tokens[0] === '-h' || tokens[0] === '--help') {
console.log(`
Usage: node html2pdf-next.js <input.html> [options]
Options:
--output, -o <file> Output PDF path (default: <input>.pdf)
--css <file> Inject extra stylesheet
--width <px> Custom page width (e.g. 720px)
--height <px> Custom page height (e.g. 960px)
--direct (no-op, kept for backward compat — always direct now)
--merge <files...> Append additional PDF files after conversion
--title <text> Set PDF document title metadata
--help, -h Show help
`);
process.exit(0);
}
const inputFile = tokens[0];
let outputFile = null, customCSS = null, width = null, height = null;
let mergeFiles = [], title = null;
for (let i = 1; i < tokens.length; i++) {
const t = tokens[i];
if (t === '--output' || t === '-o') outputFile = tokens[++i];
else if (t === '--css') customCSS = tokens[++i];
else if (t === '--width') width = tokens[++i];
else if (t === '--height') height = tokens[++i];
else if (t === '--direct') { /* no-op, always direct */ }
else if (t === '--title') title = tokens[++i];
else if (t === '--merge') {
while (i + 1 < tokens.length && !tokens[i + 1].startsWith('--')) {
mergeFiles.push(tokens[++i]);
}
}
}
if (!outputFile) {
const p = path.parse(inputFile);
outputFile = path.join(p.dir || '.', p.name + '.pdf');
}
return { inputFile, outputFile, customCSS, width, height, mergeFiles, title };
}
// ═══════════════════════════════════════════════════════════════════
// Helpers
// ═══════════════════════════════════════════════════════════════════
function prettyBytes(n) {
const units = ['B', 'KB', 'MB', 'GB'];
let u = 0;
while (n >= 1024 && u < units.length - 1) { n /= 1024; u++; }
return `${n.toFixed(1)} ${units[u]}`;
}
// ═══════════════════════════════════════════════════════════════════
// Pre-render hooks (run in browser context before PDF export)
// ═══════════════════════════════════════════════════════════════════
async function preRenderHooks(page) {
const warnings = [];
// 1. Wait for Mermaid diagrams
const hasMermaid = await page.evaluate(() => document.querySelectorAll('.mermaid').length > 0);
if (hasMermaid) {
console.log(' ⏳ Waiting for Mermaid diagrams...');
try {
await page.waitForFunction(() => {
for (const m of document.querySelectorAll('.mermaid'))
if (!m.querySelector('svg') && !m.getAttribute('data-processed')) return false;
return true;
}, { timeout: 30000 });
await sleep(2000);
console.log(' ✓ Mermaid rendered');
} catch (_) {
warnings.push('Mermaid rendering timed out (30s)');
}
}
// 2. Trigger KaTeX math rendering
const katexStatus = await page.evaluate(() => ({
lib: typeof renderMathInElement === 'function' || typeof katex !== 'undefined',
rendered: document.querySelectorAll('.katex').length > 0,
raw: /\$[^$]+\$|\$\$[^$]+\$\$|\\\(.*?\\\)|\\\[.*?\\\]/.test(document.body.innerText),
}));
// Auto-inject KaTeX CDN if raw math detected but library not loaded
if (!katexStatus.lib && katexStatus.raw && !katexStatus.rendered) {
console.log(' ⏳ Auto-injecting KaTeX CDN (math formulas detected but KaTeX not loaded)...');
await page.addStyleTag({ url: 'https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css' });
await page.addScriptTag({ url: 'https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.js' });
await page.addScriptTag({ url: 'https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/contrib/auto-render.min.js' });
await sleep(2000); // Wait for CDN scripts to load
// Re-check
const recheckLib = await page.evaluate(() => typeof renderMathInElement === 'function');
if (recheckLib) {
console.log(' ✓ KaTeX CDN loaded successfully');
} else {
console.log(' ⚠ KaTeX CDN failed to load — math will render as raw text');
warnings.push('KaTeX CDN injection failed; math formulas may appear as raw LaTeX code');
}
}
// Re-evaluate after potential CDN injection
const katexReady = await page.evaluate(() => ({
lib: typeof renderMathInElement === 'function' || typeof katex !== 'undefined',
rendered: document.querySelectorAll('.katex').length > 0,
raw: /\$[^$]+\$|\$\$[^$]+\$\$|\\\(.*?\\\)|\\\[.*?\\\]/.test(document.body.innerText),
}));
if (katexReady.lib && !katexReady.rendered && katexReady.raw) {
console.log(' ⏳ Triggering KaTeX rendering...');
await page.evaluate(() => {
if (typeof renderMathInElement === 'function')
renderMathInElement(document.body, {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '$', right: '$', display: false },
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true },
],
throwOnError: false,
});
});
await sleep(1000);
console.log(' ✓ KaTeX rendered');
} else if (katexReady.rendered) {
await sleep(500); // Font loading settle
}
// 3. Fix oversized elements that prevent page breaks
const nFixed = await page.evaluate(() => {
const LIMIT = 1000;
let n = 0;
document.querySelectorAll(
'[style*="page-break-inside: avoid"],[style*="break-inside: avoid"],' +
'.avoid-break,table,figure,.theorem,.algorithm'
).forEach(el => {
if (el.getBoundingClientRect().height > LIMIT) {
el.style.pageBreakInside = 'auto';
el.style.breakInside = 'auto';
n++;
}
});
return n;
});
if (nFixed) {
console.log(` ⚠ Fixed ${nFixed} oversized elements (removed break-inside: avoid)`);
}
// 4. Detect overflow (horizontal AND vertical)
const overflows = await page.evaluate(() => {
const out = [];
document.querySelectorAll('pre,table,figure,img,svg,.mermaid,blockquote,.equation').forEach(el => {
const hDiff = el.scrollWidth - el.clientWidth;
const vDiff = el.scrollHeight - el.clientHeight;
if (hDiff > 2 || vDiff > 2) out.push({
tag: el.tagName.toLowerCase(),
cls: el.className || '',
hOverflow: hDiff > 2 ? hDiff : 0,
vOverflow: vDiff > 2 ? vDiff : 0,
preview: (el.textContent || '').slice(0, 50).replace(/\s+/g, ' '),
});
});
return out;
});
if (overflows.length) {
console.log(' ⚠ Overflow detected:');
overflows.forEach(o => {
const parts = [];
if (o.hOverflow) parts.push(`H +${o.hOverflow}px`);
if (o.vOverflow) parts.push(`V +${o.vOverflow}px`);
console.log(` <${o.tag}${o.cls ? '.' + o.cls.split(' ')[0] : ''}> ${parts.join(', ')}`);
});
warnings.push(`${overflows.length} element(s) have overflow`);
}
// 4b. Fix vertical overflow on page-level containers
// When html/body or the main content canvas has a fixed height + overflow:hidden,
// content gets clipped. For documents (html2pdf-next.js), we DON'T expand the
// container to its scrollHeight — that creates an oversized single "page" that
// Playwright splits unevenly. Instead, we remove the fixed height and overflow:hidden
// so content flows naturally and @page CSS handles pagination.
//
// (The old "expand to scrollHeight" logic belongs in html2poster.js where a single
// continuous canvas is the desired output.)
const vOverflowFix = await page.evaluate(() => {
const fixes = [];
// Candidates: html, body, and any direct child of body that acts as a full-page canvas
const candidates = [document.documentElement, document.body];
const bodyChildren = document.body.children;
for (let i = 0; i < bodyChildren.length; i++) {
const child = bodyChildren[i];
// Skip SVG defs, script, style elements
const tag = child.tagName.toLowerCase();
if (tag === 'svg' || tag === 'script' || tag === 'style' || tag === 'link') continue;
candidates.push(child);
// Also check one level deeper (e.g., .canvas > .content)
for (let j = 0; j < child.children.length; j++) {
const grandchild = child.children[j];
const gtag = grandchild.tagName.toLowerCase();
if (gtag === 'svg' || gtag === 'script' || gtag === 'style') continue;
candidates.push(grandchild);
}
}
for (const el of candidates) {
const computed = getComputedStyle(el);
const overflow = computed.overflow || computed.overflowY;
const hasHiddenOverflow = overflow === 'hidden' || overflow === 'clip';
const diff = el.scrollHeight - el.clientHeight;
if (hasHiddenOverflow && diff > 5) {
// This element is clipping content vertically
const tag = el.tagName.toLowerCase();
const id = el.id ? `#${el.id}` : '';
const cls = el.className ? `.${String(el.className).split(' ')[0]}` : '';
const selector = `${tag}${id}${cls}`;
const oldHeight = el.clientHeight;
// Document mode: remove fixed height + overflow:hidden,
// let @page handle natural pagination
el.style.height = 'auto';
el.style.minHeight = 'auto';
el.style.maxHeight = 'none';
el.style.overflow = 'visible';
el.style.overflowY = 'visible';
fixes.push({
selector,
oldHeight,
clipped: diff,
});
}
}
// After fixing containers, re-measure to get the final content height
const finalHeight = Math.max(
document.documentElement.scrollHeight,
document.body.scrollHeight
);
return { fixes, finalHeight };
});
if (vOverflowFix.fixes.length) {
console.log(' ⚠️ Removed fixed height + overflow:hidden — content will paginate naturally:');
vOverflowFix.fixes.forEach(f => {
console.log(` ${f.selector}: was ${f.oldHeight}px with ${f.clipped}px clipped → now auto (content will flow to next page)`);
});
}
// 4c. Convert absolute-bottom elements to document flow
// Elements with `position: absolute; bottom: Npx` inside page containers
// are pinned relative to their containing block. When content paginates
// across multiple @page pages, these elements either overlap with body
// text or land on the wrong page. Fix: convert them to static positioning
// so they participate in normal document flow and paginate naturally.
const absBottomFix = await page.evaluate(() => {
const converted = [];
// Scan inside page-level containers (body children and their children)
const containers = [];
for (let i = 0; i < document.body.children.length; i++) {
const child = document.body.children[i];
const tag = child.tagName.toLowerCase();
if (tag === 'svg' || tag === 'script' || tag === 'style' || tag === 'link') continue;
containers.push(child);
}
for (const container of containers) {
const descendants = container.querySelectorAll('*');
for (const el of descendants) {
const computed = getComputedStyle(el);
if (computed.position === 'absolute' && computed.bottom !== 'auto' && computed.bottom !== '') {
// Check if this element contains visible text (not just decorative)
const hasText = el.textContent && el.textContent.trim().length > 0;
if (!hasText) continue;
const tag = el.tagName.toLowerCase();
const id = el.id ? `#${el.id}` : '';
const cls = el.className ? `.${String(el.className).split(' ')[0]}` : '';
const selector = `${tag}${id}${cls}`;
// Convert to static flow: remove absolute positioning
el.style.position = 'static';
el.style.bottom = 'auto';
el.style.left = 'auto';
el.style.right = 'auto';
// Preserve horizontal padding/margin from the original left/right values
// by keeping any existing padding or margin on the element
converted.push({ selector, bottom: computed.bottom });
}
}
}
return converted;
});
if (absBottomFix.length) {
console.log(' ⚠️ Converted absolute-bottom elements to document flow (prevents overlap on multi-page):');
absBottomFix.forEach(f => {
console.log(` ${f.selector}: was position:absolute;bottom:${f.bottom} → now static (flows with content)`);
});
}
// 5. Inject minimal @page CSS fallback
await page.evaluate(() => {
const styles = Array.from(document.querySelectorAll('style'));
const hasPageRule = styles.some(s => (s.textContent || '').includes('@page'));
if (!hasPageRule) {
const s = document.createElement('style');
s.textContent = `@page { margin: 20mm; }`;
document.head.appendChild(s);
}
});
// 6. Fix full-page cover sections for print
// In screen mode, height:100vh = viewport height. In print mode, 100vh ≠ page height.
// Detect elements using 100vh and convert to print-safe page-filling behavior.
const coverFixed = await page.evaluate(() => {
let fixed = 0;
// Find elements with height: 100vh (inline or computed)
const allEls = document.querySelectorAll('*');
for (const el of allEls) {
const style = el.style;
const computed = getComputedStyle(el);
const isVh = style.height === '100vh' || computed.height === '100vh' ||
style.minHeight === '100vh' || computed.minHeight === '100vh';
// Also detect via class name hints
const isCover = el.classList.contains('cover') || el.classList.contains('cover-page') ||
el.id === 'cover' || el.getAttribute('data-role') === 'cover';
if (isVh || (isCover && el.offsetHeight > 0)) {
// Force the element to fill the print page
el.style.height = '100vh';
el.style.minHeight = '100vh';
el.style.pageBreakAfter = 'always';
el.style.pageBreakInside = 'avoid';
el.style.boxSizing = 'border-box';
el.style.overflow = 'hidden';
fixed++;
}
}
// Inject print-specific CSS to make 100vh work correctly
if (fixed > 0) {
const s = document.createElement('style');
s.textContent = `
@media print {
.cover, .cover-page, [data-role="cover"] {
height: 100vh !important;
min-height: 100vh !important;
page-break-after: always !important;
page-break-inside: avoid !important;
overflow: hidden !important;
}
}
`;
document.head.appendChild(s);
}
return fixed;
});
if (coverFixed) {
console.log(` ✓ Fixed ${coverFixed} full-page cover section(s) for print`);
// Also inject named @page rule for cover with zero margins
await page.evaluate(() => {
const s = document.createElement('style');
s.textContent = `
@page cover-page {
margin: 0 !important;
}
@media print {
.cover, .cover-page, [data-role="cover"] {
page: cover-page;
margin: 0 !important;
padding: 40px !important;
}
}
`;
document.head.appendChild(s);
});
}
return { warnings, contentHeight: vOverflowFix.finalHeight };
}
// ═══════════════════════════════════════════════════════════════════
// Content statistics (post-render, from PDF or page)
// ═══════════════════════════════════════════════════════════════════
async function collectStats(page) {
return page.evaluate(() => {
const body = document.body;
const text = body.innerText || '';
const zhChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length;
const enWords = (text.match(/[a-zA-Z]+/g) || []).length;
return {
wordCount: zhChars + enWords,
figures: document.querySelectorAll('figure,.figure,img').length,
tables: document.querySelectorAll('table').length,
};
});
}
// ═══════════════════════════════════════════════════════════════════
// pdf-lib post-processing: page count, metadata, merge
// ═══════════════════════════════════════════════════════════════════
async function postProcess(pdfPath, options = {}) {
const { PDFDocument } = loadPdfLib();
const pdfBytes = fs.readFileSync(pdfPath);
const doc = await PDFDocument.load(pdfBytes);
// Set metadata
if (options.title) doc.setTitle(options.title);
doc.setProducer('html2pdf-next (Playwright + pdf-lib)');
doc.setCreationDate(new Date());
const pageCount = doc.getPageCount();
// Merge additional PDFs
if (options.mergeFiles && options.mergeFiles.length) {
for (const mf of options.mergeFiles) {
if (!fs.existsSync(mf)) {
console.log(` ⚠ Merge file not found: ${mf}`);
continue;
}
console.log(` 📎 Merging: ${path.basename(mf)}`);
const donorBytes = fs.readFileSync(mf);
const donorDoc = await PDFDocument.load(donorBytes);
const copiedPages = await doc.copyPages(donorDoc, donorDoc.getPageIndices());
copiedPages.forEach(p => doc.addPage(p));
}
}
// Save
const finalBytes = await doc.save();
fs.writeFileSync(pdfPath, finalBytes);
return { pageCount: doc.getPageCount(), originalPages: pageCount };
}
// ═══════════════════════════════════════════════════════════════════
// Main pipeline
// ═══════════════════════════════════════════════════════════════════
async function convert(inputFile, outputFile, customCSS, options = {}) {
const { width, height, mergeFiles, title } = options;
if (!fs.existsSync(inputFile)) {
console.error(`✗ File not found: ${inputFile}`);
process.exit(1);
}
const playwright = loadPlaywright();
const { chromium } = playwright;
// Resolve browser
const canInstall = process.env.PDF_SKIP_BROWSER_INSTALL !== '1';
const bInfo = resolveChromium(chromium, canInstall);
if (bInfo.status === 'missing') {
console.error('\n✗ Chromium not found. Run: npx playwright install chromium\n');
process.exit(2);
}
if (bInfo.status === 'fallback') {
console.log(`⚠ Using fallback Chromium: ${bInfo.executablePath}`);
}
const absIn = path.resolve(inputFile);
const absOut = path.resolve(outputFile);
console.log(`\n🔄 Converting ${path.basename(inputFile)}...`);
console.log(` Engine: Playwright + Chromium native @page (no Paged.js)`);
// Read and optionally inject CSS
let html = fs.readFileSync(absIn, 'utf-8');
if (customCSS) {
if (!fs.existsSync(customCSS)) {
console.error(`✗ CSS file not found: ${customCSS}`);
process.exit(1);
}
const tag = `<style>${fs.readFileSync(customCSS, 'utf-8')}</style>`;
html = html.includes('</head>') ? html.replace('</head>', tag + '\n</head>') : tag + '\n' + html;
// Write modified HTML for Playwright to load
const tmpHtml = absIn + '.tmp.html';
fs.writeFileSync(tmpHtml, html);
// We'll clean up later
}
// Launch browser
let browser;
try {
const opts = { headless: true };
if (bInfo.status === 'fallback') opts.executablePath = bInfo.executablePath;
browser = await chromium.launch(opts);
} catch (err) {
const msg = err.message || '';
if (msg.includes('shared libraries') || msg.includes('.so')) {
console.error('\n✗ Missing system libraries. Run: npx playwright install-deps chromium\n');
} else {
console.error(`\n✗ Browser launch failed: ${msg}\n`);
}
process.exit(1);
}
try {
const page = await browser.newPage();
const loadFile = customCSS ? absIn + '.tmp.html' : absIn;
await page.goto('file://' + loadFile, { waitUntil: 'networkidle' });
// ── Pre-render hooks ──
console.log('\n📋 Pre-render checks:');
const preRenderResult = await preRenderHooks(page);
const warnings = preRenderResult.warnings;
const measuredContentHeight = preRenderResult.contentHeight;
// ── Detect continuous-canvas mode (design_engine.py output) ──
const continuousInfo = await page.evaluate(() => {
const el = document.querySelector('.continuous-canvas');
if (!el) return null;
const root = getComputedStyle(document.documentElement);
return {
width: root.getPropertyValue('--canvas-w').trim() || '720px',
height: root.getPropertyValue('--canvas-h').trim() || '960px',
pages: el.querySelectorAll('.page-section').length,
};
});
if (continuousInfo) {
// Creative PDF: seamless multi-page canvas
console.log(`\n🎨 Continuous canvas: ${continuousInfo.pages} pages @ ${continuousInfo.width} × ${continuousInfo.height}`);
await page.pdf({
path: absOut,
printBackground: true,
margin: { top: 0, right: 0, bottom: 0, left: 0 },
width: continuousInfo.width,
height: continuousInfo.height,
});
} else {
// Standard document
console.log('\n📄 Rendering PDF...');
const pdfOpts = {
path: absOut,
printBackground: true,
preferCSSPageSize: true,
tagged: true,
};
if (width || height) {
if (width) pdfOpts.width = width;
if (height) pdfOpts.height = height;
pdfOpts.margin = { top: 0, right: 0, bottom: 0, left: 0 };
console.log(` Custom size: ${pdfOpts.width || 'auto'} × ${pdfOpts.height || 'auto'}`);
} else {
// No explicit size: check if @page CSS defines a fixed size
const pageSize = await page.evaluate(() => {
const styles = Array.from(document.querySelectorAll('style'));
for (const s of styles) {
const text = s.textContent || '';
const match = text.match(/@page\s*\{[^}]*size:\s*([\d.]+)px\s+([\d.]+)px/);
if (match) return { width: parseFloat(match[1]), height: parseFloat(match[2]) };
}
return null;
});
if (pageSize) {
// @page defines a fixed size — use preferCSSPageSize (already set above).
// Playwright will paginate content at @page height boundaries seamlessly.
// This is correct for both posters (seamless multi-page) and documents.
pdfOpts.margin = { top: 0, right: 0, bottom: 0, left: 0 };
console.log(` @page size: ${pageSize.width}px × ${pageSize.height}px`);
if (measuredContentHeight && measuredContentHeight > pageSize.height + 5) {
const estPages = Math.ceil(measuredContentHeight / pageSize.height);
console.log(` Content height: ${measuredContentHeight}px → ~${estPages} pages`);
}
} else {
pdfOpts.format = 'A4';
}
}
await page.pdf(pdfOpts);
}
// Collect content stats from the page
const stats = await collectStats(page);
// ── pdf-lib post-processing ──
console.log('\n🔧 Post-processing (pdf-lib):');
const postResult = await postProcess(absOut, { mergeFiles, title });
// Clean up temp HTML
const tmpHtml = absIn + '.tmp.html';
if (fs.existsSync(tmpHtml)) fs.unlinkSync(tmpHtml);
// ── Report ──
const sz = fs.statSync(absOut).size;
console.log('\n' + '═'.repeat(40));
console.log(' PDF Generated Successfully');
console.log('═'.repeat(40));
console.log(` File: ${path.basename(absOut)}`);
console.log(` Pages: ${postResult.pageCount}`);
console.log(` Size: ${prettyBytes(sz)}`);
console.log(` Words: ~${stats.wordCount.toLocaleString()}`);
console.log(` Assets: ${stats.figures} figures, ${stats.tables} tables`);
console.log(` Engine: Playwright (no Paged.js)`);
console.log(` Path: ${absOut}`);
if (mergeFiles && mergeFiles.length && postResult.pageCount > postResult.originalPages) {
console.log(` Merged: +${postResult.pageCount - postResult.originalPages} pages from ${mergeFiles.length} file(s)`);
}
if (warnings.length) {
console.log('\n⚠ Warnings:');
warnings.forEach(w => console.log(` · ${w}`));
}
// Anomaly detection
if (postResult.pageCount > 1 && stats.wordCount > 0) {
const avgWordsPerPage = stats.wordCount / postResult.pageCount;
if (avgWordsPerPage < 30) {
console.log(`\n⚠ Low content density: ~${Math.round(avgWordsPerPage)} words/page (expected 100+)`);
}
}
} catch (err) {
console.error('\n✗ Conversion failed:', err.message);
process.exit(1);
} finally {
await browser.close();
}
}
// ═══════════════════════════════════════════════════════════════════
// Entry
// ═══════════════════════════════════════════════════════════════════
(async () => {
try {
const args = cli();
await convert(args.inputFile, args.outputFile, args.customCSS, {
width: args.width,
height: args.height,
mergeFiles: args.mergeFiles,
title: args.title,
});
} catch (err) {
console.error('Error:', err.message);
process.exit(1);
}
})();

256
skills/pdf/scripts/html2poster.js Executable file
View File

@@ -0,0 +1,256 @@
#!/usr/bin/env node
/**
* html2poster.js — Single-page poster/long-image HTML → PDF converter
*
* Purpose: Convert a fixed-width, dynamic-height HTML poster into a single-page
* vector PDF with zero margins. This script is PURPOSE-BUILT for posters and
* infographics — it does NOT handle multi-page documents, A4 pagination, or
* document-style margins. For those, use html2pdf-next.js.
*
* Usage:
* node html2poster.js poster.html
* node html2poster.js poster.html --output out.pdf
* node html2poster.js poster.html --width 720px
* node html2poster.js poster.html --width 720px --max-height 8000
*
* What it does (in order):
* 1. Load HTML in Playwright
* 2. Force overflow:hidden on .poster/.page containers (clip decorative overflow)
* 3. Inject @page { margin: 0 } (override any existing margin)
* 4. Ensure html/body have margin:0, padding:0, matching background
* 5. Measure .poster scrollHeight (actual content height)
* 6. Generate single-page PDF with exact dimensions
*
* What it does NOT do:
* - No pagination / page breaks
* - No A4 fallback
* - No margin injection (always zero)
* - No cover adaptation
* - No pdf-lib post-processing
* - No continuous-canvas detection
* - No vertical overflow expansion (posters WANT overflow:hidden)
*/
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
// ── Chromium resolution (shared logic with html2pdf-next.js) ──
function resolveChromium(chromiumObj) {
let exe;
try { exe = chromiumObj.executablePath(); } catch (_) { exe = null; }
if (exe && fs.existsSync(exe)) return { status: 'ok', executablePath: exe };
const candidates = [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/usr/bin/chromium-browser', '/usr/bin/chromium', '/usr/bin/google-chrome',
];
if (process.env.PLAYWRIGHT_CHROMIUM_PATH) candidates.unshift(process.env.PLAYWRIGHT_CHROMIUM_PATH);
for (const c of candidates) {
if (fs.existsSync(c)) return { status: 'fallback', executablePath: c };
}
return { status: 'missing', executablePath: exe || '' };
}
// ── CLI parsing ──
function parseArgs(argv) {
const tokens = argv.slice(2);
let input = null, output = null, width = '720px', maxHeight = 16000;
for (let i = 0; i < tokens.length; i++) {
const t = tokens[i];
if (t === '--output' || t === '-o') output = tokens[++i];
else if (t === '--width') width = tokens[++i];
else if (t === '--max-height') maxHeight = parseInt(tokens[++i], 10);
else if (t === '--help' || t === '-h') {
console.log(`
Usage: node html2poster.js <input.html> [options]
Options:
--output, -o Output PDF path (default: input with .pdf extension)
--width Poster width (default: 720px)
--max-height Maximum allowed height in px (default: 16000, safety limit)
-h, --help Show this help
`);
process.exit(0);
}
else if (!input) input = t;
else if (!output) output = t;
}
if (!input) {
console.error('Error: No input HTML file specified.');
process.exit(1);
}
if (!output) {
output = input.replace(/\.html?$/i, '.pdf');
if (output === input) output = input + '.pdf';
}
return { input, output, width, maxHeight };
}
// ── Main ──
async function main() {
const { input, output, width, maxHeight } = parseArgs(process.argv);
const absIn = path.resolve(input);
const absOut = path.resolve(output);
if (!fs.existsSync(absIn)) {
console.error(`Error: File not found: ${absIn}`);
process.exit(1);
}
console.log(`\n🖼 html2poster — Single-page poster PDF generator`);
console.log(` Input: ${absIn}`);
console.log(` Output: ${absOut}`);
console.log(` Width: ${width}`);
// Load Playwright
let playwright;
try {
playwright = require('playwright');
} catch {
try {
playwright = require('playwright-core');
} catch {
console.error('Error: playwright or playwright-core not installed.');
process.exit(1);
}
}
const { chromium } = playwright;
const bInfo = resolveChromium(chromium);
if (bInfo.status === 'missing') {
console.error('Error: No Chromium found. Run: npx playwright install chromium');
process.exit(1);
}
if (bInfo.status === 'fallback') {
console.log(` ⚠ Using fallback Chromium: ${bInfo.executablePath}`);
}
// Launch browser
const launchOpts = { headless: true };
if (bInfo.status === 'fallback') launchOpts.executablePath = bInfo.executablePath;
const browser = await chromium.launch(launchOpts);
try {
// Use a wide viewport so content doesn't wrap unexpectedly
const widthPx = parseInt(width, 10) || 720;
const page = await browser.newPage({ viewport: { width: widthPx, height: 1200 } });
await page.goto('file://' + absIn, { waitUntil: 'networkidle' });
console.log(`\n ✓ HTML loaded`);
// ── Step 1: Force overflow:hidden on page containers ──
// Decorative elements with negative offsets or width>100% inflate scrollWidth,
// causing Playwright to shrink content to fit. overflow:hidden clips them.
const overflowFixed = await page.evaluate(() => {
const selectors = ['.poster', '.page', '#poster', '#page'];
let fixed = 0;
for (const sel of selectors) {
const el = document.querySelector(sel);
if (!el) continue;
const computed = getComputedStyle(el);
if (computed.overflow !== 'hidden') {
el.style.overflow = 'hidden';
fixed++;
}
}
return fixed;
});
if (overflowFixed > 0) {
console.log(` ✓ Added overflow:hidden to ${overflowFixed} container(s)`);
}
// ── Step 2: Inject @page { margin: 0 } — override any existing @page rule ──
await page.evaluate(() => {
const s = document.createElement('style');
// Use !important-equivalent: place at end so it wins cascade
s.textContent = `@page { margin: 0 !important; size: auto; }`;
document.head.appendChild(s);
});
// ── Step 3: Ensure html/body have zero margin/padding ──
const bgSync = await page.evaluate(() => {
const html = document.documentElement;
const body = document.body;
html.style.margin = '0';
html.style.padding = '0';
body.style.margin = '0';
body.style.padding = '0';
// Sync body background with poster background to avoid color gaps
const poster = document.querySelector('.poster') || document.querySelector('.page');
if (poster) {
const posterBg = getComputedStyle(poster).backgroundColor;
if (posterBg && posterBg !== 'rgba(0, 0, 0, 0)' && posterBg !== 'transparent') {
body.style.backgroundColor = posterBg;
html.style.backgroundColor = posterBg;
return posterBg;
}
}
return null;
});
if (bgSync) {
console.log(` ✓ Synced body background: ${bgSync}`);
}
// ── Step 4: Measure actual content height ──
const measurement = await page.evaluate(() => {
const poster = document.querySelector('.poster') || document.querySelector('.page') || document.body;
return {
scrollHeight: poster.scrollHeight,
scrollWidth: poster.scrollWidth,
offsetWidth: poster.offsetWidth,
selector: poster.className ? '.' + poster.className.split(' ')[0] : poster.tagName,
};
});
console.log(` ✓ Measured: ${measurement.selector} = ${measurement.scrollWidth}×${measurement.scrollHeight}px`);
if (measurement.scrollWidth > widthPx + 2) {
console.log(` ⚠ WARNING: scrollWidth (${measurement.scrollWidth}px) > width (${widthPx}px)`);
console.log(` Decorative elements may still overflow. Check for position:absolute elements with negative offsets.`);
}
let contentHeight = measurement.scrollHeight;
if (contentHeight > maxHeight) {
console.log(` ⚠ Content height ${contentHeight}px exceeds max ${maxHeight}px, clamping.`);
contentHeight = maxHeight;
}
if (contentHeight < 100) {
console.log(` ⚠ Content height ${contentHeight}px seems too small, using 960px fallback.`);
contentHeight = 960;
}
// ── Step 5: Generate PDF ──
console.log(`\n 📄 Generating PDF: ${width} × ${contentHeight}px`);
await page.pdf({
path: absOut,
width: width,
height: contentHeight + 'px',
printBackground: true,
margin: { top: '0', right: '0', bottom: '0', left: '0' },
});
console.log(`\n ✅ Done: ${absOut}`);
console.log(` Size: ${(fs.statSync(absOut).size / 1024).toFixed(1)} KB`);
} finally {
await browser.close();
}
}
main().catch(err => {
console.error(`\n✗ Fatal: ${err.message}`);
process.exit(1);
});

2959
skills/pdf/scripts/pdf.py Executable file

File diff suppressed because it is too large Load Diff

901
skills/pdf/scripts/pdf_qa.py Executable file
View File

@@ -0,0 +1,901 @@
#!/usr/bin/env python3
"""
PDF Quality Assurance Checker
=============================
Automatically detects common typesetting issues in PDFs.
Usage: python3 pdf_qa.py <pdf_path>
Checks:
1. Page size consistency across all pages
2. Blank page detection
3. CJK punctuation placement (line-start/end forbidden punctuation)
4. Color analysis (informational only — counts and lists colors)
5. Font embedding check (warns on non-embedded fonts)
6. PDF metadata check (title/author/creator)
7. Content overflow detection (text exceeding page boundaries)
8. Content fill ratio per page (multi-page docs, warns if < 40%)
9. Cover/poster full-bleed check (background extends to page edges)
10. Margin symmetry check (left/right text margins)
11. Table centering check (if detected)
12. Formula overflow check (optional)
"""
import sys
import os
import re
import json
from collections import Counter
try:
import pymupdf # PyMuPDF
except ImportError:
import fitz as pymupdf
# ============================================================
# Config
# ============================================================
# CJK punctuation forbidden at line start
LINE_START_FORBIDDEN = set(
"。、,;:!?)】〛〉」』"
"\u201c\u201d" # "" curly double quotes
"\u2026" # … ellipsis
"\u2014" # — em dash
"\uff5e" # fullwidth tilde
"\u00b7" # · middle dot
)
# CJK punctuation forbidden at line end
LINE_END_FORBIDDEN = set(
"(【《〈「"
"\u2018\u2019" # '' curly single quotes
"\u201c" # " left curly double quote
)
# Minimum fill ratio for last page (DISABLED — caused false positives)
# LAST_PAGE_MIN_FILL = 0.40
# Maximum allowed color count — REMOVED (color count is now info-only)
# MAX_COLORS = 8
# ============================================================
# Checks
# ============================================================
class QAResult:
def __init__(self):
self.issues = [] # (severity, category, message)
self.passes = [] # passed checks
self.info = [] # informational
def error(self, cat, msg):
self.issues.append(('ERROR', cat, msg))
def warn(self, cat, msg):
self.issues.append(('WARN', cat, msg))
def ok(self, msg):
self.passes.append(msg)
def add_info(self, msg):
self.info.append(msg)
def check_last_page_fill(doc, result):
"""Check content fill ratio of the last page"""
if len(doc) < 2:
result.ok("Single-page document, no last-page blank check needed")
return
last_page = doc[-1]
page_rect = last_page.rect
page_area = page_rect.width * page_rect.height
# Get bounding boxes of all content on last page
blocks = last_page.get_text("blocks")
if not blocks:
result.error("Last page blank", f"Page {len(doc)} (last page) has no content at all!")
return
# Calculate max y-coordinate covered by content
max_y = 0
min_y = page_rect.height
for b in blocks:
if b[4].strip(): # Has text content
min_y = min(min_y, b[1])
max_y = max(max_y, b[3])
if max_y == 0:
result.error("Last page blank", f"Page {len(doc)} (last page) has no valid text content")
return
content_height = max_y - min_y
fill_ratio = content_height / page_rect.height
result.add_info(f"Last page fill ratio: {fill_ratio:.0%} (content height {content_height:.0f}px / page height {page_rect.height:.0f}px)")
if fill_ratio < 0.25:
result.error("Last page blank", f"Last page fill ratio only {fill_ratio:.0%}, mostly blank! Consider compressing preceding page spacing or trimming content")
elif fill_ratio < LAST_PAGE_MIN_FILL:
result.warn("Last page blank", f"Last page fill ratio {fill_ratio:.0%}, somewhat sparse — optimization recommended")
else:
result.ok(f"Last page fill ratio {fill_ratio:.0%}")
def check_punctuation(doc, result):
"""Check CJK punctuation placement rules"""
violations = []
for page_num in range(len(doc)):
page = doc[page_num]
# Extract text by line
text_dict = page.get_text("dict")
for block in text_dict.get("blocks", []):
if block.get("type") != 0: # Only check text blocks
continue
for line in block.get("lines", []):
line_text = ""
for span in line.get("spans", []):
line_text += span.get("text", "")
line_text = line_text.strip()
if not line_text:
continue
# Check line start
first_char = line_text[0]
if first_char in LINE_START_FORBIDDEN:
violations.append((page_num + 1, f"Forbidden line-start punctuation '{first_char}': ...{line_text[:30]}"))
# Check line end
last_char = line_text[-1] if len(line_text) > 0 else ''
if last_char in LINE_END_FORBIDDEN:
violations.append((page_num + 1, f"Forbidden line-end punctuation '{last_char}': {line_text[-30:]}..."))
if violations:
# Show at most 10
shown = violations[:10]
for page_num, desc in shown:
result.warn("Punctuation rules", f"Page {page_num} - {desc}")
if len(violations) > 10:
result.warn("Punctuation rules", f"...{len(violations) - 10} more violations")
else:
result.ok("Punctuation placement check passed ✓")
def check_blank_pages(doc, result):
"""Check for completely blank pages"""
blank_pages = []
for i in range(len(doc)):
page = doc[i]
text = page.get_text().strip()
# Also check for images
images = page.get_images()
drawings = page.get_drawings()
if not text and not images and not drawings:
blank_pages.append(i + 1)
if blank_pages:
result.error("Blank pages", f"Found blank pages: {blank_pages}")
else:
result.ok("No blank pages ✓")
def check_colors(doc, result):
"""Analyze colors used in the document (informational only, no pass/fail)"""
colors = set()
for page_num in range(len(doc)):
page = doc[page_num]
text_dict = page.get_text("dict")
for block in text_dict.get("blocks", []):
if block.get("type") != 0:
continue
for line in block.get("lines", []):
for span in line.get("spans", []):
color = span.get("color", 0)
if color != 0: # Exclude pure black
r = (color >> 16) & 0xFF
g = (color >> 8) & 0xFF
b = color & 0xFF
hex_color = f"#{r:02x}{g:02x}{b:02x}"
colors.add(hex_color)
# Check drawing colors
drawings = page.get_drawings()
for d in drawings:
if d.get("color"):
c = d["color"]
if isinstance(c, (tuple, list)) and len(c) >= 3:
hex_color = f"#{int(c[0]*255):02x}{int(c[1]*255):02x}{int(c[2]*255):02x}"
colors.add(hex_color)
if d.get("fill"):
c = d["fill"]
if isinstance(c, (tuple, list)) and len(c) >= 3:
hex_color = f"#{int(c[0]*255):02x}{int(c[1]*255):02x}{int(c[2]*255):02x}"
colors.add(hex_color)
# Filter out near-black/white/gray colors
distinct_colors = []
for c in colors:
r = int(c[1:3], 16)
g = int(c[3:5], 16)
b = int(c[5:7], 16)
max_diff = max(abs(r-g), abs(g-b), abs(r-b))
if max_diff > 20:
distinct_colors.append(c)
result.add_info(f"Total text colors: {len(colors)} (chromatic: {len(distinct_colors)})")
if distinct_colors:
result.add_info(f"Chromatic colors: {', '.join(sorted(distinct_colors)[:10])}")
def check_page_size_consistency(doc, result):
"""Check whether all page sizes are consistent"""
if len(doc) < 2:
result.ok("Single-page document, size consistent ✓")
return
sizes = set()
for i in range(len(doc)):
page = doc[i]
w = round(page.rect.width, 1)
h = round(page.rect.height, 1)
sizes.add((w, h))
if len(sizes) > 1:
result.warn("Page size", f"Inconsistent page sizes: {sizes}")
else:
size = list(sizes)[0]
# Convert to mm
w_mm = size[0] * 25.4 / 72
h_mm = size[1] * 25.4 / 72
result.add_info(f"Page size: {w_mm:.0f}mm × {h_mm:.0f}mm ({len(doc)} pages)")
result.ok("Page size consistent ✓")
def check_text_overflow(doc, result):
"""Check whether text overflows page boundaries"""
overflow_pages = []
for i in range(len(doc)):
page = doc[i]
rect = page.rect
blocks = page.get_text("blocks")
for b in blocks:
# b = (x0, y0, x1, y1, text, block_no, block_type)
if b[2] > rect.width + 2 or b[3] > rect.height + 2: # 2px tolerance
overflow_pages.append(i + 1)
break
if b[0] < -2 or b[1] < -2:
overflow_pages.append(i + 1)
break
if overflow_pages:
result.warn("Content overflow", f"Pages {overflow_pages} may have content exceeding page boundaries")
else:
result.ok("No content overflow ✓")
def check_content_fill_ratio(doc, result):
"""Check content fill ratio per page — warns when content is crammed at top leaving large void below.
Rules:
- Skip single-page documents (may be intentional design)
- Skip page 1 (usually cover with intentional whitespace)
- Middle pages: warn if fill ratio < 40%
- Last page: warn if fill ratio < 25% (naturally has less content)
"""
if len(doc) < 2:
result.ok("Single-page document, skipping content fill ratio check ✓")
return
low_fill_pages = []
for i in range(len(doc)):
page = doc[i]
page_rect = page.rect
page_height = page_rect.height
# Skip page 1 (cover)
if i == 0:
continue
blocks = page.get_text("blocks")
images = page.get_images()
drawings = page.get_drawings()
if not blocks and not images and not drawings:
continue # Blank page check handles this
# Calculate content bbox
max_y = 0
for b in blocks:
if b[4].strip():
max_y = max(max_y, b[3])
# Include images in bbox
for img in images:
try:
img_rects = page.get_image_rects(img[0])
for r in img_rects:
max_y = max(max_y, r.y1)
except Exception:
pass
if max_y == 0:
continue
fill_ratio = max_y / page_height
is_last = (i == len(doc) - 1)
threshold = 0.25 if is_last else 0.40
if fill_ratio < threshold:
low_fill_pages.append((i + 1, fill_ratio, threshold))
if low_fill_pages:
for pg, ratio, thresh in low_fill_pages:
result.warn(
"Content fill ratio",
f"Page {pg} content only fills {ratio:.0%} of page height "
f"(threshold: {thresh:.0%}). Content may be crammed at the top "
f"with a large blank area below."
)
else:
result.ok("Content fill ratio adequate on all pages ✓")
def check_cover_bleed(doc, result, poster=False):
"""Check if the cover page (page 1) fills the entire page area (full-bleed).
A properly designed cover should have background color/graphics extending
to the page edges. If the content bbox has significant margins on all sides,
the cover likely wasn't rendered full-bleed (e.g. ReportLab with default margins).
For poster mode: checks ALL pages (not just the cover) since every page of a
seamlessly-paginated poster should have consistent background fill.
Strategy: combine bounding boxes of drawings (rects, paths), images, and colored
backgrounds. If the union bbox leaves > 5% margin on any side, warn.
"""
if not poster and len(doc) < 2:
# Single page doc (non-poster) — not necessarily a cover scenario
return
pages_to_check = range(len(doc)) if poster else [0]
for page_idx in pages_to_check:
page = doc[page_idx]
page_rect = page.rect
pw, ph = page_rect.width, page_rect.height
# Collect all content bounding boxes
min_x, min_y = pw, ph
max_x, max_y = 0.0, 0.0
has_content = False
# 1. Drawings (vector paths, rectangles — typical for colored backgrounds)
for d in page.get_drawings():
r = d.get("rect")
if r:
min_x = min(min_x, r.x0)
min_y = min(min_y, r.y0)
max_x = max(max_x, r.x1)
max_y = max(max_y, r.y1)
has_content = True
# 2. Images
for img in page.get_images():
try:
for r in page.get_image_rects(img[0]):
min_x = min(min_x, r.x0)
min_y = min(min_y, r.y0)
max_x = max(max_x, r.x1)
max_y = max(max_y, r.y1)
has_content = True
except Exception:
pass
page_label = f"Page {page_idx + 1}" if poster else "Cover page (p1)"
if not has_content:
blocks = page.get_text("blocks")
if blocks:
result.warn(
f"{page_label} not full-bleed",
f"{page_label} has no background graphics (no filled rectangles or images). "
"A proper cover/poster page should have a full-page background color or image "
"extending to all edges."
)
continue
# Calculate margin ratios (how far content is from page edges)
margin_left = max(0, min_x) / pw
margin_top = max(0, min_y) / ph
margin_right = max(0, pw - max_x) / pw
margin_bottom = max(0, ph - max_y) / ph
threshold = 0.05
margins_ok = (margin_left <= threshold and margin_top <= threshold and
margin_right <= threshold and margin_bottom <= threshold)
if margins_ok:
result.ok(f"{page_label} content extends to page edges (full-bleed) ✓")
else:
sides = []
if margin_left > threshold:
sides.append(f"left {margin_left:.0%}")
if margin_top > threshold:
sides.append(f"top {margin_top:.0%}")
if margin_right > threshold:
sides.append(f"right {margin_right:.0%}")
if margin_bottom > threshold:
sides.append(f"bottom {margin_bottom:.0%}")
result.warn(
f"{page_label} not full-bleed",
f"{page_label} has visible margins: {', '.join(sides)}. "
f"Background/graphics should extend to page edges."
)
def check_margin_symmetry(doc, result, skip_cover=False):
"""Check left/right margin symmetry using text block bounds."""
warn_pages = []
for page_num in range(len(doc)):
if skip_cover and page_num == 0:
continue
page = doc[page_num]
blocks = page.get_text("blocks")
text_blocks = [b for b in blocks if b[4].strip()]
if len(text_blocks) < 3:
continue # Skip decorative/cover-like pages
left_margin = min(b[0] for b in text_blocks)
right_margin = page.rect.width - max(b[2] for b in text_blocks)
diff = abs(left_margin - right_margin)
if diff > page.rect.width * 0.05:
warn_pages.append((page_num + 1, left_margin, right_margin, diff))
if warn_pages:
for pg, left, right, diff in warn_pages:
result.warn(
"Margin symmetry",
f"Page {pg} left/right margins differ by {diff:.0f}pt "
f"(L {left:.0f}pt, R {right:.0f}pt)"
)
else:
result.ok("Left/right margins appear symmetric \u2713")
def check_table_centering(doc, result):
"""Check if detected table regions are centered."""
def _bbox_intersects(a, b, tol=6):
return not (a[2] < b[0] - tol or a[0] > b[2] + tol or
a[3] < b[1] - tol or a[1] > b[3] + tol)
def _rect_tuple(r):
if hasattr(r, "x0"):
return (r.x0, r.y0, r.x1, r.y1)
return (r[0], r[1], r[2], r[3])
any_tables = False
for page_num in range(len(doc)):
page = doc[page_num]
drawings = page.get_drawings()
segments = []
for d in drawings:
for item in d.get("items", []):
if not item:
continue
op = item[0]
if op == "l" and len(item) >= 3:
p0, p1 = item[1], item[2]
segments.append((p0[0], p0[1], p1[0], p1[1]))
elif op == "re" and len(item) >= 2:
x0, y0, x1, y1 = _rect_tuple(item[1])
segments.extend([
(x0, y0, x1, y0),
(x0, y1, x1, y1),
(x0, y0, x0, y1),
(x1, y0, x1, y1),
])
if not segments:
continue
cluster_list = []
for x0, y0, x1, y1 in segments:
min_x, max_x = min(x0, x1), max(x0, x1)
min_y, max_y = min(y0, y1), max(y0, y1)
bbox = (min_x, min_y, max_x, max_y)
is_h = abs(y0 - y1) < 1 and (max_x - min_x) > 20
is_v = abs(x0 - x1) < 1 and (max_y - min_y) > 20
if not is_h and not is_v:
continue
placed = False
for cl in cluster_list:
if _bbox_intersects(bbox, cl["bbox"]):
cl["segments"].append((x0, y0, x1, y1, is_h, is_v))
cl["bbox"] = (
min(cl["bbox"][0], bbox[0]),
min(cl["bbox"][1], bbox[1]),
max(cl["bbox"][2], bbox[2]),
max(cl["bbox"][3], bbox[3]),
)
if is_h:
cl["h"] += 1
if is_v:
cl["v"] += 1
placed = True
break
if not placed:
cluster_list.append({
"bbox": bbox,
"segments": [(x0, y0, x1, y1, is_h, is_v)],
"h": 1 if is_h else 0,
"v": 1 if is_v else 0,
})
for cl in cluster_list:
if cl["h"] < 2 or cl["v"] < 2:
continue
any_tables = True
bbox = cl["bbox"]
page_width = page.rect.width
left_margin = bbox[0]
right_margin = page_width - bbox[2]
if abs(left_margin - right_margin) > page_width * 0.05:
result.warn(
"Table centering",
f"Page {page_num + 1}: Table not centered "
f"(L {left_margin:.0f}pt, R {right_margin:.0f}pt)"
)
if any_tables:
result.ok("Table centering check complete \u2713")
def check_font_embedding(doc, result):
"""Check font embedding status using PyMuPDF font list."""
fonts_used = set()
non_embedded = set()
for page_num in range(len(doc)):
page = doc[page_num]
for font in page.get_fonts():
basefont = font[3] if len(font) > 3 else "unknown"
ext = font[1] if len(font) > 1 else ""
fonts_used.add(basefont)
if not ext:
non_embedded.add(basefont)
if fonts_used:
result.add_info(f"Fonts used: {', '.join(sorted(fonts_used))}")
else:
result.add_info("Fonts used: (none detected)")
if non_embedded:
for basefont in sorted(non_embedded):
result.warn(
"Font embedding",
f"Font {basefont} is not embedded. May display differently on other systems."
)
else:
result.ok("All fonts are embedded \u2713")
def check_helvetica_in_cjk(doc, result):
"""Detect Helvetica rendering visible text in documents containing CJK text.
Helvetica is a Latin-only built-in PDF font. When it appears rendering
actual text content in a CJK document, it almost always means a raw string
was passed to a ReportLab Table or flowable without wrapping it in
Paragraph() with a CJK font. The CJK characters rendered via Helvetica
become garbled (fall back to ZapfDingbats symbols).
We only check Helvetica (not ZapfDingbats) because ZapfDingbats is
legitimately used for bullet symbols in list items.
We check actual rendered text spans (not just font presence in font list)
because ReportLab internally registers Helvetica on every page even when
only CJK fonts are used in visible content.
"""
has_cjk = False
helvetica_pages = []
for page_num in range(len(doc)):
page = doc[page_num]
text = page.get_text("text") or ""
# Check if document contains CJK characters
if not has_cjk:
for ch in text:
if '\u4e00' <= ch <= '\u9fff' or '\u3400' <= ch <= '\u4dbf':
has_cjk = True
break
# Check if Helvetica is actually used to render visible text on this page
blocks = page.get_text("dict", sort=True).get("blocks", [])
found_on_page = False
for block in blocks:
if found_on_page:
break
for line in block.get("lines", []):
if found_on_page:
break
for span in line.get("spans", []):
font = span.get("font", "")
txt = span.get("text", "").strip()
if "Helvetica" in font and len(txt) > 0:
helvetica_pages.append(page_num + 1)
found_on_page = True
break
if has_cjk and helvetica_pages:
pages_str = ', '.join(str(p) for p in helvetica_pages[:5])
if len(helvetica_pages) > 5:
pages_str += f' ...and {len(helvetica_pages) - 5} more'
result.warn(
"Helvetica in CJK document",
f"Helvetica font detected rendering text on page(s) {pages_str} in a CJK document. "
f"This usually means a raw string was passed to a ReportLab Table or flowable "
f"without wrapping in Paragraph(text, style) with a CJK-capable font. "
f"CJK characters rendered via Helvetica will appear as garbled symbols."
)
def check_metadata(doc, result):
"""Check PDF metadata presence for title, author, creator."""
meta = doc.metadata or {}
def _missing(v):
if v is None:
return True
if not str(v).strip():
return True
return False
title = meta.get("title")
author = meta.get("author")
creator = meta.get("creator")
if _missing(title) or str(title).strip().lower() in ("untitled", "(anonymous)"):
result.warn("Metadata", "Missing/invalid title metadata")
else:
result.ok("Title metadata present \u2713")
if _missing(author):
result.warn("Metadata", "Missing author metadata")
else:
result.ok("Author metadata present \u2713")
if _missing(creator):
result.warn("Metadata", "Missing creator metadata")
else:
result.ok("Creator metadata present \u2713")
def check_toc_without_cover(doc, result):
"""Detect TOC on page 1 without a preceding cover page.
If the first page contains Table of Contents / 目录, it means the document
has a TOC but no cover page. This is a structural issue — documents with
TOC should have: Cover (p1) → TOC (p2) → Content (p3+).
"""
if len(doc) < 2:
# Single-page docs don't need TOC/cover checks
return
page1 = doc[0]
text = page1.get_text("text", sort=True).strip()
# Normalize for matching
text_lower = text.lower()
first_300 = text_lower[:300]
toc_keywords = [
"table of contents", "contents",
"目录", "目 录",
]
has_toc = any(kw in first_300 for kw in toc_keywords)
if has_toc:
result.warn(
"TOC without cover",
"Page 1 appears to be a Table of Contents with no preceding cover page. "
"Documents with TOC should have: Cover (p1) → TOC (p2) → Content (p3+)."
)
def check_formula_overflow(doc, result):
"""Detect likely formula overflow past right content margin."""
math_re = re.compile(r"[=+\-*/<>\u2264\u2265\u2211\u222b\u221a\u03c0\u00b5\u221e\u2202\u2206\u2248\u2260\u00b1\u00d7\u00f7]")
for page_num in range(len(doc)):
page = doc[page_num]
blocks = page.get_text("blocks")
text_blocks = [b for b in blocks if b[4].strip()]
if len(text_blocks) < 3:
continue
right_edges = sorted(b[2] for b in text_blocks)
mid = len(right_edges) // 2
content_right = right_edges[mid] if right_edges else 0
for b in text_blocks:
x0, x1, text = b[0], b[2], b[4]
if x1 <= content_right + 10:
continue
is_single_line = "\n" not in text.strip()
is_wide = (x1 - x0) > page.rect.width * 0.5
has_math = bool(math_re.search(text))
if (is_single_line and is_wide) or has_math:
delta = x1 - content_right
result.warn(
"Formula overflow",
f"Page {page_num + 1}: Content extends {delta:.0f}pt beyond right content margin "
"(possible formula overflow)"
)
break
# ============================================================
# Main
# ============================================================
def run_qa(pdf_path, poster=False, skip_cover=False, check_tables=True, check_formulas=False):
result = QAResult()
if not os.path.exists(pdf_path):
result.error("File", f"File not found: {pdf_path}")
return result
doc = pymupdf.open(pdf_path)
result.add_info(f"File: {os.path.basename(pdf_path)}")
result.add_info(f"Size: {os.path.getsize(pdf_path) / 1024:.1f} KB")
if poster:
result.add_info("Mode: poster (creative)")
# Run all checks
check_metadata(doc, result)
check_page_size_consistency(doc, result)
check_blank_pages(doc, result)
check_punctuation(doc, result)
check_colors(doc, result)
check_font_embedding(doc, result)
check_helvetica_in_cjk(doc, result)
check_text_overflow(doc, result)
if not poster:
# Content fill ratio is not meaningful for posters — the last page
# of a seamlessly-paginated poster naturally has less content.
check_content_fill_ratio(doc, result)
check_cover_bleed(doc, result, poster=poster)
check_margin_symmetry(doc, result, skip_cover=skip_cover)
if check_tables:
check_table_centering(doc, result)
if check_formulas:
check_formula_overflow(doc, result)
if not poster:
check_toc_without_cover(doc, result)
doc.close()
return result
def format_report(result):
lines = []
lines.append("=" * 56)
lines.append(" PDF Quality Assurance Report")
lines.append("=" * 56)
# Info
if result.info:
lines.append("")
lines.append(" Info:")
for msg in result.info:
lines.append(f" {msg}")
# Passes
if result.passes:
lines.append("")
lines.append(f"✅ Passed ({len(result.passes)}):")
for msg in result.passes:
lines.append(f" {msg}")
# Issues
errors = [(s, c, m) for s, c, m in result.issues if s == 'ERROR']
warns = [(s, c, m) for s, c, m in result.issues if s == 'WARN']
if errors:
lines.append("")
lines.append(f"❌ Errors ({len(errors)}):")
for _, cat, msg in errors:
lines.append(f" [{cat}] {msg}")
if warns:
lines.append("")
lines.append(f"⚠️ Warnings ({len(warns)}):")
for _, cat, msg in warns:
lines.append(f" [{cat}] {msg}")
# Summary
lines.append("")
lines.append("-" * 56)
total_issues = len(result.issues)
if total_issues == 0:
lines.append("🎉 PASS — All checks passed!")
elif errors:
lines.append(f"💀 FAIL — {len(errors)} error(s), {len(warns)} warning(s)")
else:
lines.append(f"⚠️ WARN — {len(warns)} warning(s), optimization recommended")
lines.append("-" * 56)
return "\n".join(lines)
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python3 pdf_qa.py <pdf_path>")
print(" python3 pdf_qa.py *.pdf (batch check)")
print("Options:")
print(" --poster Poster mode (creative)")
print(" --skip-cover Skip page 1 margin symmetry check")
print(" --no-tables Disable table centering check")
print(" --formulas Enable formula overflow check")
sys.exit(1)
import glob
files = []
poster = False
skip_cover = False
check_tables = True
check_formulas = False
args = sys.argv[1:]
if '--poster' in args:
poster = True
args.remove('--poster')
if '--skip-cover' in args:
skip_cover = True
args.remove('--skip-cover')
if '--no-tables' in args:
check_tables = False
args.remove('--no-tables')
if '--formulas' in args:
check_formulas = True
args.remove('--formulas')
for arg in args:
files.extend(glob.glob(arg))
if not files:
print(f"File not found: {args}")
sys.exit(1)
for pdf_path in files:
result = run_qa(
pdf_path,
poster=poster,
skip_cover=skip_cover,
check_tables=check_tables,
check_formulas=check_formulas
)
print(format_report(result))
if len(files) > 1:
print("\n")

File diff suppressed because it is too large Load Diff

269
skills/pdf/scripts/setup.sh Executable file
View File

@@ -0,0 +1,269 @@
#!/usr/bin/env bash
# ---
# name: pdf-setup
# author: Z.AI
# version: "1.0"
# description: Environment setup for the PDF skill. Checks and installs all required dependencies.
# ---
#
# Installs only dependencies required by the PDF skill.
set -euo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
ok() { echo -e " ${GREEN}${NC} $1"; }
fail() { echo -e " ${RED}${NC} $1"; }
warn() { echo -e " ${YELLOW}${NC} $1"; }
info() { echo -e " ${BLUE}${NC} $1"; }
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "============================================"
echo " PDF Skill — Environment Setup"
echo "============================================"
echo ""
# ── Detect platform ──
OS="$(uname -s)"
ARCH="$(uname -m)"
echo "Platform: $OS $ARCH"
echo ""
# ── 0. macOS: Homebrew ──
if [ "$OS" = "Darwin" ]; then
echo "--- Homebrew (macOS package manager) ---"
if command -v brew &>/dev/null; then
BREW_VER=$(brew --version 2>/dev/null | head -1)
ok "brew ($BREW_VER)"
else
fail "brew not found — most dependencies below need Homebrew on macOS"
info "Install: /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""
fi
echo ""
fi
# ── 1. Python 3 ──
echo "--- Python ---"
if command -v python3 &>/dev/null; then
PY_VER=$(python3 --version 2>&1)
ok "python3 ($PY_VER)"
# macOS: warn if using system Python
if [ "$OS" = "Darwin" ]; then
PY_PATH=$(which python3 2>/dev/null)
if [[ "$PY_PATH" == "/usr/bin/python3" ]]; then
warn "Using macOS system Python (limited). Recommend: brew install python3"
fi
fi
else
fail "python3 not found"
case "$OS" in
Darwin) info "Install: brew install python3" ;;
Linux) info "Install: sudo apt install python3 python3-pip (Debian/Ubuntu)"
info " sudo dnf install python3 python3-pip (Fedora/RHEL)" ;;
*) info "Install: https://www.python.org/downloads/" ;;
esac
fi
# ── 2. pip ──
echo ""
echo "--- pip ---"
if python3 -m pip --version &>/dev/null 2>&1; then
PIP_VER=$(python3 -m pip --version 2>/dev/null | head -1)
ok "pip ($PIP_VER)"
else
fail "pip not found"
case "$OS" in
Darwin) info "Install: python3 -m ensurepip --upgrade"
info " or: brew install python3 (includes pip)" ;;
Linux) info "Install: sudo apt install python3-pip (Debian/Ubuntu)" ;;
*) info "Install: python3 -m ensurepip --upgrade" ;;
esac
fi
# ── 3. Python packages (pip) ──
echo ""
echo "--- Python Packages ---"
PY_PKGS=(
"pikepdf:pikepdf"
"pdfplumber:pdfplumber"
"pypdf:pypdf"
"reportlab:reportlab"
"pymupdf:PyMuPDF"
)
MISSING_PY=()
for entry in "${PY_PKGS[@]}"; do
mod="${entry%%:*}"
pkg="${entry##*:}"
if python3 -c "import $mod" 2>/dev/null; then
ver=$(python3 -c "import $mod; print(getattr($mod, '__version__', 'installed'))" 2>/dev/null)
ok "$pkg ($ver)"
else
fail "$pkg not installed"
MISSING_PY+=("$pkg")
fi
done
if [ ${#MISSING_PY[@]} -gt 0 ]; then
echo ""
if [ -t 0 ]; then
read -p " Install missing Python packages? [Y/n] " -n 1 -r REPLY
echo ""
REPLY=${REPLY:-Y}
else
warn "Non-interactive mode — skipping auto-install. Run interactively or install manually."
REPLY=N
fi
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
python3 -m pip install -q "${MISSING_PY[@]}" 2>/dev/null \
|| python3 -m pip install -q --user "${MISSING_PY[@]}" 2>/dev/null \
|| python3 -m pip install -q --break-system-packages "${MISSING_PY[@]}" 2>/dev/null \
|| { fail "pip install failed. Try manually: pip install ${MISSING_PY[*]}"; }
ok "Installed: ${MISSING_PY[*]}"
fi
fi
# ── 4. Node.js ──
echo ""
echo "--- Node.js ---"
if command -v node &>/dev/null; then
NODE_VER=$(node --version)
ok "node ($NODE_VER)"
else
fail "node not found"
case "$OS" in
Darwin) info "Install: brew install node" ;;
Linux) info "Install: curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -"
info " sudo apt install -y nodejs" ;;
*) info "Install: https://nodejs.org/" ;;
esac
fi
# ── 5. npm ──
echo ""
echo "--- npm ---"
if command -v npm &>/dev/null; then
NPM_VER=$(npm --version 2>/dev/null)
ok "npm ($NPM_VER)"
else
fail "npm not found"
case "$OS" in
Darwin) info "Install: brew install node (includes npm)" ;;
Linux) info "Install: comes with nodejs" ;;
*) info "Install: https://nodejs.org/" ;;
esac
fi
# ── 6. Playwright + Chromium ──
echo ""
echo "--- Playwright (HTML→PDF engine) ---"
if node -e "require('playwright')" 2>/dev/null; then
PW_VER=$(node -e "console.log(require('playwright/package.json').version)" 2>/dev/null)
ok "playwright ($PW_VER)"
else
fail "playwright not installed"
info "Install: npm install -g playwright"
fi
# Check Chromium
if [ "$OS" = "Darwin" ]; then
PW_CACHE="$HOME/Library/Caches/ms-playwright"
else
PW_CACHE="$HOME/.cache/ms-playwright"
fi
if ls "$PW_CACHE"/chromium-* &>/dev/null 2>&1; then
CR_DIR=$(ls -d "$PW_CACHE"/chromium-* 2>/dev/null | tail -1)
ok "chromium ($(basename "$CR_DIR"))"
else
fail "chromium not installed"
info "Install: npx playwright install chromium"
if [ "$OS" = "Linux" ]; then
info " npx playwright install-deps (system libs, needs sudo)"
fi
fi
# ── 7. Tectonic (LaTeX engine, optional) ──
echo ""
echo "--- Tectonic (LaTeX→PDF, optional) ---"
BUNDLED="$SCRIPT_DIR/tectonic"
if [ -x "$BUNDLED" ]; then
if [ "$OS" = "Darwin" ] && [ "$ARCH" = "arm64" ]; then
ok "tectonic (bundled, macOS arm64)"
else
warn "bundled tectonic is macOS arm64 only — cannot run on $OS $ARCH"
if command -v tectonic &>/dev/null; then
TEC_VER=$(tectonic --version 2>&1 | head -1)
ok "tectonic (system: $TEC_VER)"
else
fail "tectonic not in PATH"
case "$OS" in
Darwin) info "Install: brew install tectonic" ;;
Linux) info "Install: conda install -c conda-forge tectonic"
info " or: curl -fsSL https://drop-sh.fullyjustified.net | sh" ;;
MINGW*|MSYS*|CYGWIN*) info "Install: scoop install tectonic / choco install tectonic" ;;
esac
fi
fi
elif command -v tectonic &>/dev/null; then
TEC_VER=$(tectonic --version 2>&1 | head -1)
ok "tectonic ($TEC_VER)"
elif [ -x "$HOME/tectonic" ]; then
ok "tectonic (~/tectonic)"
else
warn "tectonic not installed (needed only for LaTeX/academic PDFs)"
case "$OS" in
Darwin) info "Install: brew install tectonic" ;;
Linux) info "Install: conda install -c conda-forge tectonic"
info " or: curl -fsSL https://drop-sh.fullyjustified.net | sh" ;;
MINGW*|MSYS*|CYGWIN*) info "Install: scoop install tectonic / choco install tectonic" ;;
esac
fi
# ── 8. LibreOffice (optional, for Office→PDF conversion) ──
echo ""
echo "--- LibreOffice (optional, Office→PDF) ---"
if command -v soffice &>/dev/null; then
LO_VER=$(soffice --version 2>/dev/null | head -1)
ok "libreoffice ($LO_VER)"
else
warn "libreoffice not installed (needed only for .docx/.xlsx→PDF conversion)"
case "$OS" in
Darwin) info "Install: brew install --cask libreoffice" ;;
Linux) info "Install: sudo apt install libreoffice-core (Debian/Ubuntu)" ;;
esac
fi
# ── 9. CJK Fonts ──
echo ""
echo "--- CJK Fonts ---"
FONT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)/fonts"
if [ -d "$FONT_DIR" ]; then
FONT_COUNT=$(find "$FONT_DIR" -name "*.ttf" -o -name "*.otf" 2>/dev/null | head -20 | wc -l | tr -d ' ')
ok "fonts directory ($FONT_COUNT font files in $FONT_DIR)"
else
warn "no fonts/ directory found — CJK PDFs may have missing glyphs"
info "Expected at: $FONT_DIR"
fi
# Check system CJK fonts
if [ "$OS" = "Darwin" ]; then
if ls /System/Library/Fonts/PingFang.ttc &>/dev/null 2>&1 \
|| ls /System/Library/Fonts/STHeiti*.ttc &>/dev/null 2>&1 \
|| ls "$HOME/Library/Fonts/"*SimHei* &>/dev/null 2>&1; then
ok "macOS CJK system fonts available"
else
warn "no common CJK system fonts found"
fi
elif [ "$OS" = "Linux" ]; then
if fc-list :lang=zh 2>/dev/null | head -1 | grep -q .; then
ok "system CJK fonts available (fc-list)"
else
warn "no CJK fonts found. Install: sudo apt install fonts-noto-cjk"
fi
fi
# ── Summary ──
echo ""
echo "============================================"
echo " Setup complete."
echo " Run 'python3 pdf.py env.check' for detailed status."
echo " Run 'python3 pdf.py env.fix' to auto-install Python deps."
echo "============================================"

2075
skills/pdf/scripts/toc_validate.py Executable file

File diff suppressed because it is too large Load Diff

320
skills/pdf/typesetting/charts.md Executable file
View File

@@ -0,0 +1,320 @@
# Chart Design — Chart Typesetting & Anti-Stacking Rules
> Core principle: **Data-Ink Ratio** — delete every line that doesn't represent data. Then delete some more.
---
## Part 1: Anti-Stacking (Collision Prevention)
Text stacking is fundamentally "information density exceeding available physical space." Apply these dynamic degradation strategies.
### 0. Universal Anti-Overlap Pre-Check (MANDATORY)
**Before rendering ANY chart, run this pre-flight check:**
```python
# Step 1: Reserve space for labels FIRST, then draw chart
# WRONG: draw chart → add labels → discover overlap → cry
# RIGHT: measure labels → reserve space → draw chart in remaining area
# Step 2: Chart-to-text separation
CHART_TEXT_MIN_GAP = 12 # pt — minimum gap between chart edge and adjacent text
CHART_LABEL_MIN_GAP = 6 # pt — minimum gap between chart labels and chart elements
# Step 3: Label-to-label collision check
def labels_overlap(label_a, label_b):
"""Check if two label bounding boxes overlap."""
return not (label_a.right < label_b.left or
label_a.left > label_b.right or
label_a.bottom < label_b.top or
label_a.top > label_b.bottom)
# Step 4: Resolution cascade
# 1. Reposition (nudge conflicting label)
# 2. Reduce font size (min 8pt)
# 3. Remove label (replace with legend entry)
# 4. Merge small items ("Others" grouping)
```
**Matplotlib-specific anti-overlap:**
```python
# MANDATORY for all matplotlib charts
import matplotlib.pyplot as plt
from adjustText import adjust_text # pip install adjustText
# After adding text annotations:
# adjust_text(texts, arrowprops=dict(arrowstyle='-', color='gray', lw=0.5))
# For bar value labels:
fig.tight_layout(pad=2.0) # Always use generous padding
plt.subplots_adjust(bottom=0.15) # Reserve space for rotated labels
# For pie/donut:
# autopct labels: check angular distance between adjacent slices
# If angle < 15°, use external labels with leader lines
```
**Chart-to-body-text separation (ReportLab):**
```python
# MANDATORY spacers around chart flowables
story.append(Spacer(1, 24)) # 24pt gap before chart
story.append(chart_image) # Chart
story.append(Spacer(1, 8)) # 8pt gap
story.append(chart_caption) # Caption
story.append(Spacer(1, 24)) # 24pt gap after chart
# NEVER place chart without Spacer guards
```
### 1. Pie / Donut Chart — Label Collision Prevention
When slices are too small, labels MUST NOT be placed inside the arc.
#### Strategy A: Leader Lines + Y-Axis Collision Avoidance
When slice angle `< 15°` (or area share `< 5%`), force external labels:
- Draw a polyline (leader line) from the arc's outer edge to the label text
- **Y-axis anti-collision logic**: Calculate adjacent label Y-coordinates. If `Y2 - Y1 < font_height + padding`, push `Y2` downward (or pull `Y1` upward). Extend/shorten the horizontal segment of the leader line accordingly.
- Leader lines: 1pt, same color as slice at 60% opacity
#### Strategy B: "Others" Grouping (Long-Tail Merge)
Before data reaches the renderer, intercept and merge:
- Threshold: slices `< 3%` → merge into a single "其他 (Others)" slice
- If detail is needed, add a minimal table beside the chart showing the breakdown
- This prevents 5+ tiny slivers from cluttering the chart
#### Strategy C: Strip Labels, Use Rich Legend
The most premium approach — **zero text on the chart itself**:
- Pie/donut body shows only pure shapes + colors
- All names, percentages, and values are laid out in a grid-aligned legend to the right or below
- This NEVER stacks, and looks the most professional
**Priority order**: C (best) → A (good) → B (acceptable fallback)
---
### 2. Bar Chart — Label Collision Prevention
#### Strategy A: Auto-Rotate to Horizontal Bar
**Hard rule**: When X-axis label average length exceeds **5 Chinese characters** (or 10 Latin characters), automatically convert to a horizontal bar chart.
- Y-axis has unlimited downward space for labels — stacking is impossible
- This is the single most effective anti-collision measure for bar charts
#### Strategy B: Tick Thinning + Stagger
When there are many bars (e.g., 30-day trend):
- **Thinning**: Show every 2nd or 5th label (skip intermediate ticks)
- **Stagger**: Alternate labels between two rows (offset vertically)
- **Tilt (last resort)**: 45° rotation works but reduces readability in premium reports. Prefer thinning or horizontal bars.
#### Strategy C: Value Label Inside/Outside Flip
- If bar is tall enough: place value label **inside** the bar near the top (use contrasting text color)
- If bar is too short for internal label: place value **above** the bar
- If value labels would overlap between adjacent bars: show values only on the tallest/shortest bars, or use tooltip-style callout boxes
---
### 3. Line Chart — Data Point Label Prevention
Dense data points with labels on every point = visual chaos.
#### Strategy A: "First, Last, Max, Min" Rule (Data Journalism Standard)
Only auto-label **4 points** on any line:
- **Start point** (first value)
- **End point** (last value)
- **Maximum** (peak)
- **Minimum** (valley)
All other points show only the curve shape — no labels. This instantly elevates professionalism.
#### Strategy B: Callout Boxes
For points that must be highlighted:
- Don't let text sit naked on the curve
- Wrap in a rounded-corner background box (white fill, very light shadow or thin border)
- Connect to the data point with a thin needle line
- Boxes have physical boundaries → easier collision detection and displacement
---
## Part 2: Visual Refinement (Eliminating "Cheap" Aesthetics)
### 1. Axis & Grid Line Treatment
**The #1 sign of amateur charts: thick black border frames and solid grid lines.**
| Element | Rule |
|---------|------|
| Top spine | **DELETE** (unconditionally) |
| Right spine | **DELETE** (unconditionally) |
| Left Y-axis spine | Optional — can delete if values are labeled on bars directly |
| Bottom X-axis | Keep as baseline reference (thin, gray) |
| Grid lines | **Dashed only** (dotted or dashed), 0.5pt, 15-20% opacity. NEVER solid. |
| Grid lines (when values shown) | **DELETE entirely** — if bar/line values are directly labeled, grid lines are redundant |
### 2. Geometric Shape Refinement
#### Pie → Donut (Mandatory Default)
- **Always use donut (ring) charts** instead of solid pies
- Inner radius = **6070%** of outer radius
- Center space: display the total value or core metric in large text (e.g., "100%" / "¥2.4M")
- Visual weight is lighter, information hierarchy is clearer
#### Bar Styling
| Property | Value | Why |
|----------|-------|-----|
| Bar-to-gap ratio | `1.5:1` or `2:1` | Not too thin (bamboo sticks), not too fat (no breathing room) |
| Top border-radius | `2px 4px` | Micro-rounding removes machine harshness, adds modern UI feel |
| Bottom border-radius | `0px` | Flat base anchors to the axis |
#### Line Chart Refinement
| Property | Value | Why |
|----------|-------|-----|
| Curve type | **Smooth (Bézier/spline)** | Unless showing strictly discrete data |
| Line width | `2pt 3pt` | Stands out against weakened grid |
| Area fill | Gradient from line color at 20% opacity → 0% opacity downward | Adds volume and depth |
| Data point markers | Small circles (3-4px radius), only on labeled points | Don't mark every point |
### 3. Legend & Text Hierarchy
#### Chart Title Layout
- **Main title**: Left-aligned above the chart, bold, 14-16pt
- **Subtitle**: Below main title, regular weight, smaller (11-12pt), describes data source/period/units
- Title and chart body must have clear visual separation (≥16px gap)
#### Legend Rules
| Rule | Details |
|------|---------|
| Border | **NONE** — never put a box around the legend |
| Position | Top-left horizontal row (preferred) or directly above chart area |
| Markers | Small circles (4px) or short line segments — NOT chunky squares |
| Font size | Same as axis labels (10-12pt) |
| Spacing | Generous horizontal spacing between items (≥24px) |
---
## Part 3: Default Chart Configuration
When generating any chart (HTML/SVG for Creative pipeline, or matplotlib/ReportLab for Report pipeline), apply these defaults:
```
Chart Defaults:
axes:
top_spine: hidden
right_spine: hidden
left_spine: light_gray_or_hidden
bottom_spine: light_gray_thin
grid: dashed, 0.5pt, 20% opacity (or hidden if values labeled)
pie:
type: donut
hole_ratio: 0.65
min_slice_for_internal_label: 5%
small_slice_strategy: leader_lines # or "others_merge" or "rich_legend"
others_threshold: 3%
bar:
top_radius: 3px
bar_gap_ratio: 0.5 # gap = 50% of bar width
auto_horizontal_threshold: 5_cjk_chars # or 10 latin chars
value_label_position: auto # inside if tall, outside if short
line:
smooth: true # Bézier curve
width: 2.5pt
area_fill: gradient_20_to_0
label_strategy: first_last_max_min
point_markers: labeled_points_only
legend:
border: none
position: top_left_horizontal
marker_shape: circle_small # 4px radius
marker_size: 4px
typography:
chart_title: bold, 14-16pt, left-aligned
chart_subtitle: regular, 11-12pt, left-aligned
axis_labels: 10-12pt
value_labels: 10-12pt
legend_text: 10-12pt
```
---
## Part 4: Pipeline-Specific Notes
### Creative Pipeline (Playwright HTML/CSS)
Charts in the Creative pipeline are rendered as HTML/SVG within the blueprint's components. Apply chart rules through:
- Inline SVG with proper viewBox and text positioning
- CSS classes for axis hiding, grid styling, legend layout
- JavaScript-based collision detection for leader lines (if dynamic)
### Report Pipeline (ReportLab)
Charts in the Report pipeline use ReportLab Drawing objects or embedded matplotlib figures:
- Use matplotlib with `plt.rcParams` overrides matching the defaults above
- `ax.spines['top'].set_visible(False)`, `ax.spines['right'].set_visible(False)`
- `ax.grid(True, linestyle='--', alpha=0.2, linewidth=0.5)`
- For pie charts: `plt.pie(..., wedgeprops=dict(width=0.35))` for donut effect
- For bar charts: use `matplotlib.patches.FancyBboxPatch` or `bar(..., edgecolor='none')` with manual rounded rect patches
### Academic Pipeline (LaTeX/TikZ)
- Use `pgfplots` with similar axis/grid configuration
- `\pgfplotsset{every axis/.append style={...}}`
- Donut charts via `tikz` with arc drawing
---
## Part 5: Chart Spacing & Anti-Overlap Guarantees
### 5.1 Chart-to-Body-Text Separation
| Context | Minimum Gap | Notes |
|---------|-------------|-------|
| Chart above/below body text | 24pt | Both above and below the chart |
| Chart caption to chart | 8pt | Caption immediately below chart |
| Chart caption to next body text | 18pt | Clear separation |
| Two consecutive charts | 30pt | Prevent visual merging |
### 5.2 Legend-to-Chart Overlap Prevention
**Legend MUST NOT overlap chart data area.** This is a zero-tolerance rule.
```python
# matplotlib: move legend outside plot area when overlapping
leg = ax.legend(loc='upper left', bbox_to_anchor=(0, 1), frameon=False)
# If legend still overlaps, move to below chart:
# leg = ax.legend(loc='upper center', bbox_to_anchor=(0.5, -0.12),
# ncol=3, frameon=False)
# ALWAYS call tight_layout AFTER placing legend
fig.tight_layout()
```
### 5.3 Value Label Anti-Collision
**When value labels on adjacent bars/points overlap:**
1. **Stagger vertically** — alternate above/below the bar
2. **Rotate 45°** — angled labels take less horizontal space
3. **Show only key values** — max, min, first, last
4. **Remove all and use gridlines** — let the reader estimate from axis
### 5.4 Multi-Chart Layout in Documents
When a page contains 2+ charts:
- Each chart gets its own bounding box with explicit dimensions
- Charts must not share the same vertical space (no side-by-side unless explicitly designed)
- For side-by-side charts: use a 2-column layout with `Spacer` between columns
- Each charts title/subtitle/legend must be fully within its own bounding box

View File

@@ -0,0 +1,384 @@
# Cover Background — Advanced Cover Background Construction Rules
> Background is the canvas behind the canvas. It should be felt, not seen.
> **⚠️ V2.1 Note:** Since all covers are now rendered via HTML/Playwright, the canonical implementation is **CSS/HTML**. The ReportLab Python examples below are kept as **algorithmic reference** (coordinates, ratios, opacity values) — translate them to equivalent CSS `background`, `linear-gradient`, `radial-gradient`, `transform`, and `clip-path` properties when implementing. The design intent and constraints (opacity limits, Z-index rules, WCAG contrast) apply regardless of rendering engine.
---
## 🔴 Global Constraints
Before executing any specific background algorithm, these three iron rules must be obeyed — they are the baseline to ensure backgrounds remain subtle and never overwhelm:
### 1. Ultra-Low Contrast Rule (Opacity/Alpha Control)
All background element colors are calculated based on the underlying canvas base color:
| Base Color Type | Background Element Color | Opacity Range |
|---------|-------------|-----------|
| Light/white base | `#000000` | **2% 5%** |
| Dark base | `#FFFFFF` | **3% 6%** |
**Absolutely forbidden** to exceed the above opacity ranges. Background elements must be at the "barely perceptible" threshold.
### 2. Absolute Z-Axis Bottom Layer
All background drawing commands must be executed after the base solid color fill and before any text/layout content rendering.
Rendering order (bottom to top):
```
1. Page base color fill (solid rect)
2. ▶ Background element layer (all content defined in this file)
3. Foreground layout content (text, borders, geometric anchors, etc.)
```
### 3. Anti-Overflow Clipping (Clip Path)
Canvas Clip must be enabled to ensure any oversized shapes exceeding the page `(0, 0, W, H)` coordinate system do not cause abnormal PDF physical dimensions or rendering errors.
**Core principle: All calculations must be based on dynamic page dimensions (`W` = page width, `H` = page height). Hard-coding absolute pixel values is forbidden.**
---
## Module 1: Supergraphics
**Goal:** Use minimal curves or diagonal lines to break the rigidity of the rectangular frame.
### Option 1.1: Arc (The Bleeding Circle)
**Shape type**: Solid-filled circle
**Calculation logic**:
- `Radius` = `W × 1.2` to `W × 1.5` (must be larger than page width to ensure arc curvature is gentle enough)
- `Center_X` = `W × 1.0` (center placed at the rightmost page edge, or even outside the right side)
- `Center_Y` = `H × 0.8` (center biased lower)
**Visual effect**: An extremely elegant, grand arc sweeps across the lower-left corner of the page, with most of the circle body outside the page.
**ReportLab implementation notes**:
```python
c.saveState()
c.clipRect(0, 0, W, H) # Rule 3: anti-overflow
c.setFillColorRGB(0, 0, 0, 0.03) # Rule 1: ultra-low opacity
radius = W * 1.3
cx, cy = W * 1.0, H * 0.2 # ReportLab coordinate Y starts from bottom
c.circle(cx, cy, radius, fill=1, stroke=0)
c.restoreState()
```
**Playwright/HTML implementation notes**:
```html
<!-- Option 2.1: Side giant spine -->
<!-- Note: Must use JS to measure text width, dynamically adjust font-size to ensure full display -->
<div style="position:absolute; inset:0; overflow:hidden; z-index:0;">
<div id="spine-watermark" style="
position: absolute;
left: 3%;
top: 50%;
transform: translateY(-50%) rotate(-90deg);
transform-origin: center center;
font-size: min(calc(var(--W) * 0.45), 45vw);
font-family: 'Helvetica', 'Arial Black', sans-serif;
font-weight: 900;
color: rgba(0,0,0,0.04);
white-space: nowrap;
line-height: 1;
">2026</div>
</div>
<script>
// Adaptive: ensure rotated text does not exceed 85% of page height
const el = document.getElementById('spine-watermark');
const maxH = window.innerHeight * 0.85;
if (el.offsetWidth > maxH) {
const scale = maxH / el.offsetWidth;
el.style.fontSize = (parseFloat(getComputedStyle(el).fontSize) * scale) + 'px';
}
</script>
```
---
### Option 1.2: Sharp Angle Cut (The Angle Slash)
**Shape type**: Polygon/right trapezoid
**Calculation logic (define four vertices)**:
- `Point 1` = `(0, H × 0.7)`
- `Point 2` = `(W, H × 0.4)`
- `Point 3` = `(W, H)`
- `Point 4` = `(0, H)`
**Visual effect**: Forms a tilted geometric color block at the bottom of the page. This sharp linear cut has a strong IT/consulting/engineering industry feel.
**ReportLab implementation notes**:
```python
c.saveState()
c.clipRect(0, 0, W, H)
c.setFillColorRGB(0, 0, 0, 0.04) # Rule 1
path = c.beginPath()
# ReportLab Y starts from bottom, needs flipping
path.moveTo(0, H * 0.3) # Corresponds to doc P1: (0, H*0.7) → flipped
path.lineTo(W, H * 0.6) # Corresponds to doc P2: (W, H*0.4) → flipped
path.lineTo(W, 0) # Corresponds to doc P3: (W, H) → flipped
path.lineTo(0, 0) # Corresponds to doc P4: (0, H) → flipped
path.close()
c.drawPath(path, fill=1, stroke=0)
c.restoreState()
```
---
## Module 2: Typographic Watermarks
**Goal:** Extract very short metadata text and transform it into an architectural watermark space.
### Technical Requirements (Mandatory)
| Constraint | Rule |
|-------|------|
| **Font** | Must use sans-serif, weight must be extra-bold (Black / Heavy / Bold). Recommended: Helvetica Black, Arial Black, Noto Sans SC Heavy |
| **Font prohibition** | **Absolutely forbidden** to use thin or serif fonts for this type of watermark |
| **Character count** | Extracted string length `15` characters (e.g. year "2026", abbreviation "AI", "B2B") |
| **Opacity** | Follow global Rule 1 (light bg 2-5%, dark bg 3-6%) |
### Option 2.1: Side Giant Spine (Vertical Spine)
**Calculation logic**:
- `Text` = Extract year (e.g. "2026")
- **🔴 Font Size Adaptive Algorithm (Full Text Display Iron Rule):**
1. `Max_Font_Size` = `W × 0.45` (ideal maximum)
2. Measure total text height after rotation: `Text_Width = measure(Text, Max_Font_Size)` (after 90° rotation, original width becomes vertical height)
3. Available vertical space = `H × 0.85` (leaving `H × 0.075` safety margin top and bottom)
4. If `Text_Width > available vertical space`, scale down proportionally: `Font_Size = Max_Font_Size × (available_vertical_space / Text_Width)`
5. Final `Font_Size = min(Max_Font_Size, scaled_font_size)`
- `Rotation` = Counterclockwise 90° (`-90deg`)
- `Anchor_X` = `W × 0.03` (text fully within page, flush to left but not exceeding)
- `Anchor_Y` = Vertically centered = `(H - Text_Width) / 2` (text centered after rotation)
**⚠️ Full Display Iron Rule: Background watermark text must be 100% within the visible page area. Any clipping is strictly forbidden. Reduce font size rather than truncate.**
**Visual effect**: A complete bold number watermark appears on the left side, vertically centered, becoming the visual supporting pillar. Text is fully readable.
**ReportLab implementation notes**:
```python
c.saveState()
c.clipRect(0, 0, W, H)
c.setFillColorRGB(0, 0, 0, 0.04)
# Adaptive font size: ensure full text display
max_font_size = W * 0.45
text = "2026"
text_width = c.stringWidth(text, "Helvetica-Bold", max_font_size)
available_height = H * 0.85
if text_width > available_height:
font_size = max_font_size * (available_height / text_width)
else:
font_size = max_font_size
# Recalculate actual width for centering
actual_text_width = c.stringWidth(text, "Helvetica-Bold", font_size)
c.setFont("Helvetica-Bold", font_size)
# Vertically centered, fully within page
center_y = (H - actual_text_width) / 2
c.translate(W * 0.03, center_y)
c.rotate(90) # ReportLab counter-clockwise is positive
c.drawString(0, 0, text)
c.restoreState()
```
---
### Option 2.2: Bottom Full Text
**Calculation logic**:
- `Text` = Document type English initials (e.g. "REPORT")
- **🔴 Font Size Adaptive Algorithm (Full Text Display Iron Rule):**
1. `Max_Font_Size` = `W × 0.3` (ideal maximum)
2. Measure text rendering width: `Text_Width = measure(Text, Max_Font_Size)`
3. Available horizontal space = `W × 0.90` (leaving `W × 0.05` safety margin left and right)
4. If `Text_Width > available horizontal space`, scale down proportionally: `Font_Size = Max_Font_Size × (available_horizontal_space / Text_Width)`
5. Final `Font_Size = min(Max_Font_Size, scaled_font_size)`
- `Rotation` = 0° (horizontal tiling)
- `Anchor_X` = `W × 0.05`
- `Anchor_Y` = Text baseline within the bottom safe zone of the page: `H × 0.92` (text fully displayed at page bottom, not truncated)
**⚠️ Full Display Iron Rule: Background watermark text must be 100% within the visible page area. Any clipping is strictly forbidden. Reduce font size rather than truncate.**
**Visual effect**: Text sits solidly at the bottom like a foundation, fully readable, extremely dignified. No more half-truncated text.
**ReportLab implementation notes**:
```python
c.saveState()
c.clipRect(0, 0, W, H)
c.setFillColorRGB(0, 0, 0, 0.04)
# Adaptive font size: ensure full text display
max_font_size = W * 0.3
text = "REPORT"
text_width = c.stringWidth(text, "Helvetica-Bold", max_font_size)
available_width = W * 0.90
if text_width > available_width:
font_size = max_font_size * (available_width / text_width)
else:
font_size = max_font_size
c.setFont("Helvetica-Bold", font_size)
# Text fully within page, baseline placed in bottom safe zone
# ascent ≈ font_size * 0.75, ensure letter tops don't exceed page
c.drawString(W * 0.05, font_size * 0.3, text) # baseline slightly above bottom edge
c.restoreState()
```
---
## Module 3: Blueprint Hairlines
**Goal:** Use ultra-thin interlacing lines to enhance the "anchoring feel" and logical rigor of foreground text.
### Technical Requirements
| Constraint | Rule |
|-------|------|
| **Line width** | `Stroke_Width = 0.5pt` (**must never exceed 1pt**) |
| **Line type** | Solid, or very closely spaced dotted line (Dotted, dash: `[1, 3]`) |
| **Opacity** | Follow global Rule 1 |
### Option 3.1: Coordinate Cross
**Calculation logic (height bound to foreground layout anchor)**:
- **Vertical line (Y-axis)**: Read the left-alignment safe boundary of foreground layout (e.g. `X = W × 0.15`). Draw a vertical line from `Y = 0` to `Y = H`
- **Horizontal line (X-axis)**: Read the baseline or top boundary of the foreground main title (e.g. `Y = H × 0.32`). Draw a horizontal line from `X = 0` to `X = W`
**Visual effect**: The entire page is divided by implicit golden ratio lines, foreground text appears to grow precisely on these coordinate axes, extremely rigorous.
**ReportLab implementation notes**:
```python
c.saveState()
c.setStrokeColorRGB(0, 0, 0, 0.04)
c.setLineWidth(0.5)
# Vertical line — align to foreground text left boundary
axis_x = W * 0.15
c.line(axis_x, 0, axis_x, H)
# Horizontal line — align to main title baseline
axis_y = H * 0.68 # ReportLab Y-flip: document H*0.32 → RL H*0.68
c.line(0, axis_y, W, axis_y)
c.restoreState()
```
**Playwright/HTML implementation notes**:
```html
<div style="position:absolute; inset:0; z-index:0; pointer-events:none;">
<!-- Vertical line -->
<div style="
position:absolute;
left: 15%;
top: 0;
width: 0.5px;
height: 100%;
background: rgba(0,0,0,0.04);
"></div>
<!-- Horizontal line -->
<div style="
position:absolute;
left: 0;
top: 32%;
width: 100%;
height: 0.5px;
background: rgba(0,0,0,0.04);
"></div>
</div>
```
---
## 🛠️ Combination & Circuit Breaker (The Combination Matrix)
To ensure diversity in auto-generated backgrounds while preventing visual chaos, the system must implement a **"background combination state machine"**.
For each PDF generation, randomly select one of the following 3 legal Recipes, **cross-boundary combinations are strictly forbidden**:
### ✅ Recipe A: Minimal Modern
**Combination**: `Option 1.1 (deep-space arc)`**this one only, no other elements**
**Applicable scenes**: Safest, most whitespace, suitable for all types of corporate reports.
**Pairing suggestions with cover layouts**:
- Layout 1 (diagonal tension) — arc in the blank diagonal area, extremely harmonious
- Layout 2 (vertical gravity axis) — arc provides lower-left gravity
- Layout 4 (golden ratio) — arc adds breathing space to the lower whitespace area
---
### ✅ Recipe B: Engineering/Academic
**Combination**: `Option 3.1 (coordinate cross)` **+** `Option 2.1 (side giant spine)`
**Logic**: Vertical giant text interlaces with ultra-thin coordinate lines on the left, creating extreme thick-thin contrast, perfect for investment pitches and research reports.
**Line avoidance rule**: Option 3.1's ultra-thin lines should avoid crossing directly through Option 2.1 (giant text) strokes to prevent visual interference. Adjust line coordinates to avoid text areas:
- Vertical line `X` at `W × 0.15` (foreground text left-alignment line)
- Giant spine anchor `X` at `-W × 0.05` (left of vertical line, no crossing)
**Pairing suggestions with cover layouts**:
- Layout 7 (left-aligned matrix) — perfect match, left spine + coordinate lines + matrix text three-layer overlay
- Layout 2A (left-aligned vertical) — vertical line aligns with axis, doubled structural feel
- Layout 6A (side rotation decoration) — Note: 6A already has rotated year, if using Recipe B then **skip Option 2.1**, keep only Option 3.1
---
### ✅ Recipe C: Solid/Weighty
**Combination**: `Option 1.2 (sharp angle cut)` **+** `Option 2.2 (bottom full text)`
**Logic**: Bottom angular color block overlaid with fully displayed English word at the bottom, very low center of gravity, suitable for annual summaries and white papers.
**Stacking order**: Draw Option 1.2 angular color block first, then Option 2.2 bleed text (text on top of color block, but both below foreground content).
**Pairing suggestions with cover layouts**:
- Layout 4A (top suspended) — content on top, background pressed below, extreme top-bottom contrast
- Layout 1A (diagonal tension) — bottom gravity echoes lower-right text
- Layout 5A (stepped progression) — steps extend to lower-right, converging with bottom gravity
---
## 🚫 Circuit Breaker Rules (Hard Constraints)
The following combinations are **hard-forbidden**; violations are bugs:
| Forbidden Rule | Reason |
|---------|------|
| Option 2.1 + Option 2.2 together | **No dual text**. Only one giant text watermark allowed per page |
| Option 1.1 + Option 1.2 together | **No dual geometry**. Large circle + large diagonal = visual chaos |
| Option 3.1 lines crossing Option 2.x strokes | **Line isolation**. Ultra-thin lines must not cross giant text strokes; adjust coordinates to avoid |
| All three modules enabled | **Maximum two modules**. Three layers = overwhelms foreground |
| Any background element opacity > 6% | **Rule 1 violation**. Background must be subtle and barely visible |
---
## Recipe Selection Logic
When no specific recipe is specified, auto-select based on document type:
| Document Type | Recommended Recipe | Reason |
|---------|---------|------|
| Corporate reports, general docs | **A** | Safest, zero risk |
| Technical reports, investment pitches | **B** | Engineering feel, precision |
| Annual summaries, white papers | **C** | Solid, weighty |
| Creative, design | **A** | Maximum whitespace, no conflict with creative content |
| Academic papers | **B** | Structural feel matches academic tone |
| Uncertain / default | **A** | Minimal never goes wrong |
---
## Relationship with geometry.md
This file defines **page-level macro backgrounds** (Supergraphics / Watermarks / Hairlines), applied to the entire canvas.
`geometry.md` defines **local decorative anchors** (Offset Stacking / Scale Contrast / Grid Intersection), applied to specific areas.
Both can coexist, but note:
- Background layer (this file) is at the bottom
- Geometric anchors (geometry.md) are above the background layer but below foreground text
- If both are used, geometric anchors should be placed in "blank areas" of background elements to avoid visual overlap

1442
skills/pdf/typesetting/cover.md Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,527 @@
# Fill Engine — Adaptive Anti-Void Layout Engine V2.0
> Solves the **"text too small to read"** and **"large page voids"** problems caused by varying input content length in automated PDF generation.
> Before rendering any page, it must pass through the following **four elastic filtering calculations**.
>
> **Positioning**: This is the mirror counterpart of `overflow.md` (anti-overflow) — overflow handles "too much content", fill-engine handles "too little content".
>
> Related:
> - `overflow.md` — Anti-overflow layout system (degradation strategy for excessive content)
> - `pagination.md` — Pagination & cross-page integrity control
> - `cover.md` — Cover layout engine (covers not affected by Fill Engine; they have their own layout system)
---
## ⚠️ Scope of Application
-**Body pages** (body pages of Report / Academic / Creative routes)
-**All three rendering routes** (ReportLab / LaTeX / Playwright-HTML)
-**Not applicable to covers** (covers are independently controlled by `cover.md`)
-**Not applicable to TOC pages** (TOC has a fixed format)
---
## Safety Net 1: Readability Red Line (Font Size Hard Floor)
**Principle: Never rely on shrinking font size to fit the layout. Font sizes have hard-coded minimums that cannot be breached.**
### Absolute Font Size Floor
| Element | Single-column | Double-column | Notes |
|------|---------|---------|------|
| **Body Text** | **≥ 14pt** | **≥ 12pt** | Below this → considered unreadable, must trigger page break |
| **Default base size** | 15pt (CJK) / 14pt (Latin) | 13pt (CJK) / 12pt (Latin) | Starting value, not ceiling |
### Heading Scale Hard Floor
| Level | Min Size | Recommended |
|------|---------|---------|
| **H1** (primary heading / page title) | **≥ 32pt** | 3642pt |
| **H2** (secondary heading) | **≥ 24pt** | 2630pt |
| **H3** (tertiary heading) | **≥ 18pt** | 2022pt |
### Coordination with overflow.md
`overflow.md` §5's font-size degradation staircase (`fit_text_with_degradation`) **must not breach the above floor**:
```python
# overflow.md §5's min_size parameter must be >= Fill Engine red line
def fit_text_with_degradation(text, font_name, base_size, max_width,
min_size=14): # ← Single-column floor 14pt, not 7pt
"""When overflow needs to shrink font size, it cannot go below the readability red line."""
for size in range(base_size, min_size - 1, -1):
if stringWidth(text, font_name, size) <= max_width:
return size
return min_size # Hit the floor → trigger page break, stop shrinking
```
> **Core idea: If content doesn't fit → page break, not shrinking text to ant-size.**
---
## Safety Net 2: Page Fill Ratio & Paragraph Inflation (Fill Ratio Engine)
### Virtual Rendering
Before actually rendering each page, the system **must first calculate** how much height the content would occupy under default font sizes and spacing.
```python
def calculate_fill_ratio(content_blocks, available_height, default_styles):
"""
Virtual rendering: calculate total height of current page content under default styles.
Returns fill ratio = total content height / available page height.
"""
total_height = 0
for block in content_blocks:
block_height = measure_block_height(block, default_styles)
total_height += block_height
fill_ratio = total_height / available_height
return fill_ratio
```
### Elastic Inflation Trigger Conditions
| Fill Ratio | Status | Action |
|--------|------|------|
| **≥ 80%** | ✅ Full | No adjustment, render normally |
| **65%80%** | ⚠️ Slightly empty | Light inflation (line-height + paragraph spacing only) |
| **40%65%** | 🔶 Noticeably empty | Full inflation (line-height + spacing + slight font increase + component inflation) |
| **< 40%** | 🔴 Extremely empty | Full inflation + Y-axis golden ratio anchoring (Safety Net 4) |
### Inflation Parameters (Triggered when fill ratio < 65%)
#### 2a. Line-Height Inflation
```python
def inflate_line_height(base_line_height, fill_ratio):
"""
Lower fill ratio means more line-height stretch.
base_line_height: default line-height (e.g. 1.4)
Returns inflated line-height, capped at 2.2.
"""
if fill_ratio >= 0.65:
return base_line_height # No inflation
# Linear interpolation: as fill_ratio goes from 0.65→0.30, line-height goes from base→2.2
inflation = (0.65 - fill_ratio) / (0.65 - 0.30) # 0.0 ~ 1.0
inflation = min(inflation, 1.0)
target = base_line_height + (2.2 - base_line_height) * inflation
return round(target, 2)
```
| Fill Ratio | Base line-height 1.4 → After inflation |
|--------|---------------------|
| 65% | 1.40 (unchanged) |
| 55% | 1.63 |
| 45% | 1.86 |
| 35% | 2.09 |
| ≤30% | 2.20 (cap) |
#### 2b. Paragraph Spacing Compensation (Margin-Bottom Injection)
```python
def inject_paragraph_spacing(remaining_height, paragraph_count, heading_count):
"""
Distribute 30%-50% of remaining whitespace evenly between paragraphs.
remaining_height: Available_H - content height after inflation
"""
if remaining_height <= 0:
return 0
injection_pool = remaining_height * 0.4 # Take 40%
gap_count = paragraph_count + heading_count - 1 # Number of gaps
if gap_count <= 0:
return 0
per_gap = injection_pool / gap_count
return round(per_gap, 1)
```
**Injection positions (by priority):**
1. Between headings and body text (below H1/H2/H3)
2. Between natural paragraphs
3. Between body text and charts/tables
#### 2c. Font Scaling
```python
def scale_font_size(base_size, fill_ratio):
"""
When fill ratio < 65%, allow font size to float up by 1-2pt.
Never exceed +2pt, otherwise loses professional feel.
"""
if fill_ratio >= 0.65:
return base_size
if fill_ratio >= 0.50:
return base_size + 1
return base_size + 2 # Max +2pt
```
> **Constraint: Inflated font size ≤ base_size + 2pt. 15pt body can become at most 17pt, no larger.**
---
## Safety Net 3: Component-Level Elastic Fill (Component Inflation)
**Trigger: Same as Safety Net 2, active when fill ratio < 65%.**
### 3a. Table Auto-Height Expansion (Table Padding Inflation)
```python
def inflate_table_padding(base_padding, fill_ratio):
"""
Lower fill ratio means larger table cell padding.
base_padding: default cell padding (e.g. 6pt)
"""
if fill_ratio >= 0.65:
return base_padding
# Add 10-20pt
extra = int((0.65 - fill_ratio) / 0.25 * 20)
extra = max(10, min(extra, 20))
return base_padding + extra
```
**Effect:** Originally flat compact data table → tall spacious data display board.
### 3b. Blockquote Exaggeration (Blockquote Scaling)
When encountering a blockquote and the page has voids:
```python
def scale_blockquote(base_font_size, fill_ratio):
"""
Blockquote font enlarged, italicized, massive whitespace above and below.
"""
if fill_ratio >= 0.65:
return {
"font_size": base_font_size,
"font_style": "normal",
"margin_top": 12,
"margin_bottom": 12,
"border_left_width": 3,
}
return {
"font_size": int(base_font_size * 1.5), # Scale up 1.5x
"font_style": "italic",
"margin_top": 40, # Large whitespace above
"margin_bottom": 40, # Large whitespace below
"border_left_width": 6, # Thicken blockquote left border
}
```
### 3c. List Item Spacing Expansion
```python
def inflate_list_spacing(base_spacing, fill_ratio):
"""
List item spacing expanded to 1.5x normal paragraph spacing.
"""
if fill_ratio >= 0.65:
return base_spacing
return int(base_spacing * 1.5)
```
---
## Safety Net 4: Y-Axis Golden Ratio Anchoring (Ultimate Measure for Extreme Voids)
**Trigger: After Safety Net 2 + 3 inflation, fill ratio still < 40%.**
**Core principle: Absolutely forbidden to pin content to the very top, leaving the bottom half as dead whitespace.**
### Execution Logic
```python
def anchor_content_vertically(content_bbox_height, available_height, fill_ratio):
"""
Pack all current page content as a BBox, re-align vertically within available height.
Returns content_top_y: Y coordinate offset for content top.
"""
if fill_ratio >= 0.40:
return 0 # No anchoring needed, normal flow from page top
remaining = available_height - content_bbox_height
# Option A: Golden ratio offset-up (recommended)
golden_offset = remaining * 0.382 # Top 38.2%, bottom 61.8%
# Option B: Absolute vertical center (alternative)
# center_offset = remaining / 2
return golden_offset
```
### Option Selection
| Option | Formula | Visual Effect | Applicable Scenario |
|------|------|---------|---------|
| **A. Golden ratio offset-up** (default) | `offset = remaining * 0.382` | Slightly less whitespace above, more below, visually stable | Most scenarios |
| **B. Absolute center** | `offset = remaining / 2` | Perfectly symmetrical | Minimal pages with single element |
### Effect Illustration
```
┌─────────────────────────┐ ┌─────────────────────────┐
│ ← Content starts here │ │ │
│ Section Title │ │ ← 38.2% elegant space │
│ Body text... │ │ │
│ │ │ Section Title │
│ │ │ Body text... │
│ │ │ │
│ │ │ │
│ ← Huge dead whitespace! │ │ ← 61.8% bottom space │
│ │ │ │
│ │ │ │
└─────────────────────────┘ └─────────────────────────┘
❌ No anchoring ✅ Golden ratio anchoring
```
---
## Three-Route Implementation Guide
### ReportLab Route (Report Pipeline)
```python
from reportlab.platypus import SimpleDocTemplate, Spacer, Paragraph
from reportlab.lib.units import pt
def build_page_with_fill_engine(story_blocks, page_width, page_height, margins):
"""
Fill Engine main entry — ReportLab route.
Call before doc.build().
"""
available_h = page_height - margins['top'] - margins['bottom']
available_w = page_width - margins['left'] - margins['right']
# --- Safety Net 1: Check font size floor ---
enforce_font_floor(story_blocks, min_body=14, min_h1=32, min_h2=24, min_h3=18)
# --- Safety Net 2: Virtual render + inflation ---
fill_ratio = calculate_fill_ratio(story_blocks, available_h, default_styles)
if fill_ratio < 0.65:
# 2a. Line-height inflation
new_line_height = inflate_line_height(1.4, fill_ratio)
apply_line_height(story_blocks, new_line_height)
# 2b. Paragraph spacing injection
remaining = available_h - measure_total_height(story_blocks)
extra_gap = inject_paragraph_spacing(remaining, count_paragraphs(story_blocks),
count_headings(story_blocks))
inject_spacers(story_blocks, extra_gap)
# 2c. Font scaling
font_bump = scale_font_size(15, fill_ratio) - 15
if font_bump > 0:
bump_font_sizes(story_blocks, font_bump)
# --- Safety Net 3: Component inflation ---
if fill_ratio < 0.65:
inflate_tables(story_blocks, fill_ratio)
inflate_blockquotes(story_blocks, fill_ratio)
inflate_lists(story_blocks, fill_ratio)
# --- Safety Net 4: Y-axis anchoring ---
recalc_ratio = calculate_fill_ratio(story_blocks, available_h, inflated_styles)
if recalc_ratio < 0.40:
content_height = measure_total_height(story_blocks)
top_offset = anchor_content_vertically(content_height, available_h, recalc_ratio)
story_blocks.insert(0, Spacer(1, top_offset))
return story_blocks
```
### LaTeX Route (Academic Pipeline)
```latex
% Safety Net 1: Font size floor — define in preamble
\newcommand{\bodysize}{\fontsize{14pt}{20pt}\selectfont} % 14pt floor
\renewcommand{\Large}{\fontsize{32pt}{38pt}\selectfont} % H1 ≥ 32pt
\renewcommand{\large}{\fontsize{24pt}{30pt}\selectfont} % H2 ≥ 24pt
% Safety Net 4: Vertical centering (for extreme voids)
\newcommand{\goldenpage}[1]{%
\null\vfill % Top elastic space (less)
#1 % Content
\vfill\vfill % Bottom elastic space (more, ~2:1 ratio)
}
% Usage (when content is minimal):
% \goldenpage{
% \section{Summary}
% Short content here...
% }
```
### Playwright/HTML Route (Creative Pipeline)
```css
/* Safety Net 1: Font size red line */
:root {
--body-min-font: 14px;
--h1-min-font: 32px;
--h2-min-font: 24px;
--h3-min-font: 18px;
}
body {
font-size: max(var(--body-font, 15px), var(--body-min-font));
}
h1 { font-size: max(var(--h1-font, 36px), var(--h1-min-font)); }
h2 { font-size: max(var(--h2-font, 28px), var(--h2-min-font)); }
h3 { font-size: max(var(--h3-font, 22px), var(--h3-min-font)); }
/* Safety Net 2-3: Dynamically injected after JS virtual render */
/* Before Playwright screenshot, run Fill Engine JS via page.evaluate() */
```
```javascript
// Playwright page.evaluate() — Fill Engine
function runFillEngine(pageElement) {
const pageH = pageElement.clientHeight;
const contentH = pageElement.scrollHeight;
const fillRatio = contentH / pageH;
if (fillRatio >= 0.65) return; // No inflation needed
const root = pageElement.style;
// 2a. Line-height inflation
const inflation = Math.min((0.65 - fillRatio) / 0.35, 1.0);
const newLH = 1.4 + (2.2 - 1.4) * inflation;
root.setProperty('--body-line-height', newLH.toFixed(2));
pageElement.querySelectorAll('p, li').forEach(el => {
el.style.lineHeight = newLH.toFixed(2);
});
// 2c. Font scaling
if (fillRatio < 0.50) {
pageElement.querySelectorAll('p, li').forEach(el => {
const size = parseFloat(getComputedStyle(el).fontSize);
el.style.fontSize = Math.min(size + 2, size + 2) + 'px'; // +2pt max
});
} else if (fillRatio < 0.65) {
pageElement.querySelectorAll('p, li').forEach(el => {
const size = parseFloat(getComputedStyle(el).fontSize);
el.style.fontSize = (size + 1) + 'px'; // +1pt
});
}
// 3a. Table height expansion
pageElement.querySelectorAll('td, th').forEach(cell => {
const extra = Math.min(20, Math.round((0.65 - fillRatio) / 0.25 * 20));
cell.style.paddingTop = (6 + extra) + 'px';
cell.style.paddingBottom = (6 + extra) + 'px';
});
// 3b. Blockquote exaggeration
pageElement.querySelectorAll('blockquote').forEach(bq => {
const size = parseFloat(getComputedStyle(bq).fontSize);
bq.style.fontSize = (size * 1.5) + 'px';
bq.style.fontStyle = 'italic';
bq.style.marginTop = '40px';
bq.style.marginBottom = '40px';
});
// Safety Net 4: Y-axis anchoring
const newContentH = pageElement.scrollHeight;
const newRatio = newContentH / pageH;
if (newRatio < 0.40) {
const remaining = pageH - newContentH;
const offset = remaining * 0.382;
pageElement.style.paddingTop = offset + 'px';
}
}
```
---
## Execution Order Summary
```
Input content arrives
┌─────────────────────────────────────────┐
│ Safety Net 1: Readability red line check │
│ → Font size ≥ floor? YES → Continue │
│ → Font size < floor? → Force raise to floor │
└──────────────────┬──────────────────────┘
┌─────────────────────────────────────────┐
│ Virtual render: Calculate Fill Ratio │
│ → ≥ 80%? → Render normally, exit │
│ → 65%-80%? → Light inflation (2a+2b only) │
│ → < 65%? → Enter Safety Net 2+3 full inflation │
└──────────────────┬──────────────────────┘
┌─────────────────────────────────────────┐
│ Safety Net 2: Paragraph inflation │
│ 2a. Line-height inflation (→ max 2.2) │
│ 2b. Paragraph spacing injection (30%-50% of remaining space) │
│ 2c. Font scaling (+1~2pt, max) │
└──────────────────┬──────────────────────┘
┌─────────────────────────────────────────┐
│ Safety Net 3: Component inflation │
│ 3a. Table Padding increase (+10~20pt) │
│ 3b. Blockquote scale 1.5x + 40pt whitespace above/below │
│ 3c. List spacing × 1.5 │
└──────────────────┬──────────────────────┘
┌─────────────────────────────────────────┐
│ Recalculate Fill Ratio │
│ → ≥ 40%? → Render normally, exit │
│ → < 40%? → Safety Net 4: Y-axis golden ratio anchoring │
└──────────────────┬──────────────────────┘
┌─────────────────────────────────────────┐
│ Safety Net 4: Shift content down │
│ top_offset = remaining * 0.382 │
│ → Elegant space above, slightly more below │
│ → No longer "underfilled" but "intentional whitespace" │
└─────────────────────────────────────────┘
Actual render output
```
---
## Coordination with Other Specifications
| Specification | Relationship | Coordination Rule |
|------|------|---------|
| `overflow.md` | Complementary (overflow vs void) | overflow's font degradation must not breach Fill Engine's red line |
| `pagination.md` | Complementary (pagination vs fill) | pagination's "last page ≥ 40%" aligns with Fill Engine's Fill Ratio concept |
| `cover.md` | Independent | Covers have their own layout system, not affected by Fill Engine |
| `typography.md` | Infrastructure | Fill Engine makes elastic adjustments on top of typography-defined fonts/line-heights |
---
## Checklist (Check Before Every Body Page Render)
```
□ Body font size ≥ 14pt (single-column) / 12pt (double-column)?
□ H1 ≥ 32pt、H2 ≥ 24pt、H3 ≥ 18pt
□ Virtual render calculated Fill Ratio?
□ Inflation triggered when Fill Ratio < 65%?
□ Line-height inflation not exceeding 2.2?
□ Font scaling not exceeding +2pt?
□ Table Padding increment within 10-20pt range?
□ Blockquote scale factor = 1.5x, top/bottom whitespace = 40pt?
□ Y-axis anchoring triggered when Fill Ratio < 40%?
□ Cover page not affected by Fill Engine?
```

View File

@@ -0,0 +1,142 @@
# Geometric Anchors
> Create sophisticated visual anchors from the simplest geometric shapes.
---
## What Are Visual Anchors
Visual anchors are **non-functional decorative elements** on a page, used to:
- Break the flatness of text-only / data-only layouts
- Establish a visual center of gravity on the page
- Convey abstract qualities and design intent
- Fill large whitespace areas without adding information noise
**Key principle: Anchors don't need to "look like" anything concrete. The more abstract, the more refined.**
---
## Basic Shape Vocabulary
| Shape | SVG | Mood / Character |
|-------|-----|-----------------|
| Circle | `<circle>` | Wholeness, inclusivity, softness |
| Semicircle | `<path d="M0,50 A50,50 0 0,1 100,50">` | Rising, gradual, metaphorical |
| Triangle | `<polygon>` | Direction, sharpness, modern |
| Rectangle | `<rect>` | Stability, order, architectural |
| Line | `<line>` | Connection, guidance, minimalism |
| Arc | `<path>` + Bézier | Flow, elegance, organic |
---
## Composition Patterns
### Pattern 1: Offset Stacking
Multiple identical shapes, slightly offset, rotated, with decreasing opacity.
```svg
<svg width="120" height="120" viewBox="0 0 120 120" fill="none">
<!-- Three offset circles -->
<circle cx="50" cy="50" r="35" stroke="currentColor" stroke-width="0.6" opacity="0.15"/>
<circle cx="60" cy="55" r="35" stroke="currentColor" stroke-width="0.6" opacity="0.25"/>
<circle cx="70" cy="60" r="35" stroke="currentColor" stroke-width="0.6" opacity="0.4"/>
</svg>
```
**Key points**: Same shape, 3 layers, opacity 0.15 → 0.25 → 0.4, offset 10-15px
### Pattern 2: Scale Contrast
One large shape + a few small shapes as accents.
```svg
<svg width="150" height="150" viewBox="0 0 150 150" fill="none">
<circle cx="60" cy="60" r="50" stroke="currentColor" stroke-width="0.5" opacity="0.2"/>
<circle cx="115" cy="30" r="8" fill="currentColor" opacity="0.6"/>
<circle cx="105" cy="50" r="3" fill="currentColor" opacity="0.3"/>
<circle cx="125" cy="45" r="2" fill="currentColor" opacity="0.2"/>
</svg>
```
**Key points**: Large circle stroke-only (hollow), small circles filled (solid), creating solid-void contrast
### Pattern 3: Grid Intersection
Lines + dots forming nodes at intersections.
```svg
<svg width="100" height="100" viewBox="0 0 100 100" fill="none">
<line x1="20" y1="0" x2="20" y2="100" stroke="currentColor" stroke-width="0.3" opacity="0.1"/>
<line x1="50" y1="0" x2="50" y2="100" stroke="currentColor" stroke-width="0.3" opacity="0.1"/>
<line x1="80" y1="0" x2="80" y2="100" stroke="currentColor" stroke-width="0.3" opacity="0.1"/>
<line x1="0" y1="30" x2="100" y2="30" stroke="currentColor" stroke-width="0.3" opacity="0.1"/>
<line x1="0" y1="70" x2="100" y2="70" stroke="currentColor" stroke-width="0.3" opacity="0.1"/>
<!-- Intersection points -->
<circle cx="50" cy="30" r="3" fill="currentColor" opacity="0.5"/>
<circle cx="20" cy="70" r="2" fill="currentColor" opacity="0.3"/>
<circle cx="80" cy="70" r="4" fill="currentColor" opacity="0.15"/>
</svg>
```
### Pattern 4: Arc Flow
Bézier curves + endpoint circles, expressing organic flow.
```svg
<svg width="200" height="100" viewBox="0 0 200 100" fill="none">
<path d="M10,80 C50,10 150,10 190,80" stroke="currentColor" stroke-width="0.6" opacity="0.25"/>
<path d="M10,85 C60,20 140,20 190,85" stroke="currentColor" stroke-width="0.4" opacity="0.15"/>
<circle cx="10" cy="80" r="2.5" fill="currentColor" opacity="0.4"/>
<circle cx="190" cy="80" r="2.5" fill="currentColor" opacity="0.4"/>
</svg>
```
### Pattern 5: Geometric Collage
Intentional combination of different shapes, like an architectural plan.
```svg
<svg width="120" height="120" viewBox="0 0 120 120" fill="none">
<!-- Rectangular frame -->
<rect x="10" y="10" width="60" height="60" stroke="currentColor" stroke-width="0.5" opacity="0.2"/>
<!-- Circle breaking the straight lines -->
<circle cx="70" cy="70" r="30" stroke="currentColor" stroke-width="0.5" opacity="0.2"/>
<!-- Diagonal line cutting through -->
<line x1="10" y1="10" x2="100" y2="100" stroke="currentColor" stroke-width="0.3" opacity="0.15"/>
<!-- Small solid triangle as focal point -->
<polygon points="85,20 95,40 75,40" fill="currentColor" opacity="0.4"/>
</svg>
```
---
## Placement Guide
| Context | Recommended Position | Recommended Size | Pattern |
|---------|---------------------|-----------------|---------|
| Cover | Top-right / bottom-left offset | 120-200px | Offset Stacking, Geometric Collage |
| Chapter divider | Page center | 80-120px | Scale Contrast, Arc Flow |
| Header / footer decoration | Corners | 30-50px | Small offset, single circle + line |
| Whitespace fill | Alongside content | 60-100px | Grid Intersection |
---
## Color Rules
- Anchor color = document primary color from `palette.cascade` output (`--c-accent` or `--c-text`)
- **Use only one color**, layer via opacity (0.1 → 0.5)
- Strokes over fills (solid elements ≤ 30% of total shapes)
- stroke-width range: 0.3-0.8px (ultra-thin lines = refined look)
- **⚠️ All SVG examples below use `currentColor` as placeholder.** When generating actual SVG, replace with the documents primary color from the palette system. NEVER copy `currentColor` literally into production SVG — substitute the actual hex value.
---
## Forbidden
- ❌ Figurative icons (flowers, stars, arrows, or other concrete shapes)
- ❌ Mixing multiple colors
- ❌ Over-complexity (more than 8 shape elements)
- ❌ Symmetric / centered placement (offset creates tension)
- ❌ Thick lines (> 1.5px looks heavy)
- ❌ Shadows / glow effects

View File

@@ -0,0 +1,630 @@
# Overflow Prevention — Anti-Overflow Layout System
> PDF is static: no scrollbars, no reflow, no viewport adaptation. Every element must fit within its container BEFORE rendering. This document defines the architectural approach to guarantee zero overflow in any generated PDF.
>
> **This is the "too much content" side.** For the mirror problem ("too little content" / empty pages), see `typesetting/fill-engine.md` — the anti-void adaptive engine.
>
> Related:
> - `fill-engine.md` — Anti-void engine (font floor, fill ratio, paragraph inflation, Y-axis anchoring)
> - `pagination.md` — Pagination & cross-page integrity
---
## Core Philosophy
**"Measure first, draw second."**
Never use `draw_text(x, y)` or `draw_image(x, y)` directly. All content must pass through a constraint system that pre-calculates sizes and enforces boundaries. Think CSS Box Model, but for a fixed canvas.
---
## 1. Bounding Box System (Horizontal Overflow Prevention)
Every element entering a page is a **Block** with a maximum available width (`Max_Width`).
### Calculating Max_Width
```python
# Single-column layout
max_width = page_width - left_margin - right_margin
# Dual-column layout
col_gap = 12 # points
max_width = (page_width - left_margin - right_margin - col_gap) / 2
# Nested containers (e.g., table cell)
cell_max_width = col_width - cell_padding_left - cell_padding_right
```
### Absolute Rule
> **No Block's rendered width may exceed its parent's `Max_Width`. Period.**
If a Block's calculated width > Max_Width, apply fallback strategies (see §5).
---
## 1.5 🔴 Page Content Centering (Horizontal Centering Iron Rule)
> **Symptom:** Cover or body content is shifted left on the page, with noticeably more whitespace on the right than left.
**Root cause:** Asymmetric left/right margins, or cover uses single-side anchors without considering right-side balance.
**Iron rule:**
1. **Left/right margins must be symmetric:** `left_margin == right_margin`. No asymmetric margins allowed.
2. **Cover:** For left-aligned templates (e.g., Template 01/02/03/05/07), the text starting point should be within `0.10*W ~ 0.15*W`, and the right margin should be between `0.05*W ~ 0.15*W`. Center-aligned templates (Template 04/06) must be absolutely centered.
3. **Body:** ReportLab's `Frame` / `SimpleDocTemplate` must have `leftMargin == rightMargin`. LaTeX's `\geometry{left=X, right=X}` must be symmetric. HTML must use `margin: 0 auto` or `padding-left == padding-right`.
```python
# ReportLab: Force symmetric margins
from reportlab.lib.units import inch
MARGIN = 1 * inch # Left and right must use same variable
doc = SimpleDocTemplate(
"output.pdf",
leftMargin=MARGIN,
rightMargin=MARGIN, # ← Must match leftMargin
topMargin=MARGIN,
bottomMargin=MARGIN,
)
# ❌ WRONG: leftMargin=72, rightMargin=36 → Content shifts left
```
```latex
% LaTeX: Force symmetric margins
\usepackage[left=2.5cm, right=2.5cm, top=2.5cm, bottom=2.5cm]{geometry}
% ❌ WRONG: left=3cm, right=1.5cm
```
---
## 2. Text Overflow: Font Metrics Pre-Calculation
### The Wrong Way
```python
# ❌ NEVER estimate by character count
if len(text) > 20:
wrap() # Wrong — 20 CJK chars ≠ 20 Latin chars ≠ 20 mixed chars
```
### The Right Way — ReportLab
```python
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.platypus import Paragraph
from reportlab.lib.styles import ParagraphStyle
# Measure actual rendered width
text_width = stringWidth("Your text here", "Microsoft YaHei", 10)
if text_width > max_width:
# Use Paragraph for automatic wrapping — NEVER plain strings in tables
style = ParagraphStyle(
'CellText',
fontName='Microsoft YaHei',
fontSize=10,
leading=14,
wordWrap='CJK', # Enables CJK-aware line breaking
)
element = Paragraph(text, style)
else:
element = text # Plain string is fine if it fits
```
### Key Rules
- **Always use `Paragraph()` for table cell content** — plain strings don't wrap and will overflow
- **CJK text is wider**: Budget ~12pt per character at 10pt font size (vs ~6pt for Latin)
- **URLs and long strings**: If a single "word" exceeds column width, enable `wordWrap='CJK'` or split manually
- **Hyphenation**: For English text, consider `pyphen` for proper hyphenation of long words
### The Right Way — LaTeX
```latex
% Use tabularx for auto-wrapping columns
\usepackage{tabularx}
\begin{tabularx}{\columnwidth}{lXX} % X columns auto-wrap
Header 1 & This long text will wrap & Another wrapping column \\
\end{tabularx}
% For URLs
\usepackage{url}
\url{https://very-long-url-that-would-overflow.example.com/path/to/resource}
```
### The Right Way — Playwright/HTML
```css
/* Global text overflow prevention */
p, td, li, .content {
overflow-wrap: break-word;
word-break: break-word;
hyphens: auto;
}
/* Strict CJK line-break rules */
body {
line-break: strict;
word-break: normal;
}
/* Table cells must constrain content */
/* ⚠️ overflow:hidden + ellipsis only for single-line short text cells.
Multi-line td should use overflow-wrap: break-word, not overflow: hidden,
otherwise text gets truncated. Especially important in Playwright PDFs. */
td {
max-width: 0; /* Forces column to respect assigned width */
overflow: hidden;
text-overflow: ellipsis; /* Only for single-line cells */
}
```
---
## 3. Image & Chart Overflow: Proportional Scaling
### Absolute Rule
> **Never insert an image or chart at its original dimensions. Always compute fit-to-container scaling.**
### ReportLab Pattern
```python
from reportlab.platypus import Image
from reportlab.lib.units import mm
def fit_image(img_path, max_w, max_h):
"""Scale image to fit within max_w × max_h, preserving aspect ratio."""
img = Image(img_path)
orig_w, orig_h = img.drawWidth, img.drawHeight
ratio_w = max_w / orig_w if orig_w > max_w else 1.0
ratio_h = max_h / orig_h if orig_h > max_h else 1.0
ratio = min(ratio_w, ratio_h)
img.drawWidth = orig_w * ratio
img.drawHeight = orig_h * ratio
return img
# Usage
available_width = page_width - left_margin - right_margin
max_img_height = A4[1] * 0.35 # ~294pt ≈ 10cm — prevents image from eating page + leaves room for caption
img = fit_image("chart.png", available_width, max_img_height)
story.append(img)
```
### LaTeX Pattern
```latex
\usepackage{adjustbox}
% Always constrain to column width
\includegraphics[max width=\columnwidth]{chart.png}
% Or with adjustbox for both dimensions
\begin{adjustbox}{max width=\columnwidth, max height=0.4\textheight}
\includegraphics{chart.png}
\end{adjustbox}
```
### Playwright/HTML Pattern
```css
img, svg, .chart-container {
max-width: 100%;
max-height: 45vh; /* Prevent one image from eating an entire page */
height: auto;
object-fit: contain; /* Preserve aspect ratio */
}
```
> **Why `max-height: 45vh`?** Without a height cap, a tall image combined with `break-inside: avoid` (from pagination.md) gets pushed to the next page — leaving the current page mostly empty and the image occupying an entire page alone. 45vh ensures any image fits within half a page, leaving room for surrounding text on the same page.
---
## 3.5 Horizontal Flex/Inline Layout Overflow (Flow Bars, Step Lists, Tag Rows)
**Problem:** LLMs commonly generate horizontal `display: flex` layouts (process flow bars, step indicators, tag rows, icon grids) without any width constraint or wrap control. When content is longer than expected (e.g. "Theory Framework (ASPICE / V-Model)" as a step label), the total width exceeds the container, pushing content beyond the right page boundary.
**Playwright PDF consequence:** When any element causes `scrollWidth > clientWidth`, Playwright shrinks the **entire page** to fit, causing all content to appear left-shifted with blank space on the right. This affects ALL pages, not just the one with the overflow.
### Iron Rules (Direct HTML Flow)
**Rule 3.5.1 — Mandatory `flex-wrap` for ≥3 inline items:**
```css
/* Any horizontal row with 3+ children MUST have flex-wrap */
.flow-bar, .step-row, .tag-row, .icon-grid {
display: flex;
flex-wrap: wrap; /* MANDATORY for 3+ items */
gap: 12px; /* Consistent spacing */
max-width: 100%; /* Never exceed container */
}
```
**Rule 3.5.2 — Flex children must have `min-width` + `flex-shrink`:**
```css
.flow-step, .tag-item {
flex: 1 1 auto; /* Grow, shrink, auto basis */
min-width: 80px; /* Prevent crushing to 0 */
max-width: 100%; /* Never exceed container alone */
overflow-wrap: break-word; /* Break long words */
word-break: break-all; /* CJK fallback */
}
```
**Rule 3.5.3 — Arrow/connector separators must not be rigid:**
```css
/* ❌ WRONG — rigid arrow div between flex items */
<div class="step">Step 1</div>
<div class="arrow"></div> /* Fixed-width, prevents shrinking */
<div class="step">Step 2</div>
/* ✅ RIGHT — arrow as pseudo-element, doesn't affect flex layout */
.flow-step + .flow-step::before {
content: '→';
margin: 0 8px;
color: #999;
flex-shrink: 0;
}
```
**Rule 3.5.4 — Threshold-based layout switching:**
| Item count | Recommended layout | Notes |
|------------|-------------------|-------|
| 1-3 items | Horizontal flex (no wrap needed if items are short) | Still add `max-width: 100%` on container |
| 4-6 items | Horizontal flex + `flex-wrap: wrap` | Items may wrap to 2 rows |
| 7+ items | Vertical stack or CSS Grid 2×N | Horizontal becomes unreadable |
| Items with long text (>15 CJK chars / >25 Latin chars) | Vertical stack regardless of count | Long labels don't fit side-by-side |
### Quick Self-Check
Before generating any horizontal flex layout, verify:
```
□ Container has max-width: 100% (or explicit width ≤ page width)?
□ flex-wrap: wrap is set (if ≥3 items)?
□ Each child has min-width + max-width constraints?
□ Separators (arrows, dots, lines) are pseudo-elements, not rigid divs?
□ Long text items have overflow-wrap: break-word?
```
---
## 4. Table Overflow: Dynamic Column Width Allocation
Tables are the #1 source of horizontal overflow.
### Strategy: Weight-Based Column Width
```python
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.platypus import Table, TableStyle, Paragraph
from reportlab.lib.styles import ParagraphStyle
def calculate_col_widths(data, font_name, font_size, available_width, min_col=30):
"""Calculate column widths based on content weight.
Each column's width is proportional to its widest content,
with a minimum width and total constrained to available_width.
"""
n_cols = len(data[0])
# Measure max content width per column
max_widths = [0] * n_cols
for row in data:
for i, cell in enumerate(row):
text = str(cell) if not isinstance(cell, Paragraph) else cell.text
w = stringWidth(text, font_name, font_size) + 8 # +8pt padding
max_widths[i] = max(max_widths[i], w)
total_natural = sum(max_widths)
if total_natural <= available_width:
# Everything fits — distribute remaining space proportionally
extra = available_width - total_natural
return [w + extra * (w / total_natural) for w in max_widths]
else:
# Must compress — allocate proportionally with minimum
col_widths = []
for w in max_widths:
allocated = max(min_col, available_width * (w / total_natural))
col_widths.append(allocated)
# Normalize to exactly fit available_width
scale = available_width / sum(col_widths)
return [w * scale for w in col_widths]
def build_safe_table(data, available_width, font_name='Microsoft YaHei', font_size=9):
"""Build a table guaranteed not to overflow horizontally.
All text cells are wrapped in Paragraph() for automatic line-breaking.
"""
wrap_style = ParagraphStyle(
'TableCell',
fontName=font_name,
fontSize=font_size,
leading=font_size + 3,
wordWrap='CJK',
)
# Wrap all cells in Paragraph
wrapped_data = []
for row in data:
wrapped_row = [Paragraph(str(cell), wrap_style) for cell in row]
wrapped_data.append(wrapped_row)
col_widths = calculate_col_widths(data, font_name, font_size, available_width)
# Verify total width
assert sum(col_widths) <= available_width + 0.5, \
f"Table width {sum(col_widths):.1f} exceeds available {available_width:.1f}"
table = Table(wrapped_data, colWidths=col_widths, repeatRows=1)
return table
```
### LaTeX Table Width Management
```latex
% For tables that MUST fit single column
\begin{tabularx}{\columnwidth}{l X X r} % X = flexible width columns
...
\end{tabularx}
% For wide tables in twocolumn mode → span full page
\begin{table*}[t]
\begin{tabularx}{\textwidth}{l X X X X r}
...
\end{tabularx}
\end{table*}
% Last resort: shrink to fit (verify ≥ 6pt after scaling)
\resizebox{\columnwidth}{!}{%
\begin{tabular}{lllllll}
...
\end{tabular}
}
```
### LaTeX Equation Width Management (Dual-Column)
Equations are the **#2 overflow source** after tables in two-column papers (ACM `sigconf` column ~241pt, IEEE ~252pt).
```latex
% ❌ WRONG — two full equations on one line
\begin{equation}
\mathbf{e}_u = \sum \frac{1}{\sqrt{...}} \mathbf{e}_i, \quad
\mathbf{e}_i = \sum \frac{1}{\sqrt{...}} \mathbf{e}_u
\end{equation}
% ✅ CORRECT — one equation per line
\begin{align}
\mathbf{e}_u &= \sum \frac{1}{\sqrt{...}} \mathbf{e}_i, \\
\mathbf{e}_i &= \sum \frac{1}{\sqrt{...}} \mathbf{e}_u.
\end{align}
% ✅ For wide fractions (softmax, attention)
% Factor out sub-expressions into separate definitions
\begin{equation}
\alpha_{uv} = \frac{\exp(f(u,v))}{\sum_k \exp(f(u,k))},
\quad \text{where } f(u,v) = \text{LeakyReLU}(\ldots)
\end{equation}
% ✅ For contrastive losses: use multline
\begin{multline}
\mathcal{L}_{\text{SSL}}^u =
-\log \frac{\exp(\text{sim}(z_u', z_u'')/\tau)}
{\sum_{v \neq u} \exp(\text{sim}(z_u', z_v'')/\tau)}.
\end{multline}
```
**Quick heuristic:** if `equation` body > 60 raw characters (excluding `\label`), it probably overflows dual-column. Use `align`, `split`, `multline`, or factor out sub-expressions.
See `academic.md` Rules M1M4 for full patterns.
### LaTeX Algorithm Width Management (Dual-Column)
```latex
\SetAlFnt{\small} % ❗ MANDATORY in dual-column
\SetAlCapFnt{\small}
% Break long Input/Output across lines:
\KwInput{Graph $\mathcal{G}_R$, $\mathcal{G}_S$\\
\quad dim $d$, layers $L$, lr $\eta$, reg $\lambda$}
% Or use algorithm* to span full width
\begin{algorithm*}[t] ... \end{algorithm*}
```
### ⚠️ `\columnwidth` vs `\textwidth` in Two-Column Layouts
| Context | `\columnwidth` | `\textwidth` |
|---------|---------------|-------------|
| Single-column doc | = page content width | = page content width (same) |
| Two-column doc (`table` float) | = **one column** (~252pt) | = **full page** (~504pt) |
| Two-column doc (`table*` float) | = one column | = full page |
**Rule:** Inside `table` (single-col float), ALWAYS use `\columnwidth`. Inside `table*` (full-width float), use `\textwidth`.
`check-tex` detects `\resizebox{\textwidth}` inside single-column floats as error `RESIZEBOX_TEXTWIDTH`.
---
## 5. Fallback & Degradation Strategies
When content doesn't fit even after wrapping and scaling, apply these strategies **in order**:
### Automatic Degradation Ladder
| Step | Strategy | Limit | Notes |
|------|----------|-------|-------|
| 1 | Wrap text into Paragraph | — | Always do this first |
| 2 | Shrink font by 1pt | **Min 14pt** (single-col) / **12pt** (dual-col) | ⚠️ Enforced by `fill-engine.md` Safety Net 1 |
| 3 | Reduce padding/spacing | Min 4pt padding | Don't go below 4pt cell padding |
| 4 | Switch to landscape | Only if user allows | Never change orientation silently |
| 5 | Split into multiple elements | — | e.g., one wide table → two tables |
| 6 | Log warning + render anyway | — | If all else fails, at least don't crash |
### ReportLab Font Degradation
```python
def fit_text_with_degradation(text, font_name, base_size, max_width, min_size=14):
"""Try progressively smaller font sizes until text fits.
NOTE: min_size enforced by fill-engine.md Safety Net 1.
Single-column: min_size=14. Dual-column: min_size=12.
If text still doesn't fit at min_size → trigger page break, do NOT shrink further.
"""
for size in range(base_size, min_size - 1, -1):
if stringWidth(text, font_name, size) <= max_width:
return size
return min_size # Absolute floor — log warning
```
### Table Column Degradation
```python
def degrade_table_if_needed(data, available_width, font_name, base_font_size=10):
"""Try fitting table, degrading font size if needed."""
for font_size in [base_font_size, base_font_size - 1, base_font_size - 2]:
col_widths = calculate_col_widths(data, font_name, font_size, available_width)
if all(w >= 25 for w in col_widths): # Minimum 25pt per column
return font_size, col_widths
# Still doesn't fit — consider splitting table or landscape
return base_font_size - 2, col_widths
```
---
## 6. Vertical Overflow: Y-Cursor & Smart Pagination
Horizontal overflow → wrap/scale/shrink.
Vertical overflow → paginate.
### Y-Cursor Architecture (ReportLab Platypus handles this, but understand it)
```
Page Start
├── Current_Y = top_margin
├── Draw Block A (height = 80pt)
│ └── Current_Y += 80 + spacing
├── Draw Block B (height = 120pt)
│ └── Current_Y += 120 + spacing
├── Check: Current_Y + Next_Block_Height > (page_height - bottom_margin)?
│ ├── YES → New page, reset Current_Y = top_margin
│ └── NO → Continue drawing
└── ...
```
### Anti-Tear Rules (Elements That Must Not Split)
```python
from reportlab.platypus import KeepTogether
# 1. Heading + first paragraph — MANDATORY
story.append(KeepTogether([
heading,
first_paragraph,
]))
# 2. Image/chart + caption — MANDATORY
story.append(KeepTogether([
chart_image,
caption_paragraph,
]))
# 3. Table title + table (short tables ≤ 15 rows)
if len(data) <= 15:
story.append(KeepTogether([table_title, table]))
# 4. Long tables: repeat header on each page
table = Table(data, repeatRows=1) # First row repeats on every page
```
### Orphan/Widow Prevention
- If a paragraph's last line would be alone on the next page → pull at least 2 lines forward
- If a section heading lands at page bottom with no body text → push to next page
- ReportLab: `KeepTogether` handles most cases; `allowSplitting=False` for critical blocks
### LaTeX Vertical Overflow
```latex
% Prevent orphans and widows
\widowpenalty=10000
\clubpenalty=10000
% Prevent page break after heading
\usepackage{titlesec}
\titlespacing*{\section}{0pt}{12pt plus 4pt minus 2pt}{6pt plus 2pt minus 2pt}
% Keep float near text
\usepackage[section]{placeins} % \FloatBarrier at each \section
```
---
## 7. Two-Pass Rendering (Advanced — for Complex Documents)
For critical documents where overflow would be unacceptable:
### Pass 1: Virtual Layout (Measurement)
Calculate all element sizes without rendering. Build a **Layout Tree**:
```python
layout_tree = [
{"type": "heading", "width": 450, "height": 28, "page": 1},
{"type": "paragraph", "width": 450, "height": 84, "page": 1},
{"type": "table", "width": 450, "height": 220, "page": 1},
{"type": "chart", "width": 400, "height": 300, "page": 2},
# ...
]
```
### Collision Detection
```python
def check_overflow(layout_tree, page_width, left_margin, right_margin):
"""Verify no element overflows page boundaries."""
max_x = page_width - right_margin
violations = []
for elem in layout_tree:
right_edge = left_margin + elem["width"]
if right_edge > max_x:
violations.append({
"element": elem["type"],
"page": elem["page"],
"overflow_by": right_edge - max_x,
})
return violations
```
### Pass 2: Render with Confirmed Layout
Only render after Pass 1 confirms zero violations. If violations found → apply degradation strategies from §5, then re-run Pass 1.
**In practice**: ReportLab's Platypus engine already does a form of two-pass rendering internally (`doc.multiBuild()`). Use `multiBuild` + `afterFlowable` callbacks for complex documents that need cross-referencing or dynamic layout adjustment.
---
## Quick Reference: Which Route Uses What
| Mechanism | ReportLab (Report) | LaTeX (Academic) | Playwright (Creative) |
|-----------|-------------------|-------------------|----------------------|
| Text wrapping | `Paragraph()` + `wordWrap='CJK'` | `tabularx` X columns | CSS `overflow-wrap: break-word` |
| Image scaling | `fit_image()` helper | `\includegraphics[max width=]` | CSS `max-width: 100%` |
| Table width | `calculate_col_widths()` | `tabularx` / `resizebox` | CSS `table-layout: fixed` |
| Font degradation | `fit_text_with_degradation()` | `\small` / `\footnotesize` | CSS `font-size` step-down |
| Page break | `PageBreak()` / `KeepTogether` | `\newpage` / `\FloatBarrier` | CSS `break-before: page` |
| Header repeat | `Table(repeatRows=1)` | `\endhead` in longtable | `thead { display: table-header-group }` |
| Orphan/widow | `KeepTogether` | `\widowpenalty=10000` | CSS `orphans: 2; widows: 2` |
---
## Checklist (Run Before Every PDF Build)
```
□ All table cells use Paragraph() wrapping, not plain strings?
□ sum(colWidths) ≤ available_width verified in code?
□ Images scaled to fit container (not original size)?
□ Long tables have repeatRows=1 (or thead header-group)?
□ Heading + first paragraph wrapped in KeepTogether?
□ Chart + caption wrapped in KeepTogether?
□ CJK text uses wordWrap='CJK' style?
□ URL/long-string cells have word-break handling?
□ Font degradation fallback exists for tight columns?
□ Last page content ratio ≥ 40%?
```

View File

@@ -0,0 +1,367 @@
# Pagination & Flow Control
> Core rules for multi-page document layout quality. Must be followed every time a multi-page PDF is generated.
>
> Related:
> - `typesetting/overflow.md` — the comprehensive overflow prevention system covering all three routes (ReportLab, LaTeX, Playwright).
> - `typesetting/fill-engine.md` — the **anti-void** adaptive engine (handles pages with too little content: font floor, fill ratio, paragraph inflation, Y-axis golden-ratio anchoring).
---
## 1. Last Page Blank Control (Anti-Orphan Page)
**Problem**: The last page has only one or two lines of content with large blank areas — looks terrible.
**Mandatory Rules**:
- After generating multi-page content, **you must check the content fill ratio of the last page**
- When last page content ratio < 25%, **you must backtrack and adjust**
- Adjustment strategies (by priority):
1. **Compress preceding page spacing**: Reduce margin-bottom between sections (decrease 2-4px each)
2. **Tighten line height**: Body line-height from 1.7 → 1.55 (no lower than 1.5)
3. **Reduce font size**: Body from 16px → 15px (no lower than 14px)
4. **Trim content**: Remove dispensable descriptive text without affecting core information
5. **Merge small sections**: Combine adjacent sections with little content
**Checking Method (Playwright HTML route)**:
```css
/* Check min-content on the last .page element */
/* If content is less than 25% of page, backtracking is needed */
```
**Practical Standards**:
- Last page content ratio >= 40% → ✅ Pass
- Last page content ratio 25%-40% → ⚠️ Acceptable but optimization recommended
- Last page content ratio < 25% → ❌ Must adjust
---
## 2. Table Cross-Page Integrity
**Problem**: Table header and first data row split across two pages; table cut in the middle.
**Mandatory Rules**:
### Playwright HTML Route
```css
/* Prevent table splitting */
table, .table-wrapper {
break-inside: avoid; /* Preferred: keep entire table together */
page-break-inside: avoid;
}
/* If table is too long and must split, ensure header repeats */
thead {
display: table-header-group; /* Repeat header on each page */
}
/* Don't cut table rows in the middle */
tr {
break-inside: avoid;
page-break-inside: avoid;
}
/* Bind header + at least 2 data rows together */
thead + tbody tr:first-child,
thead + tbody tr:nth-child(2) {
break-before: avoid;
page-break-before: avoid;
}
```
### ReportLab Route
```python
# Use Table's repeatRows parameter
table = Table(data, repeatRows=1) # Repeat header on each page
# Or use KeepTogether to wrap small tables
from reportlab.platypus import KeepTogether
elements.append(KeepTogether([table_title, table]))
```
**Additional Rules**:
- Table rows ≤ 8: Entire table `break-inside: avoid`, no page splitting allowed
- Table rows > 8: Splitting allowed, but must use `thead { display: table-header-group }` to repeat header on each page
- All card-grid / flex-grid layouts follow the same rule: `break-inside: avoid`
---
## 3. CJK Punctuation Placement Rules
**Problem**: Commas, periods, enumeration commas, etc. appearing at line start, violating CJK typesetting standards.
**Mandatory Rules**:
### Playwright HTML Route (Recommended)
```css
/* Global CJK punctuation rules */
body {
line-break: strict; /* Strict line-break rules */
word-break: normal; /* Don't force word breaks */
overflow-wrap: break-word; /* Allow long words to break */
hanging-punctuation: allow-end; /* Allow punctuation to hang past line end */
}
/* For body paragraphs */
p, .body-text, td, li {
line-break: strict;
text-align: justify; /* Justify to reduce line-end gaps */
}
```
**Effect of `line-break: strict`**:
- Prevents line-start: ,。、;:!?)】》…—
- Prevents line-end: (【《
- Natively supported by Chromium engine, no extra JS needed
### ReportLab Route
```python
# Set in ReportLab Paragraph style
from reportlab.lib.enums import TA_JUSTIFY
style = ParagraphStyle(
'Body',
alignment=TA_JUSTIFY,
wordWrap='CJK', # CJK line-break mode
)
```
**Verification Checklist**:
- [ ] No comma/period appears as the first character of any line
- [ ] Left parenthesis / left quotation mark does not appear at line end
- [ ] Ellipsis is not broken in the middle
---
## 4. Major Section Page-Break Rule (3/4 Threshold)
**Problem**: A major section (H1/一级标题) ends at ~75% of the page, and the next major sections title gets squeezed into the remaining 25%. This looks cramped and ugly — the new section deserves a fresh page.
**Iron Rule**: When a major section (H1-level heading, e.g., “一、”“二、” or “Chapter 1”) is about to start, check remaining page space:
| Remaining space | Action |
|----------------|--------|
| **≥ 25% of page height** | Continue on same page — enough room for heading + meaningful content |
| **< 25% of page height** | Force page break — start the new section on a fresh page |
**Why 25% (not 50%)?** A major heading needs at least its title + 2-3 lines of body text to look intentional. If theres only enough room for a title and a line or two, it looks like an accident.
### ReportLab Implementation
```python
from reportlab.platypus import CondPageBreak
# Before every H1-level heading, insert a conditional page break.
# CondPageBreak(height) breaks to next page if remaining space < height.
# Use 75% of available page height as threshold.
available_height = page_height - top_margin - bottom_margin
threshold = available_height * 0.25 # break if less than 25% remains
# In story building:
story.append(CondPageBreak(threshold)) # ← goes before H1 heading
story.append(h1_paragraph)
```
### Playwright / CSS @page Implementation
```css
/* H1-level headings always prefer starting on a new page
unless there's substantial room remaining */
h1, .major-section-title {
break-before: auto; /* Default: don't force */
page-break-before: auto;
}
```
```javascript
// Post-render check: if an H1 starts in the bottom 25% of the viewport,
// force a page-break-before to avoid orphan headings
document.querySelectorAll('h1, .major-section-title').forEach(h => {
const rect = h.getBoundingClientRect();
// In print context, check if heading is too far down the page
// This can be verified after Playwright render via page.evaluate()
const pageHeight = window.innerHeight;
const relativeY = rect.top / pageHeight;
if (relativeY > 0.75) {
h.style.breakBefore = 'page';
}
});
```
```
### LaTeX Implementation
```latex
% Before each \section{} (H1-level), check remaining space
\needspace{0.25\textheight} % requires needspace package
\section{New Major Section}
```
**Scope**: This rule applies to **H1-level headings only** (major sections, chapters, top-level numbered items like “一、”“二、”). Sub-sections (H2, H3) follow the standard heading-body binding rule (no orphan headings at page bottom) but do NOT force page breaks.
---
## 5. Other Anti-Split Rules
### HeadingBody Binding
```css
h1, h2, h3, h4, .section-title {
break-after: avoid; /* Don't page-break after heading */
page-break-after: avoid;
}
```
### Image / Card Protection
```css
figure, .card, .kpi-card, .project-card {
break-inside: avoid;
page-break-inside: avoid;
}
```
> **⚠️ Image `max-height` is critical.** `break-inside: avoid` alone can cause images to occupy an entire page when the image is tall. Always pair with `max-height` from overflow.md (`img { max-height: 45vh }`) to prevent single images from consuming a full page.
### List Item Binding
```css
li {
break-inside: avoid;
}
/* Keep at least 2 list items on the same page */
li:last-child {
break-before: avoid;
}
```
---
## Quick Checklist (After every multi-page PDF generation)
```
□ Last page content ≥ 40%?
□ Major sections (H1) not starting in bottom 25% of a page?
□ Table header and data rows not separated?
□ No punctuation appearing at line start?
□ No heading orphaned at page bottom?
□ No card/image cut in half?
□ Page numbering follows the standard scheme (see Section 6)?
```
---
## 6. Standard Page Numbering Scheme
All multi-page documents MUST follow this five-zone page numbering convention unless the user explicitly requests otherwise.
### Zone Definitions
| Zone | Section | Numbering Style | Starts At | Visibility |
|------|---------|----------------|-----------|------------|
| **1. Cover** | Title page | — | Logical page 1 | **Hidden** (no visible page number, but counts as page 1 internally) |
| **2. Front Matter** | Table of Contents, Preface, Abstract, Acknowledgments | **Lowercase Roman** (i, ii, iii, iv, v…) | i | Visible, centered footer |
| **3. Body** | Main content chapters/sections | **Arabic** (1, 2, 3…) | **Resets to 1** | Visible, centered or outer-edge footer |
| **4. Appendix** | Appendices (A, B, C…) | **Arabic, continues** from body | Continues | Visible |
| **5. References / Bibliography** | Works cited, bibliography | **Arabic, continues** from body/appendix | Continues | Visible |
### Key Rules
0. **NEVER use "Page X of Y" format (denominator is FORBIDDEN).** Footer must show only the page number itself (e.g., `1`, `2`, `iii`). Do NOT display total page count. No `Page 3 of 12`, no `第3页/共12页`, no `3 / 12`. Just the bare number.
1. **Cover page is ALWAYS page 1 internally** but the page number is **never displayed**. This is achieved by suppressing the footer/header on the first page, not by excluding it from the page count.
2. **Front matter uses a separate Roman numeral sequence.** When front matter exists (TOC, abstract, preface), it forms its own numbering sequence starting at `i`. This sequence is independent of the body numbering.
3. **Body numbering resets to Arabic 1.** The first page of actual content (Chapter 1, Introduction, etc.) is always page `1` regardless of how many front matter pages precede it.
4. **Appendix and references continue the body sequence.** There is NO reset between body → appendix → references. If the body ends on page 42, Appendix A starts on page 43.
5. **Documents without front matter** skip zone 2 entirely. Cover = hidden page 1, body starts at visible page 1.
6. **Documents without a cover** start the body (or front matter if present) at page 1 directly.
### ReportLab Implementation
```python
from reportlab.platypus import SimpleDocTemplate, PageBreak, NextPageTemplate, PageTemplate
from reportlab.lib.units import inch
from reportlab.platypus.frames import Frame
def footer_with_arabic(canvas, doc):
"""Standard Arabic page number in footer."""
canvas.saveState()
canvas.setFont('Helvetica', 9)
canvas.drawCentredString(doc.pagesize[0] / 2, 0.5 * inch,
str(doc.page))
canvas.restoreState()
def footer_with_roman(canvas, doc):
"""Roman numeral page number for front matter."""
roman_map = {1:'i',2:'ii',3:'iii',4:'iv',5:'v',6:'vi',7:'vii',8:'viii',9:'ix',10:'x'}
page_num = roman_map.get(doc.page, str(doc.page))
canvas.saveState()
canvas.setFont('Helvetica', 9)
canvas.drawCentredString(doc.pagesize[0] / 2, 0.5 * inch, page_num)
canvas.restoreState()
def no_footer(canvas, doc):
"""Cover page — no visible page number."""
pass
# Define page templates:
# - 'cover': no footer
# - 'frontmatter': Roman numeral footer
# - 'body': Arabic footer (page counter resets)
```
### Playwright / HTML + CSS Implementation
```css
/* Zone 1: Cover — suppress page number */
.page-cover {
/* No footer content */
}
/* Zone 2: Front matter — Roman numerals via CSS counter */
@page :nth(2) { /* Adjust range based on front matter pages */ }
/* For Playwright, page numbers are typically added via:
1. A footer element on each .page div, or
2. Post-processing with pypdf after PDF generation */
```
**Practical approach for Playwright route**: Since CSS `@page` counters with Roman/Arabic switching are poorly supported, the recommended pattern is:
1. Generate the PDF without page numbers
2. Use pypdf to stamp page numbers in post-processing:
- Skip page 1 (cover)
- Roman numerals for front matter pages
- Arabic starting from 1 for body pages
### LaTeX Implementation
```latex
% Cover: no page number displayed
\begin{titlepage}
\thispagestyle{empty} % Suppress page number
% ... cover content ...
\end{titlepage}
% Front matter: Roman numerals
\pagenumbering{roman} % Switches to i, ii, iii...
\tableofcontents
\newpage
% Body: Arabic, reset to 1
\pagenumbering{arabic} % Switches to 1, 2, 3... (auto-resets counter)
\section{Introduction}
% ...
% Appendix: continues Arabic numbering (no reset)
\appendix
\section{Appendix A} % Page number continues from body
% References: continues Arabic numbering (no reset)
\bibliographystyle{plain}
\bibliography{refs}
```
### When to Deviate
- **Single-page documents** (certificates, letters, posters): No page numbering at all.
- **Short documents (≤3 pages)**: Simple Arabic `1, 2, 3` throughout, no cover/frontmatter distinction.
- **User explicitly requests a different scheme**: Follow the user's instructions.
- **Exam papers**: Sequential Arabic numbering on every page, including page 1.

217
skills/pdf/typesetting/palette.md Executable file
View File

@@ -0,0 +1,217 @@
# Color Palette System
> Color is the skeleton of design. Unified, restrained, systematic. Garish = amateur.
---
## Cascade Palette System (V2 — Preferred)
The cascade palette enforces one iron law: **Area ∝ 1/Saturation**.
The larger the colored area, the lower its saturation must be.
### Tier System
| Tier | Area % | S Cap | Roles |
|------|--------|-------|-------|
| **XL** | >50% | ≤ 0.08 | `page_bg`, `section_bg` |
| **L** | 20-50% | ≤ 0.15 | `card_bg`, `table_stripe` |
| **M** | 5-20% | ≤ 0.30 | `header_fill`, `cover_block` |
| **S** | 1-5% | ≤ 0.50 | `border`, `icon` |
| **XS** | <1% | ≤ 0.75 | `accent`, `accent_secondary` |
### How It Works
One base hue → 12 roles + 4 semantic colors. Cover, body, and charts all pull from the same palette:
```
palette.cascade
├── cover subset (page_bg, header_fill, cover_block, accent, text_primary...)
├── body subset (page_bg, section_bg, card_bg, table_stripe, border...)
├── chart subset (accent as series_1, accent_secondary as series_2, ...)
└── semantic (success, warning, error, info — all low-sat)
```
No orphan colors. No "cover finished, now pick new colors for body" drift.
### Usage
```bash
# Via design_engine.py
python3 "$PDF_SKILL_DIR/scripts/design_engine.py" palette-cascade --intent cold --mode minimal
# Via pdf.py (auto-derives intent from title)
python3 "$PDF_SKILL_DIR/scripts/pdf.py" palette.cascade --title "2025年度报告" --format reportlab
# Formats: summary (default) | json | css | reportlab
```
### Output Formats
- **summary**: Human-readable table with tier/role/hex/saturation
- **json**: Full structured data (roles, cover, body, charts, semantic, meta)
- **css**: CSS custom properties ready for HTML/Playwright
- **reportlab**: Python code ready to paste into ReportLab scripts
---
## Core Iron Rules
### 1. One Document, One Color Family
**Not one color — one color family.**
- After choosing the primary color, all secondary, accent, and background colors must be derived from it
- Derivation methods: lightness shift, saturation shift, micro hue adjustment (within ±15°)
- **Forbidden** to have unrelated colors in the same document
```
Primary → Headings, key data, primary buttons
Secondary → Primary lightness ±15-25%
Accent → Primary hue ±10-15°, for highlights/warnings
Neutral → Gray series for body text, not conflicting with primary
Background → Pure white / primary at opacity 3-8%
```
### 2. Color Count Limits
| Element Type | Max Colors | Notes |
|-------------|-----------|-------|
| Entire document | 4-5 | Primary + secondary + accent + neutral + background |
| Single component (card/table) | 2-3 | Don't give each card a different color |
| Charts / data visualization | Same-family gradient | Differentiate by opacity/lightness, not different hues |
| Tags / badges | 1 color + text color | No rainbow tags |
### 3. Absolutely Forbidden Color Fills
The following are automatic failures:
- ❌ 4 cards using 4 completely different colors (red/blue/green/purple)
- ❌ Alternating table rows in different colors (blue row/pink row)
- ❌ Rainbow-colored pie charts/bar charts
- ❌ Each section with a different theme color
- ❌ Gradient transitioning from warm to cool tones (red → blue)
---
## Color Generation Rules
### Deriving a Full Palette from Primary
```
Given primary H(hue) S(saturation) L(lightness):
Primary: hsl(H, S, L) — Headings, key elements
Dark variant: hsl(H, S, L-15%) — Hover, borders, icons
Light variant: hsl(H, S-10%, L+25%) — Tag backgrounds, light fills
Ultra-light bg: hsl(H, S-20%, 96%) — Section backgrounds, card base
Accent: hsl(H+15, S, L) — Warnings, highlights (micro hue shift)
```
### Example: Deriving from a primary color
> ⚠️ The hex values below are **examples only**. In production, use `palette.cascade` or `palette.generate` to compute the full palette from intent.
```css
:root {
--c-primary: #2d5a87; /* Primary (from palette.cascade) */
--c-primary-d: #1e3d5c; /* Dark variant */
--c-primary-l: #5a8ab8; /* Light variant */
--c-primary-bg: #f0f4f8; /* Ultra-light background */
--c-accent: #2d6a87; /* Accent (hue +10°) */
--c-text: #333; /* Body text */
--c-text-muted: #888; /* Secondary text */
--c-border: #e0e4e8; /* Border lines */
}
```
---
## Multi-Element Differentiation Strategies
When distinguishing multiple sibling elements (e.g., multiple cards, categories), **don't use different colors — use these approaches instead**:
### Strategy A: Same Hue, Different Lightness
```css
.card-1 { background: hsl(220, 40%, 95%); } /* Lightest */
.card-2 { background: hsl(220, 40%, 90%); }
.card-3 { background: hsl(220, 40%, 85%); }
.card-4 { background: hsl(220, 40%, 80%); } /* Darkest */
```
### Strategy B: Same Color, Different Opacity
```css
.item-1 { background: rgba(30, 58, 95, 0.06); }
.item-2 { background: rgba(30, 58, 95, 0.12); }
.item-3 { background: rgba(30, 58, 95, 0.18); }
.item-4 { background: rgba(30, 58, 95, 0.24); }
```
### Strategy C: Primary + Whitespace + Lines
```css
/* Differentiate by border color/weight/style, uniform white background */
.card-1 { border-left: 3px solid var(--primary); }
.card-2 { border-left: 3px solid var(--primary-l); }
.card-3 { border-left: 3px solid var(--primary-d); }
```
### Strategy D: Icons / Numbering (Not Color)
```css
/* All cards same color, differentiated by icons, numbers, or layout variation */
```
---
## Gradient Usage Rules
### Allowed Gradients
- **Same-family gradient**: `linear-gradient(135deg, var(--c-primary), var(--c-primary-l))` — hue difference < 20°
- **Lightness gradient**: `linear-gradient(180deg, #fff, #f5f5f5)` — pure lightness change
- **Primary to transparent**: `linear-gradient(90deg, var(--c-primary), transparent)` — for decorative lines
### Forbidden Gradients
- ❌ Warm-to-cool crossover: `linear-gradient(#ff6b6b, #4ecdc4)`
- ❌ More than 3 colors: `linear-gradient(red, yellow, green, blue)`
- ❌ Neon gradients: Any high-saturation gradient
- ❌ Gratuitous gradients: Gradients added purely for "looks nice" without purpose
---
## Preset Palettes (Ready to Use)
### Business Blue
```
#1a365d → #2a5298 → #4a7ac7 → #dce6f5 → #f5f8fc
```
### Warm Gray
```
#2d2d2d → #5a5a5a → #8a8a8a → #e8e8e8 → #f9f9f9
```
### Forest Green
```
#1a3c2a → #2d6b4a → #4a9a6a → #d5ead8 → #f2f8f4
```
### Terracotta Red
```
#5c2018 → #8a3828 → #b85a48 → #f0d8d0 → #faf4f2
```
### Indigo Purple
```
#2d1b4e → #4a2d7a → #6a4aaa → #ddd0f0 → #f5f2fa
```
---
## Quick Check
```
□ How many colors does the entire document use? (Target ≤ 5)
□ Can every color be traced back to the primary?
□ Are there any colors that "suddenly appear" without derivation?
□ Are sibling elements rainbow-colored?
□ Gradient endpoint hue difference < 20°?
□ If you remove all color and look at grayscale only, is the hierarchy still clear?
```

View File

@@ -0,0 +1,20 @@
---
## CJK Typography Supplement
> See `pagination.md` Section 3 for detailed rules.
```css
/* CJK punctuation placement rules (always include) */
body {
line-break: strict;
word-break: normal;
overflow-wrap: break-word;
}
p, td, li {
line-break: strict;
text-align: justify;
}
```