feat: IMAP mail sync with thread-to-contact matching (PRD §6.1)
Real IMAP connection via imapflow library: - Connects to IMAP server (IMAPFlow, TLS on port 993) - Fetches recent messages (configurable limit, default 20) - Extracts: from, fromEmail, subject, date, snippet (200 chars) - Thread-to-contact matching (PRD §6.1): 1. Match sender email against persons table 2. If no person match, extract domain and match against company.website 3. Returns matchedCompany + matchedPerson for each email - Audit-logged (action: crm.mailsync:imap) - Zod validated (host, port, user, password, folder, limit) - 502 on IMAP connection failure with detail - 400 on validation failure Tested: invalid creds → 502 'IMAP connection failed', missing host → 400
This commit is contained in:
1 parent
7acc0c5451
commit
e45b68bb8b
1089 files changed
+260614
No files matched your search
Executable
+13
@@ -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.
|
||||
Executable
+879
@@ -0,0 +1,879 @@
|
||||
---
|
||||
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
|
||||
|
||||
## Script Path Setup (MANDATORY before any script call)
|
||||
|
||||
All paths are relative to `$PDF_SKILL_DIR`. Resolve it once before calling any script:
|
||||
|
||||
```bash
|
||||
PDF_SKILL_DIR="<skill_directory>" # ← parent directory of this SKILL.md
|
||||
```
|
||||
|
||||
**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)
|
||||
```
|
||||
|
||||
## 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 + `configs/fonts.md` + matched brief + **ALL files referenced by the brief** (typesetting, configs, etc.) |
|
||||
|
||||
### ⚠️ 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 pipeline** (Fixed-Canvas or Flow depending on document type) regardless of original routing. ReportLab renders emoji as □ squares; LaTeX drops them entirely.
|
||||
2. **CJK Check** - Chinese/Japanese/Korean content needs font coverage. Report brief must register CJK fonts - **probe first** with `ls /usr/share/fonts/truetype/chinese/` (Linux) or check `$FONT_DIR` (macOS) to confirm which fonts exist, then register accordingly (prefer NotoSerifSC > Noto Sans SC; never hardcode a font name without verifying it exists). Creative Fixed-Canvas and Creative Flow briefs 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-fixed-canvas.md (Playwright, fixed-canvas)
|
||||
│
|
||||
├─ Guide / handbook / catalog / introduction / collection (text-heavy + design)?
|
||||
│ └─ ────────────────────────────────── → briefs/creative-flow.md (Playwright, flowing content)
|
||||
│
|
||||
├─ 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/resume.md
|
||||
├─ Creative / design industry ────── → briefs/creative-fixed-canvas.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 Fixed-Canvas | 海报, poster, 邀请函, invitation, 信息图, infographic, 仪表盘, dashboard, 传单, flyer, 证书, certificate, 菜单, menu, 名片, business card, 奖状, award, 标签, label, 信封, envelope, 贺卡, greeting card → `briefs/creative-fixed-canvas.md` |
|
||||
| Creative (Poster) | 海报, poster, 传单, flyer, 宣传页, 宣传单 → additionally load `briefs/poster.md` scene layer rules on top of creative-fixed-canvas.md |
|
||||
| Creative Flow | 图鉴, guide, 手册, handbook, 目录, catalog, 介绍, introduction, 合集, collection → `briefs/creative-flow.md` |
|
||||
| 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-fixed-canvas.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-fixed-canvas.md + **poster.md** | Visual design + poster density/sizing rules |
|
||||
| Invitation / greeting card | creative-fixed-canvas.md | Non-standard size, decorative |
|
||||
| Certificate / award | creative-fixed-canvas.md | Single page, centered layout, decorative border |
|
||||
| Business card | creative-fixed-canvas.md | Tiny size (90×54mm), Playwright native support |
|
||||
| Envelope / label | creative-fixed-canvas.md | Non-standard size, simple layout |
|
||||
| Menu / price list | creative-fixed-canvas.md | Visual layout + may contain emoji |
|
||||
| Resume (ATS) | resume.md | Plain text structure |
|
||||
| Resume (creative) | creative-fixed-canvas.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-fixed-canvas.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-fixed-canvas.md | HTML `writing-mode: vertical-rl` + `text-orientation: upright` + `white-space: nowrap` + Playwright |
|
||||
| RTL document (Arabic/Hebrew) | creative-fixed-canvas.md | HTML `dir="rtl"` + Playwright |
|
||||
| Batch generation (mail merge) | report.md | Python loop + template variable substitution |
|
||||
| Infographic | creative-fixed-canvas.md | Data visualization + design |
|
||||
| Calendar / schedule | creative-fixed-canvas.md | Grid layout + custom dimensions |
|
||||
| Guide / handbook / catalog / introduction / collection | creative-flow.md | Flowing-document mode — text-heavy with design flair, content flows across pages naturally |
|
||||
|
||||
#### 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.md | pypdfium2 render each page to PNG |
|
||||
| Encrypt / decrypt | process.md | qpdf / pypdf 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.md | pytesseract + pdf2image |
|
||||
| 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 **Creative pipeline** (use `briefs/creative-fixed-canvas.md` for visual-first designs, `briefs/creative-flow.md` for text-heavy documents) regardless of original routing. ReportLab renders emoji as □ squares; LaTeX silently drops them. This rule overrides Report/Academic routing. Even if the user says "report" - if the content has emoji, use a Creative brief.
|
||||
|
||||
**Non-standard page size rule**: Dimensions other than A4/Letter/A3 → strongly prefer **Creative pipeline** (`briefs/creative-fixed-canvas.md` or `briefs/creative-flow.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 Resume brief (ATS-safe). Creative industry → Creative brief. Academic CV with publications → Academic brief.
|
||||
|
||||
---
|
||||
|
||||
## Shared Assets
|
||||
|
||||
These are referenced by multiple briefs. Each brief tells you when and what to load.
|
||||
|
||||
| Asset | Path | Used By | Purpose |
|
||||
|-------|------|---------|---------|
|
||||
| Palette & Typography | `typesetting/palette.md` | Report, Fixed-Canvas, Flow | Color system, font rules, anti-patterns, spacing |
|
||||
| Cover Layout System V2.1 | `typesetting/cover.md` | **Report + Fixed-Canvas + Flow + Academic** | 5 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, Fixed-Canvas, Flow, Academic | Chart defaults, collision prevention, axis/grid/legend rules |
|
||||
| Overflow Prevention | `typesetting/overflow.md` | Report, Fixed-Canvas, Flow, Academic | Bounding box system, text/image/table overflow prevention, fallback strategies |
|
||||
| **Fill Engine (Anti-Void)** | `typesetting/fill-engine.md` | **Report, Fixed-Canvas, 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, Fixed-Canvas, Flow | Cross-page integrity, orphan/widow control, CJK punctuation rules |
|
||||
| Typography System | `typesetting/typography.md` | Report, Fixed-Canvas, Flow | 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 + Fixed-Canvas + Flow + Academic** | Cover background rendering, transparency constraints |
|
||||
| Visual Framework | `configs/visual_framework.md` | Fixed-Canvas | Palette mode, color harmony, SVG background params |
|
||||
| Components Library | `configs/components.md` | Fixed-Canvas | 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.
|
||||
|
||||
### Content Depth and Richness Standards (Anti-Shallow Writing)
|
||||
|
||||
**PROBLEM TO AVOID**: Shallow content with paragraphs containing only 1-2 sentences, or sections with minimal text under headings.
|
||||
|
||||
#### Minimum Content Standards
|
||||
|
||||
1. **Paragraph Depth**
|
||||
- Each paragraph MUST contain at least 3-5 sentences
|
||||
- Single-sentence paragraphs are FORBIDDEN (except for transitional statements)
|
||||
- Each paragraph should develop ONE complete idea with supporting details
|
||||
|
||||
2. **Section Completeness**
|
||||
- Each section heading MUST be followed by substantial content (minimum 150-200 words)
|
||||
- NEVER create a section with only 1-2 short sentences
|
||||
- If a section cannot meet minimum length, merge it with related sections
|
||||
|
||||
3. **Content Enrichment Techniques**
|
||||
- Include specific examples, data points, or case studies
|
||||
- Provide context and background information
|
||||
- Explain the "why" and "how", not just the "what"
|
||||
- Add comparisons, contrasts, or alternative perspectives where relevant
|
||||
- Include implications, consequences, or recommendations
|
||||
|
||||
#### Writing Quality Checklist
|
||||
|
||||
Before finalizing ANY written document, verify:
|
||||
- [ ] No paragraph has fewer than 3 sentences
|
||||
- [ ] No section has fewer than 150 words of body content
|
||||
- [ ] Each main point is supported by examples or evidence
|
||||
- [ ] Technical terms are explained when first introduced
|
||||
- [ ] Transitions connect ideas between paragraphs and sections
|
||||
|
||||
#### Content Expansion Strategies
|
||||
|
||||
When writing feels thin, apply these techniques:
|
||||
|
||||
**For Analysis/Reports:**
|
||||
- Add background context (history, current state, trends)
|
||||
- Include multiple perspectives or stakeholder views
|
||||
- Provide specific metrics, statistics, or quantitative data
|
||||
- Discuss limitations, challenges, or counterarguments
|
||||
- Offer actionable recommendations with rationale
|
||||
|
||||
**For Explanatory Content:**
|
||||
- Use analogies to clarify complex concepts
|
||||
- Provide step-by-step breakdowns where applicable
|
||||
- Include real-world applications or use cases
|
||||
- Address common questions or misconceptions
|
||||
- Add visual descriptions or diagram explanations
|
||||
|
||||
**FORBIDDEN PATTERNS:**
|
||||
- Section with heading followed by only 1-3 sentences
|
||||
- Bullet lists without explanatory context
|
||||
- Conclusions that merely restate the introduction
|
||||
- Sections that say "as mentioned above" without adding new value
|
||||
|
||||
### Language Consistency Rule
|
||||
|
||||
Always use the same language as the user's input language for:
|
||||
- The response and document content
|
||||
- Any generated report, PDF, or plot/diagram
|
||||
- **Cover page**: All text on the cover (title, subtitle, tags, author, footer) MUST match the user's prompt language. Chinese prompt → Chinese cover; English prompt → English cover. Mixing is forbidden.
|
||||
- **Charts and figures**: Before generating any chart or figure, determine the user's language. Ensure that the title, legend, labels, and other textual elements are consistent with the user's language. If any element cannot use the user's language, explicitly explain the reason.
|
||||
|
||||
### 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` | Paged.js pagination, A4/custom size, pdf-lib metadata. `--nopaged` for Chromium native fallback |
|
||||
| **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 Mermaid/KaTeX rendering, overflow detection, and font loading. **Paged.js polyfill** is injected for pagination (break-inside/before/after, orphans/widows, named pages). Use `--nopaged` flag to fall back to Chromium native @page pagination if needed.
|
||||
|
||||
#### ⚠️ 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. Always use `html2poster.js` for cover pages — `html2pdf-next.js` + Paged.js would re-layout absolute-positioned elements into flow, destroying the cover design.
|
||||
|
||||
### No overflow:hidden on Fixed-Size Pages
|
||||
|
||||
**NEVER set `overflow: hidden` on `html`, `body`, `.page`, or the main container** in HTML intended for PDF conversion. Paged.js handles content chunking and pagination natively — it does not need overflow clipping.
|
||||
|
||||
> **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.
|
||||
|
||||
| Problem | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| Content silently clipped at page boundaries | `overflow: hidden` on container hides content exceeding bounds | Remove `overflow: hidden`; Paged.js handles pagination |
|
||||
| Decorative elements inflate scrollWidth | `width > 100%` or negative offsets on absolutely-positioned elements | Constrain within page bounds (see creative-fixed-canvas.md §0.75) |
|
||||
|
||||
**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-fixed-canvas.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: constrain decorative elements within page bounds** (`width` ≤ 100%, no negative `left`/`right` offsets). See `briefs/creative-fixed-canvas.md` §0.75 and `typesetting/overflow.md` §3.5 for details.
|
||||
|
||||
### 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 and hides layout bugs. Paged.js handles pagination without needing overflow clipping
|
||||
- `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 (Paged.js + pdf-lib) | ✅ 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 Template 01, Academic uses Templates 03-04 (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.md ← Resume/CV production: ATS-friendly single-column template
|
||||
creative-fixed-canvas.md ← Fixed-canvas: posters, infographics, certificates (5-phase blueprint design)
|
||||
creative-flow.md ← Flowing documents: guides, handbooks, catalogs, introductions (content flows across pages)
|
||||
poster.md ← Poster scene rules: density, font sizing, fill constraints (overlay on creative-fixed-canvas.md)
|
||||
academic.md ← Academic production: LaTeX workflow + templates + resume(CV)
|
||||
process.md ← PDF processing: extract/merge/split/form/convert/reformat/encrypt/OCR/batch
|
||||
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 + Paged.js + pdf-lib HTML→PDF converter for documents
|
||||
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
|
||||
|
||||
> **⚠️ DO NOT SKIP FILES. DO NOT SKIM.**
|
||||
>
|
||||
> Every rule in these files exists because a previous generation failed without it. "I already know how to make a PDF" is not a valid reason to skip reading. You don't — these files contain hundreds of engine-specific pitfalls (font registration order, overflow:hidden restrictions, cover rendering tools, page-break interactions) that you cannot guess correctly.
|
||||
|
||||
**Step 1 — ALWAYS read (every task):**
|
||||
- This file (SKILL.md) — routing + iron rules
|
||||
- `configs/fonts.md` — font stacks per pipeline (wrong font = garbled CJK)
|
||||
|
||||
**Step 2 — Read the matched brief (every task):**
|
||||
- ONE brief file from `briefs/` based on routing
|
||||
- Read it **completely, top to bottom** — do not stop at the first code example
|
||||
|
||||
**Step 3 — Read every file the brief references (MANDATORY, not optional):**
|
||||
- When the brief says "see `typesetting/cover.md`" or "see `typesetting/overflow.md`" → you **MUST** open and read that file before writing any code
|
||||
- When a typesetting file references another file → follow that link too
|
||||
- **There is no "on demand" or "only if needed".** If the brief mentions a file, read it. Period.
|
||||
|
||||
**Step 4 — Cover page? Read these BEFORE generating cover HTML:**
|
||||
- `typesetting/cover.md` — template system, layer architecture, anti-overflow rules
|
||||
- Run `cover_validate.js` after generating cover HTML (before PDF conversion)
|
||||
- Use `html2poster.js` for cover rendering — **NEVER write hand-written Playwright scripts**
|
||||
|
||||
**Checkpoint before writing code:** Can you name the exact rendering tool (html2poster.js vs html2pdf-next.js), font stack, cover template ID, and post-generation validators for this task? If not, you haven't read enough. Go back.
|
||||
|
||||
---
|
||||
|
||||
## 8. Quality Checklist (Mandatory after every PDF generation)
|
||||
|
||||
> ⚠️ **正文才是第一章。** 封面/目录/摘要不计入编号,正文编号永远从 1 开始。详见 `report.md` Step 3.5。
|
||||
|
||||
> The following checks come from the `typesetting/` spec files and are **mandatory** quality gates.
|
||||
|
||||
### Automated Detection (Must Run)
|
||||
|
||||
```bash
|
||||
# 1. PDF quality check (all pipelines)
|
||||
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
|
||||
|
||||
# 2. Cover overlap detection (Report/Academic with cover - MANDATORY)
|
||||
# Run on the cover HTML BEFORE rendering to PDF. Detects:
|
||||
# - Text vs decorative line overlap (minimum gap = 1U = 5% of page width)
|
||||
# - Layer 3 text vs text zone overflow (same-layer foreground text blocks overlapping)
|
||||
# Exit 0 = pass, Exit 1 = overlap found (must fix), Exit 2 = script error
|
||||
node "$PDF_SKILL_DIR/scripts/cover_validate.js" cover.html
|
||||
node "$PDF_SKILL_DIR/scripts/cover_validate.js" cover.html --min-gap 30 # custom min gap in px
|
||||
```
|
||||
|
||||
> **Dependencies**: `pymupdf` (`pip install pymupdf`) for pdf_qa.py; `playwright` or `playwright-core` for cover_validate.js. If not installed, skip the respective check 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**
|
||||
|
||||
### Brief-Specific Quality Checklists
|
||||
|
||||
Detailed checklist items have been moved into each brief to reduce context size:
|
||||
|
||||
- **Report** → `briefs/report.md` § "Quality Checklist — Report-Specific Items" (pagination, overflow, color, cover, charts, exam rules, layout, design restraint)
|
||||
- **Academic** → `briefs/academic.md` § "Quality Checklist — Academic-Specific Items" (pagination, LaTeX-specific)
|
||||
- **Creative Fixed-Canvas** → `briefs/creative-fixed-canvas.md` § "Quality Checklist — Creative-Specific Items" (color, geometric anchors, layout, design restraint)
|
||||
- **Creative Flow** → `briefs/creative-flow.md` § "Quality Checklist — Creative Flow" (layout & pagination, full-bleed, design quality)
|
||||
|
||||
After loading your brief, review its quality checklist before delivering.
|
||||
|
||||
### 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
|
||||
Executable
+1076
File diff suppressed because it is too large.
Load diff
Executable
+848
@@ -0,0 +1,848 @@
|
||||
# Brief: Creative Fixed-Canvas (Art Director Blueprint Mode)
|
||||
|
||||
**When to use this brief:** Each page is a self-contained visual canvas with fixed dimensions — posters, infographics, invitations, certificates, dashboards, business cards, menus, greeting cards. Visual impact is primary; content per page ≤ 150 words.
|
||||
|
||||
**NOT for:** Text-heavy flowing documents (guides, handbooks, catalogs, introductions) → use `briefs/creative-flow.md` instead.
|
||||
|
||||
**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.
|
||||
|
||||
**→ MUST READ: `typesetting/overflow.md`** for Playwright/HTML-specific overflow 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
|
||||
|
||||
**→ MUST READ: `typesetting/charts.md`** 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` 5-template system** - pick Layout 01/03/04/06/07 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 (5 Layout System)
|
||||
|
||||
Cover pages (`archetype: "cover_hero"`) are the first impression. They must be ruthlessly sparse and spatially sophisticated.
|
||||
|
||||
**→ MUST READ: `typesetting/cover.md`** before designing any cover.
|
||||
|
||||
> **⚠️ Template Numbering:** Cover HTML templates use `cover.md`'s authoritative numbering: **01** (HUD), **03** (Academic Anchor), **04** (Academic Symmetric), **06** (Institutional), **07** (Crystal Blue). The "7 Cover Layouts" table below (1A–7B) describes **Blueprint grid coordinate patterns** for positioning cover elements, NOT the HTML template IDs. Do not confuse the two systems.
|
||||
|
||||
#### 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 | 2A, 3A, 7A | **3A** |
|
||||
| **Authority** | Government / Bidding | 7A, 7B, 3A | **7A** |
|
||||
| **Authority** | Thesis proposal / Dissertation | Use **Template 06** (Institutional) from `cover.md` | **Template 06** |
|
||||
| **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 | 07 Crystal Blue, 01 HUD | A (极简弧线) | 210° (steel blue-grey) |
|
||||
| **Tension** | dark | complementary | grid | 01 HUD | C (锐角切割) | 0° (warm vs cold) |
|
||||
| **Energy** | pastel / light | triadic | flow (5+ curves) | 07 Crystal Blue, 04 Academic Symmetric | B (工程十字轴) | 30° (amber) |
|
||||
| **Authority** | minimal | split_complementary | noise | 04 Academic Symmetric, 03 Academic Anchor, 07 Crystal Blue | A or B | 280° (muted violet) |
|
||||
| **Warmth** | pastel / light | analogous | flow (soft) | 07 Crystal Blue, 01 HUD | 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, use `cover.md`'s template numbering (01/03/04/06/07). Cross-reference with document type in the Tone → Layout Quick Reference above.
|
||||
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 cover layout (1A-7B) that matches the document tone? If unsure, default to Layout 1A (Diagonal Tension). For government/bidding documents, use Layout 7A (Left-Matrix). For thesis/institutional documents, use **Template 06** from `cover.md`. Cover HTML templates follow `cover.md`'s numbering: 01, 03, 04, 06, 07.
|
||||
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; }
|
||||
.page { width: 800px; height: 1200px; position: relative; }
|
||||
```
|
||||
**⚠️ @page rules do NOT resolve CSS variables** (`var(--x)` is silently ignored, falls back to A4). Always use concrete `px` values.
|
||||
- Each `.page` div = one PDF page, with fixed `height` matching `@page` size.
|
||||
- **No `overflow: hidden`** on `.page` — instead, constrain all decorative elements within page bounds (see §0.75).
|
||||
- Content must fit within each `.page` — no cross-page flow.
|
||||
- `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 anywhere + Browser Preview Adaptive Scaling (MANDATORY)**:
|
||||
**Never** set `overflow: hidden` on `html`, `body`, `.page`, or any container. Reasons:
|
||||
- It silently clips content that slightly exceeds bounds, making layout bugs invisible.
|
||||
- `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.
|
||||
- Instead, constrain decorative elements within page bounds at the CSS level (see §0.75).
|
||||
|
||||
**`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. **Decorative Element Containment (MANDATORY — replaces overflow:hidden)**:
|
||||
**Do NOT use `overflow: hidden` on `.page` containers.** It silently clips content that slightly exceeds bounds, making layout errors invisible and debugging painful.
|
||||
|
||||
Instead, **constrain all decorative elements within page bounds at the CSS level**:
|
||||
|
||||
```css
|
||||
/* ✅ CORRECT: decorative element stays inside .page */
|
||||
.deco-circle {
|
||||
position: absolute;
|
||||
width: 80%; /* NOT >100% */
|
||||
height: 40%;
|
||||
top: 10%;
|
||||
right: 0; /* NOT negative */
|
||||
border-radius: 50%;
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
/* ❌ FORBIDDEN: causes scrollWidth > pageWidth → Playwright shrinks everything */
|
||||
.deco-circle-bad {
|
||||
position: absolute;
|
||||
width: 200%; /* inflates scrollWidth */
|
||||
left: -50%; /* extends beyond left edge */
|
||||
}
|
||||
```
|
||||
|
||||
**Iron rules for decorative elements:**
|
||||
- `width` ≤ 100% of `.page` width. No exceptions.
|
||||
- `left` ≥ 0, `right` ≥ 0. No negative offsets that push content beyond page edges.
|
||||
- For edge-bleeding effects, position the element at the edge and let `border-radius` or `opacity` create the visual bleed within bounds.
|
||||
- For horizontal flex layouts (≥3 items), always add `flex-wrap: wrap` to prevent horizontal overflow.
|
||||
|
||||
**Why not overflow:hidden?**
|
||||
Playwright’s `page.pdf()` checks `body.scrollWidth`. If any absolutely-positioned child pushes `scrollWidth` beyond `@page` width, Playwright shrinks **all content** to fit. `overflow:hidden` masks this by clipping, but also silently hides any content that slightly exceeds bounds. Constraining elements at the source eliminates both the shrink bug and the silent clipping.
|
||||
|
||||
See `typesetting/overflow.md` §3.5 for additional horizontal overflow rules.
|
||||
|
||||
1. **Character Encoding Safety**: Never use Japanese kana (の, が, は), rare symbols, or Private Use Area characters in content strings. They corrupt to U+FFFD (�) 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.
|
||||
|
||||
---
|
||||
|
||||
## Quality Checklist — Creative-Specific Items
|
||||
|
||||
> Moved from SKILL.md to reduce context size. These rules apply specifically to the Creative pipeline.
|
||||
|
||||
### Color (palette.md)
|
||||
|
||||
- [ ] **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
|
||||
|
||||
### 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
|
||||
|
||||
### Global Layout
|
||||
|
||||
- [ ] **Full-bleed enforcement**: `@page { size: <w> <h>; margin: 0; }` and `html,body { margin:0; padding:0; }`
|
||||
- [ ] **Background color consistency**: `html, body { background }` set explicitly. Single-color docs: match content canvas. Multi-page mixed docs: use darkest page's background
|
||||
- [ ] **Content centering**: Symmetric inset/padding, no left/right drift
|
||||
- [ ] **Anti-void edges**: Content fills ≥ 60% (multi-page) or ≥ 70% (single-page poster/infographic)
|
||||
- [ ] **Fill Engine applied**: Pages with < 80% fill ratio trigger the fill engine
|
||||
|
||||
### Design Restraint (Anti-Gaudy)
|
||||
|
||||
- [ ] **Decorative elements ≤ 3 per page**: Cover page exempt
|
||||
- [ ] **No rainbow/multi-color schemes**: Stick to single-family palette
|
||||
- [ ] **No texture/pattern backgrounds**: Solid or ultra-light tinted only
|
||||
- [ ] **Whitespace is design**: Don't fill every gap with decorative elements
|
||||
- [ ] **Typography over decoration**: Create hierarchy through font size/weight/spacing/color
|
||||
- [ ] **2-typeface maximum**: At most 2 font families
|
||||
- [ ] **🚫 NO stock images / clipart / AI-generated decorations**: Only user-provided content images allowed
|
||||
Executable
+255
@@ -0,0 +1,255 @@
|
||||
# Brief: Creative Flow (Designed Long Documents)
|
||||
|
||||
**When to use this brief:** Text-heavy documents that need design flair but content flows naturally across pages — guides, handbooks, catalogs, introductions, collections, illustrated manuals. The content is the star; design supports readability.
|
||||
|
||||
**How it differs from `creative.md`:** `creative.md` (Blueprint Mode) treats each page as a self-contained fixed-size visual canvas. This brief lets content flow continuously, with Chromium's `@page` auto-paginating at page boundaries.
|
||||
|
||||
**→ MUST READ: `typesetting/overflow.md`** for Playwright/HTML-specific overflow patterns.
|
||||
|
||||
---
|
||||
|
||||
## Core Principle: Content Flows, Design Wraps
|
||||
|
||||
You write raw HTML/CSS directly (no JSON Blueprint, no `design_engine.py`). The document is one continuous HTML body, and the browser's print engine splits it into pages automatically.
|
||||
|
||||
**⚠️ Iron rule: All HTML→PDF conversions MUST use `html2pdf-next.js` — do NOT write custom Python Playwright scripts.** It automatically handles @page injection, overflow detection, font waiting, Mermaid/KaTeX rendering, PDF metadata, etc.
|
||||
|
||||
```bash
|
||||
node "$PDF_SKILL_DIR/scripts/html2pdf-next.js" input.html --output output.pdf --width 720px --height 1020px
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pagination Model
|
||||
|
||||
### The Iron Rules
|
||||
|
||||
1. **NO fixed height on content containers.** Let content flow naturally. The browser decides where to cut pages.
|
||||
2. **NO `.page` divs for content sections.** Use a single `<div class="main-content">` with padding for all body content.
|
||||
3. **NO `break-before: page` between content sections.** This is the #1 cause of blank space — when the previous section doesn't fill a page, the remainder becomes a void. Use `margin-top` for visual chapter separation instead.
|
||||
4. **`break-inside: avoid`** on individual cards, items, and guide entries to prevent them from being split across pages.
|
||||
5. **`break-after: avoid`** on chapter headers (tag + title + divider group) to keep them attached to the first content block below.
|
||||
6. **Fixed-height containers ONLY for cover and ending pages** that must occupy a full page:
|
||||
|
||||
```css
|
||||
/* Cover: fixed height, standalone page */
|
||||
.cover {
|
||||
width: 720px;
|
||||
height: 1020px;
|
||||
box-sizing: border-box;
|
||||
break-after: page;
|
||||
overflow: hidden; /* OK here — clips decorative elements on cover only */
|
||||
/* ⚠️ Use justify-content: center (NOT flex-end) to avoid top-heavy blank */
|
||||
/* Add decorative elements (circles, lines) + bottom edition label for visual balance */
|
||||
}
|
||||
|
||||
/* Ending: fixed height, standalone page */
|
||||
.ending {
|
||||
width: 720px;
|
||||
height: 1020px;
|
||||
box-sizing: border-box;
|
||||
break-before: page;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Content: flows naturally, NO fixed height */
|
||||
.main-content {
|
||||
padding: 60px 65px 40px 65px;
|
||||
/* NO height property */
|
||||
/* NO overflow: hidden */
|
||||
}
|
||||
```
|
||||
|
||||
### What NOT to Do (Common Mistakes)
|
||||
|
||||
| Mistake | Why it breaks | Fix |
|
||||
|---------|--------------|-----|
|
||||
| `.content { height: 1020px; }` | Content that exceeds the height gets clipped or overflows | Remove fixed height |
|
||||
| Multiple `<div class="content">` each with `padding: 60px` | Double padding (60+60=120px) between sections creates voids | One container, chapters separated by `margin-top` |
|
||||
| `break-before: page` on chapter headers | Previous chapter doesn't fill the page → blank space before new chapter | Use `margin-top: 36px` instead |
|
||||
| `overflow: hidden` on content container | Clips content at page boundaries | Only use on fixed-height cover/ending |
|
||||
| `justify-content: flex-end` on cover | Title sinks to bottom, 60%+ blank above | Use `center` + decorative anchors for balance |
|
||||
| Chapter with < 3 paragraphs of content | Leaves half a page blank before next chapter | Write enough content to fill at least 50% of a page |
|
||||
| `margin-top: 50px+` on chapters | Compounds with `break-inside: avoid`, creates large voids | Keep chapter `margin-top` ≤ 30px |
|
||||
|
||||
---
|
||||
|
||||
## CSS Template
|
||||
|
||||
```css
|
||||
@page {
|
||||
size: 720px 1020px; /* Concrete px values — CSS variables NOT supported in @page */
|
||||
margin: 0;
|
||||
}
|
||||
:root {
|
||||
--c-bg: #0d0d0d;
|
||||
--c-text: #e8e6e3;
|
||||
--c-accent: #ff4757;
|
||||
}
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 720px;
|
||||
background: var(--c-bg);
|
||||
color: var(--c-text);
|
||||
font-family: 'Noto Sans SC', sans-serif;
|
||||
}
|
||||
@media screen {
|
||||
html {
|
||||
height: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background: #222;
|
||||
}
|
||||
body {
|
||||
transform-origin: top center;
|
||||
margin: 20px auto;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**⚠️ @page rules do NOT resolve CSS variables** (`var(--x)` is silently ignored, falls back to A4). Always use concrete `px` values in `@page`.
|
||||
|
||||
---
|
||||
|
||||
## HTML Structure Template
|
||||
|
||||
```html
|
||||
<body>
|
||||
|
||||
<!-- Cover: fixed height, occupies one full page -->
|
||||
<div class="cover">
|
||||
<div class="tag">CATEGORY</div>
|
||||
<div class="title">Document Title</div>
|
||||
<div class="subtitle">Description text</div>
|
||||
</div>
|
||||
|
||||
<!-- All body content in ONE continuous container -->
|
||||
<div class="main-content">
|
||||
|
||||
<!-- Chapter 1 -->
|
||||
<div class="section-tag">Chapter 1</div>
|
||||
<div class="section-title">Title</div>
|
||||
<div class="divider"></div>
|
||||
<div class="body-text">Paragraph content...</div>
|
||||
<div class="body-text">More paragraphs...</div>
|
||||
|
||||
<!-- Chapter 2: separated by margin-top, NOT break-before -->
|
||||
<div class="chapter-header">
|
||||
<div class="section-tag">Chapter 2</div>
|
||||
<div class="section-title">Title</div>
|
||||
<div class="divider"></div>
|
||||
</div>
|
||||
|
||||
<!-- Cards / items with break-inside: avoid -->
|
||||
<div class="card">Card content...</div>
|
||||
<div class="card">Card content...</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Ending: fixed height, occupies one full page -->
|
||||
<div class="ending">
|
||||
<div class="big-text">Closing text</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
```
|
||||
|
||||
Key CSS for chapter headers and cards:
|
||||
```css
|
||||
.chapter-header {
|
||||
break-after: avoid; /* Keep header with first content block */
|
||||
break-inside: avoid;
|
||||
margin-top: 28px; /* ≤ 30px! Larger values compound with break-inside: avoid → blank pages */
|
||||
}
|
||||
.card {
|
||||
break-inside: avoid; /* Don't split a single card across pages */
|
||||
margin-bottom: 14px; /* ≤ 16px! Every px of margin is lost space if card is pushed to next page */
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Body Background Rule
|
||||
|
||||
Set `html, body { background }` to the document's dominant/darkest color. This prevents sub-pixel gap artifacts between content and page boundaries.
|
||||
|
||||
- All pages same dark color → `body { background: <that color> }`
|
||||
- Mixed dark cover + light body → `body { background: <darkest color> }` (dark body is invisible under white content pages)
|
||||
- All pages white/light → `body { background: <lightest content bg> }` (e.g. `#f8fafc`)
|
||||
|
||||
---
|
||||
|
||||
## Shared Rules with creative.md
|
||||
|
||||
The following rules apply identically to both Creative Flow and Creative Blueprint pipelines:
|
||||
|
||||
### Character Encoding Safety
|
||||
Never use Japanese kana (の, が, は), rare symbols, or Private Use Area characters. They corrupt to U+FFFD (�) during LLM→file write→read transit. Replace with plain Chinese equivalents: `の`→`之/的/缔`.
|
||||
|
||||
### Vertical Chinese Text
|
||||
When using `writing-mode: vertical-rl`:
|
||||
```css
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: upright;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 12px;
|
||||
```
|
||||
|
||||
### Font Coverage
|
||||
For CJK content, always load Google Fonts via `<link>` tag in `<head>` (NOT `@import`):
|
||||
```html
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;700;900&family=Noto+Serif+SC:wght@400;700;900&display=swap" rel="stylesheet">
|
||||
```
|
||||
|
||||
### Post-Generation Text Verification
|
||||
After Playwright renders the PDF, extract text and scan for `?` or `\ufffd`. If found, fix encoding-corrupted characters in the source HTML.
|
||||
|
||||
### HTML Pre-Render Validation (MANDATORY)
|
||||
Before calling `html2pdf-next.js`, run the validator:
|
||||
```bash
|
||||
python3 "$PDF_SKILL_DIR/scripts/poster_validate.py" check-html <file>.html
|
||||
```
|
||||
- **ERROR** → must fix before PDF generation. Use `--fix --output <file>.html` for auto-repair.
|
||||
- **WARNING** → review and fix where appropriate.
|
||||
|
||||
---
|
||||
|
||||
## Quality Checklist — Creative Flow
|
||||
|
||||
### Layout & Pagination
|
||||
- [ ] **No fixed height on content containers** (only cover/ending pages)
|
||||
- [ ] **No `break-before: page` between content sections**
|
||||
- [ ] **`break-inside: avoid`** on all cards/items/guide entries
|
||||
- [ ] **Chapter headers have `break-after: avoid`** to stay with first content block
|
||||
- [ ] **Single `<div class="main-content">`** wraps all body content
|
||||
- [ ] **No double padding** between sections (one container, not multiple)
|
||||
|
||||
### Full-Bleed & Background
|
||||
- [ ] **`@page { margin: 0 }`** with concrete px size values
|
||||
- [ ] **`html, body { margin: 0; padding: 0 }`**
|
||||
- [ ] **Body background matches dominant page color**
|
||||
- [ ] **No white edge artifacts** on dark pages
|
||||
|
||||
### Cover Page Design
|
||||
- [ ] **Cover `justify-content: center`** (not `flex-end`) — `flex-end` pushes content to the bottom and leaves 60%+ blank at top, making the page feel empty
|
||||
- [ ] **Add visual anchors** to fill the cover: decorative elements (circles, lines), edition/date label at bottom, badge at top. A cover with only title+subtitle will look hollow.
|
||||
- [ ] **Cover padding ≤ 70px** — excessive padding shrinks the visual footprint further
|
||||
|
||||
### Content Density (Anti-Blank-Page)
|
||||
- [ ] **Each chapter must have enough content to fill at least 50% of a page.** If a chapter (text + quote + cards) occupies < 50% of a page, it will leave a large blank area before the next chapter. Solution: expand the content (add 1-2 more paragraphs, examples, or context).
|
||||
- [ ] **Chapter `margin-top` ≤ 30px.** Larger values (50px+) compound with `break-inside: avoid` on cards, pushing content to the next page and leaving voids on the current page.
|
||||
- [ ] **Card/callout `margin` ≤ 16px.** Every pixel of vertical margin inside a `break-inside: avoid` block is space that cannot be reclaimed if the block is pushed to the next page.
|
||||
- [ ] **After generating PDF, visually check every page** for >30% trailing blank space. If found, either: (a) add more content to that chapter, (b) reduce margins/padding, or (c) reorder elements so a smaller block fills the gap.
|
||||
|
||||
### Design Quality
|
||||
- [ ] **Entire document ≤ 5 colors**: Primary + secondary + accent + neutral + background
|
||||
- [ ] **2-typeface maximum**: At most 2 font families
|
||||
- [ ] **Decorative elements ≤ 3 per page** (cover exempt)
|
||||
- [ ] **No stock images / clipart / AI-generated decorations** unless user-provided
|
||||
|
||||
### Output
|
||||
- [ ] **Vector PDF** via `html2pdf-next.js` (not screenshot)
|
||||
- [ ] **HTML source file** delivered alongside PDF
|
||||
- [ ] **PDF metadata** set via `pdf.py meta.brand`
|
||||
- [ ] **QA passed** via `pdf_qa.py --no-tables`
|
||||
Executable
+810
@@ -0,0 +1,810 @@
|
||||
# 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.
|
||||
|
||||
**→ MUST READ: `typesetting/overflow.md`** for Playwright/HTML-specific overflow 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
|
||||
|
||||
**→ MUST READ: `typesetting/charts.md`** 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` 5-template system** - pick Layout 01/03/04/06/07 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 (5 Layout System)
|
||||
|
||||
Cover pages (`archetype: "cover_hero"`) are the first impression. They must be ruthlessly sparse and spatially sophisticated.
|
||||
|
||||
**→ MUST READ: `typesetting/cover.md`** 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 | 12 Crystal Blue, 01 HUD | 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) | 12 Crystal Blue, 09 Academic Symmetric | B (工程十字轴) | 30° (amber) |
|
||||
| **Authority** | minimal | split_complementary | noise | 09 Academic Symmetric, 07 Sidebar, 12 Crystal Blue | A or B | 280° (muted violet) |
|
||||
| **Warmth** | pastel / light | analogous | flow (soft) | 12 Crystal Blue, 01 HUD | 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 (�) 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.
|
||||
|
||||
---
|
||||
|
||||
## Quality Checklist — Creative-Specific Items
|
||||
|
||||
> Moved from SKILL.md to reduce context size. These rules apply specifically to the Creative pipeline.
|
||||
|
||||
### Color (palette.md)
|
||||
|
||||
- [ ] **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
|
||||
|
||||
### 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
|
||||
|
||||
### Global Layout
|
||||
|
||||
- [ ] **Full-bleed enforcement**: `@page { size: <w> <h>; margin: 0; }` and `html,body { margin:0; padding:0; }`
|
||||
- [ ] **Background color consistency**: `html, body { background }` set explicitly. Single-color docs: match content canvas. Multi-page mixed docs: use darkest page's background
|
||||
- [ ] **Content centering**: Symmetric inset/padding, no left/right drift
|
||||
- [ ] **Anti-void edges**: Content fills ≥ 60% (multi-page) or ≥ 70% (single-page poster/infographic)
|
||||
- [ ] **Fill Engine applied**: Pages with < 80% fill ratio trigger the fill engine
|
||||
|
||||
### Design Restraint (Anti-Gaudy)
|
||||
|
||||
- [ ] **Decorative elements ≤ 3 per page**: Cover page exempt
|
||||
- [ ] **No rainbow/multi-color schemes**: Stick to single-family palette
|
||||
- [ ] **No texture/pattern backgrounds**: Solid or ultra-light tinted only
|
||||
- [ ] **Whitespace is design**: Don't fill every gap with decorative elements
|
||||
- [ ] **Typography over decoration**: Create hierarchy through font size/weight/spacing/color
|
||||
- [ ] **2-typeface maximum**: At most 2 font families
|
||||
- [ ] **🚫 NO stock images / clipart / AI-generated decorations**: Only user-provided content images allowed
|
||||
Executable
+702
@@ -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-fixed-canvas.md`.
|
||||
> These rules take priority over `creative-fixed-canvas.md` generic rules; in case of conflict, this file prevails.
|
||||
>
|
||||
> **Positioning**: This is a scene-layer patch on `creative-fixed-canvas.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 |
|
||||
| 50–200 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 | 70–80% | Enlarged cards, large font sizes, generous decorative whitespace is intentional |
|
||||
| 50–200 chars | 75–85% | Content modules distributed evenly, must not be crammed in the top half |
|
||||
| > 200 chars | 80–90% | 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** | 56–72px | Poster title must have visual impact |
|
||||
| **Body text** | **24px** | 24–28px | Posters are not reports — body font must be large |
|
||||
| **Subtitle / card title** | **28px** | 32–40px | Secondary headings |
|
||||
| **Floating Meta** | **16px** | 16–20px | Metadata text |
|
||||
| **Stat Number** | **48px** | 56–72px | Data sculptures must be eye-catching |
|
||||
| **Stat Label** | **14px** | 14–16px | 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 won’t 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 can’t 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.30–0.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 | Noto Sans SC | `Noto Sans SC:wght@400` | 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)
|
||||
```
|
||||
Executable
+433
@@ -0,0 +1,433 @@
|
||||
# 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
|
||||
│ └─ No text extracted? → §OCR Fallback
|
||||
├─ "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
|
||||
├─ "Decrypt/repair a PDF" → §Encrypted / §Corrupted
|
||||
├─ "Optimize file size" → §Optimize
|
||||
└─ "Batch process many PDFs" → §Batch Processing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Check
|
||||
|
||||
```bash
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" env.check
|
||||
```
|
||||
|
||||
Reports availability but does **not** auto-install.
|
||||
|
||||
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
|
||||
|
||||
### Basic Extraction (pdf.py)
|
||||
|
||||
```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
|
||||
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/
|
||||
```
|
||||
|
||||
### Precise Coordinates (pdfplumber)
|
||||
|
||||
```python
|
||||
import pdfplumber
|
||||
|
||||
with pdfplumber.open("document.pdf") as pdf:
|
||||
page = pdf.pages[0]
|
||||
|
||||
# Characters with exact coordinates
|
||||
for char in page.chars[:10]:
|
||||
print(f"'{char['text']}' at x:{char['x0']:.1f} y:{char['y0']:.1f}")
|
||||
|
||||
# Text within a bounding box (left, top, right, bottom)
|
||||
bbox_text = page.within_bbox((100, 100, 400, 200)).extract_text()
|
||||
|
||||
# Advanced table extraction with custom settings
|
||||
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")
|
||||
```
|
||||
|
||||
### Fast Rendering (pypdfium2)
|
||||
|
||||
pypdfium2 is a Python binding for PDFium (Chromium's PDF library) — fast rendering and text extraction.
|
||||
|
||||
```python
|
||||
import pypdfium2 as pdfium
|
||||
|
||||
pdf = pdfium.PdfDocument("document.pdf")
|
||||
|
||||
# Render single page
|
||||
bitmap = pdf[0].render(scale=2.0)
|
||||
bitmap.to_pil().save("page_1.png", "PNG")
|
||||
|
||||
# Batch render all pages
|
||||
for i, page in enumerate(pdf):
|
||||
bitmap = page.render(scale=1.5)
|
||||
bitmap.to_pil().save(f"page_{i+1}.jpg", "JPEG", quality=90)
|
||||
|
||||
# Extract text
|
||||
for i, page in enumerate(pdf):
|
||||
print(f"Page {i+1}: {len(page.get_text())} chars")
|
||||
```
|
||||
|
||||
### CLI Rendering & Extraction (poppler-utils)
|
||||
|
||||
```bash
|
||||
# High-resolution PNG rendering
|
||||
pdftoppm -png -r 300 document.pdf output_prefix
|
||||
pdftoppm -png -r 600 -f 1 -l 3 document.pdf high_res # specific pages
|
||||
|
||||
# Text with bounding box coordinates
|
||||
pdftotext -bbox-layout document.pdf output.xml
|
||||
|
||||
# Extract embedded images (faster than full-page rendering)
|
||||
pdfimages -all document.pdf images/img
|
||||
pdfimages -list document.pdf
|
||||
```
|
||||
|
||||
### §OCR Fallback (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)
|
||||
return "".join(pytesseract.image_to_string(img) for img in images)
|
||||
```
|
||||
|
||||
Prerequisites: `pip install pdf2image pytesseract` + install Tesseract OCR and poppler.
|
||||
|
||||
---
|
||||
|
||||
## §Pages
|
||||
|
||||
### Basic Operations (pdf.py)
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
### Complex Page Manipulation (qpdf)
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §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`.
|
||||
|
||||
---
|
||||
|
||||
## §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
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" form.fill input.pdf -o filled.pdf \
|
||||
-d '{"name": "John", "agree": "true", "country": "US"}'
|
||||
```
|
||||
|
||||
| Type | Value | Example |
|
||||
|------|-------|---------|
|
||||
| text | Free string | `"name": "Jane Doe"` |
|
||||
| checkbox | `"true"` / `"false"` | `"agree": "true"` |
|
||||
| radio | One of `radio_options[].value` | `"gender": "/Choice1"` |
|
||||
| dropdown | One of `choice_options[].value` | `"country": "US"` |
|
||||
|
||||
For complex forms: `form.detail input.pdf -o fields.json` (full field info) and `form.render input.pdf -o ./pages/` (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**:
|
||||
```bash
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" form.render input.pdf -o ./pages/
|
||||
```
|
||||
|
||||
**Step 2 — Create `fields.json`** with annotation regions. Determine bbox coordinates by inspecting the rendered PNG (use PIL to get pixel dimensions, then estimate [left, top, right, bottom] for each field). `dims` must match 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"}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3 — Validate**:
|
||||
```bash
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" form.check-bbox fields.json
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" form.validate 1 fields.json page1.png validation.png
|
||||
```
|
||||
|
||||
**Step 4 — Fill**:
|
||||
```bash
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" form.annotate input.pdf fields.json -o filled.pdf
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §Encrypted
|
||||
|
||||
```bash
|
||||
# Check encryption status
|
||||
qpdf --show-encryption encrypted.pdf
|
||||
|
||||
# Decrypt with password
|
||||
qpdf --password=secret123 --decrypt encrypted.pdf decrypted.pdf
|
||||
|
||||
# Encrypt a PDF
|
||||
qpdf --encrypt user_pass owner_pass 256 --print=none --modify=none -- input.pdf encrypted.pdf
|
||||
```
|
||||
|
||||
Python alternative:
|
||||
```python
|
||||
from pypdf import PdfReader
|
||||
reader = PdfReader("encrypted.pdf")
|
||||
if reader.is_encrypted:
|
||||
reader.decrypt("password")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §Corrupted
|
||||
|
||||
```bash
|
||||
qpdf --check corrupted.pdf
|
||||
qpdf --replace-input corrupted.pdf # in-place repair
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §Optimize
|
||||
|
||||
```bash
|
||||
qpdf --linearize input.pdf optimized.pdf # web-optimized (fast first-page load)
|
||||
qpdf --optimize-level=all input.pdf compressed.pdf # maximum compression
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §Reformat
|
||||
|
||||
Take an existing document and rebuild it with a new visual design. Content is preserved; layout is rebuilt.
|
||||
|
||||
```
|
||||
1. EXTRACT → Extract content from source
|
||||
2. STRUCTURE → Organize into sections (headings, body, tables, lists)
|
||||
3. DELEGATE → Route to appropriate brief:
|
||||
Structured → briefs/report.md (ReportLab)
|
||||
Visual → briefs/creative-fixed-canvas.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:
|
||||
- meta.get (page size), extract.image (color samples),
|
||||
extract.text (structure), pdftoppm (visual reference)
|
||||
2. DOCUMENT → Record: page size, margins, colors, fonts, layout, header/footer
|
||||
3. DELEGATE → Route to brief WITH design constraints (not 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. Prefer source files over PDF when available. Declare font substitutions upfront.
|
||||
|
||||
---
|
||||
|
||||
## §Convert
|
||||
|
||||
### Office → PDF (LibreOffice)
|
||||
|
||||
```bash
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" convert.office input.docx -o output.pdf
|
||||
# Or directly:
|
||||
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; use `--env:UserInstallation=file:///tmp/libreoffice_tmp` if needed
|
||||
- Missing Chinese fonts → squares. Ensure Noto Sans SC/Noto Serif SC are installed
|
||||
- Large files (>50MB) may take 1-2 min; set reasonable timeout
|
||||
|
||||
**Priority**: Always prefer soffice for Office→PDF. Fall back to python-pptx/python-docx + HTML + Playwright only if soffice is unavailable.
|
||||
|
||||
### Spreadsheet → PDF (without LibreOffice)
|
||||
|
||||
Use openpyxl + HTML + Playwright. Columns ≤ 6 → portrait; > 6 → landscape. Scale font inversely with column count.
|
||||
|
||||
### §DOCX Pipeline (5-Step with TOC)
|
||||
|
||||
For DOCX files that need TOC generation/correction (LibreOffice `--headless` does not recalculate PAGEREF fields).
|
||||
|
||||
**When to use**: DOCX has 3+ headings, or mentions "table of contents" / "TOC", or already contains a TOC section. When in doubt, run `toc_validate.py fix-docx` — if it returns `no_toc_needed`, simple conversion is fine.
|
||||
|
||||
```bash
|
||||
# Step 1: Convert to PDF (pass1)
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" convert.office input.docx -o pass1.pdf
|
||||
|
||||
# Step 2: Clean blank pages
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" pages.clean pass1.pdf -o pass1_clean.pdf
|
||||
|
||||
# Step 3: Fix TOC (adds HYPERLINK + PAGEREF + bookmarks)
|
||||
python3 "$PDF_SKILL_DIR/scripts/toc_validate.py" fix-docx input.docx -o fixed.docx
|
||||
|
||||
# Step 4: Correct TOC page numbers using pass1 as reference
|
||||
python3 "$PDF_SKILL_DIR/scripts/toc_validate.py" fix-pages fixed.docx pass1_clean.pdf -o final.docx
|
||||
|
||||
# Step 5: Final convert + clean
|
||||
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
|
||||
```
|
||||
|
||||
Check `fix-docx` output `action`: `fixed` → use fixed.docx, `skipped` → use original, `no_toc_needed` → skip to Step 5.
|
||||
|
||||
Optional post-validation: `toc_validate.py check-conversion final.docx output_clean.pdf`
|
||||
|
||||
---
|
||||
|
||||
## §Batch Processing
|
||||
|
||||
```python
|
||||
import os, glob, logging
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def batch_process(input_dir, operation='merge'):
|
||||
pdf_files = sorted(glob.glob(os.path.join(input_dir, "*.pdf")))
|
||||
if operation == 'merge':
|
||||
writer = PdfWriter()
|
||||
for f in pdf_files:
|
||||
try:
|
||||
for page in PdfReader(f).pages:
|
||||
writer.add_page(page)
|
||||
logger.info(f"Processed: {f}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed: {f}: {e}")
|
||||
with open("merged.pdf", "wb") as out:
|
||||
writer.write(out)
|
||||
elif operation == 'extract_text':
|
||||
for f in pdf_files:
|
||||
try:
|
||||
text = "".join(p.extract_text() for p in PdfReader(f).pages)
|
||||
with open(f.replace('.pdf', '.txt'), 'w') as out:
|
||||
out.write(text)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed: {f}: {e}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Caveats & Performance
|
||||
|
||||
| Topic | Detail |
|
||||
|-------|--------|
|
||||
| < 50 MB | Instant |
|
||||
| 50–200 MB | 1–2 minutes |
|
||||
| > 200 MB | Split first, or extend timeout |
|
||||
| Memory | ~2-3× input file size |
|
||||
| Text extraction speed | `pdftotext -bbox-layout` fastest; pdfplumber for structured data |
|
||||
| Image extraction | `pdfimages` faster than full-page rendering |
|
||||
| Large PDFs | Process in chunks to manage memory |
|
||||
|
||||
## 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 |
|
||||
Executable
+1703
File diff suppressed because it is too large.
Load diff
Executable
+227
@@ -0,0 +1,227 @@
|
||||
## Resume / CV Template (ATS-Friendly)
|
||||
|
||||
Single-column, clean layout optimised for Applicant Tracking Systems (ATS). No graphics, no sidebars, no colour blocks - just well-structured text that parses perfectly by HR software.
|
||||
|
||||
**When to use this template:**
|
||||
- Applying to corporate jobs through online portals
|
||||
- Any scenario where the PDF will be machine-parsed before a human reads it
|
||||
- When the recruiter explicitly asks for "a standard resume"
|
||||
|
||||
**When NOT to use (use Academic brief instead):**
|
||||
- Creative/design industry positions → Academic brief (AltaCV-style)
|
||||
- Academic CV with publications → Academic brief (Academic CV)
|
||||
|
||||
### Resume Design Rules
|
||||
- **Target 1 page** unless user specifies otherwise
|
||||
- **Margins**: `left=1.5cm, right=1.5cm, top=1.5cm, bottom=1.5cm`
|
||||
- **No cover page** - content starts immediately
|
||||
- **No TOC** - too short for table of contents
|
||||
- **Font**: FreeSerif (English) or Noto Sans SC (Chinese)
|
||||
- **Body font size**: 10-10.5pt; Name: 22-26pt; Section titles: 13-14pt
|
||||
- **⚠️ Minimum font size: 12px (9pt) - HARD FLOOR.** No text in the entire resume may render smaller than 12px. This includes contact info, meta text, footnotes, and captions. Anything below 12px is unreadable in print and fails accessibility checks.
|
||||
- **Section separator**: thin horizontal rule (`HRFlowable`)
|
||||
- **Bullet style**: `•` with tight spacing
|
||||
|
||||
### Resume Line-Break Rules (Language-Aware)
|
||||
- **English**: Prefer breaking at word boundaries (spaces, hyphens). If a long word must be split to avoid excessive whitespace, break at a valid syllable boundary and insert a hyphen (`-`) - this is standard typographic practice (e.g., `experi-\nence`, `develop-\nment`). ReportLab supports `wordWrap='CJK'` only for CJK content; for English use default paragraph wrapping with `allowWidows=0, allowOrphans=0`.
|
||||
- **Chinese/CJK**: Break allowed between any two CJK characters. Never break between a CJK character and its adjacent punctuation (、。,)》 etc. must stay with the preceding character.
|
||||
- **Mixed content** (e.g., "Python 开发工程师"): Break at CJK boundaries or English word boundaries. Never split an English word in a CJK paragraph unless hyphenated.
|
||||
- **Contact line**: Email, phone, location separated by `|` or `·`. Each segment must stay on one line - if too long, move to next line at the separator, not mid-segment.
|
||||
- **Dates and ranges**: "Jan 2022 - Present" must stay as one unit. Never break a date range across lines.
|
||||
|
||||
### Resume Page-Fill Rules (Anti-Blank-Space)
|
||||
- **Goal: Fill ≥85% of the page height.** Content should reach at least the bottom quarter of the page. A resume that stops at 60% height with blank space below = FAIL.
|
||||
- **Adaptive spacing strategy** (apply in order until page is ≥85% filled):
|
||||
1. Increase `spaceBefore` / `spaceAfter` on section headers (from 10pt up to 18pt)
|
||||
2. Increase `leading` (line height) on body text (from 14pt up to 18pt)
|
||||
3. Increase body `fontSize` by 0.5-1pt (from 10pt up to 11.5pt max)
|
||||
4. Add a "Professional Summary" or "Key Achievements" section if none exists
|
||||
5. Increase margins slightly (from 1.5cm up to 2cm) to reduce line width and push content downward
|
||||
- **Never leave a visible blank area > 3cm at the bottom of the page.**
|
||||
- **If content overflows to page 2**: do the reverse - reduce spacing, tighten leading (min 12pt), reduce fontSize (min 9pt / 12px), before removing content.
|
||||
|
||||
### Complete Resume Template
|
||||
|
||||
```python
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.units import cm, mm
|
||||
from reportlab.lib.styles import ParagraphStyle
|
||||
from reportlab.lib.enums import TA_LEFT, TA_CENTER
|
||||
from reportlab.lib import colors
|
||||
from reportlab.platypus import (
|
||||
SimpleDocTemplate, Paragraph, Spacer, HRFlowable, Table, TableStyle
|
||||
)
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
from reportlab.pdfbase.pdfmetrics import registerFontFamily
|
||||
|
||||
# ── Font Registration ──
|
||||
pdfmetrics.registerFont(TTFont('FreeSerif', '/usr/share/fonts/truetype/freefont/FreeSerif.ttf'))
|
||||
pdfmetrics.registerFont(TTFont('FreeSerif-Bold', '/usr/share/fonts/truetype/freefont/FreeSerifBold.ttf'))
|
||||
pdfmetrics.registerFont(TTFont('FreeSerif-Italic', '/usr/share/fonts/truetype/freefont/FreeSerifItalic.ttf'))
|
||||
pdfmetrics.registerFont(TTFont('FreeSerif-BoldItalic', '/usr/share/fonts/truetype/freefont/FreeSerifBoldItalic.ttf'))
|
||||
registerFontFamily('FreeSerif', normal='FreeSerif', bold='FreeSerif-Bold', italic='FreeSerif-Italic', boldItalic='FreeSerif-BoldItalic')
|
||||
# For Chinese resumes, also register Noto Sans SC:
|
||||
# pdfmetrics.registerFont(TTFont('Noto Sans SC', '/usr/share/fonts/truetype/chinese/NotoSansSC-Regular.ttf'))
|
||||
# pdfmetrics.registerFont(TTFont('Noto Sans SC Bold', '/usr/share/fonts/truetype/chinese/NotoSansSC-Bold.ttf'))
|
||||
# registerFontFamily('Noto Sans SC', normal='Noto Sans SC', bold='Noto Sans SC Bold')
|
||||
|
||||
# ── Styles ──
|
||||
# ACCENT must come from palette.generate (Step 2)
|
||||
# Run: python3 "$PDF_SKILL_DIR/scripts/pdf.py" palette.generate --title "Resume" --mode minimal
|
||||
ACCENT = colors.HexColor('<accent from palette>') # Replace with palette output
|
||||
|
||||
name_style = ParagraphStyle(
|
||||
'ResumeName', fontName='FreeSerif', fontSize=24,
|
||||
leading=28, alignment=TA_CENTER, spaceAfter=2
|
||||
)
|
||||
contact_style = ParagraphStyle(
|
||||
'ResumeContact', fontName='FreeSerif', fontSize=10,
|
||||
leading=14, alignment=TA_CENTER, textColor=TEXT_MUTED, # From palette --c-muted
|
||||
spaceAfter=8
|
||||
)
|
||||
section_title_style = ParagraphStyle(
|
||||
'ResumeSectionTitle', fontName='FreeSerif', fontSize=13,
|
||||
leading=16, spaceBefore=10, spaceAfter=4,
|
||||
textColor=ACCENT
|
||||
)
|
||||
job_title_style = ParagraphStyle(
|
||||
'ResumeJobTitle', fontName='FreeSerif', fontSize=11,
|
||||
leading=14, spaceAfter=1
|
||||
)
|
||||
job_meta_style = ParagraphStyle(
|
||||
'ResumeJobMeta', fontName='FreeSerif', fontSize=10,
|
||||
leading=13, textColor=TEXT_MUTED, spaceAfter=4 # From palette --c-muted
|
||||
)
|
||||
bullet_style = ParagraphStyle(
|
||||
'ResumeBullet', fontName='FreeSerif', fontSize=10,
|
||||
leading=14, leftIndent=14, bulletIndent=0,
|
||||
spaceBefore=1, spaceAfter=1
|
||||
)
|
||||
body_style = ParagraphStyle(
|
||||
'ResumeBody', fontName='FreeSerif', fontSize=10,
|
||||
leading=14, spaceAfter=2
|
||||
)
|
||||
|
||||
# ── Helpers ──
|
||||
def section_header(title):
|
||||
"""Section title + thin rule separator."""
|
||||
return [
|
||||
Paragraph(f'<b>{title}</b>', section_title_style),
|
||||
HRFlowable(width='100%', thickness=0.8, color=ACCENT,
|
||||
spaceBefore=0, spaceAfter=6),
|
||||
]
|
||||
|
||||
def experience_entry(title, company, dates, location, bullets):
|
||||
"""One work experience block."""
|
||||
elements = [
|
||||
Paragraph(f'<b>{title}</b>', job_title_style),
|
||||
Paragraph(f'{company} | {dates} | {location}', job_meta_style),
|
||||
]
|
||||
for b in bullets:
|
||||
elements.append(Paragraph(f'• {b}', bullet_style))
|
||||
elements.append(Spacer(1, 4))
|
||||
return elements
|
||||
|
||||
def education_entry(degree, school, dates, details=None):
|
||||
"""One education block."""
|
||||
elements = [
|
||||
Paragraph(f'<b>{degree}</b>', job_title_style),
|
||||
Paragraph(f'{school} | {dates}', job_meta_style),
|
||||
]
|
||||
if details:
|
||||
elements.append(Paragraph(details, body_style))
|
||||
elements.append(Spacer(1, 4))
|
||||
return elements
|
||||
|
||||
def skills_row(categories):
|
||||
"""
|
||||
Skills as compact label: value pairs.
|
||||
categories = [('Programming', 'Python, Java, C++'), ('Tools', 'Git, Docker')]
|
||||
"""
|
||||
elements = []
|
||||
for cat, vals in categories:
|
||||
elements.append(Paragraph(f'<b>{cat}:</b> {vals}', body_style))
|
||||
return elements
|
||||
|
||||
# ── Build Document ──
|
||||
doc = SimpleDocTemplate(
|
||||
'resume.pdf', pagesize=A4,
|
||||
leftMargin=1.5*cm, rightMargin=1.5*cm,
|
||||
topMargin=1.5*cm, bottomMargin=1.5*cm,
|
||||
title='Resume - Your Name',
|
||||
author='Z.ai', creator='Z.ai'
|
||||
)
|
||||
|
||||
story = []
|
||||
|
||||
# Header
|
||||
story.append(Paragraph('<b>YOUR NAME</b>', name_style))
|
||||
story.append(Paragraph(
|
||||
'email@example.com | +86 138-0000-0000 | Shanghai, China | github.com/yourname',
|
||||
contact_style
|
||||
))
|
||||
|
||||
# Summary
|
||||
story.extend(section_header('PROFESSIONAL SUMMARY'))
|
||||
story.append(Paragraph(
|
||||
'Results-driven software engineer with 5+ years of experience in backend systems, '
|
||||
'distributed computing, and cloud-native architectures. Led a team of 8 engineers '
|
||||
'delivering a real-time data pipeline processing 2M+ events/sec.',
|
||||
body_style
|
||||
))
|
||||
|
||||
# Experience
|
||||
story.extend(section_header('WORK EXPERIENCE'))
|
||||
story.extend(experience_entry(
|
||||
'Senior Software Engineer', 'Tech Company Inc.', 'Jan 2022 - Present', 'Shanghai',
|
||||
[
|
||||
'Designed and deployed a microservices architecture serving 10M daily active users',
|
||||
'Reduced API latency by 40% through query optimisation and caching strategies',
|
||||
'Mentored 3 junior engineers; established code review standards adopted team-wide',
|
||||
]
|
||||
))
|
||||
story.extend(experience_entry(
|
||||
'Software Engineer', 'Startup Co.', 'Jul 2019 - Dec 2021', 'Beijing',
|
||||
[
|
||||
'Built real-time recommendation engine using collaborative filtering (CTR +25%)',
|
||||
'Implemented CI/CD pipeline reducing deployment time from 2 hours to 15 minutes',
|
||||
]
|
||||
))
|
||||
|
||||
# Education
|
||||
story.extend(section_header('EDUCATION'))
|
||||
story.extend(education_entry(
|
||||
'M.Sc. Computer Science', 'Tsinghua University', '2017 - 2019',
|
||||
'GPA: 3.8/4.0 | Thesis: Distributed Graph Processing on Heterogeneous Clusters'
|
||||
))
|
||||
story.extend(education_entry(
|
||||
'B.Eng. Software Engineering', 'Zhejiang University', '2013 - 2017'
|
||||
))
|
||||
|
||||
# Skills
|
||||
story.extend(section_header('SKILLS'))
|
||||
story.extend(skills_row([
|
||||
('Languages', 'Python, Java, Go, SQL, TypeScript'),
|
||||
('Frameworks', 'Spring Boot, FastAPI, React, Kubernetes, Kafka'),
|
||||
('Tools', 'Git, Docker, Terraform, AWS (EC2/S3/Lambda), PostgreSQL, Redis'),
|
||||
]))
|
||||
|
||||
doc.build(story)
|
||||
```
|
||||
|
||||
### Resume Checklist
|
||||
- [ ] **1 page** (unless user says otherwise)
|
||||
- [ ] **No cover page, no TOC**
|
||||
- [ ] Tight margins (1.5cm all sides)
|
||||
- [ ] Name prominent at top (22-26pt)
|
||||
- [ ] Contact info single line, centered
|
||||
- [ ] Section headers with consistent separator style
|
||||
- [ ] Bullets concise - start with action verbs
|
||||
- [ ] Quantified achievements (%, $, count)
|
||||
- [ ] No photos, no icons, no colour blocks (ATS-safe)
|
||||
- [ ] Font: only registered fonts (FreeSerif / Noto Sans SC)
|
||||
- [ ] **⚠️ Minimum font size 12px (9pt)** - no text smaller than this anywhere
|
||||
- [ ] **Line breaks are language-aware** - no mid-word English breaks, no CJK punctuation orphans, no date range splits
|
||||
- [ ] **Page fill ≥85%** - no large blank area at bottom. If sparse, increase spacing/leading/font size adaptively
|
||||
|
||||
Executable
+153
@@ -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 1–6) 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.
|
||||
Executable
+103
@@ -0,0 +1,103 @@
|
||||
# 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 (100–900):
|
||||
|
||||
```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, Liberation Sans, Noto Color Emoji, sans-serif | Body text, UI elements, stats |
|
||||
| `--font-serif` | Playfair Display, Noto Serif SC, Cormorant Garamond, Noto Color Emoji, serif | Hero text, editorial headlines |
|
||||
| `--font-mono` | Liberation Mono, DejaVu Sans Mono, Noto Color Emoji, monospace | Floating meta, timestamps, codes |
|
||||
|
||||
### Report Pipeline (ReportLab)
|
||||
|
||||
ReportLab requires registered fonts. Primary Chinese font is **Noto Serif SC** (思源宋体, serif with sharp elegant strokes):
|
||||
|
||||
```python
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
from reportlab.pdfbase.pdfmetrics import registerFontFamily
|
||||
|
||||
# Primary Chinese font — Noto Serif SC (serif, high quality, OFL licensed)
|
||||
# FONT_DIR = '/usr/share/fonts' (Linux) or '~/.openclaw/workspace/fonts' (macOS)
|
||||
pdfmetrics.registerFont(TTFont('NotoSerifSC', f'{FONT_DIR}/truetype/noto-serif-sc/NotoSerifSC-Regular.ttf'))
|
||||
pdfmetrics.registerFont(TTFont('NotoSerifSC-Bold', f'{FONT_DIR}/truetype/noto-serif-sc/NotoSerifSC-Bold.ttf'))
|
||||
registerFontFamily('NotoSerifSC', normal='NotoSerifSC', bold='NotoSerifSC-Bold')
|
||||
|
||||
# Fallback / legacy Chinese fonts (sans-serif, static weight)
|
||||
pdfmetrics.registerFont(TTFont('Noto Sans SC', f'{FONT_DIR}/truetype/chinese/NotoSansSC-Regular.ttf')) # sans-serif fallback
|
||||
pdfmetrics.registerFont(TTFont('Noto Sans SC Bold', f'{FONT_DIR}/truetype/chinese/NotoSansSC-Bold.ttf'))
|
||||
# Use 'NotoSerifSC' for body, 'NotoSerifSC-Bold' for 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**: `Noto Color Emoji` (cross-platform, full color emoji)
|
||||
- **Windows**: `Noto Color 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 300–900 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.6–1.7 |
|
||||
| Caption | 14px | 400 | 1.5 |
|
||||
| Floating Meta | 10px | 400 (mono) | 1.4 |
|
||||
| Stat Label | 11px | 400 | 1.2 |
|
||||
Executable
+263
@@ -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.30–0.70 for backgrounds |
|
||||
| **Light** (Editorial) | > 0.95 | < 0.15 | L between 0.30–0.70 for backgrounds |
|
||||
|
||||
Muddy mid-tones (L 0.30–0.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) | 4–6× 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.6–1.8 | Comfortable reading |
|
||||
| Headings | 0.85–1.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 **10–12% 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-fixed-canvas.md` § Intent Mapping Table and `design_engine.py` INTENT_HUES/INTENT_HARMONY_MAP.
|
||||
|
||||
### Calm
|
||||
Stillness. The viewer’s 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.0–1.0) to `Glass_Canvas` components. The engine maps this to a continuous font weight via Inter Variable font (300–900 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 (100–200 words) — the shape needs room
|
||||
|
||||
**When NOT to use**:
|
||||
- Dense content pages (shape eats 30–40% 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? |
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
# Lightweight environment check for PDF skill.
|
||||
# Exit 0 = all OK, exit 1 = missing dependencies.
|
||||
# Also resolves and exports PDF_SKILL_DIR and FONT_DIR.
|
||||
# Usage: source env_check.sh (preferred, exports vars to caller)
|
||||
# or: bash env_check.sh [--quiet]
|
||||
QUIET=false; [ "${1:-}" = "--quiet" ] && QUIET=true
|
||||
FAIL=0
|
||||
check() { local desc="$1"; shift; if ! "$@" &>/dev/null; then $QUIET || echo "MISSING: $desc"; FAIL=1; fi; }
|
||||
|
||||
# ── Resolve PDF_SKILL_DIR & FONT_DIR ──
|
||||
_ENV_CHECK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
|
||||
PDF_SKILL_DIR="$(cd "$_ENV_CHECK_DIR/.." && pwd)"
|
||||
export PDF_SKILL_DIR
|
||||
|
||||
if [ "$(uname -s)" = "Darwin" ]; then
|
||||
FONT_DIR="${HOME}/Library/Fonts"
|
||||
else
|
||||
FONT_DIR="/usr/share/fonts"
|
||||
fi
|
||||
export FONT_DIR
|
||||
|
||||
check "python3" command -v python3
|
||||
check "node" command -v node
|
||||
check "pikepdf" python3 -c "import pikepdf"
|
||||
check "pdfplumber" python3 -c "import pdfplumber"
|
||||
check "pypdf" python3 -c "import pypdf"
|
||||
check "reportlab" python3 -c "import reportlab"
|
||||
check "PyMuPDF" python3 -c "import fitz"
|
||||
check "playwright" node -e "require('playwright')"
|
||||
|
||||
# LaTeX engine (tectonic) — needed for Academic pipeline
|
||||
if [ -x "$PDF_SKILL_DIR/scripts/tectonic" ]; then
|
||||
: # bundled binary OK
|
||||
elif command -v tectonic &>/dev/null; then
|
||||
: # system tectonic OK
|
||||
else
|
||||
$QUIET || echo "MISSING: tectonic (needed for LaTeX/Academic PDFs)"
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
# Font check: verify CJK fonts are available
|
||||
if command -v fc-list &>/dev/null; then
|
||||
fc-list :lang=zh 2>/dev/null | grep -qi "noto\|simhei\|wenquanyi" || { $QUIET || echo "MISSING: CJK fonts"; FAIL=1; }
|
||||
fi
|
||||
|
||||
$QUIET || echo "PDF_SKILL_DIR=$PDF_SKILL_DIR"
|
||||
$QUIET || echo "FONT_DIR=$FONT_DIR"
|
||||
return $FAIL 2>/dev/null || exit $FAIL
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
/usr/share/fonts/truetype/chinese/NotoSansSC[wght].ttf
|
||||
/usr/share/fonts/truetype/chinese/SarasaMonoSC-Regular.ttf
|
||||
/usr/share/fonts/truetype/chinese/SarasaMonoSC-Bold.ttf
|
||||
/usr/share/fonts/truetype/chinese/SarasaMonoSC-Italic.ttf
|
||||
/usr/share/fonts/truetype/chinese/SarasaMonoSC-BoldItalic.ttf
|
||||
/usr/share/fonts/truetype/chinese/SarasaMonoSC-Light.ttf
|
||||
/usr/share/fonts/truetype/chinese/SarasaMonoSC-LightItalic.ttf
|
||||
/usr/share/fonts/truetype/chinese/SarasaMonoSC-ExtraLight.ttf
|
||||
/usr/share/fonts/truetype/chinese/SarasaMonoSC-ExtraLightItalic.ttf
|
||||
/usr/share/fonts/truetype/chinese/SarasaMonoSC-SemiBold.ttf
|
||||
/usr/share/fonts/truetype/chinese/SarasaMonoSC-SemiBoldItalic.ttf
|
||||
/usr/share/fonts/truetype/chinese/NotoSansSC[wght].ttf
|
||||
/usr/share/fonts/truetype/chinese/NotoSansSC-Thin.ttf
|
||||
/usr/share/fonts/truetype/chinese/NotoSansSC-ExtraLight.ttf
|
||||
/usr/share/fonts/truetype/chinese/NotoSansSC-Light.ttf
|
||||
/usr/share/fonts/truetype/chinese/NotoSansSC-Regular.ttf
|
||||
/usr/share/fonts/truetype/chinese/NotoSansSC-Medium.ttf
|
||||
/usr/share/fonts/truetype/chinese/NotoSansSC-SemiBold.ttf
|
||||
/usr/share/fonts/truetype/chinese/NotoSansSC-Bold.ttf
|
||||
/usr/share/fonts/truetype/chinese/NotoSansSC-ExtraBold.ttf
|
||||
/usr/share/fonts/truetype/chinese/NotoSansSC-Black.ttf
|
||||
/usr/share/fonts/truetype/chinese/LiberationSans-Regular.ttf
|
||||
/usr/share/fonts/truetype/chinese/LiberationMono-Regular.ttf
|
||||
/usr/share/fonts/truetype/chinese/LiberationSerif-Regular.ttf
|
||||
/usr/share/fonts/truetype/noto-serif-sc/NotoSerifSC-VariableFont_wght.ttf
|
||||
/usr/share/fonts/truetype/noto-serif-sc/NotoSerifSC-VariableFont_wght.ttf
|
||||
/usr/share/fonts/truetype/noto-serif-sc/NotoSerifSC-ExtraLight.ttf
|
||||
/usr/share/fonts/truetype/noto-serif-sc/NotoSerifSC-Light.ttf
|
||||
/usr/share/fonts/truetype/noto-serif-sc/NotoSerifSC-Regular.ttf
|
||||
/usr/share/fonts/truetype/noto-serif-sc/NotoSerifSC-Medium.ttf
|
||||
/usr/share/fonts/truetype/noto-serif-sc/NotoSerifSC-SemiBold.ttf
|
||||
/usr/share/fonts/truetype/noto-serif-sc/NotoSerifSC-Bold.ttf
|
||||
/usr/share/fonts/truetype/noto-serif-sc/NotoSerifSC-ExtraBold.ttf
|
||||
/usr/share/fonts/truetype/noto-serif-sc/NotoSerifSC-Black.ttf
|
||||
/usr/share/fonts/truetype/lxgw-wenkai/LXGWWenKai-Regular.ttf
|
||||
/usr/share/fonts/truetype/lxgw-wenkai/LXGWWenKai-Medium.ttf
|
||||
/usr/share/fonts/truetype/lxgw-wenkai/LXGWWenKai-Light.ttf
|
||||
/usr/share/fonts/truetype/lxgw-wenkai/LXGWWenKaiMono-Regular.ttf
|
||||
/usr/share/fonts/truetype/lxgw-wenkai/LXGWWenKaiMono-Medium.ttf
|
||||
/usr/share/fonts/truetype/lxgw-wenkai/LXGWWenKaiMono-Light.ttf
|
||||
/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc
|
||||
/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf
|
||||
/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf
|
||||
/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf
|
||||
/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf
|
||||
/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Oblique.ttf
|
||||
/usr/share/fonts/truetype/dejavu/DejaVuSansMono-BoldOblique.ttf
|
||||
/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf
|
||||
/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf
|
||||
/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf
|
||||
/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf
|
||||
/usr/share/fonts/truetype/liberation/LiberationSans-Italic.ttf
|
||||
/usr/share/fonts/truetype/liberation/LiberationSans-BoldItalic.ttf
|
||||
/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf
|
||||
/usr/share/fonts/truetype/liberation/LiberationSerif-Bold.ttf
|
||||
/usr/share/fonts/truetype/liberation/LiberationSerif-Italic.ttf
|
||||
/usr/share/fonts/truetype/liberation/LiberationSerif-BoldItalic.ttf
|
||||
/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf
|
||||
/usr/share/fonts/truetype/liberation/LiberationMono-Bold.ttf
|
||||
/usr/share/fonts/truetype/liberation/LiberationMono-Italic.ttf
|
||||
/usr/share/fonts/truetype/liberation/LiberationMono-BoldItalic.ttf
|
||||
/usr/share/fonts/truetype/english/Tinos-Regular.ttf
|
||||
/usr/share/fonts/truetype/english/Tinos-Bold.ttf
|
||||
/usr/share/fonts/truetype/english/Tinos-Italic.ttf
|
||||
/usr/share/fonts/truetype/english/Tinos-BoldItalic.ttf
|
||||
/usr/share/fonts/truetype/english/Carlito-Regular.ttf
|
||||
/usr/share/fonts/truetype/english/Carlito-Bold.ttf
|
||||
/usr/share/fonts/truetype/english/Carlito-Italic.ttf
|
||||
/usr/share/fonts/truetype/english/Carlito-BoldItalic.ttf
|
||||
/usr/share/fonts/truetype/freefont/FreeSans.ttf
|
||||
/usr/share/fonts/truetype/freefont/FreeSansBold.ttf
|
||||
/usr/share/fonts/truetype/freefont/FreeSansOblique.ttf
|
||||
/usr/share/fonts/truetype/freefont/FreeSansBoldOblique.ttf
|
||||
/usr/share/fonts/truetype/freefont/FreeSerif.ttf
|
||||
/usr/share/fonts/truetype/freefont/FreeSerifBold.ttf
|
||||
/usr/share/fonts/truetype/freefont/FreeSerifItalic.ttf
|
||||
/usr/share/fonts/truetype/freefont/FreeSerifBoldItalic.ttf
|
||||
/usr/share/fonts/truetype/freefont/FreeMono.ttf
|
||||
/usr/share/fonts/truetype/freefont/FreeMonoBold.ttf
|
||||
/usr/share/fonts/truetype/freefont/FreeMonoOblique.ttf
|
||||
/usr/share/fonts/truetype/freefont/FreeMonoBoldOblique.ttf
|
||||
/usr/share/fonts/truetype/libreoffice/opens___.ttf
|
||||
Executable
+212
@@ -0,0 +1,212 @@
|
||||
# PDF Skill — Environment Setup Guide
|
||||
|
||||
This document contains full platform-specific instructions for setting up the PDF skill environment.
|
||||
The model should read this file when first-time setup is needed.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Platform Detection
|
||||
|
||||
Detect the OS and set core variables:
|
||||
|
||||
### macOS / Linux (bash/zsh)
|
||||
|
||||
```bash
|
||||
OS="$(uname -s)" # Darwin = macOS, Linux = Linux
|
||||
ARCH="$(uname -m)" # x86_64 or arm64
|
||||
|
||||
PDF_SKILL_DIR="<skill_directory>"
|
||||
export PDF_SKILL_DIR
|
||||
```
|
||||
|
||||
### Windows (PowerShell, Win10/Win11)
|
||||
|
||||
```powershell
|
||||
$WinVer = [System.Environment]::OSVersion.Version
|
||||
$Arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture
|
||||
|
||||
$env:PDF_SKILL_DIR = "<skill_directory>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Dependency Check & Install
|
||||
|
||||
Run the platform-appropriate setup script:
|
||||
|
||||
| Platform | Command |
|
||||
|----------|---------|
|
||||
| macOS / Linux | `bash "$PDF_SKILL_DIR/env_setup/setup_mac_linux.sh"` |
|
||||
| Windows | `powershell -ExecutionPolicy Bypass -File "$env:PDF_SKILL_DIR\env_setup\setup_windows.ps1"` |
|
||||
|
||||
### Required Dependencies
|
||||
|
||||
| Category | Package | Purpose |
|
||||
|----------|---------|---------|
|
||||
| Runtime | Python 3 + pip | PDF generation and processing |
|
||||
| Runtime | Node.js + npm | Playwright for Creative pipeline |
|
||||
| Python pkg | pikepdf | PDF manipulation (merge/split/encrypt) |
|
||||
| Python pkg | pdfplumber | Text/table extraction |
|
||||
| Python pkg | pypdf | PDF reading/writing |
|
||||
| Python pkg | reportlab | Report pipeline PDF generation |
|
||||
| Python pkg | PyMuPDF (fitz) | Fast PDF rendering and extraction |
|
||||
| npm pkg | playwright | HTML-to-PDF for Creative pipeline |
|
||||
| Browser | Chromium (via Playwright) | Headless rendering |
|
||||
| Optional | tectonic | LaTeX/Academic PDF compilation |
|
||||
| Optional | LibreOffice | Office-to-PDF conversion |
|
||||
| Font | CJK fonts (from CDN) | Chinese text in PDFs |
|
||||
|
||||
### Manual Install by Platform
|
||||
|
||||
#### macOS
|
||||
|
||||
```bash
|
||||
brew install python3 node
|
||||
python3 -m pip install pikepdf pdfplumber pypdf reportlab PyMuPDF
|
||||
npm install -g playwright@1.50.0 && npx playwright install chromium
|
||||
# Pinned to 1.50.0 — npmmirror has Chromium rev 1155 for this version
|
||||
brew install tectonic # optional: LaTeX
|
||||
brew install --cask libreoffice # optional: Office-to-PDF
|
||||
```
|
||||
|
||||
#### Linux (Debian/Ubuntu)
|
||||
|
||||
```bash
|
||||
sudo apt install python3 python3-pip nodejs npm
|
||||
python3 -m pip install pikepdf pdfplumber pypdf reportlab PyMuPDF
|
||||
npm install -g playwright@1.50.0 && npx playwright install chromium && npx playwright install-deps
|
||||
# Pinned to 1.50.0 — npmmirror has Chromium rev 1155 for this version
|
||||
# optional:
|
||||
conda install -c conda-forge tectonic # use Tsinghua mirror: conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/
|
||||
sudo apt install libreoffice-core # LibreOffice
|
||||
```
|
||||
|
||||
#### Windows (PowerShell)
|
||||
|
||||
```powershell
|
||||
winget install Python.Python.3.11
|
||||
winget install OpenJS.NodeJS.LTS
|
||||
python -m pip install pikepdf pdfplumber pypdf reportlab PyMuPDF
|
||||
npm install -g playwright@1.50.0
|
||||
# Pinned to 1.50.0 — npmmirror has Chromium rev 1155 for this version
|
||||
npx playwright install chromium
|
||||
# optional:
|
||||
scoop install tectonic # LaTeX
|
||||
winget install TheDocumentFoundation.LibreOffice # Office-to-PDF
|
||||
```
|
||||
|
||||
Alternative Windows package managers:
|
||||
- `choco install python3 nodejs-lts`
|
||||
- `scoop install python nodejs-lts`
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Font Installation
|
||||
|
||||
Fonts are downloaded individually from CDN on first setup.
|
||||
|
||||
- **CDN base**: `https://z-cdn.chatglm.cn/office-skill/fonts/`
|
||||
- **Font list**: `env_setup/font_list.txt` (78 fonts, one relative path per line)
|
||||
- **Marker file**: `.office-skill-fonts-installed` in the user font directory prevents re-download
|
||||
- Special characters in filenames (e.g., `[`, `]`) are URL-encoded automatically by the setup script
|
||||
|
||||
The setup scripts read `font_list.txt`, check which fonts are already installed, and download only the missing ones. Each font is saved flat (filename only) to the user font directory.
|
||||
|
||||
### CDN Directory Structure (78 fonts)
|
||||
|
||||
| Directory | Count | Description |
|
||||
|-----------|-------|-------------|
|
||||
| `truetype/lxgw-wenkai/` | 6 | LXGW WenKai — Chinese handwriting style |
|
||||
| `truetype/noto-serif-sc/` | 9 | Noto Serif SC — Chinese serif (variable + 8 static weights) |
|
||||
| `chinese/` | 14 | Noto Sans SC, Sarasa Mono SC, Liberation fallbacks |
|
||||
| `dejavu/` | 8 | DejaVu Sans/Serif/Mono — Latin/symbol fallback |
|
||||
| `emoji/` | 1 | Noto Color Emoji |
|
||||
| `english/` | 12 | Tinos, Carlito, Calibri |
|
||||
| `freefont/` | 12 | FreeSans/FreeSerif/FreeMono — open-source fallback |
|
||||
| `liberation/` | 12 | Liberation Sans/Serif/Mono — MS-metric-compatible |
|
||||
| `libreoffice/` | 1 | OpenSymbol |
|
||||
| `noto/` | 1 | Noto Color Emoji (duplicate) |
|
||||
| `wqy/` | 1 | WenQuanYi Zen Hei — CJK fallback |
|
||||
| *(root)* | 1 | Japanese Gothic |
|
||||
|
||||
### Install Targets by Platform
|
||||
|
||||
| Platform | User Font Directory |
|
||||
|----------|-------------------|
|
||||
| macOS | `~/Library/Fonts/` |
|
||||
| Linux | `~/.local/share/fonts/` (then run `fc-cache -f`) |
|
||||
| Windows | `%LOCALAPPDATA%\Microsoft\Windows\Fonts` (per-user, no admin) |
|
||||
|
||||
### Manual Font Installation
|
||||
|
||||
If CDN is unreachable, download fonts manually. Example:
|
||||
|
||||
```bash
|
||||
# Download a single font (use static-weight files, NOT variable fonts)
|
||||
curl -fSLO "https://z-cdn.chatglm.cn/office-skill/fonts/chinese/NotoSansSC-Regular.ttf"
|
||||
# Copy to user font dir
|
||||
cp "NotoSansSC-Regular.ttf" ~/Library/Fonts/ # macOS
|
||||
```
|
||||
|
||||
Or download all fonts listed in `font_list.txt`:
|
||||
|
||||
```bash
|
||||
while read f; do
|
||||
encoded=$(echo "$f" | sed 's/\[/%5B/g; s/\]/%5D/g')
|
||||
curl -fSLO "https://z-cdn.chatglm.cn/office-skill/fonts/$encoded"
|
||||
done < env_setup/font_list.txt
|
||||
```
|
||||
|
||||
### Post-Install Variable
|
||||
|
||||
After font installation, `FONT_DIR` points to the user font directory:
|
||||
|
||||
| Platform | FONT_DIR |
|
||||
|----------|---------------|
|
||||
| macOS | `~/Library/Fonts` |
|
||||
| Linux | `~/.local/share/fonts` |
|
||||
| Windows | `%LOCALAPPDATA%\Microsoft\Windows\Fonts` |
|
||||
|
||||
---
|
||||
|
||||
## China Network Fallback
|
||||
|
||||
If default sources are unreachable, use China mirrors:
|
||||
|
||||
### pip (Tsinghua mirror)
|
||||
|
||||
```bash
|
||||
python3 -m pip install -i https://pypi.tuna.tsinghua.edu.cn/simple \
|
||||
--trusted-host pypi.tuna.tsinghua.edu.cn \
|
||||
pikepdf pdfplumber pypdf reportlab PyMuPDF
|
||||
```
|
||||
|
||||
### npm (npmmirror)
|
||||
|
||||
```bash
|
||||
npm install -g playwright@1.50.0 --registry https://registry.npmmirror.com
|
||||
# Pinned to 1.50.0 — npmmirror has Chromium rev 1155 for this version
|
||||
```
|
||||
|
||||
### Playwright browser download (npmmirror)
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_DOWNLOAD_HOST=https://npmmirror.com/mirrors/playwright/ npx playwright install chromium
|
||||
```
|
||||
|
||||
### Windows (PowerShell) China mirrors
|
||||
|
||||
```powershell
|
||||
python -m pip install -i https://pypi.tuna.tsinghua.edu.cn/simple --trusted-host pypi.tuna.tsinghua.edu.cn pikepdf pdfplumber pypdf reportlab PyMuPDF
|
||||
npm install -g playwright@1.50.0 --registry https://registry.npmmirror.com
|
||||
# Pinned to 1.50.0 — npmmirror has Chromium rev 1155 for this version
|
||||
$env:PLAYWRIGHT_DOWNLOAD_HOST = "https://npmmirror.com/mirrors/playwright/"
|
||||
npx playwright install chromium
|
||||
```
|
||||
|
||||
### Installer downloads (China)
|
||||
|
||||
| Software | China Mirror |
|
||||
|----------|-------------|
|
||||
| Node.js | https://npmmirror.com/mirrors/node/ |
|
||||
| Python | https://npmmirror.com/mirrors/python/ |
|
||||
Executable
+350
@@ -0,0 +1,350 @@
|
||||
#!/usr/bin/env bash
|
||||
# ---
|
||||
# name: pdf-env-setup (macOS / Linux)
|
||||
# description: Environment detection, dependency check & install for PDF skill on macOS and Linux.
|
||||
# ---
|
||||
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"; }
|
||||
|
||||
# ── Resolve PDF_SKILL_DIR ──
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PDF_SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
export PDF_SKILL_DIR
|
||||
|
||||
echo "============================================"
|
||||
echo " PDF Skill — Environment Setup"
|
||||
echo " (macOS / Linux)"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
# ── Step 1: Platform Detection ──
|
||||
OS="$(uname -s)"
|
||||
ARCH="$(uname -m)"
|
||||
echo "Platform: $OS $ARCH"
|
||||
|
||||
if [ "$OS" = "Darwin" ]; then
|
||||
PLATFORM="mac"
|
||||
elif [ "$OS" = "Linux" ]; then
|
||||
PLATFORM="linux"
|
||||
else
|
||||
echo "Unsupported platform: $OS. For Windows use env_setup/setup_windows.ps1"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Detected: $PLATFORM"
|
||||
echo "PDF_SKILL_DIR=$PDF_SKILL_DIR"
|
||||
echo ""
|
||||
|
||||
# ── China mirror detection & config ──
|
||||
USE_CN_MIRROR=false
|
||||
PIP_MIRROR_ARGS=""
|
||||
NPM_MIRROR_ARGS=""
|
||||
PLAYWRIGHT_HOST=""
|
||||
|
||||
if [ "${USE_CN_MIRROR_FORCE:-}" = "true" ]; then
|
||||
USE_CN_MIRROR=true
|
||||
elif curl -s --connect-timeout 3 https://pypi.org > /dev/null 2>&1; then
|
||||
USE_CN_MIRROR=false
|
||||
else
|
||||
warn "pypi.org unreachable — enabling China mirrors"
|
||||
USE_CN_MIRROR=true
|
||||
fi
|
||||
|
||||
if [ "$USE_CN_MIRROR" = true ]; then
|
||||
PIP_MIRROR_ARGS="-i https://pypi.tuna.tsinghua.edu.cn/simple --trusted-host pypi.tuna.tsinghua.edu.cn"
|
||||
NPM_MIRROR_ARGS="--registry https://registry.npmmirror.com"
|
||||
PLAYWRIGHT_HOST="https://npmmirror.com/mirrors/playwright/"
|
||||
info "China mirrors enabled (pip: tuna, npm: npmmirror, playwright: npmmirror)"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
ERRORS=0
|
||||
|
||||
# ── Step 2a: Homebrew (macOS only) ──
|
||||
if [ "$PLATFORM" = "mac" ]; then
|
||||
echo "--- [1/10] Homebrew (macOS package manager) ---"
|
||||
if command -v brew &>/dev/null; then
|
||||
ok "brew installed"
|
||||
else
|
||||
fail "brew not found"
|
||||
info "Install: /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ── Step 2b: Python 3 ──
|
||||
echo "--- [2/10] Python 3 ---"
|
||||
if command -v python3 &>/dev/null; then
|
||||
PY_VER=$(python3 --version 2>&1)
|
||||
ok "python3 ($PY_VER)"
|
||||
if [ "$PLATFORM" = "mac" ]; 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 "$PLATFORM" in
|
||||
mac) 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)" ;;
|
||||
esac
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── Step 2c: pip ──
|
||||
echo "--- [3/10] pip ---"
|
||||
if python3 -m pip --version &>/dev/null 2>&1; then
|
||||
ok "pip installed"
|
||||
else
|
||||
fail "pip not found"
|
||||
case "$PLATFORM" in
|
||||
mac) info "Install: python3 -m ensurepip --upgrade" ;;
|
||||
linux) info "Install: sudo apt install python3-pip" ;;
|
||||
esac
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── Step 2d: Python packages ──
|
||||
echo "--- [4/10] Python Packages (pikepdf, pdfplumber, pypdf, reportlab, PyMuPDF) ---"
|
||||
PY_PKGS=(
|
||||
"pikepdf:pikepdf"
|
||||
"pdfplumber:pdfplumber"
|
||||
"pypdf:pypdf"
|
||||
"reportlab:reportlab"
|
||||
"pymupdf:PyMuPDF"
|
||||
)
|
||||
|
||||
MISSING_PY=()
|
||||
for entry in "${PY_PKGS[@]}"; do
|
||||
mod="${entry%%:*}"
|
||||
pkg="${entry##*:}"
|
||||
# PyMuPDF imports as 'fitz'
|
||||
if [ "$mod" = "pymupdf" ]; then mod="fitz"; fi
|
||||
if python3 -c "import $mod" 2>/dev/null; then
|
||||
ver=$(python3 -c "import $mod; print(getattr($mod, '__version__', getattr($mod, 'version', 'ok')))" 2>/dev/null)
|
||||
ok "$pkg ($ver)"
|
||||
else
|
||||
fail "$pkg not installed"
|
||||
MISSING_PY+=("$pkg")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#MISSING_PY[@]} -gt 0 ]; then
|
||||
info "Installing missing Python packages: ${MISSING_PY[*]}"
|
||||
# shellcheck disable=SC2086
|
||||
python3 -m pip install -q $PIP_MIRROR_ARGS "${MISSING_PY[@]}" 2>/dev/null \
|
||||
|| python3 -m pip install -q --user $PIP_MIRROR_ARGS "${MISSING_PY[@]}" 2>/dev/null \
|
||||
|| python3 -m pip install -q --break-system-packages $PIP_MIRROR_ARGS "${MISSING_PY[@]}" 2>/dev/null \
|
||||
|| { fail "pip install failed. Try: pip install $PIP_MIRROR_ARGS ${MISSING_PY[*]}"; ERRORS=$((ERRORS + 1)); }
|
||||
ok "Installed: ${MISSING_PY[*]}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── Step 2e: Node.js + npm ──
|
||||
echo "--- [5/10] Node.js + npm ---"
|
||||
if command -v node &>/dev/null; then
|
||||
ok "node ($(node --version))"
|
||||
else
|
||||
fail "node not found"
|
||||
case "$PLATFORM" in
|
||||
mac) 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"
|
||||
if [ "$USE_CN_MIRROR" = true ]; then
|
||||
info "China alt: https://npmmirror.com/mirrors/node/"
|
||||
fi ;;
|
||||
esac
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
|
||||
if command -v npm &>/dev/null; then
|
||||
ok "npm ($(npm --version))"
|
||||
else
|
||||
fail "npm not found"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── Step 2f: Playwright + Chromium ──
|
||||
echo "--- [6/10] Playwright + Chromium ---"
|
||||
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"
|
||||
if [ "$USE_CN_MIRROR" = true ]; then
|
||||
info "Install: npm install -g playwright@1.50.0 $NPM_MIRROR_ARGS"
|
||||
else
|
||||
info "Install: npm install -g playwright@1.50.0"
|
||||
fi
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
|
||||
if [ "$PLATFORM" = "mac" ]; 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
|
||||
ok "chromium installed"
|
||||
else
|
||||
fail "chromium not installed"
|
||||
if [ "$USE_CN_MIRROR" = true ]; then
|
||||
info "Install: PLAYWRIGHT_DOWNLOAD_HOST=$PLAYWRIGHT_HOST npx playwright install chromium"
|
||||
else
|
||||
info "Install: npx playwright install chromium"
|
||||
fi
|
||||
if [ "$PLATFORM" = "linux" ]; then
|
||||
info " npx playwright install-deps (system libs, needs sudo)"
|
||||
fi
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── Step 2g: Tectonic (optional, LaTeX engine) ──
|
||||
echo "--- [7/10] Tectonic (optional, LaTeX/Academic PDFs) ---"
|
||||
BUNDLED="$PDF_SKILL_DIR/scripts/tectonic"
|
||||
if [ -x "$BUNDLED" ] && [ "$OS" = "Darwin" ] && [ "$ARCH" = "arm64" ]; then
|
||||
ok "tectonic (bundled, macOS arm64)"
|
||||
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 "$PLATFORM" in
|
||||
mac) info "Install: brew install tectonic" ;;
|
||||
linux) info "Install: conda install -c conda-forge tectonic"
|
||||
info " or: conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/ && conda install tectonic" ;;
|
||||
esac
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── Step 2h: LibreOffice (optional) ──
|
||||
echo "--- [8/10] 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 "$PLATFORM" in
|
||||
mac) info "Install: brew install --cask libreoffice" ;;
|
||||
linux) info "Install: sudo apt install libreoffice-core" ;;
|
||||
esac
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── Step 2i: Font Installation (from CDN) ──
|
||||
echo "--- [9/10] Font Installation ---"
|
||||
FONT_CDN_BASE="https://z-cdn.chatglm.cn/office-skill/fonts"
|
||||
FONT_LIST="$SCRIPT_DIR/font_list.txt"
|
||||
|
||||
if [ "$PLATFORM" = "mac" ]; then
|
||||
USER_FONT_DIR="$HOME/Library/Fonts"
|
||||
else
|
||||
USER_FONT_DIR="$HOME/.local/share/fonts"
|
||||
fi
|
||||
mkdir -p "$USER_FONT_DIR"
|
||||
|
||||
MARKER="$USER_FONT_DIR/.office-skill-fonts-installed"
|
||||
if [ -f "$MARKER" ]; then
|
||||
ok "Fonts already installed (marker found). To re-install, delete $MARKER"
|
||||
else
|
||||
if [ ! -f "$FONT_LIST" ]; then
|
||||
fail "Font list not found: $FONT_LIST"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
TOTAL=$(wc -l < "$FONT_LIST" | tr -d ' ')
|
||||
INSTALLED=0
|
||||
SKIPPED=0
|
||||
FAILED=0
|
||||
info "Downloading $TOTAL fonts from CDN..."
|
||||
|
||||
while IFS= read -r rel_path || [ -n "$rel_path" ]; do
|
||||
[ -z "$rel_path" ] && continue
|
||||
fname="$(basename "$rel_path")"
|
||||
|
||||
if [ -f "$USER_FONT_DIR/$fname" ]; then
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# URL-encode special chars (brackets)
|
||||
encoded=$(echo "$rel_path" | sed 's/\[/%5B/g; s/\]/%5D/g; s/ /%20/g')
|
||||
url="$FONT_CDN_BASE/$encoded"
|
||||
|
||||
if curl -fSL --connect-timeout 15 --retry 2 -o "$USER_FONT_DIR/$fname" "$url" 2>/dev/null; then
|
||||
INSTALLED=$((INSTALLED + 1))
|
||||
else
|
||||
warn "Failed to download: $rel_path"
|
||||
FAILED=$((FAILED + 1))
|
||||
rm -f "$USER_FONT_DIR/$fname" 2>/dev/null
|
||||
fi
|
||||
done < "$FONT_LIST"
|
||||
|
||||
if [ "$PLATFORM" = "linux" ] && [ $INSTALLED -gt 0 ]; then
|
||||
fc-cache -f "$USER_FONT_DIR" 2>/dev/null
|
||||
fi
|
||||
|
||||
if [ $FAILED -eq 0 ]; then
|
||||
touch "$MARKER"
|
||||
ok "Fonts: $INSTALLED newly installed, $SKIPPED already present (target: $USER_FONT_DIR)"
|
||||
else
|
||||
warn "Fonts: $INSTALLED installed, $SKIPPED skipped, $FAILED failed (marker not written, will retry next run)"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Set FONT_DIR to user font directory (fonts are now installed there)
|
||||
FONT_DIR="$USER_FONT_DIR"
|
||||
export FONT_DIR
|
||||
echo ""
|
||||
|
||||
# ── Step 2j: CJK Font Verification ──
|
||||
echo "--- [10/10] CJK Font Verification ---"
|
||||
CJK_FOUND=false
|
||||
|
||||
if [ "$PLATFORM" = "mac" ]; then
|
||||
if [ -f "$HOME/Library/Fonts/NotoSansSC[wght].ttf" ] || fc-list :lang=zh 2>/dev/null | head -1 | grep -q .; then
|
||||
ok "CJK fonts available (Noto Sans SC or system)"
|
||||
CJK_FOUND=true
|
||||
fi
|
||||
if [ -f "$HOME/Library/Fonts/NotoSansSC[wght].ttf" ]; then
|
||||
ok "Noto Sans SC (user fonts)"
|
||||
CJK_FOUND=true
|
||||
fi
|
||||
elif [ "$PLATFORM" = "linux" ]; then
|
||||
if fc-list :lang=zh 2>/dev/null | head -1 | grep -q .; then
|
||||
ok "CJK fonts available (fc-list)"
|
||||
CJK_FOUND=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$CJK_FOUND" = false ]; then
|
||||
warn "No CJK font detected — fonts were downloaded and copied; rebuild font cache if needed"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── Summary ──
|
||||
echo "============================================"
|
||||
if [ $ERRORS -eq 0 ]; then
|
||||
echo " All dependencies OK."
|
||||
else
|
||||
echo " $ERRORS issue(s) found. Fix them above."
|
||||
fi
|
||||
echo " PDF_SKILL_DIR=$PDF_SKILL_DIR"
|
||||
echo " FONT_DIR=$FONT_DIR (user font directory)"
|
||||
echo "============================================"
|
||||
Executable
+326
@@ -0,0 +1,326 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
PDF Skill — Environment Setup for Windows (Win10/Win11)
|
||||
.DESCRIPTION
|
||||
Detects platform, checks and installs all dependencies for the PDF skill.
|
||||
Supports China mirror fallback for pip/npm/playwright.
|
||||
#>
|
||||
|
||||
param(
|
||||
[switch]$UseChinaMirror
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Continue"
|
||||
|
||||
function Write-Ok { param($msg) Write-Host " [OK] $msg" -ForegroundColor Green }
|
||||
function Write-Fail { param($msg) Write-Host " [FAIL] $msg" -ForegroundColor Red }
|
||||
function Write-Warn { param($msg) Write-Host " [WARN] $msg" -ForegroundColor Yellow }
|
||||
function Write-Info { param($msg) Write-Host " [->] $msg" -ForegroundColor Cyan }
|
||||
|
||||
# ── Resolve PDF_SKILL_DIR ──
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$PDF_SKILL_DIR = Split-Path -Parent $ScriptDir
|
||||
$env:PDF_SKILL_DIR = $PDF_SKILL_DIR
|
||||
|
||||
Write-Host "============================================"
|
||||
Write-Host " PDF Skill - Environment Setup"
|
||||
Write-Host " (Windows)"
|
||||
Write-Host "============================================"
|
||||
Write-Host ""
|
||||
|
||||
# ── Step 1: Platform Detection ──
|
||||
$WinVer = [System.Environment]::OSVersion.Version
|
||||
$WinName = if ($WinVer.Build -ge 22000) { "Windows 11" } elseif ($WinVer.Build -ge 10240) { "Windows 10" } else { "Windows (older)" }
|
||||
Write-Host "Platform: $WinName (Build $($WinVer.Build)), $([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)"
|
||||
Write-Host "PDF_SKILL_DIR=$PDF_SKILL_DIR"
|
||||
Write-Host ""
|
||||
|
||||
# ── China mirror detection ──
|
||||
$PipMirrorArgs = @()
|
||||
$NpmMirrorArgs = @()
|
||||
$PlaywrightHost = ""
|
||||
|
||||
if ($UseChinaMirror) {
|
||||
$global:UseCN = $true
|
||||
} else {
|
||||
try {
|
||||
$null = Invoke-WebRequest -Uri "https://pypi.org" -TimeoutSec 3 -UseBasicParsing -ErrorAction Stop
|
||||
$global:UseCN = $false
|
||||
} catch {
|
||||
Write-Warn "pypi.org unreachable - enabling China mirrors"
|
||||
$global:UseCN = $true
|
||||
}
|
||||
}
|
||||
|
||||
if ($global:UseCN) {
|
||||
$PipMirrorArgs = @("-i", "https://pypi.tuna.tsinghua.edu.cn/simple", "--trusted-host", "pypi.tuna.tsinghua.edu.cn")
|
||||
$NpmMirrorArgs = @("--registry", "https://registry.npmmirror.com")
|
||||
$PlaywrightHost = "https://npmmirror.com/mirrors/playwright/"
|
||||
Write-Info "China mirrors enabled (pip: tuna, npm: npmmirror, playwright: npmmirror)"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
$Errors = 0
|
||||
|
||||
# ── Step 2a: Python 3 ──
|
||||
Write-Host "--- [1/9] Python 3 ---"
|
||||
$PyCmd = $null
|
||||
foreach ($cmd in @("python3", "python", "py")) {
|
||||
try {
|
||||
$ver = & $cmd --version 2>&1
|
||||
if ($ver -match "Python 3") {
|
||||
$PyCmd = $cmd
|
||||
Write-Ok "$cmd ($ver)"
|
||||
break
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if (-not $PyCmd) {
|
||||
Write-Fail "Python 3 not found"
|
||||
Write-Info "Install option 1: winget install Python.Python.3.11"
|
||||
Write-Info "Install option 2: https://www.python.org/downloads/"
|
||||
Write-Info "Install option 3: choco install python3"
|
||||
if ($global:UseCN) {
|
||||
Write-Info "China alt: https://npmmirror.com/mirrors/python/"
|
||||
}
|
||||
$Errors++
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
# ── Step 2b: pip ──
|
||||
Write-Host "--- [2/9] pip ---"
|
||||
if ($PyCmd) {
|
||||
try {
|
||||
$pipVer = & $PyCmd -m pip --version 2>&1
|
||||
if ($pipVer -match "pip") {
|
||||
Write-Ok "pip ($pipVer)"
|
||||
} else { throw "no pip" }
|
||||
} catch {
|
||||
Write-Fail "pip not found"
|
||||
Write-Info "Install: $PyCmd -m ensurepip --upgrade"
|
||||
$Errors++
|
||||
}
|
||||
} else {
|
||||
Write-Fail "pip - skipped (Python not found)"
|
||||
$Errors++
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
# ── Step 2c: Python packages ──
|
||||
Write-Host "--- [3/9] Python Packages (pikepdf, pdfplumber, pypdf, reportlab, PyMuPDF) ---"
|
||||
$PyPkgs = @(
|
||||
@{ Module = "pikepdf"; Package = "pikepdf" },
|
||||
@{ Module = "pdfplumber"; Package = "pdfplumber" },
|
||||
@{ Module = "pypdf"; Package = "pypdf" },
|
||||
@{ Module = "reportlab"; Package = "reportlab" },
|
||||
@{ Module = "fitz"; Package = "PyMuPDF" }
|
||||
)
|
||||
|
||||
$MissingPy = @()
|
||||
if ($PyCmd) {
|
||||
foreach ($pkg in $PyPkgs) {
|
||||
try {
|
||||
$result = & $PyCmd -c "import $($pkg.Module); print(getattr($($pkg.Module), '__version__', getattr($($pkg.Module), 'version', 'ok')))" 2>&1
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Ok "$($pkg.Package) ($result)"
|
||||
} else { throw "not installed" }
|
||||
} catch {
|
||||
Write-Fail "$($pkg.Package) not installed"
|
||||
$MissingPy += $pkg.Package
|
||||
}
|
||||
}
|
||||
|
||||
if ($MissingPy.Count -gt 0) {
|
||||
Write-Info "Installing: $($MissingPy -join ', ')"
|
||||
$installArgs = @("-m", "pip", "install") + $PipMirrorArgs + $MissingPy
|
||||
try {
|
||||
& $PyCmd @installArgs 2>&1 | Out-Null
|
||||
Write-Ok "Installed: $($MissingPy -join ', ')"
|
||||
} catch {
|
||||
Write-Fail "pip install failed. Try: $PyCmd -m pip install $($PipMirrorArgs -join ' ') $($MissingPy -join ' ')"
|
||||
$Errors++
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Write-Fail "Python packages - skipped (Python not found)"
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
# ── Step 2d: Node.js + npm ──
|
||||
Write-Host "--- [4/9] Node.js + npm ---"
|
||||
try {
|
||||
$nodeVer = & node --version 2>&1
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Ok "node ($nodeVer)"
|
||||
} else { throw "no node" }
|
||||
} catch {
|
||||
Write-Fail "node not found"
|
||||
Write-Info "Install option 1: winget install OpenJS.NodeJS.LTS"
|
||||
Write-Info "Install option 2: https://nodejs.org/"
|
||||
Write-Info "Install option 3: choco install nodejs-lts"
|
||||
if ($global:UseCN) {
|
||||
Write-Info "China alt: https://npmmirror.com/mirrors/node/"
|
||||
}
|
||||
$Errors++
|
||||
}
|
||||
|
||||
try {
|
||||
$npmVer = & npm --version 2>&1
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Ok "npm ($npmVer)"
|
||||
} else { throw "no npm" }
|
||||
} catch {
|
||||
Write-Fail "npm not found (installed with Node.js)"
|
||||
$Errors++
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
# ── Step 2e: Playwright + Chromium ──
|
||||
Write-Host "--- [5/9] Playwright + Chromium ---"
|
||||
try {
|
||||
$null = & node -e "require('playwright')" 2>&1
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$pwVer = & node -e "console.log(require('playwright/package.json').version)" 2>&1
|
||||
Write-Ok "playwright ($pwVer)"
|
||||
} else { throw "no pw" }
|
||||
} catch {
|
||||
Write-Fail "playwright not installed"
|
||||
if ($global:UseCN) {
|
||||
Write-Info "Install: npm install -g playwright@1.50.0 $($NpmMirrorArgs -join ' ')"
|
||||
} else {
|
||||
Write-Info "Install: npm install -g playwright@1.50.0"
|
||||
}
|
||||
$Errors++
|
||||
}
|
||||
|
||||
$PwCache = Join-Path $env:LOCALAPPDATA "ms-playwright"
|
||||
if (Test-Path "$PwCache\chromium-*") {
|
||||
Write-Ok "chromium installed"
|
||||
} else {
|
||||
Write-Fail "chromium not installed"
|
||||
if ($global:UseCN) {
|
||||
Write-Info "Install: `$env:PLAYWRIGHT_DOWNLOAD_HOST='$PlaywrightHost'; npx playwright install chromium"
|
||||
} else {
|
||||
Write-Info "Install: npx playwright install chromium"
|
||||
}
|
||||
$Errors++
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
# ── Step 2f: Tectonic (optional) ──
|
||||
Write-Host "--- [6/9] Tectonic (optional, LaTeX/Academic PDFs) ---"
|
||||
try {
|
||||
$tecVer = & tectonic --version 2>&1
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Ok "tectonic ($tecVer)"
|
||||
} else { throw "no tectonic" }
|
||||
} catch {
|
||||
Write-Warn "tectonic not installed (needed only for LaTeX/academic PDFs)"
|
||||
Write-Info "Install option 1: scoop install tectonic"
|
||||
Write-Info "Install option 2: choco install tectonic"
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
# ── Step 2g: LibreOffice (optional) ──
|
||||
Write-Host "--- [7/9] LibreOffice (optional, Office->PDF conversion) ---"
|
||||
$SofficePath = "C:\Program Files\LibreOffice\program\soffice.exe"
|
||||
if (Test-Path $SofficePath) {
|
||||
Write-Ok "libreoffice found"
|
||||
} elseif (Get-Command soffice -ErrorAction SilentlyContinue) {
|
||||
Write-Ok "libreoffice (in PATH)"
|
||||
} else {
|
||||
Write-Warn "libreoffice not installed (needed only for .docx/.xlsx -> PDF conversion)"
|
||||
Write-Info "Install: winget install TheDocumentFoundation.LibreOffice"
|
||||
Write-Info " or: https://www.libreoffice.org/download/"
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
# ── Step 2h: Font Installation (from CDN) ──
|
||||
Write-Host "--- [8/9] Font Installation ---"
|
||||
$FontCdnBase = "https://z-cdn.chatglm.cn/office-skill/fonts"
|
||||
$FontList = Join-Path $ScriptDir "font_list.txt"
|
||||
|
||||
$UserFontDir = Join-Path $env:LOCALAPPDATA "Microsoft\Windows\Fonts"
|
||||
if (-not (Test-Path $UserFontDir)) { New-Item -ItemType Directory -Path $UserFontDir -Force | Out-Null }
|
||||
|
||||
$Marker = Join-Path $UserFontDir ".office-skill-fonts-installed"
|
||||
if (Test-Path $Marker) {
|
||||
Write-Ok "Fonts already installed (marker found). To re-install, delete $Marker"
|
||||
} else {
|
||||
if (-not (Test-Path $FontList)) {
|
||||
Write-Fail "Font list not found: $FontList"
|
||||
$Errors++
|
||||
} else {
|
||||
$lines = Get-Content $FontList | Where-Object { $_.Trim() -ne "" }
|
||||
$Total = $lines.Count
|
||||
$Installed = 0; $Skipped = 0; $Failed = 0
|
||||
Write-Info "Downloading $Total fonts from CDN..."
|
||||
|
||||
foreach ($relPath in $lines) {
|
||||
$fname = Split-Path $relPath -Leaf
|
||||
$dest = Join-Path $UserFontDir $fname
|
||||
|
||||
if (Test-Path $dest) {
|
||||
$Skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
$encoded = $relPath -replace '\[','%5B' -replace '\]','%5D' -replace ' ','%20'
|
||||
$url = "$FontCdnBase/$encoded"
|
||||
|
||||
try {
|
||||
Invoke-WebRequest -Uri $url -OutFile $dest -TimeoutSec 30 -ErrorAction Stop
|
||||
$regPath = "HKCU:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts"
|
||||
$null = New-ItemProperty -Path $regPath -Name $fname -Value $dest -PropertyType String -Force -ErrorAction SilentlyContinue
|
||||
$Installed++
|
||||
} catch {
|
||||
Write-Warn "Failed to download: $relPath"
|
||||
$Failed++
|
||||
Remove-Item $dest -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
if ($Failed -eq 0) {
|
||||
New-Item -ItemType File -Path $Marker -Force | Out-Null
|
||||
Write-Ok "Fonts: $Installed newly installed, $Skipped already present (target: $UserFontDir)"
|
||||
} else {
|
||||
Write-Warn "Fonts: $Installed installed, $Skipped skipped, $Failed failed (marker not written, will retry next run)"
|
||||
$Errors++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Set PDF_FONTS_DIR to user font directory (fonts are now installed there)
|
||||
$PDF_FONTS_DIR = $UserFontDir
|
||||
$env:PDF_FONTS_DIR = $PDF_FONTS_DIR
|
||||
Write-Host ""
|
||||
|
||||
# ── Step 2i: CJK Font Verification ──
|
||||
Write-Host "--- [9/9] CJK Font Verification ---"
|
||||
$CjkFound = $false
|
||||
$FontsDir = Join-Path $env:WINDIR "Fonts"
|
||||
$UserFontDir2 = Join-Path $env:LOCALAPPDATA "Microsoft\Windows\Fonts"
|
||||
$CjkFonts = @("NotoSansSC[wght].ttf", "NotoSansSC[wght].ttf", "NotoSansSC[wght].ttf", "NotoSerifSC-Regular.ttf")
|
||||
foreach ($f in $CjkFonts) {
|
||||
if ((Test-Path (Join-Path $FontsDir $f)) -or (Test-Path (Join-Path $UserFontDir2 $f))) {
|
||||
Write-Ok "CJK font found: $f"
|
||||
$CjkFound = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $CjkFound) {
|
||||
Write-Warn "No CJK font verified yet - fonts were just installed, restart may be needed"
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
# ── Summary ──
|
||||
Write-Host "============================================"
|
||||
if ($Errors -eq 0) {
|
||||
Write-Host " All dependencies OK."
|
||||
} else {
|
||||
Write-Host " $Errors issue(s) found. Fix them above."
|
||||
}
|
||||
Write-Host " PDF_SKILL_DIR=$PDF_SKILL_DIR"
|
||||
Write-Host "============================================"
|
||||
Executable
+130
@@ -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}
|
||||
Executable
+190
@@ -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&\else{\textbf{\color{accent}#2}\par\smallskip}\fi%
|
||||
\ifx&\else{%
|
||||
\small\makebox[0.5\linewidth][l]{\faCalendar[regular]~#3}%
|
||||
}\fi%
|
||||
\ifx&\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}
|
||||
Executable
+599
@@ -0,0 +1,599 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* cover_validate.js — Cover page overlap detection via Playwright rendering
|
||||
*
|
||||
* Two detection passes:
|
||||
* Pass 1: Text vs decorative line overlap (original)
|
||||
* Pass 2: Layer 3 text vs text zone overflow (NEW)
|
||||
*
|
||||
* Pass 2 detects foreground text blocks that overlap each other on the
|
||||
* same z-layer (Layer 3). It excludes Layer 1 elements (watermarks,
|
||||
* sidebar decorative text) by checking opacity ≤ 0.10 or z-index ≤ 1.
|
||||
*
|
||||
* 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)
|
||||
* node cover_validate.js cover.html --no-text-overlap # skip Pass 2 (text vs text)
|
||||
*
|
||||
* 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, noTextOverlap = false;
|
||||
|
||||
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 === '--no-text-overlap') noTextOverlap = true;
|
||||
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)
|
||||
--no-text-overlap Skip Pass 2 (text vs text zone overlap detection)
|
||||
--help Show this help`);
|
||||
process.exit(0);
|
||||
} else if (!t.startsWith('-') && !input) {
|
||||
input = t;
|
||||
}
|
||||
}
|
||||
return { input, width, height, minGap, noTextOverlap };
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
|
||||
// Determine layer: Layer 1 = background decoration (low opacity, low z-index)
|
||||
// Layer 3 = foreground text (normal opacity, higher z-index)
|
||||
const opacity = parseFloat(style.opacity);
|
||||
const zIndex = parseInt(style.zIndex, 10) || 0;
|
||||
const parentOpacity = el.parentElement ? parseFloat(getComputedStyle(el.parentElement).opacity) : 1;
|
||||
const effectiveOpacity = opacity * parentOpacity;
|
||||
const isPointerNone = style.pointerEvents === 'none';
|
||||
|
||||
// Parse color alpha from rgba(r, g, b, a) using string split (regex unreliable in evaluate)
|
||||
let colorAlpha = 1;
|
||||
const colorStr = style.color;
|
||||
if (colorStr.indexOf('rgba') === 0) {
|
||||
const parts = colorStr.replace('rgba(', '').replace(')', '').split(',');
|
||||
if (parts.length >= 4) {
|
||||
const a = parseFloat(parts[3].trim());
|
||||
if (!isNaN(a)) colorAlpha = a;
|
||||
}
|
||||
}
|
||||
const visualOpacity = effectiveOpacity * colorAlpha;
|
||||
|
||||
// Layer 1 heuristic: very low visual opacity (CSS opacity * color alpha)
|
||||
// These are watermarks, sidebar decoration text, etc.
|
||||
const isLayer1 = (
|
||||
visualOpacity <= 0.10 ||
|
||||
(zIndex <= 1 && visualOpacity <= 0.15) ||
|
||||
(isPointerNone && visualOpacity <= 0.20)
|
||||
);
|
||||
|
||||
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 },
|
||||
layer: isLayer1 ? 1 : 3,
|
||||
opacity: Math.round(visualOpacity * 1000) / 1000,
|
||||
zIndex: zIndex,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
textElements_by_layer: {
|
||||
layer3: textElements.filter(t => t.layer === 3).length,
|
||||
layer1: textElements.filter(t => t.layer === 1).length,
|
||||
},
|
||||
lineElements: lineElements.length,
|
||||
overlaps: overlaps,
|
||||
};
|
||||
})
|
||||
`;
|
||||
|
||||
// ── Pass 2: Layer 3 text vs text zone overlap detection ──
|
||||
// Only checks foreground text (Layer 3) against each other.
|
||||
// Skips Layer 1 elements (watermarks, sidebar decoration) to avoid false positives.
|
||||
// A "zone overflow" is when two Layer 3 text blocks' bounding boxes overlap
|
||||
// or are closer than minGapPx on the Y axis (both must share X range).
|
||||
|
||||
const TEXT_ZONE_DETECTION = `
|
||||
(function detectTextZoneOverlaps(minGapPx) {
|
||||
const allElements = document.querySelectorAll('*');
|
||||
const layer3Blocks = [];
|
||||
const layer1Count = { value: 0 };
|
||||
|
||||
const textTags = ['h1','h2','h3','h4','h5','h6','p','span','a','li','td','th','label','summary','div'];
|
||||
|
||||
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);
|
||||
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') continue;
|
||||
|
||||
// Must have direct text content
|
||||
const hasDirectText = Array.from(el.childNodes).some(n => n.nodeType === 3 && n.textContent.trim());
|
||||
if (!textTags.includes(tag) && !hasDirectText) continue;
|
||||
if (!el.textContent.trim()) continue;
|
||||
if (rect.height < 5) continue;
|
||||
|
||||
// Compute effective opacity (walk up to find multiplicative opacity)
|
||||
const opacity = parseFloat(style.opacity);
|
||||
let parentEl = el.parentElement;
|
||||
let parentOpacity = 1;
|
||||
while (parentEl) {
|
||||
const po = parseFloat(getComputedStyle(parentEl).opacity);
|
||||
if (!isNaN(po)) parentOpacity *= po;
|
||||
parentEl = parentEl.parentElement;
|
||||
}
|
||||
const effectiveOpacity = opacity * parentOpacity;
|
||||
|
||||
// Parse color alpha from rgba(r, g, b, a) using string split
|
||||
let colorAlpha = 1;
|
||||
const colorStr = style.color;
|
||||
if (colorStr.indexOf('rgba') === 0) {
|
||||
const parts = colorStr.replace('rgba(', '').replace(')', '').split(',');
|
||||
if (parts.length >= 4) {
|
||||
const a = parseFloat(parts[3].trim());
|
||||
if (!isNaN(a)) colorAlpha = a;
|
||||
}
|
||||
}
|
||||
const visualOpacity = effectiveOpacity * colorAlpha;
|
||||
|
||||
const zIndex = parseInt(style.zIndex, 10) || 0;
|
||||
const isPointerNone = style.pointerEvents === 'none';
|
||||
|
||||
// Layer 1 detection: watermark / sidebar decoration
|
||||
// Uses visualOpacity (CSS opacity * color alpha) to catch both patterns:
|
||||
// - opacity: 0.03 (CSS property)
|
||||
// - color: rgba(0,0,0,0.03) (color alpha)
|
||||
const isLayer1 = (
|
||||
visualOpacity <= 0.10 ||
|
||||
(zIndex <= 1 && visualOpacity <= 0.15) ||
|
||||
(isPointerNone && visualOpacity <= 0.20)
|
||||
);
|
||||
|
||||
if (isLayer1) {
|
||||
layer1Count.value++;
|
||||
continue; // Skip Layer 1 entirely
|
||||
}
|
||||
|
||||
// Also skip decorative lines (thin elements) — they're handled by Pass 1
|
||||
const isThin = (rect.height <= 5 && rect.width > 20) || (rect.width <= 5 && rect.height > 20);
|
||||
if (isThin) continue;
|
||||
|
||||
layer3Blocks.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 },
|
||||
opacity: Math.round(effectiveOpacity * 100) / 100,
|
||||
zIndex: zIndex,
|
||||
area: rect.width * rect.height,
|
||||
});
|
||||
}
|
||||
|
||||
// De-duplicate: keep only the most specific (smallest area) text block
|
||||
// when a parent and child both contain the same text region.
|
||||
// Sort by area ascending, then for each block, remove any larger block
|
||||
// that fully contains it.
|
||||
layer3Blocks.sort((a, b) => a.area - b.area);
|
||||
|
||||
const filtered = [];
|
||||
for (const block of layer3Blocks) {
|
||||
// Check if this block is a parent container of an already-added smaller block
|
||||
const isParent = filtered.some(existing => {
|
||||
return (
|
||||
block.rect.x <= existing.rect.x &&
|
||||
block.rect.y <= existing.rect.y &&
|
||||
block.rect.x + block.rect.width >= existing.rect.x + existing.rect.width &&
|
||||
block.rect.y + block.rect.height >= existing.rect.y + existing.rect.height
|
||||
);
|
||||
});
|
||||
if (!isParent) {
|
||||
filtered.push(block);
|
||||
}
|
||||
}
|
||||
|
||||
// Check pairwise overlaps among Layer 3 blocks
|
||||
const overlaps = [];
|
||||
const reported = new Set();
|
||||
|
||||
for (let i = 0; i < filtered.length; i++) {
|
||||
for (let j = i + 1; j < filtered.length; j++) {
|
||||
const a = filtered[i];
|
||||
const b = filtered[j];
|
||||
const ar = a.rect;
|
||||
const br = b.rect;
|
||||
|
||||
// Check X-axis overlap (they must share horizontal range)
|
||||
const xOverlap = !(ar.x + ar.width < br.x || br.x + br.width < ar.x);
|
||||
if (!xOverlap) continue;
|
||||
|
||||
// Calculate Y-axis gap
|
||||
const aTop = ar.y;
|
||||
const aBottom = ar.y + ar.height;
|
||||
const bTop = br.y;
|
||||
const bBottom = br.y + br.height;
|
||||
|
||||
let yGap;
|
||||
if (bTop >= aBottom) {
|
||||
yGap = bTop - aBottom;
|
||||
} else if (aTop >= bBottom) {
|
||||
yGap = aTop - bBottom;
|
||||
} else {
|
||||
yGap = -Math.min(aBottom - bTop, bBottom - aTop); // negative = overlapping
|
||||
}
|
||||
|
||||
// Only flag if blocks actually overlap (yGap < 0) or touch (yGap == 0)
|
||||
// We use 0 as threshold, NOT minGapPx — because tight spacing between
|
||||
// Kicker and Hero Title is intentional design (gap may be only 8-15px).
|
||||
// Zone overflow means actual pixel overlap, not "too close".
|
||||
if (yGap < 0) {
|
||||
const key = a.text.substring(0, 20) + ':' + b.text.substring(0, 20);
|
||||
if (reported.has(key)) continue;
|
||||
reported.add(key);
|
||||
|
||||
overlaps.push({
|
||||
a: { tag: a.tag, class: a.class, text: a.text, rect: ar, opacity: a.opacity },
|
||||
b: { tag: b.tag, class: b.class, text: b.text, rect: br, opacity: b.opacity },
|
||||
axis: 'vertical',
|
||||
overlap: Math.round(Math.abs(yGap)),
|
||||
required: 0, // actual pixel overlap, not gap threshold
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
layer3Count: filtered.length,
|
||||
layer1Count: layer1Count.value,
|
||||
overlaps: overlaps,
|
||||
};
|
||||
})
|
||||
`;
|
||||
|
||||
// ── Main ──
|
||||
|
||||
async function main() {
|
||||
const { input, width, height, minGap, noTextOverlap } = 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)`);
|
||||
console.log(` Pass 2 (text↔text): ${noTextOverlap ? 'DISABLED' : 'enabled'}`);
|
||||
|
||||
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 Pass 1: text vs decorative line overlap detection
|
||||
const result = await page.evaluate(`(${DECORATIVE_LINE_DETECTION})(${gap})`);
|
||||
|
||||
const layer3Text = result.textElements_by_layer ? result.textElements_by_layer.layer3 : '?';
|
||||
const layer1Text = result.textElements_by_layer ? result.textElements_by_layer.layer1 : '?';
|
||||
console.log(` ✓ Found ${result.textElements} text elements (L3: ${layer3Text}, L1: ${layer1Text}), ${result.lineElements} decorative lines`);
|
||||
|
||||
let allIssues = [];
|
||||
|
||||
// Pass 1 results
|
||||
if (result.overlaps.length > 0) {
|
||||
console.error(`\n ❌ Pass 1: Found ${result.overlaps.length} text-line overlap(s):`);
|
||||
for (const o of result.overlaps) {
|
||||
const direction = o.lineType === 'vertical' ? 'horizontal' : 'vertical';
|
||||
console.error(`\n ERROR [text↔line]: ${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.`);
|
||||
}
|
||||
allIssues.push(...result.overlaps.map(o => ({ pass: 1, ...o })));
|
||||
} else {
|
||||
console.log(` ✅ Pass 1: No text-line overlaps`);
|
||||
}
|
||||
|
||||
// Pass 2: Layer 3 text vs text zone overlap detection
|
||||
if (!noTextOverlap) {
|
||||
const textOverlaps = await page.evaluate(`(${TEXT_ZONE_DETECTION})(${gap})`);
|
||||
|
||||
if (textOverlaps.overlaps.length > 0) {
|
||||
console.error(`\n ❌ Pass 2: Found ${textOverlaps.overlaps.length} text-text zone overflow(s):`);
|
||||
for (const o of textOverlaps.overlaps) {
|
||||
console.error(`\n ERROR [text↔text]: ${o.axis} overlap = ${o.overlap}px (gap should be ≥ ${o.required}px)`);
|
||||
console.error(` Block A: <${o.a.tag}.${o.a.class.split(' ')[0] || '?'}> "${o.a.text}" @ y=${Math.round(o.a.rect.y)}-${Math.round(o.a.rect.y + o.a.rect.height)}`);
|
||||
console.error(` Block B: <${o.b.tag}.${o.b.class.split(' ')[0] || '?'}> "${o.b.text}" @ y=${Math.round(o.b.rect.y)}-${Math.round(o.b.rect.y + o.b.rect.height)}`);
|
||||
console.error(` Fix: Move block B down by at least ${Math.ceil(o.overlap + o.required)}px, or reduce block A font size.`);
|
||||
}
|
||||
allIssues.push(...textOverlaps.overlaps.map(o => ({ pass: 2, ...o })));
|
||||
} else {
|
||||
console.log(` ✅ Pass 2: No text-text zone overflows (checked ${textOverlaps.layer3Count} L3 blocks, skipped ${textOverlaps.layer1Count} L1 blocks)`);
|
||||
}
|
||||
}
|
||||
|
||||
if (allIssues.length === 0) {
|
||||
console.log(`\n ✅ All checks passed`);
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error(`\n ❌ Total: ${allIssues.length} issue(s) found`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(`✗ Unexpected error: ${err.message}`);
|
||||
process.exit(2);
|
||||
});
|
||||
Executable
+2834
File diff suppressed because it is too large.
Load diff
Executable
+822
@@ -0,0 +1,822 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* html2pdf-next.js — HTML → PDF converter using Playwright + Paged.js + pdf-lib
|
||||
*
|
||||
* Uses Paged.js polyfill for pagination (break-inside/before/after, named pages,
|
||||
* orphans/widows) and Chromium for rendering. 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 --merge a.pdf b.pdf (merge additional PDFs after)
|
||||
*
|
||||
* Architecture:
|
||||
* 1. Playwright loads HTML, runs pre-render hooks (Mermaid, KaTeX, overflow detection)
|
||||
* 2. Paged.js polyfill injected — reads @page rules, chunks content into .pagedjs_page containers
|
||||
* 3. Chromium renders the Paged.js output to PDF via page.pdf()
|
||||
* 4. pdf-lib for merge, metadata, page count extraction
|
||||
* 5. Continuous-canvas mode (design_engine.py) skips Paged.js, uses Chromium native print
|
||||
*/
|
||||
|
||||
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@1.50.0');
|
||||
}
|
||||
|
||||
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)
|
||||
--nopaged Skip Paged.js, use Chromium native @page pagination
|
||||
--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, noPaged = false;
|
||||
|
||||
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' || t === '--nopaged') { noPaged = true; }
|
||||
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, noPaged };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// 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. Remove overflow:hidden BEFORE Paged.js injection.
|
||||
// Paged.js reads the DOM layout to chunk content into pages.
|
||||
// If a container has overflow:hidden + fixed height, Paged.js only sees
|
||||
// the visible portion and silently loses the clipped content.
|
||||
// We must remove overflow:hidden first so Paged.js gets the full content.
|
||||
const vOverflowFix = await page.evaluate(() => {
|
||||
const fixes = [];
|
||||
const candidates = [document.documentElement, document.body];
|
||||
const bodyChildren = document.body.children;
|
||||
for (let i = 0; i < bodyChildren.length; i++) {
|
||||
const child = bodyChildren[i];
|
||||
const tag = child.tagName.toLowerCase();
|
||||
if (tag === 'svg' || tag === 'script' || tag === 'style' || tag === 'link') continue;
|
||||
candidates.push(child);
|
||||
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) {
|
||||
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}`;
|
||||
|
||||
// ONLY remove overflow — do NOT touch height, position, or any
|
||||
// other layout property (preserves absolute-positioned layouts).
|
||||
el.style.overflow = 'visible';
|
||||
el.style.overflowY = 'visible';
|
||||
|
||||
fixes.push({ selector, clipped: diff });
|
||||
}
|
||||
}
|
||||
|
||||
const finalHeight = Math.max(
|
||||
document.documentElement.scrollHeight,
|
||||
document.body.scrollHeight
|
||||
);
|
||||
return { fixes, finalHeight };
|
||||
});
|
||||
|
||||
if (vOverflowFix.fixes.length) {
|
||||
console.log(' ⚠️ Removed overflow:hidden (content was clipped):');
|
||||
vOverflowFix.fixes.forEach(f => {
|
||||
console.log(` ${f.selector}: ${f.clipped}px clipped → overflow:visible`);
|
||||
});
|
||||
}
|
||||
|
||||
// Measure content height for diagnostics
|
||||
const contentHeight = vOverflowFix.fixes.length > 0
|
||||
? vOverflowFix.finalHeight
|
||||
: await page.evaluate(() => Math.max(
|
||||
document.documentElement.scrollHeight,
|
||||
document.body.scrollHeight
|
||||
));
|
||||
|
||||
// 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. (Removed) — Paged.js handles cover sections (100vh, break-after: page,
|
||||
// page-break-inside: avoid) natively. No manual fixup needed.
|
||||
|
||||
return { warnings, contentHeight };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// 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, noPaged } = 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 + ${noPaged ? 'Chromium native @page' : '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: design_engine.py output with fixed-canvas page-sections.
|
||||
// These are already laid out as absolute-positioned pages — skip Paged.js,
|
||||
// use Chromium native print directly.
|
||||
console.log(`\n🎨 Creative layout: ${continuousInfo.pages} pages @ ${continuousInfo.width} × ${continuousInfo.height}`);
|
||||
await page.pdf({
|
||||
path: absOut,
|
||||
printBackground: true,
|
||||
preferCSSPageSize: false,
|
||||
margin: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||
width: continuousInfo.width,
|
||||
height: continuousInfo.height,
|
||||
});
|
||||
} else if (noPaged) {
|
||||
// ── Chromium native pagination (--nopaged flag) ──
|
||||
console.log('\n📄 Rendering PDF (Chromium native @page)...');
|
||||
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 {
|
||||
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) {
|
||||
pdfOpts.margin = { top: 0, right: 0, bottom: 0, left: 0 };
|
||||
console.log(` @page size: ${pageSize.width}px × ${pageSize.height}px`);
|
||||
} else {
|
||||
pdfOpts.format = 'A4';
|
||||
}
|
||||
}
|
||||
|
||||
await page.pdf(pdfOpts);
|
||||
} else {
|
||||
// Inject Paged.js polyfill to take over pagination.
|
||||
// Paged.js reads @page rules, chunks content into .pagedjs_page containers,
|
||||
// and handles break-inside/before/after, orphans/widows, named pages, etc.
|
||||
console.log('\n📖 Injecting Paged.js for pagination...');
|
||||
|
||||
// Resolve the paged.polyfill.js path
|
||||
const pagedPolyfillPath = (() => {
|
||||
// Try workspace node_modules first
|
||||
const candidates = [
|
||||
path.resolve(__dirname, '..', '..', '..', 'node_modules', 'pagedjs', 'dist', 'paged.polyfill.js'),
|
||||
path.resolve(process.cwd(), 'node_modules', 'pagedjs', 'dist', 'paged.polyfill.js'),
|
||||
];
|
||||
for (const c of candidates) {
|
||||
if (fs.existsSync(c)) return c;
|
||||
}
|
||||
// Try require.resolve
|
||||
try {
|
||||
const mod = require.resolve('pagedjs/dist/paged.polyfill.js');
|
||||
return mod;
|
||||
} catch (_) {}
|
||||
return null;
|
||||
})();
|
||||
|
||||
if (!pagedPolyfillPath) {
|
||||
console.error('\n✗ pagedjs not found. Run: npm install pagedjs');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pagedScript = fs.readFileSync(pagedPolyfillPath, 'utf-8');
|
||||
await page.addScriptTag({ content: pagedScript });
|
||||
|
||||
// Wait for Paged.js to finish rendering
|
||||
try {
|
||||
await page.waitForSelector('.pagedjs_page', { timeout: 30000 });
|
||||
// Give it extra time for all pages to settle
|
||||
await sleep(2000);
|
||||
} catch (_) {
|
||||
console.log(' ⚠ Paged.js did not create .pagedjs_page elements (timeout 30s)');
|
||||
console.log(' Falling back to Chromium native @page pagination.');
|
||||
warnings.push('Paged.js rendering timed out; fell back to native pagination');
|
||||
}
|
||||
|
||||
// Check Paged.js results
|
||||
const pagedInfo = await page.evaluate(() => {
|
||||
const pages = document.querySelectorAll('.pagedjs_page');
|
||||
if (pages.length === 0) return null;
|
||||
return {
|
||||
pageCount: pages.length,
|
||||
pageWidth: pages[0].offsetWidth,
|
||||
pageHeight: pages[0].offsetHeight,
|
||||
};
|
||||
});
|
||||
|
||||
if (pagedInfo) {
|
||||
console.log(` ✓ Paged.js: ${pagedInfo.pageCount} pages @ ${pagedInfo.pageWidth}×${pagedInfo.pageHeight}px`);
|
||||
}
|
||||
|
||||
// ── Post-Paged.js background & centering fix ──
|
||||
// Paged.js creates .pagedjs_page containers that are transparent by default.
|
||||
// If body has a background color/image, the empty areas of each page may show
|
||||
// the wrong color. Fix: propagate background to .pagedjs_page containers.
|
||||
const bgFix = await page.evaluate(() => {
|
||||
const body = document.body;
|
||||
const html = document.documentElement;
|
||||
const bodyStyle = getComputedStyle(body);
|
||||
const htmlStyle = getComputedStyle(html);
|
||||
const bodyBg = bodyStyle.backgroundColor;
|
||||
const htmlBg = htmlStyle.backgroundColor;
|
||||
const bodyBgImg = bodyStyle.backgroundImage;
|
||||
const htmlBgImg = htmlStyle.backgroundImage;
|
||||
|
||||
const isTransparent = (c) => !c || c === 'rgba(0, 0, 0, 0)' || c === 'transparent';
|
||||
const hasGradient = (img) => img && img !== 'none';
|
||||
|
||||
// Check if body or html uses a gradient/image background
|
||||
const bodyHasGradient = hasGradient(bodyBgImg);
|
||||
const htmlHasGradient = hasGradient(htmlBgImg);
|
||||
|
||||
// If body uses gradient, propagate the full background shorthand
|
||||
// (not just backgroundColor, which would be transparent for gradients)
|
||||
if (bodyHasGradient) {
|
||||
// Copy body's full background to each .pagedjs_page
|
||||
const pages = document.querySelectorAll('.pagedjs_page');
|
||||
pages.forEach(p => {
|
||||
p.style.backgroundImage = bodyBgImg;
|
||||
p.style.backgroundSize = bodyStyle.backgroundSize;
|
||||
p.style.backgroundPosition = bodyStyle.backgroundPosition;
|
||||
p.style.backgroundRepeat = bodyStyle.backgroundRepeat;
|
||||
if (!isTransparent(bodyBg)) p.style.backgroundColor = bodyBg;
|
||||
});
|
||||
// Unify html to prevent color leaking
|
||||
html.style.backgroundColor = isTransparent(bodyBg) ? '#000' : bodyBg;
|
||||
html.style.backgroundImage = bodyBgImg;
|
||||
|
||||
const pagesContainer = document.querySelector('.pagedjs_pages');
|
||||
if (pagesContainer) { pagesContainer.style.margin = '0'; pagesContainer.style.padding = '0'; }
|
||||
|
||||
return { pageBg: 'gradient', pagesFixed: pages.length, bodyBg, htmlBg, gradient: true };
|
||||
}
|
||||
|
||||
// Solid color path: body bg > html bg > white
|
||||
const pageBg = !isTransparent(bodyBg) ? bodyBg
|
||||
: !isTransparent(htmlBg) ? htmlBg
|
||||
: 'white';
|
||||
|
||||
const pages = document.querySelectorAll('.pagedjs_page');
|
||||
let fixed = 0;
|
||||
pages.forEach(p => {
|
||||
if (isTransparent(getComputedStyle(p).backgroundColor)) {
|
||||
p.style.backgroundColor = pageBg;
|
||||
fixed++;
|
||||
}
|
||||
});
|
||||
|
||||
// Unify html/body background to match (prevents color leaking around pages)
|
||||
html.style.backgroundColor = pageBg;
|
||||
// Only set body bg if it was transparent (don't override an intentional body bg)
|
||||
if (isTransparent(bodyBg)) body.style.backgroundColor = pageBg;
|
||||
|
||||
const pagesContainer = document.querySelector('.pagedjs_pages');
|
||||
if (pagesContainer) { pagesContainer.style.margin = '0'; pagesContainer.style.padding = '0'; }
|
||||
|
||||
return { pageBg, pagesFixed: fixed, bodyBg, htmlBg, gradient: false };
|
||||
});
|
||||
|
||||
if (bgFix.pagesFixed > 0) {
|
||||
console.log(` ✓ Background fix: ${bgFix.pagesFixed} pages → ${bgFix.pageBg}`);
|
||||
}
|
||||
|
||||
// ── Post-Paged.js layout alignment fix ──
|
||||
// Paged.js renders pages inside .pagedjs_pages with padding/margin for screen
|
||||
// preview (visual spacing between pages). In print/PDF mode this causes the
|
||||
// .pagedjs_page to start at (40,40) instead of (0,0), shifting content.
|
||||
// Fix: reset all Paged.js wrapper margins/paddings so pages start at origin.
|
||||
if (pagedInfo) {
|
||||
const alignFix = await page.evaluate(() => {
|
||||
// Reset pagedjs_pages container
|
||||
const pc = document.querySelector('.pagedjs_pages');
|
||||
if (pc) {
|
||||
pc.style.margin = '0';
|
||||
pc.style.padding = '0';
|
||||
}
|
||||
// Reset all pagedjs_page wrappers
|
||||
document.querySelectorAll('.pagedjs_page').forEach(p => {
|
||||
p.style.margin = '0';
|
||||
});
|
||||
// Reset body/html margins
|
||||
document.body.style.margin = '0';
|
||||
document.body.style.padding = '0';
|
||||
document.documentElement.style.margin = '0';
|
||||
document.documentElement.style.padding = '0';
|
||||
// Check final alignment
|
||||
const firstPage = document.querySelector('.pagedjs_page');
|
||||
if (!firstPage) return { fixed: false };
|
||||
const rect = firstPage.getBoundingClientRect();
|
||||
return { fixed: true, x: rect.x, y: rect.y, w: rect.width, h: rect.height };
|
||||
});
|
||||
if (alignFix.fixed && (alignFix.x > 1 || alignFix.y > 1)) {
|
||||
console.log(` ⚠ Page offset after reset: (${alignFix.x}, ${alignFix.y}) — may still have internal pagedjs margins`);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate PDF
|
||||
console.log('\n📄 Rendering PDF...');
|
||||
const pdfOpts = {
|
||||
path: absOut,
|
||||
printBackground: true,
|
||||
preferCSSPageSize: true,
|
||||
tagged: true,
|
||||
margin: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||
};
|
||||
|
||||
if (width || height) {
|
||||
if (width) pdfOpts.width = width;
|
||||
if (height) pdfOpts.height = height;
|
||||
console.log(` Custom size: ${pdfOpts.width || 'auto'} × ${pdfOpts.height || 'auto'}`);
|
||||
} else if (!pagedInfo) {
|
||||
// Paged.js didn't run (fallback) — detect @page or use A4
|
||||
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) {
|
||||
console.log(` @page size: ${pageSize.width}px × ${pageSize.height}px`);
|
||||
} 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`);
|
||||
const engineLabel = continuousInfo ? 'Playwright (Creative canvas)'
|
||||
: noPaged ? 'Playwright (Chromium native @page)'
|
||||
: 'Playwright + Paged.js';
|
||||
console.log(` Engine: ${engineLabel}`);
|
||||
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,
|
||||
noPaged: args.noPaged,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error:', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
Executable
+256
@@ -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);
|
||||
});
|
||||
Executable
+2991
File diff suppressed because it is too large.
Load diff
Executable
+1174
File diff suppressed because it is too large.
Load diff
Executable
+1414
File diff suppressed because it is too large.
Load diff
Executable
+329
@@ -0,0 +1,329 @@
|
||||
#!/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@1.50.0"
|
||||
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: conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/ && conda install tectonic" ;;
|
||||
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: conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/ && conda install tectonic" ;;
|
||||
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 [ -f "$HOME/Library/Fonts/NotoSansSC[wght].ttf" ] || fc-list :lang=zh 2>/dev/null | head -1 | grep -q . \
|
||||
|| ls "$HOME/Library/Fonts/"*NotoSansSC* &>/dev/null 2>&1; then
|
||||
ok "CJK fonts available (Noto Sans SC or system)"
|
||||
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
|
||||
|
||||
# ── 9b. Noto Serif SC (Chinese serif for covers / academic docs) ──
|
||||
echo ""
|
||||
echo "--- Noto Serif SC (Chinese serif, covers/academic) ---"
|
||||
ENV_SETUP_FONT="$(cd "$SCRIPT_DIR/../.." && pwd)/env-setup/fonts/noto-serif-sc/NotoSerifSC-Regular.ttf"
|
||||
NOTO_SERIF_FOUND=0
|
||||
|
||||
# Method 1: Check via fc-list
|
||||
if command -v fc-list &>/dev/null; then
|
||||
if fc-list | grep -qi "Noto Serif SC"; then
|
||||
ok "Noto Serif SC (fc-list)"
|
||||
NOTO_SERIF_FOUND=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Method 2: Check common font directories
|
||||
if [ $NOTO_SERIF_FOUND -eq 0 ]; then
|
||||
for CHECK_PATH in \
|
||||
"$HOME/Library/Fonts/NotoSerifSC"*.ttf \
|
||||
"/Library/Fonts/NotoSerifSC"*.ttf \
|
||||
"$HOME/.local/share/fonts/NotoSerifSC"*.ttf \
|
||||
"/usr/share/fonts/"*"/NotoSerifSC"*.ttf; do
|
||||
if ls $CHECK_PATH &>/dev/null 2>&1; then
|
||||
ok "Noto Serif SC ($CHECK_PATH)"
|
||||
NOTO_SERIF_FOUND=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Method 3: Check env-setup bundled font
|
||||
if [ $NOTO_SERIF_FOUND -eq 0 ]; then
|
||||
if [ -f "$ENV_SETUP_FONT" ]; then
|
||||
warn "Noto Serif SC not installed system-wide, but available in env-setup bundle"
|
||||
info "Run this setup interactively to auto-install, or copy manually"
|
||||
if [ -t 0 ]; then
|
||||
read -p " Install Noto Serif SC to user fonts? [Y/n] " -n 1 -r REPLY
|
||||
echo ""
|
||||
REPLY=${REPLY:-Y}
|
||||
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
||||
USER_FONT_DIR="$HOME/Library/Fonts"
|
||||
if [ "$OS" = "Linux" ]; then
|
||||
USER_FONT_DIR="$HOME/.local/share/fonts"
|
||||
fi
|
||||
mkdir -p "$USER_FONT_DIR"
|
||||
cp "$ENV_SETUP_FONT" "$USER_FONT_DIR/"
|
||||
if command -v fc-cache &>/dev/null; then
|
||||
fc-cache -f "$USER_FONT_DIR" 2>/dev/null
|
||||
fi
|
||||
ok "Noto Serif SC installed to $USER_FONT_DIR"
|
||||
NOTO_SERIF_FOUND=1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
fail "Noto Serif SC not found"
|
||||
info "Download from: https://fonts.google.com/noto/specimen/Noto+Serif+SC"
|
||||
case "$OS" in
|
||||
Linux) info "Or install: sudo apt install fonts-noto-cjk" ;;
|
||||
esac
|
||||
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 "============================================"
|
||||
Executable
+2085
File diff suppressed because it is too large.
Load diff
Executable
+321
@@ -0,0 +1,321 @@
|
||||
# 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 = **60–70%** 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)
|
||||
- **⚠️ Caption vs Title rule**: When the chart has an external caption (figure caption / 图注, e.g. “图 1: XXX” below the chart), **do NOT add a title inside the chart** (`ax.set_title()`). The caption already serves as the chart’s identifier — an internal title would duplicate it and waste vertical space. Only add an internal title when the chart is standalone (no caption).
|
||||
|
||||
#### 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 chart’s title/subtitle/legend must be fully within its own bounding box
|
||||
Executable
+384
@@ -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: 'FreeSerif', 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: FreeSerif Bold, Noto Sans SC Heavy |
|
||||
| **Font prohibition** | **Absolutely forbidden** to use thin or serif fonts for this type of watermark |
|
||||
| **Character count** | Extracted string length `1–5` 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, "FreeSerif", 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, "FreeSerif", font_size)
|
||||
|
||||
c.setFont("FreeSerif", 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, "FreeSerif", 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("FreeSerif", 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
|
||||
Executable
+985
@@ -0,0 +1,985 @@
|
||||
# Cover Design - Cover Layout Engine Specification
|
||||
|
||||
> The cover is the first impression. Either skip it, or build it like architecture.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Critical Rules (Read First)
|
||||
|
||||
1. **Cover is OPTIONAL.** Do NOT force a cover on documents that don't need one. When in doubt, skip.
|
||||
2. **Unified cover system.** All routes (Report, Creative, Academic) use the HTML/Playwright cover system. **Template 01** is general-purpose (also covers government/bidding scenarios); **Template 07 is Crystal Blue (dark cover + light-blue body)**; **Templates 03-04** are academic-specific (dark backgrounds, scholarly typography; 04 also covers Chinese journal/keyword scenarios); **Template 06 is institutional (white bg, black border frame, structured fields)**. All routes generate covers via Playwright and merge via pypdf.
|
||||
3. **Single PDF output.** Never deliver a separate cover PDF.
|
||||
- **Creative route**: Cover is part of the same HTML document → single PDF output inherently.
|
||||
- **Report route**: Cover PDF (Playwright) + body PDF (ReportLab) → merged via pypdf into one final PDF.
|
||||
- **Academic route**: Cover PDF (Playwright) + body PDF (Tectonic) → merged via pypdf into one final PDF.
|
||||
4. **Page isolation.** Cover content must NEVER share a page with TOC, body text, or any subsequent content. Report/Academic: isolation is inherent in the merge pipeline. Creative: CSS page-break enforces isolation. Cover + TOC on the same page = **critical bug**.
|
||||
|
||||
---
|
||||
|
||||
## When to Include a Cover
|
||||
|
||||
| Document Type | Cover Needed | Notes |
|
||||
|---------------|-------------|-------|
|
||||
| Formal report (annual, research, white paper) | ✅ Required | Conveys professionalism |
|
||||
| Proposal / plan | ✅ Required | First impression is everything |
|
||||
| Resume | ❌ Not needed | Content itself is the cover |
|
||||
| Menu / flyer / card | ❌ Not needed | Single page or function-oriented |
|
||||
| Invitation | ❌ Not needed | The front side IS the cover |
|
||||
| Lab report / academic paper | ⚠️ Situational | Add when template requires it |
|
||||
| Portfolio / lookbook | ✅ Required | Cover sets the tone |
|
||||
|
||||
---
|
||||
|
||||
# PART 0: GLOBAL ENGINE ARCHITECTURE (Mandatory)
|
||||
|
||||
These three architectural upgrades are **mandatory** for ALL cover rendering. They eliminate 90% of squished/misaligned/overflow bugs at the engine level.
|
||||
|
||||
---
|
||||
|
||||
## A0.0 - Global Base Parameters
|
||||
|
||||
**All templates MUST obey these.**
|
||||
|
||||
```
|
||||
W = page total width
|
||||
H = page total height
|
||||
U = W * 0.05 # Base spacing unit (5% of page width)
|
||||
# All spacing should be multiples of U
|
||||
```
|
||||
|
||||
**Coordinate origin:** `(0, 0)` at top-left corner. Some PDF libraries (ReportLab) use bottom-left origin - invert Y axis accordingly: `y_pdf = H - y_spec`.
|
||||
|
||||
---
|
||||
|
||||
## A0.1 - Absolute Anchor Grid (Replaces Flow Layout)
|
||||
|
||||
**Old (DEPRECATED):** `Y = previous_element_Y + height + spacing` - if one element overflows, everything below shifts and gets crushed.
|
||||
|
||||
**New (MANDATORY):** Page height = 100%. Every element group gets an **absolute percentage Y-anchor**. Elements may only grow **within their own bounding box**. They NEVER push or compress other blocks.
|
||||
|
||||
### Implementation Rule
|
||||
|
||||
```
|
||||
# WRONG - flow layout (banned)
|
||||
y_cursor = PAGE_H - top_margin
|
||||
y_cursor -= title_height
|
||||
y_cursor -= gap
|
||||
y_cursor -= subtitle_height # ← if title wraps, subtitle gets crushed
|
||||
|
||||
# RIGHT - absolute anchor grid
|
||||
ANCHOR_TITLE_Y = H * 0.30
|
||||
ANCHOR_SUMMARY_Y = H * 0.50
|
||||
ANCHOR_META_Y = H * 0.70
|
||||
ANCHOR_FOOTER_Y = H * 0.90
|
||||
# Each block renders at its anchor, independent of others
|
||||
```
|
||||
|
||||
### Bounding Box Containment
|
||||
|
||||
Each block has a **maximum bounding box** defined by two consecutive anchors:
|
||||
- Block can grow downward within `[own_anchor_Y, next_anchor_Y - min_gap]`
|
||||
- If content exceeds its bounding box → trigger overflow protection (see Part 3)
|
||||
- Blocks NEVER consume space belonging to adjacent blocks
|
||||
|
||||
---
|
||||
|
||||
## A0.2 - Typography Weight & Spacing System
|
||||
|
||||
Use **weight**, **letter-spacing**, and **opacity** to create hierarchy - not just font size.
|
||||
|
||||
### Mandatory Type Roles
|
||||
|
||||
| Role | Size | Weight | Letter-Spacing | Line-Height | Opacity | Purpose |
|
||||
|------|------|--------|----------------|-------------|---------|---------|
|
||||
| **Kicker / Footer** (decorative text) | 16pt | Regular | 3pt (very wide) | - | 60% | Wide spacing + transparency makes 16pt text feel delicate and recessive |
|
||||
| **Summary / Description** (summary paragraph) | 16-18pt | Regular | normal | **1.6** | 85% | **Fill visual space** - 2-4 lines of descriptive text that prevents empty covers |
|
||||
| **Meta / Subtitle** (secondary text) | 20-22pt | Light / Regular | normal | 1.4 | 85% | Comfortable reading rhythm, clear secondary hierarchy |
|
||||
| **Hero Title** (main title) | 32-65pt (CJK: 32-80pt) | Black / Heavy (extra bold) | normal-tight | **1.15** (multi-line) | 100% | Must create overwhelming scale contrast; visually dominates the page. CJK characters need +15-20% size to match Latin visual weight. **⚠️ For 3-line CJK titles with subtitle, max ~42pt (see S3.1 table).** |
|
||||
|
||||
### Data-to-Drawer Binding Rule
|
||||
|
||||
> **Hero Title = Company/entity name. Kicker = Report type/subtitle. Never reverse.**
|
||||
|
||||
When users provide structured information (company name + report name/type), they must be bound to typography drawers by these rules:
|
||||
|
||||
| User Data Field | Bound to Typography Role | Notes |
|
||||
|-------------|-------------|------|
|
||||
| **Company/entity name** (e.g. "GREENTECH") | **Hero Title** (45-65pt Heavy) | Company name is the **absolute visual center**, largest font, heaviest weight |
|
||||
| **Report type/subtitle** (e.g. "2025 Annual Report Summary") | **Kicker** (16pt, letter-spacing 3pt, opacity 60%) | Report name is decorative supplementary text, placed in small text position at top-left/above title |
|
||||
| **Summary/description** | **Summary** (16-18pt) | Detailed description text |
|
||||
| **Date/author/version** | **Meta** (20-22pt) | Auxiliary information |
|
||||
| **Document number/org signature** | **Footer** (16pt, opacity 60%) | Bottom closing |
|
||||
|
||||
**Mapping priority (when ambiguous):**
|
||||
1. If user provides only one name → treat as company/entity name, bind to Hero Title
|
||||
2. If user provides two names → shorter/more brand-like → Hero Title; longer/descriptive → Kicker or Summary
|
||||
3. If user explicitly labels "title" and "subtitle" → title → Hero Title, subtitle → Kicker
|
||||
4. **Never use report type names (e.g. "Annual Report", "White Paper") as Hero Title's largest text** - report type is always Kicker-level decorative text
|
||||
|
||||
### The Summary Block Rule (Anti-Void Iron Rule)
|
||||
|
||||
> Every cover MUST include a Summary/Description text block. If the user provides no summary, the system MUST auto-generate one.
|
||||
|
||||
**Why:** A cover with only a title and date looks barren. The Summary block physically fills 2-4 lines of space, preventing the "empty field" aesthetic.
|
||||
|
||||
**Auto-generation rule:** When no summary/description is provided:
|
||||
```python
|
||||
# Generate a default summary
|
||||
if not summary_text:
|
||||
summary_text = f"This report was generated by {org_name or 'the system'}, containing comprehensive data analysis and insights."
|
||||
# Or in English:
|
||||
# summary_text = f"This report presents comprehensive analysis and key insights prepared by {org_name or 'the organization'}."
|
||||
```
|
||||
|
||||
**Constraints:**
|
||||
- Width: template-specific (typically `W * 0.5` to `W * 0.6`)
|
||||
- Lines: 2-4 lines (auto-wrap at width boundary)
|
||||
- Never truncate summary - if too long, reduce to 4 lines max with `...`
|
||||
|
||||
### Font Weight Fallback
|
||||
|
||||
- If font family lacks Black/Heavy weight → use Bold + slightly larger size (+4pt)
|
||||
- If font family lacks Light weight → use Regular + increased letter-spacing (+1pt)
|
||||
- CJK fonts: Noto Serif SC for Regular/Light roles; for Heavy/Black → increase size by 15% to compensate for single-weight CJK fonts
|
||||
- English kickers/footers: **FORCE UPPERCASE** via code (`text.upper()` / `toUpperCase()`)
|
||||
|
||||
---
|
||||
|
||||
## A0.3 - Z-Index Layer Management
|
||||
|
||||
All cover elements must be rendered in strict layer order. No exceptions.
|
||||
|
||||
| Layer | Z-Index | Contents | Rules |
|
||||
|-------|---------|----------|-------|
|
||||
| **Layer 0** (base) | 0 | Background fill (white / light gray) | Always rendered first; full page |
|
||||
| **Layer 1** (background) | 1 | Grids, watermark letters, decorative blocks, large clipped graphics | **MUST enable clip-path** - background elements may extend beyond logical bounds but must be clipped to page physical bounds. Never let background elements inflate PDF page size. |
|
||||
| **Layer 2** (structure) | 2 | Ultra-thin divider lines, sidebars, corner crop marks | Structural guides that define spatial zones |
|
||||
| **Layer 3** (content) | 3 | All readable text content | Rendered last, always on top |
|
||||
|
||||
### Clip-Path Enforcement
|
||||
|
||||
```css
|
||||
/* clip background overflow — ONLY on Layer 1 */
|
||||
.cover-bg-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden; /* MANDATORY */
|
||||
z-index: 1;
|
||||
}
|
||||
```
|
||||
|
||||
> ⚠️ **Clip scope = Layer 1 ONLY.** `overflow: hidden` must ONLY be on the Layer 1 background container. Layer 2 (lines) and Layer 3 (text) containers must NOT have `overflow: hidden`.
|
||||
|
||||
### Anti-Clip Bug (Layer 3 Text Truncation Fix)
|
||||
|
||||
**Symptom:** Cover text is visible but truncated by an invisible boundary.
|
||||
|
||||
**Root cause:** `overflow: hidden` scope too broad, clipping text in Layer 3.
|
||||
|
||||
**Iron rule:**
|
||||
```html
|
||||
<!-- ✅ CORRECT - overflow:hidden only on Layer 1 -->
|
||||
<div class="cover-layer-1" style="position:absolute; inset:0; overflow:hidden; z-index:1;">
|
||||
<!-- Background decorative elements -->
|
||||
</div>
|
||||
<div class="cover-layer-2" style="position:absolute; inset:0; z-index:2;">
|
||||
<!-- Structure lines, no overflow:hidden -->
|
||||
</div>
|
||||
<div class="cover-layer-3" style="position:absolute; inset:0; z-index:3;">
|
||||
<!-- Text content, no overflow:hidden -->
|
||||
</div>
|
||||
|
||||
<!-- ❌ WRONG - global overflow:hidden clips text -->
|
||||
<div class="cover-container" style="overflow:hidden;">
|
||||
<div class="layer-1">...</div>
|
||||
<div class="layer-2">...</div>
|
||||
<div class="layer-3">...</div> <!-- Text gets clipped! -->
|
||||
</div>
|
||||
```
|
||||
|
||||
### No Page Border/Frame
|
||||
|
||||
**Symptom:** A rectangular border appears around the entire cover page.
|
||||
|
||||
**Iron rule:**
|
||||
```css
|
||||
.cover-page {
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
```
|
||||
|
||||
### Minimum Spacing Between Decorative Lines and Text (Line-to-Text Spacing)
|
||||
|
||||
**Symptom:** Decorative lines on the cover (Layer 2 dividers, corner marks, sidebar edges) are flush against or overlapping with text.
|
||||
|
||||
**Iron rule:**
|
||||
```
|
||||
Minimum spacing between decorative lines and any text content = U (= W * 0.05)
|
||||
i.e., at least 1 U of whitespace between line edges and text edges
|
||||
```
|
||||
|
||||
| Line Type | Minimum Spacing | Notes |
|
||||
|---------|---------|------|
|
||||
| Horizontal divider | `U` above and below the line | Line must not be flush against title or body text |
|
||||
| Vertical sidebar edge | `U` to the right of the line | Text inside sidebar must maintain spacing from the edge |
|
||||
| Ultra-thick anchor line (Template 01) | `2 * U` to the right of the line | Thick line and title need ample breathing room |
|
||||
|
||||
---
|
||||
|
||||
# PART 1: 5 Cover Templates (Cover Rendering Specifications)
|
||||
|
||||
Coordinates use `W` (page width) and `H` (page height). `U = W * 0.05` (base spacing unit).
|
||||
|
||||
All templates inherit the A0.0-A0.3 architecture rules above.
|
||||
|
||||
**Each template now includes a mandatory Summary/Description drawer** to fill visual space.
|
||||
|
||||
---
|
||||
|
||||
## Template 01: HUD Data Terminal - Ultra-Thick Vertical Anchor Line
|
||||
|
||||
**Design intent:** A single bold vertical line on the left anchors all visual weight. The thick line eliminates left-side floating. Clean, data-driven, authoritative.
|
||||
|
||||
### Layer 1 - Background
|
||||
- Full-page grid pattern: horizontal + vertical lines at ~50pt intervals
|
||||
- Grid color: primary color at **2% opacity** (white background, nearly invisible)
|
||||
- Grid line width: `0.5pt`
|
||||
|
||||
### Layer 2 - Structure
|
||||
- **Left anchor line:** Start `(0.12*W, 0.1*H)`, End `(0.12*W, 0.9*H)`. Line width = **6pt**, primary color.
|
||||
|
||||
|
||||
### Layer 3 - Content
|
||||
|
||||
**Content left edge: `X_content = 0.12*W + 30pt`** (offset from the thick line)
|
||||
|
||||
| Drawer | Y-Anchor | Content | Constraints |
|
||||
|--------|----------|---------|-------------|
|
||||
| **A - Kicker** | `0.15 * H` | Report type / subtitle (e.g. "2025 Annual Report Summary") | 16pt, Regular, letter-spacing 3pt, opacity 60%, uppercase |
|
||||
| **B - Hero Title** | `0.30 * H` | **Company/entity name** (e.g. "GREENTECH") | 45-65pt (CJK: 50-80pt), Heavy. Company name is the visual center |
|
||||
| **C - Summary** | `0.50 * H` | 2-3 lines descriptive text about the report | 16-18pt, Regular, line-height 1.6, opacity 85%. **Width limit: `W * 0.6`**, auto-wrap. This drawer fills the mid-page void |
|
||||
| **D - Meta/Date** | `0.75 * H` | Author, org, date | 16-20pt, Regular |
|
||||
|
||||
### Best For
|
||||
Technology reports, data analysis, dashboard summaries, technical white papers
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
# PART 2: TEMPLATE SELECTION GUIDE
|
||||
|
||||
Template selection uses a two-dimensional matrix: **Intent** (from `visual_framework.md` 5-intent system) × **Document Type**. This replaces the old "Document Tone" classification and aligns with the Intent Mapping Table in `creative-fixed-canvas.md`.
|
||||
|
||||
| Intent | Document Type | Recommended Templates | Default |
|
||||
|--------|---------------|----------------------|---------|
|
||||
| **Calm** | Healthcare / Wellness / Minimalist | 07 Crystal Blue, 01 HUD | **07** |
|
||||
| **Calm** | Academic / Research | 07 Crystal Blue, 04 Academic Symmetric | **07** |
|
||||
| **Tension** | Crisis / Alert / Disruption | 01 HUD, 04 Academic Symmetric | **01** |
|
||||
| **Energy** | Marketing / Creative / Design | 07 Crystal Blue, 04 Academic Symmetric | **07** |
|
||||
| **Energy** | Technology / Data | 01 HUD, **07 Crystal Blue** | **07** |
|
||||
| **Authority** | Formal / Corporate / Financial | 07 Crystal Blue, 04 Academic Symmetric | **04** |
|
||||
| **Authority** | Government / Bidding | 01 HUD, 04 Academic Symmetric, **06 Institutional** | **01** |
|
||||
| **Authority** | Thesis proposal / Dissertation cover | **06 Institutional** | **06** |
|
||||
| **Authority** | Luxury / Editorial | 04 Academic Symmetric, 07 Crystal Blue | **04** |
|
||||
| **Warmth** | Food / Lifestyle / Home | 07 Crystal Blue, 01 HUD | **07** |
|
||||
|
||||
> **Legacy mapping:** "Formal/Corporate" tone → Authority intent, "Minimalist" tone → Calm intent, "Luxurious/Editorial" tone → Authority intent.
|
||||
|
||||
**⚠️ No Global Default.** When no specific style is explicitly requested, the LLM MUST analyze the document's content, tone, and audience to autonomously select the most fitting template. Cross-reference the Intent (derived from content via `design_engine.py derive` or manual judgment) with the Document Type to find the best match. Every cover selection must be a deliberate design decision.
|
||||
|
||||
---
|
||||
|
||||
# PART 3: CODE-LEVEL SAFETY MEASURES (Pre-Render Safeguards)
|
||||
|
||||
These checks run BEFORE final rendering. They are **mandatory** - not optional optimizations.
|
||||
|
||||
---
|
||||
|
||||
## S3.0 - Cover Overlap Validation (MANDATORY)
|
||||
|
||||
**After generating the cover HTML and before rendering to PDF, run:**
|
||||
|
||||
```bash
|
||||
node "$PDF_SKILL_DIR/scripts/cover_validate.js" cover.html
|
||||
```
|
||||
|
||||
This detects text-vs-decorative-line overlaps by rendering the HTML and measuring actual bounding boxes. Exit code 1 = overlap found, must fix before generating the PDF.
|
||||
|
||||
The minimum gap between any text element and any decorative line is **1U (= 5% of page width ≈ 40px on A4)**. This catches the exact bug shown in the "text overlapping decorative lines" screenshots.
|
||||
|
||||
**If the check fails:**
|
||||
1. Adjust the decorative line's Y position to maintain ≥ 1U gap from the nearest text
|
||||
2. Or adjust the text block's position/size to avoid the overlap
|
||||
3. Re-run `cover_validate.js` until it passes (exit code 0)
|
||||
|
||||
---
|
||||
|
||||
## S3.1 - Hero Title Overflow Protection (Title Line-Wrapping)
|
||||
|
||||
**Rule:** Hero title must NEVER exceed its template's width boundary.
|
||||
|
||||
1. If rendered width > max_width → word-wrap (CJK: any character; Latin: space/hyphen)
|
||||
2. Multi-line hero titles: **lock line-height to 1.15**
|
||||
3. Maximum lines: **3** — if 4+ lines needed, reduce font size by 4pt increments
|
||||
4. Minimum font size floor: **32pt** (below this, truncate with `...`)
|
||||
|
||||
### Title Size vs Lines Quick-Reference (Template 01, zone B→C ≈ 225px)
|
||||
|
||||
| Lines | With subtitle | Without subtitle |
|
||||
|-------|--------------|------------------|
|
||||
| 1 | ≤ 80pt | ≤ 80pt |
|
||||
| 2 | ≤ 55pt | ≤ 70pt |
|
||||
| 3 | ≤ 42pt | ≤ 48pt |
|
||||
|
||||
**⚠️ CJK 3-line titles must reduce to ~42pt.** Do NOT use 50-80pt for 3-line CJK titles — it WILL overlap the Summary zone.
|
||||
|
||||
---
|
||||
|
||||
## S3.2 - Zone Collision Detection
|
||||
|
||||
After placing each text block, check if its bottom edge penetrates the next zone's top:
|
||||
|
||||
1. **Reposition first:** Push the collided zone down by `overlap + 20px` gap. All downstream zones shift together.
|
||||
2. **Font reduction (if reposition insufficient):** Decrease title by 2pt increments. Floor: **32pt** for titles, 14pt for meta/summary.
|
||||
3. **Truncation (last resort):** If font reduction hits floor and still collides, truncate with `...`
|
||||
|
||||
---
|
||||
|
||||
## S3.3 - Uppercase Lock
|
||||
|
||||
**Force-uppercase when content is English/Latin:**
|
||||
- Kicker (category label / lead-in text)
|
||||
- Footer (closing date / document number)
|
||||
- Background watermark text
|
||||
- Any Layer 1 decorative text
|
||||
|
||||
CJK text is exempt. Mixed CJK+Latin strings: uppercase only the Latin portions. Use `text-transform: uppercase` in CSS or `.upper()` in code.
|
||||
|
||||
---
|
||||
|
||||
## S3.4 - Hard Width Boundary Enforcement
|
||||
|
||||
**Every drawer/zone has a maximum width. Text MUST respect this width exactly.** Use CSS `max-width` on text containers.
|
||||
|
||||
Vertical growth (more lines) is acceptable. Horizontal overflow is a **critical bug**.
|
||||
|
||||
---
|
||||
|
||||
## S3.5 - Mandatory Summary Auto-Generation
|
||||
|
||||
**If the user provides only a title and no description/summary, auto-generate a 2-4 line placeholder.** A title-only cover looks barren. The Summary drawer physically fills mid-page void.
|
||||
|
||||
---
|
||||
|
||||
## S3.6 - Background Watermark Full-Display Enforcement
|
||||
|
||||
**All watermark text in Layer 1 must be 100% within the visible page area. Cropping is strictly forbidden.**
|
||||
|
||||
**Applicable scope:**
|
||||
- `cover-backgrounds.md` Recipe 2.1 giant sidebar pillar
|
||||
- `cover-backgrounds.md` Recipe 2.2 bottom full-size text
|
||||
- Any other decorative text in the background layer
|
||||
|
||||
**Rules:**
|
||||
1. Horizontal text: rendered width must not exceed `W * 0.90` (5% safety margin each side)
|
||||
2. Vertical/rotated text: rendered height must not exceed `H * 0.85` (7.5% safety margin top/bottom)
|
||||
3. If exceeded, scale down font size proportionally — never truncate
|
||||
4. Anchor coordinates must never exceed page boundaries
|
||||
|
||||
---
|
||||
|
||||
## S3.7 - Line-Length Alignment (Line Must Match Text Span)
|
||||
|
||||
**Decorative lines must be sized relative to the text they serve:**
|
||||
|
||||
- **Vertical lines:** height = text block height (± 1U padding above/below first/last element)
|
||||
- **Horizontal lines:** width ≥ widest text element in its zone (up to 120%, NEVER shorter)
|
||||
|
||||
```css
|
||||
.vline {
|
||||
position: absolute;
|
||||
top: var(--content-top); /* align with first text element */
|
||||
bottom: var(--content-bottom); /* align with last text element */
|
||||
}
|
||||
.hline {
|
||||
width: max(100%, 200px); /* at least as wide as parent text container */
|
||||
}
|
||||
```
|
||||
|
||||
**Checklist:**
|
||||
- [ ] Every vertical line’s height matches its adjacent text block span
|
||||
- [ ] Every horizontal line’s width ≥ widest text element in its zone
|
||||
- [ ] No decorative line is shorter than the text it accompanies
|
||||
|
||||
---
|
||||
|
||||
## S3.8 - Vertical Balance (Anti-Top-Heavy Layout)
|
||||
|
||||
**Problem:** Content clusters at top of page, bottom 40%+ is dead whitespace.
|
||||
|
||||
**Rule:** When total content height < 50% of page height, switch to centered distribution (flexbox `justify-content: center`). Otherwise use anchor grid:
|
||||
|
||||
| Content Volume | Title Anchor | Summary Anchor | Meta Anchor |
|
||||
|---------------|-------------|----------------|-------------|
|
||||
| **Sparse** (fill < 50%) | Centered mode | Centered mode | Centered mode |
|
||||
| **Normal** (fill 50-80%) | `H * 0.30` | `H * 0.48` | `H * 0.70` |
|
||||
| **Dense** (fill > 80%) | `H * 0.20` | `H * 0.40` | `H * 0.65` |
|
||||
|
||||
**CJK Title Size Compensation:** CJK characters appear smaller at same pt size. Increase by 15-20%:
|
||||
- CJK Hero Title: 50-80pt (Latin: 45-65pt)
|
||||
- CJK Kicker: 11-12pt (Latin: 9pt)
|
||||
- CJK Summary: 17-20pt (Latin: 16-18pt)
|
||||
|
||||
**Checklist:**
|
||||
- [ ] No cover has >40% dead whitespace at the bottom
|
||||
- [ ] CJK titles are 15-20% larger than equivalent Latin titles
|
||||
- [ ] Sparse-content covers use centered distribution
|
||||
|
||||
---
|
||||
|
||||
## S3.9 - Percentage Positioning Requires Known-Size Container
|
||||
|
||||
**Problem:** `top: XX%` resolves against containing block’s height. If height is undefined (all children absolutely positioned), percentages collapse and elements stack.
|
||||
|
||||
**Iron rule:** Container with `top: XX%` children MUST have deterministic height (`height: 100%`, `inset: 0`, or `top` + `bottom` pair).
|
||||
|
||||
```css
|
||||
/* ✅ Safest: flat structure with px values */
|
||||
.cover { position: relative; width: 794px; height: 1123px; }
|
||||
.kicker { position: absolute; top: 225px; left: 95px; }
|
||||
.title { position: absolute; top: 292px; left: 95px; }
|
||||
|
||||
/* ✅ OK: wrapper with inset:0 gives it known height */
|
||||
.content-left { position: absolute; inset: 0; width: 55%; }
|
||||
.title { position: absolute; top: 26%; } /* 26% of 1123px ✓ */
|
||||
|
||||
/* ❌ BANNED: wrapper without height, percentage is undefined */
|
||||
.content-left { position: absolute; left: 12%; top: 0; width: 55%; }
|
||||
.title { position: absolute; top: 26%; } /* 26% of WHAT? → overlap */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# PART 4: COVER COLOR RULES
|
||||
|
||||
> Cover colors must be consistent with the body color system - they cannot exist independently.
|
||||
> **The cascade palette CSS output includes `--c-*` aliases** that map directly to cover template variables. Paste the CSS output into cover HTML's `:root` block — template 01 works without any manual color translation.
|
||||
|
||||
```
|
||||
Cover primary = Body theme color
|
||||
Cover secondary = Primary lightness variant (±20% lightness)
|
||||
Cover background = Pure white / very light gray / primary at 5-8% opacity
|
||||
```
|
||||
|
||||
### Absolutely Forbidden
|
||||
|
||||
- ❌ Dark large-area solid backgrounds (dark blue, dark green, black filling the page)
|
||||
- ❌ Gradient backgrounds (any `linear-gradient` / `radial-gradient` as large-area fill)
|
||||
- ❌ High-saturation color schemes
|
||||
- ❌ Rainbow / multi-color gradients
|
||||
- ❌ Dense textures or patterns
|
||||
- ❌ Piling on decorative elements - restraint > clutter
|
||||
- ❌ More than 2 typefaces on a cover
|
||||
- ❌ Centered text + gradient/solid background (PowerPoint aesthetic)
|
||||
|
||||
### Safe Cover Color Schemes (Reference Only)
|
||||
|
||||
> ⚠️ These are **examples for reference**. In normal workflow, run `palette.cascade --title "<title>" --mode minimal --format css` to generate the actual cover colors. Do NOT copy these hex values directly.
|
||||
|
||||
> ✅ **Cascade CSS output includes `--c-*` aliases.** The `--format css` output automatically appends cover-compatible aliases (`--c-bg`, `--c-accent`, `--c-text`, `--c-muted`, `--c-mid`, `--c-surface`) mapped from cascade roles. Template 01 can consume the CSS output directly — no manual variable translation needed.
|
||||
>
|
||||
> | Cover alias | Cascade source | Tier | Purpose |
|
||||
> |------------|---------------|------|---------|
|
||||
> | `--c-bg` | `--page-bg` | XL | Cover background |
|
||||
> | `--c-accent` | `--accent` | XS | Decorative lines, labels |
|
||||
> | `--c-text` | `--text-primary` | text | Title text |
|
||||
> | `--c-muted` | `--text-muted` | text | Subtitle, footer |
|
||||
> | `--c-mid` | `--header-fill` | M | Structural fills |
|
||||
> | `--c-surface` | `--card-bg` | L | Card/container bg |
|
||||
|
||||
| Name | Primary | Secondary | Background | Use Case |
|
||||
|------|---------|-----------|------------|----------|
|
||||
| Ink Stone | `#1a1a2e` | `#4a4a5e` | `#fafafa` | Business, formal |
|
||||
| Indigo | `#1e3a5f` | `#2d5f8a` | `#f5f8fb` | Technology, reports |
|
||||
| Warm Chestnut | `#5c3d2e` | `#8a6b5a` | `#faf6f3` | Culture, branding |
|
||||
| Moss Green | `#2d4a3e` | `#4a7a6a` | `#f5f8f6` | Nature, health |
|
||||
| Deep Crimson | `#6b2d3e` | `#9a4a5e` | `#faf5f6` | Traditional, elegant |
|
||||
|
||||
---
|
||||
|
||||
# PART 4.5: ACADEMIC COVER TEMPLATES (Templates 03-04)
|
||||
|
||||
> **Academic covers are exempt from PART 4 color rules.** Academic papers, theses, and research reports traditionally use dark backgrounds with light text - this is the established scholarly visual language. Templates 03-04 follow LaTeX title page conventions translated to HTML/CSS.
|
||||
|
||||
**All 2 templates share these rules:**
|
||||
- Page size: `width: 794px; height: 1123px` (A4 at 96dpi)
|
||||
- Full-bleed dark background (edge-to-edge, no margins)
|
||||
- Serif font for titles (Playfair Display / Noto Serif SC), sans-serif for metadata
|
||||
- Generated as HTML → Playwright `page.pdf()` → pypdf merge (same pipeline as Report covers)
|
||||
- `<link>` tag for Google Fonts (NOT `@import`)
|
||||
|
||||
**Content slots (all templates):**
|
||||
| Slot | Required | Example |
|
||||
|------|----------|---------|
|
||||
| `label` | Optional | `RESEARCH PAPER`, `博士论文` |
|
||||
| `title` | **Required** | Paper title (auto-wrap, max 3 lines) |
|
||||
| `subtitle` | Optional | Subtitle or abstract excerpt |
|
||||
| `authors` | **Required** | Author name(s) |
|
||||
| `institution` | Optional | University / lab / affiliation |
|
||||
| `keywords` | Optional | Keyword list |
|
||||
| `footer_left` | Optional | Journal name, DOI |
|
||||
| `footer_right` | Optional | Date, version |
|
||||
|
||||
---
|
||||
|
||||
## Template 03: Academic Vertical Anchor - Dark bg + Left vertical line + Left-aligned
|
||||
|
||||
**Design intent:** Emulates the classic arXiv/preprint cover. A bold vertical accent line anchors the left edge, all text left-aligned with generous vertical rhythm. Serious, no-frills.
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ ┃ │
|
||||
│ ┃ LABEL (9pt, accent) │ ← y = H - 3.5cm
|
||||
│ ┃ │
|
||||
│ ┃ Title (32pt Bold) │ ← y = H - 6cm, line-height 42pt
|
||||
│ ┃ Title line 2 │
|
||||
│ ┃ │
|
||||
│ ┃ Subtitle (12pt) │ ← y = H - 14cm
|
||||
│ ┃ │
|
||||
│ ┃ Authors (12pt, white) │ ← y = H - 18cm
|
||||
│ ┃ Institution (10pt) │
|
||||
│ ┃ │
|
||||
│ ┃ Footer L Footer R │
|
||||
└─────────────────────────────┘
|
||||
┃ = vertical accent line at x=1.5cm, 2.5pt width
|
||||
```
|
||||
|
||||
**Best for:** Research papers, technical reports, arXiv preprints
|
||||
|
||||
**HTML structure:**
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700;900&family=Noto+Serif+SC:wght@400;700;900&family=Inter:wght@300;400;500&family=Noto+Sans+SC:wght@300;400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
@page { size: 794px 1123px; margin: 0; }
|
||||
:root {
|
||||
--c-bg: #162032;
|
||||
--c-accent: #8B7E5A;
|
||||
--c-text: #FFFFFF;
|
||||
--c-muted: #8898A8;
|
||||
--c-footer: #607080;
|
||||
}
|
||||
html, body { margin: 0; padding: 0; width: 794px; height: 1123px; background: var(--c-bg); color: var(--c-text); font-family: 'Inter', 'Noto Sans SC', sans-serif; }
|
||||
@media screen {
|
||||
html { height: auto; display: flex; justify-content: center; min-height: 100vh; background: var(--c-bg); }
|
||||
body { transform-origin: top center; scale: min(1, calc(100vw / 794), calc(100vh / 1123)); margin: 0 auto; box-shadow: 0 0 60px rgba(0,0,0,0.3); }
|
||||
}
|
||||
.cover { width: 794px; height: 1123px; position: relative; box-sizing: border-box; }
|
||||
.vline { position: absolute; left: 57px; top: 76px; bottom: 76px; width: 2.5px; background: var(--c-accent); }
|
||||
.content { position: absolute; left: 83px; right: 76px; top: 0; bottom: 0; }
|
||||
.label { position: absolute; top: 132px; font-size: 9pt; color: var(--c-accent); letter-spacing: 3px; text-transform: uppercase; font-family: 'Inter', 'Noto Sans SC', sans-serif; }
|
||||
.title { position: absolute; top: 228px; font-size: 32pt; font-weight: 700; line-height: 1.3; font-family: 'Playfair Display', 'Noto Serif SC', serif; color: var(--c-text); max-width: 580px; }
|
||||
.subtitle { position: absolute; top: 530px; font-size: 12pt; line-height: 1.5; color: var(--c-muted); max-width: 500px; }
|
||||
.authors { position: absolute; top: 680px; font-size: 12pt; color: var(--c-text); }
|
||||
.institution { position: absolute; top: 740px; font-size: 10pt; color: var(--c-muted); line-height: 1.4; }
|
||||
.footer { position: absolute; bottom: 76px; left: 0; right: 0; display: flex; justify-content: space-between; font-size: 9pt; color: var(--c-footer); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="cover">
|
||||
<div class="vline"></div>
|
||||
<div class="content">
|
||||
<div class="label"><!-- LABEL --></div>
|
||||
<div class="title"><!-- TITLE --></div>
|
||||
<div class="subtitle"><!-- SUBTITLE --></div>
|
||||
<div class="authors"><!-- AUTHORS --></div>
|
||||
<div class="institution"><!-- INSTITUTION --></div>
|
||||
<div class="footer">
|
||||
<span><!-- FOOTER_LEFT --></span>
|
||||
<span><!-- FOOTER_RIGHT --></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
**Default palette (override via `palette.cascade --intent <intent> --mode dark --format css`):**
|
||||
| Name | `--c-bg` | `--c-accent` | `--c-muted` | Use case |
|
||||
|------|----------|------------|----------|----------|
|
||||
| Deep Sea | `#162032` | `#8B7E5A` | `#8898A8` | General academic |
|
||||
| Indigo | `#1e3a5f` | `#2d5f8a` | `#7A90A5` | Technical |
|
||||
| Ink Stone | `#1a1a2e` | `#4a4a5e` | `#8080A0` | Formal occasions |
|
||||
|
||||
> ⚠️ These are **fallback defaults** when the palette system is unavailable. In normal workflow, run `palette.cascade` to generate mathematically harmonious colors and inject them into the `:root` variables.
|
||||
|
||||
---
|
||||
|
||||
## Template 04: Academic Symmetric - Dark bg + Top/bottom lines + Centered
|
||||
|
||||
**Design intent:** Emulates the classic IEEE/ACM Transactions title page. Perfect bilateral symmetry, thick horizontal rules frame the content zone. Formal and authoritative.
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ │
|
||||
│ ══════════════════════ │ ← Top rule y=H-3cm, 2pt
|
||||
│ │
|
||||
│ LABEL (centered) │
|
||||
│ │
|
||||
│ Title (28-30pt Bold) │
|
||||
│ │
|
||||
│ Subtitle │
|
||||
│ │
|
||||
│ Authors │
|
||||
│ Institution │
|
||||
│ │
|
||||
│ ══════════════════════ │ ← Bottom rule y=3cm, 2pt
|
||||
│ Journal · Date │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
**Best for:** Top journal submissions, IEEE/ACM papers, theses
|
||||
|
||||
**HTML structure:**
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700;900&family=Noto+Serif+SC:wght@400;700;900&family=Inter:wght@300;400;500&family=Noto+Sans+SC:wght@300;400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
@page { size: 794px 1123px; margin: 0; }
|
||||
:root {
|
||||
--c-bg: #162032;
|
||||
--c-accent: #4A90C4;
|
||||
--c-text: #FFFFFF;
|
||||
--c-muted: #90A8C0;
|
||||
}
|
||||
html, body { margin: 0; padding: 0; width: 794px; height: 1123px; background: var(--c-bg); color: var(--c-text); font-family: 'Inter', 'Noto Sans SC', sans-serif; }
|
||||
@media screen {
|
||||
html { height: auto; display: flex; justify-content: center; min-height: 100vh; background: var(--c-bg); }
|
||||
body { transform-origin: top center; scale: min(1, calc(100vw / 794), calc(100vh / 1123)); margin: 0 auto; box-shadow: 0 0 60px rgba(0,0,0,0.3); }
|
||||
}
|
||||
.cover { width: 794px; height: 1123px; position: relative; display: flex; flex-direction: column; align-items: center; box-sizing: border-box; }
|
||||
.rule-top, .rule-bottom { position: absolute; left: 114px; right: 114px; height: 2px; background: var(--c-accent); }
|
||||
.rule-top { top: 114px; }
|
||||
.rule-bottom { bottom: 114px; }
|
||||
.center-block { position: absolute; top: 0; bottom: 0; left: 114px; right: 114px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; }
|
||||
.label { font-size: 9pt; color: var(--c-accent); letter-spacing: 3px; text-transform: uppercase; margin-bottom: 40px; font-family: 'Inter', 'Noto Sans SC', sans-serif; }
|
||||
.title { font-size: 30pt; font-weight: 700; line-height: 1.3; font-family: 'Playfair Display', 'Noto Serif SC', serif; margin-bottom: 24px; max-width: 500px; }
|
||||
.subtitle { font-size: 14pt; color: var(--c-muted); margin-bottom: 40px; max-width: 450px; line-height: 1.5; }
|
||||
.authors { font-size: 12pt; margin-bottom: 12px; }
|
||||
.institution { font-size: 10pt; color: var(--c-muted); line-height: 1.4; }
|
||||
.footer { position: absolute; bottom: 57px; left: 114px; right: 114px; text-align: center; font-size: 9pt; color: var(--c-muted); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="cover">
|
||||
<div class="rule-top"></div>
|
||||
<div class="rule-bottom"></div>
|
||||
<div class="center-block">
|
||||
<div class="label"><!-- LABEL --></div>
|
||||
<div class="title"><!-- TITLE --></div>
|
||||
<div class="subtitle"><!-- SUBTITLE --></div>
|
||||
<div class="authors"><!-- AUTHORS --></div>
|
||||
<div class="institution"><!-- INSTITUTION --></div>
|
||||
</div>
|
||||
<div class="footer"><!-- FOOTER --></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
**Default palette (override via `palette.cascade --intent <intent> --mode dark --format css`):**
|
||||
| Name | `--c-bg` | `--c-accent` | `--c-muted` | Use case |
|
||||
|------|----------|------------|----------|----------|
|
||||
| Midnight Blue | `#162032` | `#4A90C4` | `#90A8C0` | Math/theoretical |
|
||||
| Ink Blue | `#0D1B2A` | `#3D5A80` | `#8898A8` | Formal reports |
|
||||
| Deep Navy | `#0a1628` | `#5B8DB8` | `#7A9AB5` | Engineering |
|
||||
|
||||
> ⚠️ These are **fallback defaults**. In normal workflow, run `palette.cascade` to generate colors and inject into `:root`.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## Template 06: Institutional - White bg + Black border frame + Structured field slots
|
||||
|
||||
**Design intent:** The universal institutional cover. White background with a thick black border frame, all content centered, structured field slots with underline placeholders. Matches the style required by most universities worldwide for thesis proposals, dissertations, and formal institutional documents. Also suitable for government reports and official submissions. Zero decorative elements - the formality IS the design.
|
||||
|
||||
**⚠️ This template is exempt from PART 4 Academic Cover Color Rules (dark backgrounds).** It uses a white/light background by design, aligning with institutional formatting requirements.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ INSTITUTION NAME │ │ ← y = 12%, serif 28-34pt Bold
|
||||
│ │ (校名/机构名) │ │
|
||||
│ │ │ │
|
||||
│ │ ━━━━━━━━━━━━━━━━━━━━ │ │ ← thick divider (2pt)
|
||||
│ │ │ │
|
||||
│ │ DOCUMENT TYPE │ │ ← y = 30%, 20-24pt
|
||||
│ │ (开题报告/毕业论文/申报书) │ │
|
||||
│ │ │ │
|
||||
│ │ TITLE │ │ ← y = 40%, serif 26-30pt Bold
|
||||
│ │ (论文题目) │ │ max 3 lines, centered
|
||||
│ │ │ │
|
||||
│ │ Field: _______________ │ │ ← y = 58-78%, structured fields
|
||||
│ │ Field: _______________ │ │ left-label + underline value
|
||||
│ │ Field: _______________ │ │ e.g. 姓名、学号、导师、院系、日期
|
||||
│ │ Field: _______________ │ │
|
||||
│ │ Field: _______________ │ │
|
||||
│ │ │ │
|
||||
│ │ DATE │ │ ← y = 88%, centered, 14pt
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
└─────────────────────────────────┘
|
||||
││ = 2.5pt black border, inset 5% from page edge
|
||||
```
|
||||
|
||||
**Best for:** Thesis proposals (开题报告), dissertations, institutional reports, government documents, any formal submission with structured metadata fields
|
||||
|
||||
**Content slots:**
|
||||
| Slot | Required | Example |
|
||||
|------|----------|---------|
|
||||
| `institution` | **Required** | "北京大学", "Massachusetts Institute of Technology" |
|
||||
| `doc_type` | Optional | "开题报告", "Thesis Proposal", "毕业设计" |
|
||||
| `title` | **Required** | Paper/document title (auto-wrap, max 3 lines) |
|
||||
| `fields` | Optional | Array of `{label, value}` pairs. Common: 姓名/Name, 学号/ID, 导师/Advisor, 院系/Department, 专业/Major |
|
||||
| `date` | Optional | "2026年4月", "April 2026" |
|
||||
|
||||
**HTML structure:**
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;600;700;900&family=Noto+Sans+SC:wght@300;400;500;700&family=Playfair+Display:wght@400;700;900&family=Inter:wght@300;400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
@page { size: 794px 1123px; margin: 0; }
|
||||
:root {
|
||||
--c-bg: #ffffff;
|
||||
--c-text: #1a1a1a;
|
||||
--c-accent: #1a1a1a;
|
||||
--c-muted: #4a4a4a;
|
||||
--c-line: #333333;
|
||||
}
|
||||
html, body { margin: 0; padding: 0; width: 794px; height: 1123px; background: var(--c-bg); color: var(--c-text); font-family: 'Noto Sans SC', 'Inter', sans-serif; }
|
||||
@media screen {
|
||||
html { height: auto; display: flex; justify-content: center; min-height: 100vh; background: #e8e8e8; }
|
||||
body { transform-origin: top center; scale: min(1, calc(100vw / 794), calc(100vh / 1123)); margin: 0 auto; box-shadow: 0 0 60px rgba(0,0,0,0.15); }
|
||||
}
|
||||
.cover {
|
||||
width: 794px; height: 1123px; position: relative; box-sizing: border-box;
|
||||
}
|
||||
/* Black border frame - inset 5% from page edge */
|
||||
.border-frame {
|
||||
position: absolute;
|
||||
top: 56px; left: 40px; right: 40px; bottom: 56px;
|
||||
border: 2.5px solid var(--c-accent);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Content area inside frame */
|
||||
.content {
|
||||
position: absolute;
|
||||
top: 56px; left: 40px; right: 40px; bottom: 56px;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
padding: 60px 50px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.institution {
|
||||
font-size: 30pt; font-weight: 700; letter-spacing: 6px;
|
||||
font-family: 'Noto Serif SC', 'Playfair Display', serif;
|
||||
text-align: center; margin-bottom: 30px;
|
||||
max-width: 580px;
|
||||
}
|
||||
.thick-divider {
|
||||
width: 70%; height: 2px; background: var(--c-accent);
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.doc-type {
|
||||
font-size: 22pt; font-weight: 400; letter-spacing: 4px;
|
||||
text-align: center; margin-bottom: 50px;
|
||||
color: var(--c-text);
|
||||
}
|
||||
.title {
|
||||
font-size: 26pt; font-weight: 700; line-height: 1.4;
|
||||
font-family: 'Noto Serif SC', 'Playfair Display', serif;
|
||||
text-align: center; margin-bottom: 60px;
|
||||
max-width: 520px;
|
||||
}
|
||||
.fields-block {
|
||||
width: 400px; margin-bottom: auto;
|
||||
}
|
||||
.field-row {
|
||||
display: flex; align-items: baseline;
|
||||
margin-bottom: 28px; font-size: 14pt;
|
||||
}
|
||||
.field-label {
|
||||
white-space: nowrap; margin-right: 12px;
|
||||
color: var(--c-text); font-weight: 400;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.field-value {
|
||||
flex: 1; text-align: center;
|
||||
border-bottom: 1px solid var(--c-line);
|
||||
padding-bottom: 4px; min-height: 24px;
|
||||
font-family: 'Noto Sans SC', 'Inter', sans-serif;
|
||||
}
|
||||
.date-block {
|
||||
font-size: 14pt; color: var(--c-muted);
|
||||
text-align: center; letter-spacing: 2px;
|
||||
margin-top: auto; padding-top: 30px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="cover">
|
||||
<div class="border-frame"></div>
|
||||
<div class="content">
|
||||
<div class="institution"><!-- INSTITUTION --></div>
|
||||
<div class="thick-divider"></div>
|
||||
<div class="doc-type"><!-- DOC_TYPE --></div>
|
||||
<div class="title"><!-- TITLE --></div>
|
||||
<div class="fields-block">
|
||||
<div class="field-row">
|
||||
<span class="field-label"><!-- LABEL_1 --></span>
|
||||
<span class="field-value"><!-- VALUE_1 --></span>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<span class="field-label"><!-- LABEL_2 --></span>
|
||||
<span class="field-value"><!-- VALUE_2 --></span>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<span class="field-label"><!-- LABEL_3 --></span>
|
||||
<span class="field-value"><!-- VALUE_3 --></span>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<span class="field-label"><!-- LABEL_4 --></span>
|
||||
<span class="field-value"><!-- VALUE_4 --></span>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<span class="field-label"><!-- LABEL_5 --></span>
|
||||
<span class="field-value"><!-- VALUE_5 --></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="date-block"><!-- DATE --></div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
**Layout rules:**
|
||||
1. **Border frame**: 2.5pt solid black, inset ~5% from all page edges (40px left/right, 56px top/bottom on A4 at 96dpi). This is the defining visual element.
|
||||
2. **Institution name**: Centered, serif, 28-34pt Bold, letter-spacing 4-6px. For CJK names, use wider letter-spacing (6px). For Latin names, use standard (3px).
|
||||
3. **Thick divider**: 2pt solid line, 70% width, separates institution from content below.
|
||||
4. **Document type**: 20-24pt, lighter weight than institution name, letter-spacing 3-4px. This slot differentiates document categories (e.g. "开题报告" / "Thesis Proposal" / "毕业论文" / "Graduation Design").
|
||||
5. **Title**: Serif, 26-30pt Bold, max 3 lines, centered. Auto-wrap at 520px width.
|
||||
6. **Structured fields**: Left-aligned label + centered underline value. Label width fixed by longest label in the set. 3-7 field rows supported. Common fields: Name/姓名, Student ID/学号, Advisor/导师, Department/院系, Major/专业.
|
||||
7. **Date**: Centered at bottom, 14pt, letter-spacing 2px.
|
||||
|
||||
**Field auto-detection:**
|
||||
When the user provides structured metadata (name, student ID, advisor, etc.), auto-populate the fields block. When no fields are provided, omit the fields-block entirely and let the title expand vertically into the freed space.
|
||||
|
||||
**Variant 06B - Double border:**
|
||||
For extra formality (government documents, official submissions), replace the single border with a double border (outer 2.5pt + inner 1pt, 6px gap):
|
||||
```css
|
||||
.border-frame {
|
||||
border: 2.5px solid var(--c-accent);
|
||||
outline: 1px solid var(--c-accent);
|
||||
outline-offset: 6px;
|
||||
}
|
||||
```
|
||||
|
||||
**This template has NO background decoration layer, NO watermarks, NO gradients.** The black frame + white space IS the design.
|
||||
|
||||
---
|
||||
|
||||
## Template 07: Crystal Blue - Deep Blue Night Sky with Luminous Accents
|
||||
|
||||
**Design intent:** Crystalline, translucent deep blue with glowing accents. Dark cover transitions to light-blue body. The entire document lives in one blue hue family (~215°). Subtle radial glow hints create glass-like depth without being flashy.
|
||||
|
||||
### Color System
|
||||
|
||||
**Cover (dark):**
|
||||
| Role | Hex | Description |
|
||||
|------|-----|-------------|
|
||||
| Background | `#0a1628` | Deep blue night sky |
|
||||
| Accent line/label | `#4da8da` | Luminous bright blue |
|
||||
| Title text | `#e8f0f8` | Cool white with blue tint |
|
||||
| Muted text | `#7a9bb8` | Blue-gray |
|
||||
| Glow/sidebar | `#2d7ab3` | Mid-blue (gradient source) |
|
||||
|
||||
**Body (light) — same hue family:**
|
||||
| Role | Cascade Tier | Hex | Usage |
|
||||
|------|-------------|-----|-------|
|
||||
| Page background | XL | `#f5f8fc` | Ultra-light blue-white |
|
||||
| Section background | XL | `#edf2f9` | Light blue-gray |
|
||||
| Card background | L | `#e4ecf5` | Soft blue card |
|
||||
| Table stripe | L | `#eef3fa` | Subtle blue row |
|
||||
| Table header | M | `#1a4a7a` | Deep blue (bridges to cover) |
|
||||
| Border | S | `#c0d0e2` | Blue-gray lines |
|
||||
| Accent | XS | `#2d7ab3` | = cover glow color |
|
||||
| Text primary | text | `#142840` | Deep blue-black (not pure black) |
|
||||
| Text muted | text | `#5a7a96` | Blue-gray secondary |
|
||||
|
||||
### Layer 2 - Structure
|
||||
- **Rectangular frame:** A single continuous rectangular border, inset 60px from all edges (top/bottom = 80px). Stroke = **2px**, color = `#4da8da` (accent). This forms a clean luminous blue frame around the entire cover content area.
|
||||
- Top edge: `Y = 80px`, from `X = 60px` to `X = W - 60px`
|
||||
- Bottom edge: `Y = H - 80px`, from `X = 60px` to `X = W - 60px`
|
||||
- Left edge: `X = 60px`, from `Y = 80px` to `Y = H - 80px`
|
||||
- Right edge: `X = W - 60px`, from `Y = 80px` to `Y = H - 80px`
|
||||
- **Background glow (optional):** Two subtle `radial-gradient` circles (6-8% opacity) positioned at upper-left and lower-right. Creates depth without visible shapes.
|
||||
|
||||
### Layer 3 - Content
|
||||
|
||||
| Element | Position | Constraints |
|
||||
|---------|----------|-------------|
|
||||
| Kicker | `X = 90px`, `Y = 0.12*H` | 11pt, accent color (#4da8da), letter-spacing 5px, uppercase, font-weight 300 |
|
||||
| Hero Title | `X = 90px`, `Y = 0.21*H` | 50pt (CJK 55pt), weight 900, Noto Serif SC, cool white (#e8f0f8), subtle text-shadow with blue glow |
|
||||
| Summary | `X = 90px`, `Y = 0.45*H` | 15pt, line-height 1.7, muted (#7a9bb8), max-width 520px |
|
||||
| Organization | `X = 90px`, `Y = 0.64*H` | 18pt, cool white |
|
||||
| Date | `X = 90px`, `Y = H - 80px` | 10pt, muted, letter-spacing 3px |
|
||||
|
||||
### Recommended Palette Generation
|
||||
|
||||
```bash
|
||||
# For documents using this template, the palette is fixed (not auto-generated).
|
||||
# Copy the body palette values directly from the table above.
|
||||
# Cover colors are hardcoded in the template HTML.
|
||||
```
|
||||
|
||||
### Best For
|
||||
Technology reports, data analysis, AI/ML research, fintech, SaaS product reports, anything that benefits from a modern, polished, slightly premium blue aesthetic.
|
||||
|
||||
---
|
||||
|
||||
### Academic Template Selection Guide
|
||||
|
||||
| Scenario | Template | Rationale |
|
||||
|----------|----------|-----------|
|
||||
| arXiv preprint, technical report | **03** (Vertical Anchor) | Left-aligned, data-dense feel |
|
||||
| IEEE/ACM paper, English thesis | **04** (Symmetric) | Classic bilateral symmetry |
|
||||
| Chinese thesis, journal with keywords | **04** (Symmetric) | Classic symmetry + keyword support via subtitle/summary slots |
|
||||
| **Thesis proposal, institutional cover, government doc** | **06** (Institutional) | **White bg, black border frame, structured field slots** |
|
||||
| Light/formal academic (white bg) | **01** (HUD) | Use standard cover system |
|
||||
|
||||
|
||||
Covers support a **background decoration layer** rendered behind all foreground content (Layer 1). This layer adds subtle depth through supergraphics, typographic watermarks, and blueprint hairlines.
|
||||
|
||||
See → `typesetting/cover-backgrounds.md` - complete specification with modules, recipes, and constraint matrix.
|
||||
|
||||
**Quick reference:**
|
||||
- **Recipe A (Minimalist Modern)**: Deep-space arc only - safest, max whitespace
|
||||
- **Recipe B (Engineering Academic)**: Coordinate cross + vertical spine text - precision, engineering feel
|
||||
- **Recipe C (Stable & Authoritative)**: Angle slash + bottom bleed text - heavy, authoritative
|
||||
|
||||
**Background layer is OPTIONAL.** Not every cover needs one. Template 01 already defines its own Layer 1 background - use the recipes only when a template's built-in background is insufficient.
|
||||
|
||||
Executable
+527
@@ -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** | 36–42pt |
|
||||
| **H2** (secondary heading) | **≥ 24pt** | 26–30pt |
|
||||
| **H3** (tertiary heading) | **≥ 18pt** | 20–22pt |
|
||||
|
||||
### 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?
|
||||
```
|
||||
Executable
+142
@@ -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 document’s 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
|
||||
Executable
+630
@@ -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), 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/05/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", "Noto Sans SC", 10)
|
||||
|
||||
if text_width > max_width:
|
||||
# Use Paragraph for automatic wrapping — NEVER plain strings in tables
|
||||
style = ParagraphStyle(
|
||||
'CellText',
|
||||
fontName='Noto Sans SC',
|
||||
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='Noto Sans SC', 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 M1–M4 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%?
|
||||
```
|
||||
Executable
+367
@@ -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 section’s 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 there’s 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
|
||||
|
||||
### Heading–Body 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('FreeSerif', 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('FreeSerif', 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.
|
||||
Executable
+252
@@ -0,0 +1,252 @@
|
||||
# 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.
|
||||
|
||||
### Primary Accent Hue Rule (V2.1)
|
||||
|
||||
**`accent` (XS tier) MUST use `base_hue`** — the same hue as `header_fill`, `cover_block`, `border`, `icon`, and all other structural roles. Only the saturation and lightness differ (S=0.50-0.70, L=0.40-0.55 for light modes).
|
||||
|
||||
This ensures that cover structural lines, body H2 headings, table headers, and accent highlights all visibly belong to the **same color family**.
|
||||
|
||||
`accent_secondary` uses the geometric harmony hue (`accent_hue`) for chart series differentiation — this is the **only** role that may deviate from `base_hue`.
|
||||
|
||||
```
|
||||
base_hue (e.g. 200° steel blue)
|
||||
├── header_fill: H=200°, S=0.22, L=0.25 (M tier, structural)
|
||||
├── accent: H=200°, S=0.65, L=0.45 (XS tier, same hue, higher sat)
|
||||
└── accent_2: H=350°, S=0.50, L=0.50 (XS tier, geometric hue for charts)
|
||||
```
|
||||
|
||||
### 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
|
||||
- **`accent` must share the same hue as `header_fill`** — only saturation and lightness differ
|
||||
- Derivation methods: lightness shift, saturation shift, micro hue adjustment (within ±15°)
|
||||
- **Forbidden** to have unrelated colors in the same document
|
||||
- The only exception is `accent_secondary` which may use a geometric harmony hue for chart differentiation
|
||||
|
||||
```
|
||||
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. Table Color Rules (Iron Law)
|
||||
|
||||
Tables are M+L tier elements. Their colors **must** come from the cascade palette's body_subset:
|
||||
|
||||
| Table Element | Cascade Role | Tier | S Cap | ❌ Never Use |
|
||||
|---------------|-------------|------|-------|------------|
|
||||
| Header bg | `HEADER_FILL` | M | ≤ 0.30 | `ACCENT` (too vivid for header area) |
|
||||
| Header text | `colors.white` | — | — | Colored text on colored bg |
|
||||
| Even rows | `colors.white` | — | — | Colored fills |
|
||||
| Odd rows | `TABLE_STRIPE` | L | ≤ 0.15 | High-saturation stripes |
|
||||
| Borders/grid | `BORDER` | S | ≤ 0.50 | Pure black `#000` |
|
||||
|
||||
**Why this matters:** Table headers occupy ~5-20% of visual area. The cascade tier system says M-tier elements (5-20% area) must have S ≤ 0.30. Using `ACCENT` (XS tier, S = 0.50-0.75) on table headers violates the area∝1/saturation rule and creates jarring color blocks that clash with the surrounding low-saturation body.
|
||||
|
||||
**All tables in one document must use identical colors.** No per-table color themes.
|
||||
|
||||
### 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)
|
||||
- ❌ Using `ACCENT` (XS tier) for table header backgrounds — use `HEADER_FILL` (M tier)
|
||||
- ❌ Using any color outside the cascade palette for table styling
|
||||
|
||||
---
|
||||
|
||||
## 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?
|
||||
```
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
|
||||
---
|
||||
|
||||
## CJK Typography Supplement
|
||||
|
||||
> See `pagination.md` Section 3 for detailed rules.
|
||||
|
||||
### Scope: Creative Flow pipeline (html2pdf-next.js) ONLY
|
||||
|
||||
These CSS rules apply to **multi-page HTML documents** rendered via `html2pdf-next.js` (Creative Flow brief). In this pipeline, Chromium's `text-align: justify` + `line-break: strict` produces proper CJK typesetting with correct punctuation handling.
|
||||
|
||||
**Do NOT apply these rules to:**
|
||||
- **Poster pipeline** (html2poster.js) — short lines + fixed containers make justify stretch CJK character spacing. Use `text-align: left` instead. See `briefs/poster.md` §7.1.
|
||||
- **Report pipeline** (ReportLab) — use `TA_LEFT` for CJK body text. ReportLab’s justify algorithm handles CJK poorly. See `briefs/report.md` §CJK alignment.
|
||||
|
||||
```css
|
||||
/* CJK punctuation placement rules — Creative Flow pipeline only */
|
||||
body {
|
||||
line-break: strict;
|
||||
word-break: normal;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
p, td, li {
|
||||
line-break: strict;
|
||||
text-align: justify; /* Only for Creative Flow; poster/report use left */
|
||||
}
|
||||
```
|
||||
Reference in new issue
Block a user