Initial commit
This commit is contained in:
13
skills/pdf/LICENSE.txt
Executable file
13
skills/pdf/LICENSE.txt
Executable file
@@ -0,0 +1,13 @@
|
||||
Copyright (c) 2026 Z.ai All rights reserved.
|
||||
|
||||
Permission is granted for personal, educational, and non-commercial use only.
|
||||
|
||||
Commercial use is strictly prohibited without prior written permission from the author.
|
||||
|
||||
Unauthorized copying, modification, or distribution of the software for commercial purposes is prohibited.
|
||||
|
||||
The author reserves the right to make the final determination of what constitutes "commercial use".
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
|
||||
IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY ARISING FROM THE USE OF THE SOFTWARE.
|
||||
915
skills/pdf/SKILL.md
Executable file
915
skills/pdf/SKILL.md
Executable file
@@ -0,0 +1,915 @@
|
||||
---
|
||||
name: pdf
|
||||
metadata:
|
||||
author: Z.AI
|
||||
version: "1.0"
|
||||
description: Professional PDF toolkit with four production lines: (1) Report - structured documents via ReportLab (reports, proposals, contracts, white papers) (2) Creative - visual design via JSON Blueprint → design_engine.py → Playwright snapshot (posters, infographics, invitations, dashboards). The LLM acts as Art Director outputting ONLY JSON spatial blueprints; convert.blueprint compiles to pixel-perfect PDF. (3) Academic - scholarly work via LaTeX/Tectonic (papers, theses, math-heavy documents) (4) Process - manipulate existing PDFs (extract, merge, split, fill forms, convert) Auto-routes based on document type. Includes ATS/creative/academic resume sub-paths.
|
||||
license: Proprietary. LICENSE.txt has complete terms
|
||||
---
|
||||
|
||||
# PDF - Document Production Workbench
|
||||
|
||||
## Quick Setup
|
||||
|
||||
```bash
|
||||
bash "$PDF_SKILL_DIR/scripts/setup.sh" # Interactive environment check + install
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" env.check # Detailed dependency status (JSON: add -j)
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" env.fix # Auto-install missing Python packages
|
||||
```
|
||||
|
||||
## Triage
|
||||
|
||||
Determine task weight to control how much context to load:
|
||||
|
||||
| Weight | Triggers | What to Load |
|
||||
|--------|----------|--------------|
|
||||
| **Light** | Format conversion, form fill, text extract, merge/split, simple certificate | SKILL.md + `briefs/process.md` only |
|
||||
| **Standard** | Multi-page report, poster, academic paper, resume, reformat - any document with design decisions | SKILL.md + matched brief + typesetting assets on demand |
|
||||
|
||||
Light tasks skip typesetting files entirely. Standard tasks load them on demand per the brief's instructions.
|
||||
|
||||
### ⚠️ Pre-Routing Checks (run BEFORE matching brief)
|
||||
|
||||
1. **Emoji Check** - Scan user content for intentional emoji (decorative 📊🎯🔥, not OS-level emoji input). If found → **force Creative brief** regardless of document type. ReportLab renders emoji as □ squares; LaTeX drops them entirely.
|
||||
2. **CJK Check** - Chinese/Japanese/Korean content needs font coverage. Report brief must use `UniSong`/`UniHei` registered fonts; Creative brief must load Google Fonts Noto Sans SC with `font-display: swap`; Academic brief must use `\usepackage{ctex}`.
|
||||
3. **Size Check** - Non-standard page sizes (not A4/Letter/A3) → prefer Creative brief (Playwright handles any dimension). ReportLab can do custom sizes but pagination is manual.
|
||||
4. **Character Safety Check** - Before writing any content string, scan for Japanese kana (の、が、は etc.), unusual Unicode symbols, or non-CJK characters that may corrupt during encoding transit ( Especially when code is written via heredoc/base64/LLM output). Replace with plain Chinese equivalents: `の`→`之/的/缔`, `々`→omit or write full character. **If content must preserve Japanese, use only standard CJK Unified Ideographs (U+4E00-U+9FFF) and common kana; avoid rare/private-use codepoints.**
|
||||
|
||||
---
|
||||
|
||||
## Briefing
|
||||
|
||||
Match the user's intent to a production brief. Each brief contains the full workflow, tech stack specifics, and references to shared typesetting assets.
|
||||
|
||||
```
|
||||
User Request
|
||||
│
|
||||
├─ Work with existing PDF? ─────────────┬─ Extract/merge/split/fill/convert → briefs/process.md
|
||||
│ ├─ Reformat/redesign → briefs/process.md (extract) → delegate to report or creative brief
|
||||
│ └─ User provides a PDF template/reference to match style
|
||||
│ → briefs/process.md "Template-Guided Reformat" → delegate to matched brief
|
||||
│
|
||||
├─ Report / proposal / white paper / contract / analysis?
|
||||
│ └─ ────────────────────────────────── → briefs/report.md (ReportLab)
|
||||
│
|
||||
├─ Poster / invitation / infographic / dashboard / creative layout?
|
||||
│ └─ ────────────────────────────────── → briefs/creative.md (Playwright)
|
||||
│
|
||||
├─ Academic paper / thesis / math / IEEE / ACM / LaTeX?
|
||||
│ └─ ────────────────────────────────── → briefs/academic.md (Tectonic)
|
||||
│
|
||||
├─ Math-heavy doc / TikZ diagram / algorithm pseudocode / Beamer slides?
|
||||
│ └─ ────────────────────────────────── → briefs/academic.md (Tectonic, Scenarios A-D)
|
||||
│
|
||||
├─ Document needs complex embedded diagrams (flowcharts, architecture, neural nets)?
|
||||
│ └─ Route by target brief:
|
||||
│ ├─ Report → Playwright+CSS → PNG → ReportLab Image() flowable
|
||||
│ ├─ Creative → directly in HTML (CSS flexbox/grid + connectors)
|
||||
│ └─ Academic → complexity-based:
|
||||
│ ├─ Simple (≤6 nodes, linear/tree) → TikZ native (vector)
|
||||
│ └─ Complex (>6 nodes, branches, annotations) → Playwright+CSS → PNG → \includegraphics
|
||||
│
|
||||
└─ Resume / CV?
|
||||
├─ ATS-safe / corporate ─────────── → briefs/report.md (resume sub-section)
|
||||
├─ Creative / design industry ────── → briefs/creative.md (resume sub-section)
|
||||
└─ Academic CV / publications ────── → briefs/academic.md (resume sub-section)
|
||||
```
|
||||
|
||||
### Detection Keywords
|
||||
|
||||
| Brief | Keywords |
|
||||
|-------|----------|
|
||||
| Report | 报告, report, 分析, analysis, 白皮书, white paper, 提案, proposal, 合同, contract, 方案, 规划, 发票, invoice, 收据, receipt, 试卷, exam, quiz, test paper, 练习, exercise, worksheet, 考试, 测验 |
|
||||
| Creative | 海报, poster, 邀请函, invitation, 信息图, infographic, 仪表盘, dashboard, 传单, flyer, 证书, certificate, 菜单, menu, 名片, business card, 奖状, award, 标签, label, 信封, envelope, 贺卡, greeting card |
|
||||
| Creative (Poster) | 海报, poster, 传单, flyer, 宣传页, 宣传单 → additionally load `briefs/poster.md` scene layer rules |
|
||||
| Academic | 论文, paper, 学术, academic, LaTeX, 数学, math, IEEE, ACM, 毕业, thesis, 研究, research, Beamer, slides, 开题报告, 学位, dissertation, proposal |
|
||||
| Process | 提取, extract, 合并, merge, 拆分, split, 填写, fill, 转换, convert, OCR, 重排, reformat, 重新排版, redesign, 模板, template, 参照, 照着这个做, match this style, 压缩, compress, 水印, watermark, 加密, encrypt, 签名, sign |
|
||||
|
||||
### Complete Scenario Routing Matrix
|
||||
|
||||
Below is an exhaustive map of every known PDF request type to its handling strategy. If a scenario is not listed, route to the closest match or ask the user.
|
||||
|
||||
#### 📄 Creation (Generate PDF from scratch)
|
||||
|
||||
| Scenario | Route | Notes |
|
||||
|----------|-------|-------|
|
||||
| Report / white paper / analysis | report.md | ReportLab structured document |
|
||||
| Report with emoji | **creative.md** | 🚨 Emoji rule override |
|
||||
| Business proposal | report.md | Structured + data tables |
|
||||
| Contract / legal document | report.md | Add signature placeholders (dotted line + label) |
|
||||
| Invoice / receipt | report.md | Table-heavy, precision alignment |
|
||||
| Exam / quiz / test paper / worksheet | report.md | Indented options, answer space reservation, structured numbering (see Exam Paper Rules in report.md) |
|
||||
| Math exam / math worksheet (with formulas/equations) | academic.md | LaTeX for proper math typesetting. See §Exam Paper Rules in academic.md |
|
||||
| Poster / flyer | creative.md + **poster.md** | Visual design + poster density/sizing rules |
|
||||
| Invitation / greeting card | creative.md | Non-standard size, decorative |
|
||||
| Certificate / award | creative.md | Single page, centered layout, decorative border |
|
||||
| Business card | creative.md | Tiny size (90×54mm), Playwright native support |
|
||||
| Envelope / label | creative.md | Non-standard size, simple layout |
|
||||
| Menu / price list | creative.md | Visual layout + may contain emoji |
|
||||
| Resume (ATS) | report.md | Plain text structure |
|
||||
| Resume (creative) | creative.md | Visual design |
|
||||
| Resume (academic CV) | academic.md | Publication list + BibTeX |
|
||||
| Academic paper | academic.md | LaTeX/Tectonic |
|
||||
| Math-heavy document | academic.md | LaTeX typesetting |
|
||||
| Presentation / PPT-style | creative.md | Landscape (1280×720), one topic per page |
|
||||
| Book / long document | report.md | Add TOC + chapter numbering, validate with toc_validate.py |
|
||||
| CJK vertical text | creative.md | HTML `writing-mode: vertical-rl` + `text-orientation: upright` + `white-space: nowrap` + Playwright |
|
||||
| RTL document (Arabic/Hebrew) | creative.md | HTML `dir="rtl"` + Playwright |
|
||||
| Batch generation (mail merge) | report.md | Python loop + template variable substitution |
|
||||
| Infographic | creative.md | Data visualization + design |
|
||||
| Calendar / schedule | creative.md | Grid layout + custom dimensions |
|
||||
|
||||
#### 🔧 Processing (Manipulate existing PDF)
|
||||
|
||||
| Scenario | Route | Command / Method |
|
||||
|----------|-------|------------------|
|
||||
| Merge multiple PDFs | process.md | `pages.merge a.pdf b.pdf -o out.pdf` |
|
||||
| Split PDF | process.md | `pages.split input.pdf -o ./output/` |
|
||||
| Extract text | process.md | `extract.text input.pdf` |
|
||||
| Extract tables | process.md | `extract.table input.pdf` |
|
||||
| Extract images | process.md | `extract.image input.pdf` |
|
||||
| Fill forms | process.md | `form.fill input.pdf` |
|
||||
| Office → PDF | process.md | `convert.office input.docx` |
|
||||
| HTML → PDF (documents) | process.md | `convert.html input.html` or `node html2pdf-next.js` |
|
||||
| HTML → PDF (posters) | poster.md | `node html2poster.js poster.html` |
|
||||
| Image → PDF | process.md | pikepdf: one image per page, embed as XObject |
|
||||
| PDF → image | process-advanced.md | pypdfium2 render each page to PNG |
|
||||
| Encrypt / decrypt | process-advanced.md | pikepdf encryption |
|
||||
| Add watermark | process.md | pikepdf overlay: create watermark page → merge onto each page |
|
||||
| Compress PDF | process.md | Ghostscript: `gs -sDEVICE=pdfwrite -dPDFSETTINGS=/screen` |
|
||||
| OCR scanned PDF | process-advanced.md | ocrmypdf or Tesseract |
|
||||
| Rotate pages | process.md | `pages.rotate input.pdf 90 -o out.pdf` |
|
||||
| Crop pages | process.md | `pages.crop input.pdf l,b,r,t -o out.pdf` |
|
||||
| Remove blank pages | process.md | `pages.clean input.pdf` |
|
||||
| Reformat by template | process.md → delegate | Extract content → regenerate via report/creative |
|
||||
| PDF diff / compare | process.md | `diff-pdf` CLI or Python per-page text comparison |
|
||||
| Digital signature | process.md | `pyhanko` library (requires extra install) |
|
||||
| Edit metadata | process.md | `meta.set input.pdf -o out.pdf -d '{...}'` |
|
||||
|
||||
### Special Routing Rules
|
||||
|
||||
**🚨 Emoji rule (CRITICAL - check FIRST)**: Content with intentional emoji (📊🎯🔥💡 etc.) → force **briefs/creative.md** regardless of document type. ReportLab renders emoji as □ squares; LaTeX silently drops them. This rule overrides all other routing. Even if the user says "report" - if the content has emoji, use Creative pipeline.
|
||||
|
||||
**Non-standard page size rule**: Dimensions other than A4/Letter/A3 → strongly prefer **briefs/creative.md**. Playwright handles any arbitrary page size natively. ReportLab requires manual pagination math.
|
||||
|
||||
**Academic auto-detect**: Papers, theses, or heavy math → **briefs/academic.md** even without explicit "LaTeX" mention.
|
||||
|
||||
**Template-guided rule**: When the user uploads a PDF and says "match this template" / "follow this style" / "reformat like this" → **briefs/process.md** Template-Guided Reformat section. This is a Standard triage (not Light), because it involves design decisions.
|
||||
|
||||
**Resume routing**: Default to Report brief (ATS-safe). Creative industry → Creative brief. Academic CV with publications → Academic brief.
|
||||
|
||||
---
|
||||
|
||||
## Shared Assets
|
||||
|
||||
These are referenced by multiple briefs. **Do not load upfront** - each brief tells you when and what to load.
|
||||
|
||||
| Asset | Path | Used By | Purpose |
|
||||
|-------|------|---------|---------|
|
||||
| Palette & Typography | `typesetting/palette.md` | Report, Creative | Color system, font rules, anti-patterns, spacing |
|
||||
| Cover Layout System V2.1 | `typesetting/cover.md` | **Report + Creative + Academic** | 7 industrial-grade templates with absolute anchor grid, Z-index layers, typography weight system, mandatory Summary Block, code-level safety (5 checks), base unit `U = W*0.05`. **Unified HTML/Playwright cover system for all routes.** |
|
||||
| Chart Styling & Anti-Stacking | `typesetting/charts.md` | Report, Creative, Academic | Chart defaults, collision prevention, axis/grid/legend rules |
|
||||
| Overflow Prevention | `typesetting/overflow.md` | Report, Creative, Academic | Bounding box system, text/image/table overflow prevention, fallback strategies |
|
||||
| **Fill Engine (Anti-Void)** | `typesetting/fill-engine.md` | **Report, Creative, Academic** | **Anti-Void Engine V2.0: font floor enforcement, fill ratio calculation, paragraph inflation, component elevation, Y-axis golden-ratio anchoring** |
|
||||
| Pagination & Flow Control | `typesetting/pagination.md` | Report, Creative | Cross-page integrity, orphan/widow control, CJK punctuation rules |
|
||||
| Typography System | `typesetting/typography.md` | Report, Creative | Font size scale, line-height, spacing hierarchy |
|
||||
| Geometric Anchors | `typesetting/geometry.md` | Creative + Report | Decorative geometric elements, anchor placement rules |
|
||||
| Cover Backgrounds | `typesetting/cover-backgrounds.md` | **Report + Creative + Academic** | Cover background rendering, transparency constraints |
|
||||
| Visual Framework | `configs/visual_framework.md` | Creative | Palette mode, color harmony, SVG background params |
|
||||
| Components Library | `configs/components.md` | Creative | Non-grid composition components (floating cards, oversized text, etc.) |
|
||||
| Font Stacks | `configs/fonts.md` | All pipelines | Font families per pipeline (Google Fonts, ReportLab, LaTeX) |
|
||||
|
||||
---
|
||||
|
||||
## Content Rules
|
||||
|
||||
- **Language**: Match user's query language. Chinese query → Chinese PDF.
|
||||
- **Page/word count**: Respect explicit constraints (±20%). Unspecified → completeness over brevity.
|
||||
- **Outline**: User-provided outlines are sacred. No reordering without asking.
|
||||
- **Citations**: No fabrication. Chinese → GB/T 7714, English → APA. Search to verify.
|
||||
- **Multi-part requests**: Generate ALL parts - never silently drop a component.
|
||||
|
||||
### HTML Image Source Path Rules
|
||||
|
||||
When embedding images in HTML documents (Creative pipeline, Playwright-rendered diagrams, or any HTML→PDF flow):
|
||||
|
||||
| Image location | `<img src>` value | Example |
|
||||
|---|---|---|
|
||||
| **Local file** | **Relative path** from the HTML file's directory | `<img src="images/chart.png">` or `<img src="./diagram.png">` |
|
||||
| **Remote URL** | Full URL (no change needed) | `<img src="https://example.com/photo.jpg">` |
|
||||
|
||||
**Iron rules:**
|
||||
1. **NEVER use absolute paths** for local files in HTML `<img>`, `<source>`, CSS `url()`, or any other asset reference (e.g. `/Users/alice/project/img.png`). Absolute paths break portability across machines and environments.
|
||||
2. **Always use relative paths** anchored to the HTML file's own directory. If the image lives in a subdirectory, use `images/foo.png` or `./images/foo.png`.
|
||||
3. **Remote URLs (`http://` / `https://`) are fine as-is** — do not convert them to local paths.
|
||||
4. When generating HTML from a script or blueprint, ensure all referenced assets are either (a) in the same directory as the output HTML, or (b) in a clearly named subdirectory (e.g. `assets/`, `images/`), and referenced with relative paths.
|
||||
5. If a build script needs to resolve paths programmatically, compute relative paths at generation time (e.g. `os.path.relpath(image_path, html_dir)`) rather than embedding absolute filesystem paths.
|
||||
|
||||
---
|
||||
|
||||
## Figure & Diagram Embedding (All Briefs)
|
||||
|
||||
### Iron Rule: Figures Are Block-Level
|
||||
|
||||
Figures, diagrams, and charts MUST be independent block elements occupying full width. **Never** float/wrap figures alongside body text - this causes the text-diagram overlap badcase.
|
||||
|
||||
| Brief | Correct embedding | Forbidden |
|
||||
|-------|-------------------|-----------|
|
||||
| Report (ReportLab) | `story.append(Image(...))` as standalone Flowable | Placing images inside Paragraph text, simulating float |
|
||||
| Creative (Playwright) | `<figure style="display:block; width:100%; margin:2em auto">` | `float:right`, `display:flex` with text, `wrapfigure`-style CSS |
|
||||
| Academic (LaTeX) | `\begin{figure}[t] ... \end{figure}` | Bare `\includegraphics` in text body (no figure env), bare `tikzpicture` in multi-column |
|
||||
|
||||
### Complex Diagram Strategy
|
||||
|
||||
When a diagram has **>12 nodes, >3 subgroups, or intricate connections**, do NOT try to render it as one giant figure. Instead:
|
||||
|
||||
1. **Table for details** - structured data (phases, components, specs) goes into a proper table
|
||||
2. **Simplified overview diagram** - a stripped-down flowchart/Mermaid showing only the top-level flow (≤8 nodes)
|
||||
3. **Cross-reference** - table caption + diagram caption reference each other
|
||||
|
||||
This "table + simple diagram" pattern prevents:
|
||||
- Diagrams overflowing page boundaries
|
||||
- Text becoming unreadably small to fit everything
|
||||
- Layout engines mishandling oversized graphics
|
||||
|
||||
### Diagram Content Quality Rules (Cross-reference: charts)
|
||||
|
||||
The rules above handle **how** to embed diagrams in PDF. For **what the diagram itself looks like** (node layout, connector routing, color, readability), follow the `charts` skill rules:
|
||||
|
||||
**Before generating ANY flowchart/diagram for PDF embedding, check these:**
|
||||
|
||||
1. **Connectors must not pass through nodes** - If 3+ layers exist, connect adjacent layers only (top→mid, mid→bottom). Never draw top→bottom lines through middle nodes. Use detour paths if cross-layer links are needed.
|
||||
2. **Multiple arrows into one node must not pile up** - Distribute entry points evenly along target edge, or use merge-then-enter pattern (sources converge to a vertical merge line, then single arrow to target).
|
||||
3. **Low-saturation fills only** - Node backgrounds must be pale (`#EFF6FF`, `#F0FDF4`). High-saturation colors (`#3B82F6`, `#10B981`) only for borders or small accents. No children's-art color schemes.
|
||||
4. **Phase titles vs sub-steps must be visually distinct** - Different background color, font size, and font weight. Never same-style boxes for both.
|
||||
5. **Font sizes must be readable at final output size** - Sizes depend on the embedding context:
|
||||
| Output context | Node title min | Description min | Label min |
|
||||
|---------------|----------------|-----------------|-----------|
|
||||
| Standalone PNG (web/presentation, ≥1200px wide) | 14px | 12px | 11px |
|
||||
| Embedded in A4 PDF (ReportLab/LaTeX, ~450pt content width) | 10pt | 8pt | 7pt |
|
||||
| Embedded in slide deck (landscape, ~720pt wide) | 12pt | 10pt | 9pt |
|
||||
|
||||
**Principle**: After embedding, the smallest text in the diagram must still be legible when the document is viewed at 100% zoom. If the diagram is scaled down to fit page width, recalculate: `effective_size = original_size × (display_width / canvas_width)`. If effective size drops below the minimum, either increase original font size or reduce diagram complexity.
|
||||
6. **Legend/annotations must not overlap content** - Separate container, ≥ 40px gap from last node, fully within canvas bounds.
|
||||
|
||||
**For Playwright-rendered diagrams**: Use low-saturation fills (`#EFF6FF`, `#F0FDF4`), CSS flexbox/grid for node layout, SVG `<line>`/`<path>` for connectors, and verify no overlap at final render size.
|
||||
**For ReportLab-drawn diagrams**: Same principles apply - use `Drawing()` with explicit coordinates, check node bounding boxes for overlap before finalizing.
|
||||
|
||||
### Diagram Generation Strategy (Per-Brief)
|
||||
|
||||
Diagram rendering depends on the target brief - **NOT** a one-size-fits-all TikZ pipeline.
|
||||
|
||||
| Target Brief | Diagram Method | Rationale |
|
||||
|---|---|---|
|
||||
| **Report** (ReportLab) | Playwright+CSS → PNG → `Image()` | No LaTeX compiler in this route; HTML/CSS handles any layout natively |
|
||||
| **Creative** (Playwright) | Directly in HTML (CSS flexbox/grid + JS connectors) | Already in browser context |
|
||||
| **Academic** (Tectonic) - simple (≤6 nodes) | TikZ native `tikzpicture` | Vector output, font consistency, LaTeX-native |
|
||||
| **Academic** (Tectonic) - complex (>6 nodes) | Playwright+CSS → PNG @2× → `\includegraphics` | TikZ branch logic is error-prone for models; 300dpi PNG is publication-ready |
|
||||
|
||||
**Playwright+CSS diagram pipeline (Report & Academic-complex):**
|
||||
|
||||
```bash
|
||||
# 1. Write diagram HTML (CSS grid/flexbox + connectors)
|
||||
cat > diagram.html << 'EOF'
|
||||
<!-- LLM generates: nodes as divs, arrows as SVG/CSS -->
|
||||
EOF
|
||||
|
||||
# 2. Screenshot at 2× for print quality (300dpi equivalent)
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" convert.blueprint diagram.html --device-scale-factor 2 --output diagram.png
|
||||
# Or via Playwright directly:
|
||||
# page.screenshot(path='diagram.png', scale='device', device_scale_factor=2)
|
||||
|
||||
# 3a. Embed in ReportLab (Report brief)
|
||||
from reportlab.platypus import Image
|
||||
img = Image('diagram.png', width=450) # auto height via aspect ratio
|
||||
story.append(img)
|
||||
|
||||
# 3b. Embed in LaTeX (Academic brief, complex diagrams only)
|
||||
# \includegraphics[width=\columnwidth]{diagram.png}
|
||||
```
|
||||
|
||||
**🚫 FORBIDDEN for Report/Creative briefs:** Do NOT use TikZ standalone → compile → pdftoppm → PNG pipeline. This route has no LaTeX compiler and the extra compilation steps are error-prone.
|
||||
|
||||
**TikZ remains valid ONLY for:**
|
||||
- Academic brief with simple diagrams (≤6 nodes, linear/hierarchical)
|
||||
- Direct `tikzpicture` embedding in LaTeX documents
|
||||
- Math-annotated diagrams where LaTeX math rendering matters
|
||||
|
||||
See `briefs/academic.md` Scenario B for TikZ templates (simple diagrams only).
|
||||
|
||||
---
|
||||
|
||||
## Vector Rendering Iron Rule
|
||||
|
||||
**The final PDF MUST be generated via `page.pdf()` (Playwright) or ReportLab/LaTeX native output - NEVER via screenshot-to-PDF.**
|
||||
|
||||
| Scenario | Correct Method | Forbidden |
|
||||
|----------|---------------|-----------|
|
||||
| Creative pipeline (single/multi-page) | `page.pdf()` via `convert.blueprint` or `html2pdf-next.js` | `page.screenshot()` → image → wrap as PDF |
|
||||
| Report cover (HTML/Playwright) | `page.pdf()` → merge via pypdf | Screenshot cover → embed as image |
|
||||
| Academic cover | `page.pdf()` → merge via pypdf | Screenshot → `\includegraphics` for cover |
|
||||
| Full-page posters/infographics | `html2poster.js` (auto overflow:hidden + height measurement + `page.pdf()`) | Any raster pipeline for the final output |
|
||||
|
||||
**Why:** `page.pdf()` produces vector text + vector shapes. Text remains selectable, sharp at any zoom, and file size is smaller. Screenshot-based PDFs are raster images - blurry when zoomed, unsearchable, and 3-5× larger.
|
||||
|
||||
**The ONLY place screenshot/PNG embedding is acceptable:**
|
||||
- **Diagrams** embedded as sub-elements inside a larger document (e.g., flowcharts in a Report). These use `page.screenshot()` at 2× device scale factor for 300dpi print quality, then embed via `Image()` (ReportLab) or `\includegraphics` (LaTeX).
|
||||
- **Chart images** generated by matplotlib/plotly saved as PNG, then embedded.
|
||||
|
||||
These are sub-elements, not the document itself. The document-level PDF output must always be vector.
|
||||
|
||||
**Quick test:** Open the generated PDF, zoom to 400%. If text is blurry, you used a screenshot pipeline. Fix it.
|
||||
|
||||
### HTML→PDF Engine Selection Rules
|
||||
|
||||
There are **two dedicated scripts** for HTML→PDF. Choose based on document type:
|
||||
|
||||
| Document type | Script | Reason |
|
||||
|---------------|--------|--------|
|
||||
| **Posters, infographics, long-image single-page designs** | `html2poster.js` | Auto overflow:hidden, auto height measurement, zero margin, single-page output |
|
||||
| **Cover pages (Report/Academic route)** | `html2poster.js` | Covers are single-page fixed layouts with absolute positioning — same nature as posters. `html2pdf-next.js` would convert absolute→static and destroy the layout |
|
||||
| **Multi-page documents, reports, academic papers, resumes** | `html2pdf-next.js` | A4/custom pagination, 20mm margin fallback, cover adaptation, pdf-lib metadata |
|
||||
| **Creative pipeline (Blueprint → HTML → PDF)** | `html2pdf-next.js` via `convert.blueprint` | Called internally by design_engine pipeline |
|
||||
|
||||
#### Poster / Single-Page Long-Image → `html2poster.js`
|
||||
|
||||
```bash
|
||||
node "$PDF_SKILL_DIR/scripts/html2poster.js" poster.html --output poster.pdf --width 720px
|
||||
```
|
||||
|
||||
`html2poster.js` automatically:
|
||||
- Forces `overflow: hidden` on `.poster` / `.page` containers (clips decorative overflow)
|
||||
- Injects `@page { margin: 0 }` (zero margins always)
|
||||
- Syncs `html/body` background with poster background color
|
||||
- Measures `.poster` scrollHeight and uses it as PDF height
|
||||
- Generates a single-page vector PDF with exact content dimensions
|
||||
|
||||
**Use this for ANY fixed-width, dynamic-height, single-page design.**
|
||||
|
||||
#### Documents / Multi-Page → `html2pdf-next.js`
|
||||
|
||||
```bash
|
||||
node "$PDF_SKILL_DIR/scripts/html2pdf-next.js" input.html --output output.pdf --width 210mm --height 297mm
|
||||
# Or via pdf.py wrapper:
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" convert.html input.html --output output.pdf
|
||||
```
|
||||
|
||||
Pre-render hooks auto-handle @page injection, overflow detection, cover adaptation, font loading, and pdf-lib metadata.
|
||||
|
||||
#### ⚠️ Iron Rule: No Hand-Written Playwright Scripts
|
||||
|
||||
Common issues with hand-written Python `page.pdf()` (the dedicated scripts handle these automatically):
|
||||
1. **Missing `@page` rule** → browser default margin causes content overflow to second page or white edges
|
||||
2. **Oversized elements not fixed** → large elements with `break-inside: avoid` block pagination, content gets truncated
|
||||
3. **Rendering before fonts are loaded** → Chinese text displays as squares or falls back to wrong font
|
||||
4. **No overflow detection** → content exceeds page boundary without awareness
|
||||
5. **No metadata** → PDF title, author, and other info missing
|
||||
|
||||
**Iron rule: Posters and cover pages use `html2poster.js`, multi-page documents use `html2pdf-next.js`. Do not write hand-written Python Playwright scripts.**
|
||||
|
||||
> **⚠️ Cover page gotcha:** Cover HTML uses `position: absolute` for layout. `html2pdf-next.js` pre-render hooks convert absolute-positioned elements to `static` flow (to prevent multi-page overlap), which **destroys** cover layouts. Always use `html2poster.js` for cover pages.
|
||||
|
||||
### No overflow:hidden on Fixed-Size Pages (html2pdf-next.js only)
|
||||
|
||||
When using `html2pdf-next.js` for documents, **NEVER set `overflow: hidden` on `html`, `body`, or the main page container**.
|
||||
|
||||
> **Note:** This rule does NOT apply to posters rendered via `html2poster.js` — that script automatically adds `overflow: hidden` to `.poster`/`.page` containers to clip decorative overflow. You don't need to add or remove it manually.
|
||||
|
||||
| Problem | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| Browser preview cuts off bottom content, can't scroll | `overflow: hidden` on container + viewport < design height | Remove `overflow: hidden` |
|
||||
| html2pdf-next.js "Fixed vertical overflow" warning, layout may break | Pre-render detects `scrollHeight > clientHeight` + hidden overflow, force-expands container | Remove `overflow: hidden` |
|
||||
|
||||
**Always pair fixed-size pages with `@media screen` auto-scale** so the full page is visible in any browser window without scrolling. See `briefs/creative.md` § 0.5 for the CSS pattern.
|
||||
|
||||
### Full-Bleed Rule (No White Margins)
|
||||
|
||||
When generating HTML for Playwright `page.pdf()`, the content **MUST fill the entire page** with zero margins. White side margins = broken layout.
|
||||
|
||||
**Mandatory CSS for any HTML → PDF:**
|
||||
```css
|
||||
@page {
|
||||
size: <width> <height>; /* e.g., 720px 960px, or A4 */
|
||||
margin: 0;
|
||||
}
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
```
|
||||
|
||||
**Common causes of white margins:**
|
||||
1. Missing `@page { margin: 0 }` - browser default margins kick in (~1cm each side)
|
||||
2. Content width doesn't match page width - e.g., canvas is 720px but page is A4 (794px)
|
||||
3. Missing `@page { size }` declaration in the HTML
|
||||
4. Content has explicit `max-width` that's narrower than the page
|
||||
|
||||
**For blueprint pipeline:** `design_engine.py` now injects `@page { size: var(--canvas-w) var(--canvas-h); margin: 0; }` automatically.
|
||||
**For raw HTML:** YOU must include the `@page` rule. No exceptions.
|
||||
**For direct Playwright:** Pass `margin: { top: 0, right: 0, bottom: 0, left: 0 }` to `page.pdf()`.
|
||||
|
||||
### Background Color Consistency (No Color Mismatch)
|
||||
|
||||
**`html` / `body` background color must match the content canvas background color.**
|
||||
|
||||
Playwright `page.pdf({ printBackground: true })` renders the body background color. If body is white while the content area is gray/colored, color-inconsistent borders/gaps will appear in the PDF.
|
||||
|
||||
#### Single-color documents (all pages same background)
|
||||
|
||||
```css
|
||||
/* MANDATORY: body background = content background */
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--c-bg); /* Same color as content canvas */
|
||||
}
|
||||
```
|
||||
|
||||
#### Multi-page documents with mixed backgrounds (e.g. dark cover + white body pages)
|
||||
|
||||
**Root cause:** Playwright resolves `.page { width: 210mm }` and `@page { size: 210mm }` to slightly different sub-pixel values (e.g. 793.688px vs 793.701px). This creates a <1px gap at the right/bottom edge of each `.page` div where `body`'s background shows through. On dark pages, a white `body` background makes this gap visible as a white edge.
|
||||
|
||||
**Fix — set `body` background to the document's dominant dark color:**
|
||||
|
||||
```css
|
||||
:root {
|
||||
--primary: #0f172a; /* darkest page background */
|
||||
}
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 210mm; /* match @page size */
|
||||
background: var(--primary); /* fallback for sub-pixel gaps */
|
||||
}
|
||||
```
|
||||
|
||||
**Why this works and doesn't break white pages:**
|
||||
- Dark pages: sub-pixel gap reveals dark `body` → gap invisible.
|
||||
- White pages: `.page-white { background: #ffffff }` fully covers `body` → dark body never visible.
|
||||
- The gap is <1px — even on white pages, the dark body at the extreme pixel edge is imperceptible after anti-aliasing.
|
||||
|
||||
**Rule: when generating multi-page HTML with mixed backgrounds, always set `html, body { background }` to the darkest page's background color.** If all pages are light/white, use the lightest content background (e.g. `#f8fafc`). Never leave `body` background unset (browser default = white = guaranteed white edges on dark pages).
|
||||
```
|
||||
|
||||
### Content Centering (No Left/Right Drift)
|
||||
|
||||
**After HTML-to-PDF conversion, content must be centered, no left or right drift allowed.**
|
||||
|
||||
Common drift causes:
|
||||
1. `@page { margin }` not 0 — browser default margin causes drift
|
||||
2. `.safe-zone` or content container `inset` / `padding` left-right asymmetric
|
||||
3. Content container has `max-width` but no `margin: 0 auto`
|
||||
4. Grid components only occupy partial column width (e.g. `1/1 → X/7` only uses left half)
|
||||
5. **Decorative elements overflow page boundary** — elements with `width > 100%` or negative offsets (e.g. glow circles, gradient overlays) inflate `scrollWidth` beyond page width. Playwright shrinks all content to fit, causing left-shift. **Fix: add `overflow: hidden` to `.page` containers.** See `typesetting/overflow.md` §3.5 for horizontal flex overflow rules.
|
||||
|
||||
### Anti-Void Edges (No Large Blank Margins)
|
||||
|
||||
**Content should not have large meaningless whitespace at page edges, top, or bottom.**
|
||||
|
||||
- Content should make full use of page area; do not cram all content in the top half while leaving the bottom blank
|
||||
- For multi-page documents, each page's fill rate should be ≥ 60% (see `pagination.md` last page ≥ 40% rule)
|
||||
- For single-page posters/infographics, fill rate should be ≥ 70%
|
||||
|
||||
---
|
||||
|
||||
## Preflight (Quality Assurance)
|
||||
|
||||
Every PDF must pass preflight checks before delivery. Each brief specifies the exact commands.
|
||||
|
||||
### HTML Pre-Render Validation (MANDATORY for ALL HTML→PDF paths)
|
||||
|
||||
**Before** calling `html2pdf-next.js`, `html2poster.js`, `convert.blueprint`, or any Playwright `page.pdf()`, run:
|
||||
|
||||
```bash
|
||||
python3 "$PDF_SKILL_DIR/scripts/poster_validate.py" check-html <your_file>.html
|
||||
```
|
||||
|
||||
| Result | Action |
|
||||
|--------|--------|
|
||||
| **PASS** (no errors) | Proceed to PDF generation |
|
||||
| **ERROR** items | Must fix before generating PDF. Use `--fix --output <file>.html` for auto-repair |
|
||||
| **WARNING** items | Review; non-blocking but should be addressed |
|
||||
|
||||
**Key checks:**
|
||||
- `OVERFLOW_HIDDEN_CONTAINER` (error): `overflow:hidden` on html/body/.page clips content in browser preview and triggers html2pdf-next.js auto-fix that may break layout
|
||||
- `FIXED_SIZE_NO_SCREEN_ADAPT` (warning): fixed-size page without `@media screen` auto-scale — browser preview requires scrolling
|
||||
- `SCREEN_ADAPT_NO_SCALE` (warning): `@media screen` exists but lacks scale/transform/zoom
|
||||
- `FONT_NO_FALLBACK` (error): font-family without generic fallback
|
||||
- `COLOR_CONTRAST` (warning): text/background contrast ratio < 3:1
|
||||
- Plus: remote images, absolute paths, missing margin reset, tiny fonts, background mismatch, etc.
|
||||
|
||||
This applies to **all three HTML routes**: Creative blueprint pipeline, Report HTML covers, and bypass/custom HTML.
|
||||
|
||||
### Overflow Prevention System
|
||||
|
||||
**→ Full spec: `typesetting/overflow.md`** - read it for any document with tables, images, or multi-column layouts.
|
||||
|
||||
Core principles:
|
||||
1. **Measure first, draw second** - never render content without pre-calculating its dimensions
|
||||
2. **Bounding Box constraint** - every element's width ≤ its parent container's `Max_Width`
|
||||
3. **Text: use font metrics**, not character count, for width calculation
|
||||
4. **Images: proportional scaling** - never insert at original size
|
||||
5. **Tables: weight-based column width** + `Paragraph()` wrapping (never plain strings)
|
||||
6. **Fallback ladder**: wrap → shrink font (max -3pt) → reduce padding → split element → log warning
|
||||
7. **Vertical: KeepTogether** for heading+body, chart+caption; `repeatRows=1` for long tables
|
||||
|
||||
### Table Overflow Prevention (ReportLab)
|
||||
**Most common layout bug: table columns exceed page margins.**
|
||||
|
||||
Before building any ReportLab Table:
|
||||
1. Calculate `available_width = page_width - left_margin - right_margin`
|
||||
2. Use proportional colWidths (`[0.25, 0.40, 0.20, 0.15]` × available_width) or fixed+flex pattern
|
||||
3. `sum(colWidths)` must be ≤ `available_width` - **verify this in code**
|
||||
4. Long text columns must use `Paragraph()` wrapping, not plain strings (plain strings don't wrap)
|
||||
5. CJK text is wider: budget ~12pt per character at 10pt font size
|
||||
|
||||
See `briefs/report.md` § "Table Width Management" for code patterns.
|
||||
|
||||
### Table Overflow Prevention (LaTeX/Academic)
|
||||
**Most common bug in dual-column papers: wide tables overflow single-column width.**
|
||||
|
||||
Before writing any LaTeX table:
|
||||
1. Count data columns - ≤ 4 fits single column; 5-6 needs `\small`; 7-8 needs `\resizebox`; ≥ 9 use `table*` (full width)
|
||||
2. Use `tabular*{\columnwidth}` or `tabularx{\columnwidth}` instead of plain `tabular` for 5+ columns
|
||||
3. Never use plain `tabular` with 8+ columns in twocolumn layout - guaranteed overflow
|
||||
4. `\resizebox{\columnwidth}{!}` as last resort - verify smallest text ≥ 6pt after scaling
|
||||
|
||||
See `briefs/academic.md` § "Table width management" for LaTeX patterns.
|
||||
|
||||
### Playwright PDF CSS Blacklist
|
||||
These CSS properties **silently break** in Playwright's PDF renderer:
|
||||
- `backdrop-filter` / `-webkit-backdrop-filter` - **drops entire element content**. Use solid `rgba()` backgrounds.
|
||||
- `overflow: hidden` on content containers - clips content. Only safe on small decorative elements (< 200px).
|
||||
|
||||
After generating any Playwright PDF, **verify every page has content** (pypdf text extraction, check non-empty).
|
||||
|
||||
### PDF Metadata (all briefs)
|
||||
ALL PDFs must have: Title, Author (default "Z.ai"), Creator, Subject.
|
||||
|
||||
### Delivery Summary (all briefs)
|
||||
Report to user: file path, size, page count. Academic adds word/image count. Creative adds per-page verification.
|
||||
|
||||
**HTML→PDF route deliverables (MANDATORY — applies to ALL briefs that use Playwright/HTML to generate PDF):**
|
||||
Whenever the HTML→PDF pipeline is used (Creative route, Report cover bypass, Direct HTML Flow posters, or any Playwright `page.pdf()` path), you MUST deliver **both files** to the user:
|
||||
1. **HTML** — the source HTML file, so the user can edit and reuse the design
|
||||
2. **PDF** — the final vector PDF (`page.pdf()` output)
|
||||
|
||||
Optionally also provide:
|
||||
3. **Image** — a full-page screenshot/preview image (PNG or JPG) for quick sharing on chat/social media
|
||||
|
||||
All file paths must be reported to the user. **Never deliver only the PDF without the HTML source.**
|
||||
|
||||
---
|
||||
|
||||
## Tooling Reference
|
||||
|
||||
### CLI: `python3 "$PDF_SKILL_DIR/scripts/pdf.py" <command>`
|
||||
|
||||
```bash
|
||||
# Environment
|
||||
env.check # Check deps
|
||||
env.fix # Auto-install missing
|
||||
|
||||
# Quality
|
||||
code.sanitize <script> # Sanitize forbidden Unicode
|
||||
content.sanitize <file> [--apply] # Fix content issues (CJK, encoding)
|
||||
meta.brand <pdf> # Add Z.ai metadata
|
||||
font.check <pdf> # Scan for missing glyphs
|
||||
toc.check <pdf> # Validate TOC
|
||||
|
||||
# Conversion
|
||||
convert.blueprint <llm_json_response.md> -o final.pdf # CRITICAL FOR CREATIVE: Auto-extracts JSON, compiles, and renders PDF.
|
||||
convert.html <html> # HTML → PDF (Playwright)
|
||||
convert.latex <tex> # LaTeX → PDF (Tectonic). Bundled binary is macOS arm64 only; see academic.md for other-platform install.
|
||||
convert.office <file> # Office → PDF (LibreOffice)
|
||||
|
||||
# Processing
|
||||
extract.text <pdf> # Extract text
|
||||
extract.table <pdf> # Extract tables
|
||||
extract.image <pdf> # Extract images
|
||||
pages.merge a.pdf b.pdf -o out.pdf
|
||||
pages.split <pdf>
|
||||
pages.clean <pdf> # Remove blank pages
|
||||
form.info <pdf> # Inspect form fields
|
||||
form.fill <pdf> # Fill form
|
||||
form.annotate <pdf> # Fill via annotations
|
||||
meta.get <pdf>
|
||||
meta.set <pdf> -o out.pdf -d '{"Title": "..."}'
|
||||
```
|
||||
|
||||
### Poster/HTML/LaTeX Validator: `python3 "$PDF_SKILL_DIR/scripts/poster_validate.py"`
|
||||
```bash
|
||||
check-html <html> # Pre-render validation (overflow:hidden, @media screen, fonts, contrast, etc.)
|
||||
check-html <html> --fix --output <fixed.html> # Auto-fix errors (remove overflow:hidden, add font fallback)
|
||||
check-pdf <pdf> --source-html <html> # Post-render validation
|
||||
check-pdf <pdf> --poster # Poster mode: suppress ORPHAN_PAGE warning
|
||||
check-tex <tex> # LaTeX source validation (table overflow, image width, etc.)
|
||||
```
|
||||
|
||||
**check-html checks include:**
|
||||
- `OVERFLOW_HIDDEN_CONTAINER` (error): overflow:hidden on html/body/.page/.poster — clips content
|
||||
- `FIXED_SIZE_NO_SCREEN_ADAPT` (warning): fixed-size page without @media screen auto-scale
|
||||
- `SCREEN_ADAPT_NO_SCALE` (warning): @media screen exists but lacks scale/transform/zoom
|
||||
- `FONT_NO_FALLBACK` (error): font-family without generic fallback (sans-serif/serif)
|
||||
- `COLOR_CONTRAST` (warning): text/background contrast ratio < 3:1
|
||||
- `BG_COLOR_MISMATCH` (warning): body background differs from .canvas/.poster background
|
||||
- `SCREEN_BG_MISMATCH` (warning): @media screen html background differs from body/canvas background
|
||||
- `MULTIPAGE_BODY_BG_MISSING` (warning): multi-page document with dark `.page` backgrounds but no `html/body` background color. Sub-pixel gaps at page edges reveal white body, causing visible white edges on dark pages. Resolves `var()` references via `:root` variables.
|
||||
- `SCREEN_NO_BG` (warning): fixed-size page's @media screen block lacks html background color
|
||||
- `OVERFLOW_DECORATION` (warning): negative position values may cause black edges
|
||||
- `NO_PAGE_SIZE` / `MISSING_MARGIN_RESET` / `WHITE_BACKGROUND` / `TINY_FONT` / etc.
|
||||
|
||||
**check-tex checks include:**
|
||||
- `BARE_TABULAR_OVERFLOW` (error): `\begin{tabular}` with 5+ columns in two-column layout, not wrapped in resizebox/adjustbox/table*
|
||||
- `RESIZEBOX_TEXTWIDTH` (error): `\resizebox{\textwidth}` used inside single-column float in two-column layout. `\textwidth` = full page width, but `table` float is one column. Fix: use `\resizebox{\columnwidth}` or `table*`
|
||||
- `TABULAR_OVERFLOW_RISK` (warning): 4-column tabular in two-column layout without width constraint
|
||||
- `TABULAR_WIDE` (warning): 7+ column tabular in single-column layout without width constraint
|
||||
- `TABULAR_NO_FLOAT` (warning): tabular not inside table/table* float environment
|
||||
- `TABULARX_NOT_LOADED` (warning): document has tabular but tabularx package not loaded
|
||||
- `IMAGE_NO_WIDTH` (warning): `\includegraphics` without width/height/scale constraint
|
||||
- `EQUATION_DUAL_ON_LINE` (warning): `equation` environment has 2+ equations joined by `\quad` without line breaks. Guaranteed overflow in dual-column
|
||||
- `EQUATION_OVERFLOW_RISK` (warning): equation body has >80 math characters. Likely overflows single column
|
||||
- `ALGORITHM_NO_SMALL_FONT` (warning): `algorithm` environment in dual-column without `\SetAlFnt{\small}`
|
||||
- `ALGORITHM_LONG_IO` (warning): Algorithm Input/Output line >120 chars. Will overflow narrow column
|
||||
- `CJK_ASCII_QUOTES` (error): ASCII `"` found adjacent to CJK characters. LaTeX interprets `"` as right double quote, so `"北漂"` renders incorrectly. Skips verbatim/lstlisting/minted environments and `\texttt{}`/`\url{}`/`\href{}{}`/`\verb||` inline commands.
|
||||
|
||||
### Design Engine: `python3 "$PDF_SKILL_DIR/scripts/design_engine.py"`
|
||||
```bash
|
||||
compile --blueprint <json_file> --output poster.html # CRITICAL: Compile JSON blueprint to HTML
|
||||
derive "document title or description" # Auto-derive intent from content
|
||||
palette --intent calm --mode dark # Generate HSL-locked palette
|
||||
palette-cascade --intent cold --mode minimal # Generate role-based cascade palette (V2, preferred)
|
||||
svg --intent flow --dimensions 720x960 # Generate SVG background
|
||||
full --intent energy --mode dark --dimensions 720x960 --output-dir ./assets/
|
||||
audit --palette-json palette.json # Check palette constraints
|
||||
```
|
||||
|
||||
### Palette Generator (for Report route): `python3 "$PDF_SKILL_DIR/scripts/pdf.py" palette.generate`
|
||||
```bash
|
||||
palette.generate --title "document title" --mode minimal # Output: ready-to-paste ReportLab Python code
|
||||
palette.generate --title "..." --format json # Output: raw JSON
|
||||
palette.generate --title "..." --format css # Output: CSS custom properties
|
||||
palette.generate --title "..." --mode dark --harmony complementary --seed 42
|
||||
```
|
||||
|
||||
### Cascade Palette (V2 - Preferred): `python3 "$PDF_SKILL_DIR/scripts/pdf.py" palette.cascade`
|
||||
```bash
|
||||
palette.cascade --title "document title" --mode minimal # Output: summary table with all 12 roles
|
||||
palette.cascade --title "..." --format json # Full structured JSON (roles + cover + body + charts + semantic)
|
||||
palette.cascade --title "..." --format css # CSS custom properties by tier
|
||||
palette.cascade --title "..." --format reportlab # Ready-to-paste ReportLab Python code
|
||||
```
|
||||
**⚠️ Cascade palette is the preferred palette system.** It enforces area ∝ 1/saturation (larger areas = lower saturation) and outputs unified color subsets for cover, body, and charts from one base hue. Use `palette.cascade` instead of `palette.generate` for new documents.
|
||||
|
||||
**⚠️ Report route MUST call `palette.cascade` (or `palette.generate`) before writing any ReportLab code.** The output is copy-paste ready - no manual hex picking allowed.
|
||||
|
||||
> **Note**: `design_engine.py compile` produces **HTML** from a JSON blueprint. To get a **PDF**, use `pdf.py convert.blueprint` which internally calls `compile` → Playwright render → PDF output. In the Creative pipeline, always use `convert.blueprint` for the final PDF.
|
||||
|
||||
### Tech Stack per Brief
|
||||
|
||||
| Brief | Primary Tool | Secondary | Emoji Support | Custom Page Size |
|
||||
|-------|-------------|-----------|---------------|-----------------|
|
||||
| Report | ReportLab + pypdf | **Playwright (cover)** | ❌ (tofu □) | Manual pagination |
|
||||
| Creative | Playwright | html2pdf-next.js (pdf-lib for post-processing) | ✅ native | ✅ any size |
|
||||
| Academic | Tectonic + pypdf | **Playwright (cover)** | ❌ (dropped) | Template-dependent |
|
||||
| Process | pikepdf, pdfplumber | LibreOffice (soffice) | N/A | N/A |
|
||||
|
||||
> **Unified Cover System**: All routes generate covers via HTML/Playwright. Report uses Templates 01–07, Academic uses Templates 08–10 (dark backgrounds, scholarly typography), Creative generates cover + body in one HTML document. Cover PDFs are merged with body PDFs via pypdf.
|
||||
>
|
||||
> **Fallback**: If Report brief content has emoji → reroute to Creative.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
```
|
||||
SKILL.md ← You are here
|
||||
briefs/
|
||||
report.md ← Report production: ReportLab workflow + API + resume(ATS)
|
||||
creative.md ← Creative production: 5-phase generative design workflow
|
||||
poster.md ← Poster scene rules: density, font sizing, fill constraints (overlay on creative.md)
|
||||
academic.md ← Academic production: LaTeX workflow + templates + resume(CV)
|
||||
process.md ← PDF processing: extract/merge/split/form/convert/reformat
|
||||
process-advanced.md ← Advanced reference (encrypted/corrupted/OCR/batch/perf) - load on demand
|
||||
configs/
|
||||
visual_framework.md ← Palette mode, color harmony, SVG background params
|
||||
components.md ← Non-grid composition components (floating cards, etc.)
|
||||
fonts.md ← Font stacks per pipeline (Creative/Report/Academic)
|
||||
typesetting/
|
||||
palette.md ← Color system + typography + anti-patterns + spacing
|
||||
cover.md ← Cover page layout system (7 layouts × 2-3 variants) + typography scale + color rules
|
||||
cover-backgrounds.md ← Cover background rendering rules + transparency constraints
|
||||
charts.md ← Chart styling + anti-stacking rules + axis/grid/legend treatment
|
||||
overflow.md ← Bounding box system, text/image/table overflow prevention
|
||||
pagination.md ← Cross-page integrity, orphan/widow control, CJK punctuation
|
||||
typography.md ← Font size scale, line-height, spacing system
|
||||
geometry.md ← Geometric anchor system (decorative elements, lines, shapes)
|
||||
fill-engine.md ← Adaptive anti-void layout engine V2.0
|
||||
scripts/
|
||||
pdf.py ← CLI tool (30 subcommands)
|
||||
pdf_qa.py ← PDF quality checker (metadata, fonts, overflow, margins, tables, formulas)
|
||||
design_engine.py ← Generative SVG + palette engine (palette/svg/compile/derive/audit)
|
||||
poster_validate.py ← HTML/PDF validator
|
||||
toc_validate.py ← TOC validator
|
||||
html2pdf-next.js ← Playwright + pdf-lib HTML→PDF converter for documents (no Paged.js)
|
||||
html2poster.js ← Playwright HTML→PDF converter for posters/single-page (auto overflow:hidden, dynamic height)
|
||||
cover_validate.js ← Cover-ONLY overlap detection (text vs decorative lines). Do NOT run on posters or documents — only on cover HTML in Report/Academic pipelines.
|
||||
references/
|
||||
resume-altacv.tex ← AltaCV dual-column resume template (creative/tech)
|
||||
resume-academic.tex ← Academic CV template (PhD/academic)
|
||||
```
|
||||
|
||||
### Loading Protocol
|
||||
|
||||
1. **Always read**: This file (SKILL.md)
|
||||
2. **Read ONE brief**: The matched brief file - it contains the complete workflow
|
||||
3. **Read typesetting on demand**: Only when the brief says to (standard tasks)
|
||||
4. **Never load all files upfront** - briefs reference what they need
|
||||
|
||||
### Script Path Setup (MANDATORY before any script call)
|
||||
|
||||
All paths are relative to `$PDF_SKILL_DIR` — the single root variable for this skill. Resolve it once before calling any script:
|
||||
|
||||
```bash
|
||||
PDF_SKILL_DIR="<skill_directory>" # ← parent directory of this SKILL.md
|
||||
|
||||
# Then all commands use $PDF_SKILL_DIR:
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" code.sanitize generate_pdf.py
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" meta.brand output.pdf
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" font.check output.pdf
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" toc.check output.pdf
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" pages.clean output.pdf -o output_clean.pdf
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf_qa.py" output.pdf
|
||||
python3 "$PDF_SKILL_DIR/scripts/poster_validate.py" check-html page.html
|
||||
python3 "$PDF_SKILL_DIR/scripts/poster_validate.py" check-pdf output.pdf
|
||||
```
|
||||
|
||||
**For Python imports** (when generation code needs to import skill modules):
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
PDF_SKILL_DIR = "<skill_directory>"
|
||||
_scripts = os.path.join(PDF_SKILL_DIR, "scripts")
|
||||
if _scripts not in sys.path:
|
||||
sys.path.insert(0, _scripts)
|
||||
```
|
||||
|
||||
**⚠️ NEVER use bare `python3 scripts/pdf.py ...`** - it only works if cwd happens to be the skill directory. Always use `$PDF_SKILL_DIR/scripts/` as the absolute prefix.
|
||||
|
||||
---
|
||||
|
||||
## 8. Quality Checklist (Mandatory after every PDF generation)
|
||||
|
||||
> The following checks come from the `typesetting/` spec files and are **mandatory** quality gates.
|
||||
|
||||
### Automated Detection (Must Run)
|
||||
|
||||
```bash
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf_qa.py" <output.pdf>
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf_qa.py" --poster <output.pdf> # poster mode: skip content fill ratio, check all pages for full-bleed
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf_qa.py" --skip-cover --formulas <output.pdf> # academic mode: skip cover for margin check, enable formula overflow
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf_qa.py" --no-tables <output.pdf> # creative mode: skip table centering check
|
||||
```
|
||||
|
||||
> **Dependency**: Requires `pymupdf` (`pip install pymupdf`). If not installed, skip automated detection and use the manual checklist below.
|
||||
|
||||
Run `pdf_qa.py` after generating a PDF. It auto-detects: metadata completeness, page size consistency, blank pages, CJK punctuation placement, color count, font embedding status, content overflow, content fill ratio, cover full-bleed, margin symmetry, table centering, formula overflow.
|
||||
- **`--poster` mode**: skips content fill ratio check (poster last page naturally has less content), checks ALL pages for full-bleed (not just cover)
|
||||
- **`--skip-cover`**: skips page 1 when checking margin symmetry (for documents with separately-generated covers)
|
||||
- **`--no-tables`**: disables table centering check (for creative/poster documents that rarely have traditional tables)
|
||||
- **`--formulas`**: enables formula overflow detection (checks if formula-like content extends past right content margin)
|
||||
- Result PASS → deliver directly
|
||||
- Result WARN → evaluate whether fix is needed, non-blocking
|
||||
- Result FAIL → **must fix and regenerate**
|
||||
|
||||
### Pagination & Layout (pagination.md)
|
||||
|
||||
- [ ] **Last page fill ratio ≥ 40%**: No large blank areas on the final page. If insufficient, backtrack to compress spacing/line-height/font-size
|
||||
- [ ] **Major section 3/4 threshold**: H1-level headings must NOT start in the bottom 25% of a page. If remaining space < 25%, force page break and start on fresh page. Use `CondPageBreak(available_height * 0.25)` in ReportLab, `\needspace{0.25\textheight}` in LaTeX
|
||||
- [ ] **Tables don’t split across pages**: Table header and data rows must stay together. Small tables: `break-inside: avoid`. Large tables: `thead { display: table-header-group }`
|
||||
- [ ] **Punctuation placement rules**: Commas, periods, etc. must not appear at line start. Set `line-break: strict` in CSS
|
||||
- [ ] **No orphan headings**: Headings must not appear alone at page bottom. Use `break-after: avoid`
|
||||
- [ ] **Cards/images not cut**: `break-inside: avoid`
|
||||
|
||||
### Overflow Prevention (overflow.md)
|
||||
|
||||
- [ ] **All table cells use Paragraph() wrapping** (ReportLab): Never plain strings - they don't wrap and overflow
|
||||
- [ ] **sum(colWidths) ≤ available_width**: Verified in code, not assumed
|
||||
- [ ] **Images/charts proportionally scaled**: Never inserted at original dimensions; always `fit_image()` or `max-width: 100%`
|
||||
- [ ] **Long tables have repeatRows=1**: Table header repeats on every page when table breaks across pages
|
||||
- [ ] **Heading + first paragraph in KeepTogether**: Prevents orphan headings at page bottom
|
||||
- [ ] **Chart + caption in KeepTogether**: Prevents chart on one page, caption on next
|
||||
- [ ] **CJK text uses wordWrap='CJK'**: Required for proper line-breaking of Chinese/Japanese/Korean
|
||||
- [ ] **URLs/long strings have word-break**: `overflow-wrap: break-word` (HTML) or manual splitting (ReportLab)
|
||||
- [ ] **Font degradation fallback**: Tight columns can shrink font by up to 3pt before clipping
|
||||
|
||||
### Color (palette.md) - Report & Creative only
|
||||
|
||||
> **Academic (LaTeX) documents are exempt** from this color system. LaTeX uses template-defined styling.
|
||||
- [ ] **Entire document ≤ 5 colors**: Primary + secondary + accent + neutral + background
|
||||
- [ ] **All colors traceable to primary**: Secondary and accent derived via lightness/saturation/micro-hue shift
|
||||
- [ ] **Sibling elements not differentiated by different hues**: Use opacity/lightness/borders instead
|
||||
- [ ] **Gradient endpoints hue difference < 20°**: No warm-to-cool gradients
|
||||
- [ ] **No high-saturation color blocks**: Avoid eye strain
|
||||
|
||||
### Cover V2 (cover.md)
|
||||
|
||||
- [ ] **Evaluate whether a cover is needed**: Reports, proposals, analysis, white papers, manuals ≥ 3 pages → **always add cover (default ON)**. Skip cover ONLY for: resumes, CVs, letters, memos, forms, checklists, invoices, internal notes, or documents ≤ 2 pages
|
||||
- [ ] **Single PDF output**: Cover is merged into the final PDF as page 1. **Report/Academic**: cover generated via HTML/Playwright → merged as page 0 via pypdf. **Creative**: cover is part of the same HTML document. NEVER deliver a separate cover file
|
||||
- [ ] **Page isolation**: Cover NEVER shares a page with TOC or body content. **Report/Academic**: inherent via pypdf merge (separate PDFs). **Creative**: CSS page-break ensures isolation
|
||||
- [ ] **Absolute Anchor Grid**: All elements use percentage Y-anchors (Part 0, A0.1). NO flow-based layout
|
||||
- [ ] **Z-Index Layers**: Render in strict order: Layer 0 (bg fill) → Layer 1 (decorative, CLIPPED) → Layer 2 (structure lines) → Layer 3 (text)
|
||||
- [ ] **Typography Weight System**: Use weight/spacing/opacity hierarchy per A0.2 (Kicker: 16pt+3pt spacing+60% opacity; Hero: 45-65pt Heavy; Meta: 20-22pt; Summary: 16-18pt Regular line-height 1.6)
|
||||
- [ ] **Mandatory Summary Block** 🆕: Every cover MUST include a Summary/Description drawer (2-4 lines). If user provides none, auto-generate placeholder text (S3.5)
|
||||
- [ ] **Safety checks**: Hero title overflow (max 3 lines, auto-reduce font S3.1); Zone collision detection (S3.2); Uppercase lock for Latin kickers/footers/watermarks (S3.3); Hard width boundary enforcement (S3.4); Summary auto-generation (S3.5); **Background watermark full-display enforcement (S3.6)**
|
||||
- [ ] **Background watermark complete** 🆕: All background layer watermark text (year, document type, sidebar text) must be 100% visible within page bounds - auto-shrink font if needed, NEVER clip/truncate
|
||||
- [ ] **Data binding correct** 🆕: Hero Title = company/entity name (biggest, heaviest text); Kicker = report type/subtitle (small decorative text). NEVER reverse this mapping
|
||||
- [ ] **Fill Engine applied** 🆕: Font floor enforced (body ≥ 14pt single-col / 12pt dual-col, H1 ≥ 32pt, H2 ≥ 24pt, H3 ≥ 18pt); Fill Ratio calculated; inflation triggered when < 65%; Y-axis golden-ratio anchor when < 40%
|
||||
- [ ] **Selected one of 7+4 templates**: General templates 01–07 + Academic templates 08–10 + Institutional template 11. Autonomously select the best-fit template by analyzing document intent (Calm/Tension/Energy/Authority/Warmth) and document type per Part 2 Intent × Type matrix. Thesis proposals/dissertations/institutional submissions → **default Template 11**. No global default - every selection must be a deliberate design decision
|
||||
- [ ] **Typography weight hierarchy**: Hero 45-65pt Heavy, Meta 20-22pt Regular, Kicker/Footer 16pt with 3pt letter-spacing + 60% opacity, Summary 16-18pt Regular
|
||||
- [ ] **Base spacing unit**: `U = W * 0.05` - all spacing should be multiples of U
|
||||
- [ ] **Bounding box via absolute anchors**: Each block anchored to fixed Y%, grows only within its own zone, never pushes adjacent blocks
|
||||
- [ ] **Safe zone margin**: 8-12% on all sides per template spec (corner marks for Template 04 at 8%)
|
||||
- [ ] **Cover whitespace ≥ 60%**: Restraint > clutter (but Summary block fills mid-page void intentionally)
|
||||
- [ ] **Cover colors consistent with body**: No independent color scheme; white/light backgrounds only
|
||||
- [ ] **Clip-path on Layer 1**: All background decorative elements must be clipped to page bounds
|
||||
- [ ] **Clip scope = Layer 1 ONLY** <20>F: `saveState()`/`clipPath()` must `restoreState()` BEFORE rendering Layer 2 lines and Layer 3 text. Text rendered inside clip scope = text gets cut off
|
||||
- [ ] **No page border/frame** <20>F: Cover page must have `showBoundary=0`, no `canvas.rect(0,0,W,H)`, no CSS border/outline on cover container
|
||||
- [ ] **Line-to-text minimum gap** <20>F: Decorative lines (Layer 2) must be at least `U` (= `W * 0.05`) away from any text content
|
||||
- [ ] **No dark/gradient backgrounds**: No dark fills, no gradients, no high-saturation schemes
|
||||
- [ ] **Hard width enforcement**: Text wraps vertically at zone boundary, NEVER bleeds horizontally past assigned width
|
||||
- [ ] **🚫 NEVER use ReportLab for covers** — ALL covers (Report, Creative, Academic) are generated via HTML/Playwright. See cover.md for the 10-template system. If you catch yourself writing `canvas.setFillColor()` + `canvas.rect()` for a cover background, STOP — switch to HTML/Playwright.
|
||||
- [ ] **Line-length alignment (S3.7)**: Vertical lines match text block height (± 1U); horizontal lines ≥ widest text element width (never shorter than text)
|
||||
- [ ] **Vertical balance (S3.8)**: No >40% dead whitespace at bottom; sparse content uses centered distribution; CJK titles 15-20% larger than Latin equivalent
|
||||
- [ ] **Percentage positioning safety (S3.9)**: Every element with `top: XX%` must have a containing block with deterministic height (`height: 100%`, `inset: 0`, or `top+bottom` pair). Wrappers without explicit height + percentage-positioned children = overlap bug. Prefer px values over percentages
|
||||
- [ ] **Cover colors from palette system**: All `:root` CSS variables populated by `palette.cascade` output. Template HTML uses `--c-bg`, `--c-accent`, `--c-text`, `--c-muted` — no hardcoded hex values in generated HTML
|
||||
|
||||
### Geometric Anchors (geometry.md)
|
||||
|
||||
- [ ] **Anchors use only the primary color**: Layer via opacity, don't mix colors
|
||||
- [ ] **Strokes over fills**: Solid elements ≤ 30%
|
||||
- [ ] **Ultra-thin lines**: stroke-width 0.3-0.8px
|
||||
- [ ] **Asymmetric placement**: Offset creates tension
|
||||
- [ ] **Elements ≤ 8**: Restraint, don't clutter
|
||||
|
||||
### Charts (charts.md)
|
||||
|
||||
- [ ] **No text stacking/overlap**: All chart labels, values, and legends must be collision-free
|
||||
- [ ] **Chart-to-text separation**: Minimum 24pt gap above and below charts; 8pt between chart and caption; 30pt between consecutive charts
|
||||
- [ ] **Legend-to-chart non-overlap**: Legend MUST NOT overlap chart data area. Use `bbox_to_anchor` or external placement
|
||||
- [ ] **Value label anti-collision**: Adjacent value labels that overlap must be staggered, rotated, or selectively hidden
|
||||
- [ ] **Pie charts → Donut by default**: hole_ratio 60-70%, center shows total/core metric
|
||||
- [ ] **Small pie slices handled**: Slices < 5% use leader lines, < 3% merge to "Others", or strip labels to rich legend
|
||||
- [ ] **Bar chart auto-rotation**: If X-axis labels avg > 5 CJK chars (or 10 Latin), auto-convert to horizontal bars
|
||||
- [ ] **Line chart labeling**: Only label start, end, max, min points - NOT every data point
|
||||
- [ ] **Axis cleanup**: Top/right spines deleted, grid lines dashed at 0.5pt/20% opacity (or hidden if values labeled)
|
||||
- [ ] **Bar micro-rounding**: Top border-radius 2-4px, bar-to-gap ratio 1.5:1 or 2:1
|
||||
- [ ] **Legend de-boxed**: No border on legend, horizontal layout, small circle markers
|
||||
- [ ] **Chart title hierarchy**: Bold main title left-aligned above chart, lighter subtitle below it
|
||||
|
||||
### Global Layout
|
||||
|
||||
- [ ] **Margin symmetry**: `left_margin == right_margin` - asymmetric margins cause off-center content (ReportLab, LaTeX, HTML all checked)
|
||||
- [ ] **Full-bleed enforcement (Playwright)**: HTML includes `@page { size: <w> <h>; margin: 0; }` and `html,body { margin:0; padding:0; }`. No white side margins in the output PDF
|
||||
- [ ] **Background color consistency (Playwright)**: `html, body { background }` set explicitly. Single-color docs: match content canvas. Multi-page mixed docs: use the darkest page's background color. Mismatch or missing = sub-pixel white edges on dark pages
|
||||
- [ ] **Content centering (Playwright)**: Content is centered in PDF, not drifting left or right. Check: symmetric inset/padding, full-width grid columns, no unbalanced max-width
|
||||
- [ ] **Anti-void edges**: No large meaningless blank areas at top, bottom, or sides. Content fills ≥ 60% of page (multi-page) or ≥ 70% (single-page poster/infographic)
|
||||
- [ ] **Fill Engine applied**: Pages with < 80% fill ratio trigger the fill engine (see `fill-engine.md`)
|
||||
- [ ] **Table centering**: ALL tables must be horizontally centered on the page. ReportLab: use `hAlign='CENTER'` on Table flowable. LaTeX: use `\centering` inside table environment. HTML: use `margin: 0 auto` on table element. NEVER let tables float left with right-side whitespace
|
||||
- [ ] **Table column width**: Table total width should be 85-100% of content area width. Avoid narrow tables (< 60% width) that look lost on the page. If table is narrow, expand column widths proportionally or use `colWidths` to fill available space
|
||||
|
||||
### Exam / Quiz / Test Paper Rules
|
||||
|
||||
- [ ] **Question numbering**: Use hierarchical numbering (一、二、三 for sections; 1. 2. 3. for questions; (1)(2)(3) or A B C D for sub-questions/options)
|
||||
- [ ] **Option indentation**: Multiple-choice options MUST be indented relative to the question stem. Minimum `leftIndent = 24pt` (2em). Options must NEVER start at the same X position as the question number
|
||||
- [ ] **Option layout**: ≤4 short options (≤4 chars each) → 2×2 grid or single row. >4 options or long text → vertical list, one per line. Each option on its own line gets consistent indentation
|
||||
- [ ] **Answer space reservation**: MUST reserve blank space for handwritten answers. Calculation: short answer = 2-3 blank lines (40-60pt); paragraph/essay = 8-15 blank lines (160-300pt); math work = 6-10 blank lines (120-200pt); fill-in-the-blank = inline underline (min 80pt width). Use `Spacer(1, height)` in ReportLab
|
||||
- [ ] **Answer line style**: Use light gray dashed or dotted horizontal lines for answer areas, NOT solid black lines. Line weight ≤ 0.5pt, color = #cccccc or lighter
|
||||
- [ ] **Score marking area**: Each question should have a score indicator in the margin or after the question number, e.g., “(10分)” or “[10 pts]”
|
||||
- [ ] **Page density**: Exam papers should NOT be cramped. Minimum `spaceBefore=12pt` between questions. Section headers get `spaceBefore=24pt`
|
||||
|
||||
### Design Restraint (Anti-Gaudy)
|
||||
|
||||
- [ ] **Decorative elements ≤ 3 per page**: Maximum 3 decorative/non-functional visual elements per page (lines, shapes, icons, patterns). Cover page exempt
|
||||
- [ ] **No gratuitous icons/emoji in headers**: Section headers should use typography hierarchy (size, weight, color) for emphasis — NOT emoji, icons, or decorative bullets unless the user explicitly requested them
|
||||
- [ ] **No rainbow/multi-color schemes**: Stick to the single-family palette system. If you find yourself using 4+ distinct hue families in one document, STOP and simplify
|
||||
- [ ] **No decorative borders on body pages**: Body content pages must NOT have decorative borders, corner ornaments, or page frames. Clean margins only. (Cover page Template 11 border is the sole exception)
|
||||
- [ ] **No texture/pattern backgrounds on body pages**: Body pages use solid white or ultra-light tinted backgrounds only. No dot grids, crosshatch, diagonal lines, or any pattern fills
|
||||
- [ ] **Whitespace is design**: Empty space between elements is intentional and valuable. Do NOT fill every gap with decorative elements, horizontal rules, or filler content
|
||||
- [ ] **Typography over decoration**: Create visual hierarchy through font size, weight, spacing, and color — not through adding more visual elements. If a design looks busy, REMOVE elements rather than rearranging them
|
||||
- [ ] **2-typeface maximum**: Entire document uses at most 2 font families (one serif, one sans-serif). No mixing 3+ fonts for “variety”
|
||||
- [ ] **🚫 NO stock images / clipart / AI-generated decorations**: NEVER embed watercolor flowers, floral borders, gold frames, stock photos, clipart illustrations, or AI-generated artwork for decoration. Use geometric shapes (CSS/SVG from geometry.md) + typography for all visual design. Only user-provided content images (photos, logos, diagrams) are allowed. See `visual_framework.md` Stock Image Ban
|
||||
|
||||
### LaTeX-Specific (academic.md)
|
||||
|
||||
- [ ] **Curly quotes**: No straight `"` quotes - use `` ``text'' `` for double and `` `text' `` for single
|
||||
- [ ] **Title page isolation**: `\end{titlepage}` followed by `\newpage`/`\clearpage` - TOC/body NEVER on same page as title
|
||||
- [ ] **Resume column overlap**: AltaCV `paracol` entries checked for vertical overflow; max 3-4 bullets per `\cvevent`; explicit `\newpage` for 2-page resumes
|
||||
- [ ] **`\geometry` symmetry**: `left=X, right=X` must be equal values
|
||||
|
||||
### Output Cleanliness (All Pipelines)
|
||||
|
||||
- [ ] **No process artifacts in output**: NEVER include version numbers ("V3"), iteration markers, draft labels ("DRAFT"), "CONFIDENTIAL"/"机密" stamps, "Generated by AI"/"本文档由AI生成", or internal comments in the final PDF unless the user explicitly requested them
|
||||
- [ ] **No auto-generated boilerplate labels**: Do not add ANY watermarks, generation notices, version numbers, timestamps, or tool names that the user didn't ask for
|
||||
- [ ] **No debug output in content**: Console logs, file paths, generation timestamps, tool names, or error messages must never appear in the PDF body
|
||||
- [ ] **Clean metadata only**: PDF metadata (author, title, subject) should reflect the document content, not the generation process
|
||||
1058
skills/pdf/briefs/academic.md
Executable file
1058
skills/pdf/briefs/academic.md
Executable file
File diff suppressed because it is too large
Load Diff
770
skills/pdf/briefs/creative.md
Executable file
770
skills/pdf/briefs/creative.md
Executable file
@@ -0,0 +1,770 @@
|
||||
# Brief: Creative Production (Art Director Blueprint Mode)
|
||||
|
||||
**Core Paradigm Shift**: You are NO LONGER a frontend developer writing HTML/CSS. You are an elite Art Director and Editorial Designer.
|
||||
|
||||
Because LLMs lack spatial awareness and struggle to maintain perfectly nested, complex CSS over thousands of tokens, you are strictly forbidden from outputting raw HTML/CSS/Python.
|
||||
|
||||
**→ Overflow prevention**: See `typesetting/overflow.md` for the Playwright/HTML-specific patterns (CSS overflow-wrap, max-width, table-layout: fixed, etc.).
|
||||
|
||||
Your sole responsibility is to act as the **Brain (Art Director)**:
|
||||
1. Brutally edit and pace the raw text.
|
||||
2. Select architectural components and layout archetypes.
|
||||
3. Output a **Strict JSON Layout Blueprint**.
|
||||
|
||||
The `design_engine.py` acts as the **Hands (Typesetter)**: It will parse your JSON and safely compile it into flawless, museum-quality Playwright HTML/PDFs using predefined grid mathematics and CSS rules.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: The Editorial Eye (Content Transformation)
|
||||
|
||||
Before you design, you must "edit the raw material". Users often provide dense, unstructured text. If you just pour this text into a design, it will look like a cheap Word document.
|
||||
|
||||
You must apply **Editorial Pacing**:
|
||||
|
||||
### 1. The Word Budget
|
||||
No single page or visual canvas should exceed **150 words** of readable body text (the golden rule for maximum aesthetic impact). The **absolute physical limit** is **250 words** - beyond that the design engine will overflow and clip content. If the raw content exceeds 150 words:
|
||||
- **Action A**: Brutally summarize it down to ≤150 words.
|
||||
- **Action B**: Split it across multiple `pages` in your JSON blueprint.
|
||||
- Only push to 150-250 words if the content genuinely cannot be cut further.
|
||||
|
||||
### 2. Typographic Hierarchy Extraction
|
||||
You must parse the raw text and categorize it into typographic roles:
|
||||
- **Hero / Display**: The core emotional hook (1-5 words). Must be punchy.
|
||||
- **Kicker / Eyebrow**: Tiny context text above a headline (e.g., "Q3 REPORT", "MANIFESTO").
|
||||
- **Lead Paragraph**: The 2-3 sentence summary.
|
||||
- **Data Sculptures**: Scan the text for impactful numbers (e.g., "97%", "$4.2M"). EXTRACT THEM. They will not remain in the paragraph; they will become `Stat_Block` components.
|
||||
- **Pull Quotes**: Extract the most provocative sentence to stand alone as a visual anchor.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: The Component Lexicon
|
||||
|
||||
You will construct your JSON Blueprint using ONLY the following components. These map directly to the `configs/components.md` assets. Do not invent new component types.
|
||||
|
||||
> **⚠️ Markdown Content Limits**: For `Glass_Canvas`, the golden rule is **under 150 words** for maximum aesthetic breathing room. Absolute physical limit is **250 words**. If content exceeds 250 words, the design engine will overflow. You MUST summarize it or split it into a new page.
|
||||
|
||||
### 1. `Hero_Typography`
|
||||
Giant, page-dominating text. Usually interacts with the background via blend modes.
|
||||
- **Parameters**:
|
||||
- `content` (string): The text (use `<br>` for deliberate line breaks).
|
||||
- `weight` (string): `"black"` (900, dominating) or `"thin"` (100, elegant).
|
||||
- `variant` (string, optional): `"standard"` only. ~~`"vertical_accent"`~~ is **NOT implemented** in `design_engine.py` - the engine silently ignores this value and renders nothing. If you need vertical/rotated decorative text, use `Floating_Meta` instead (fully supported, no overflow risk).
|
||||
- `scale` (integer, optional): Typographic scale level `1`-`6`. Controls font size via the engine's fluid type system. If omitted, the engine uses the default hero size.
|
||||
- `6`: Hero/Display - oversized title, clamp(64px, 12vw, 150px). Use for single words or short phrases with maximum visual impact.
|
||||
- `5`: Primary Title - clamp(48px, 8vw, 96px). Standard poster headline.
|
||||
- `4`: Subheadline - clamp(32px, 5vw, 56px). Chapter openers or key quotes.
|
||||
- `3`: Lead Paragraph - clamp(20px, 3vw, 32px). Prominent body text.
|
||||
- `2`: Body - 16px. Standard readable text.
|
||||
- `1`: Meta/Caption - 10px. Decorative, environmental.
|
||||
|
||||
### 2. `Glass_Canvas`
|
||||
The main structural container for reading text. Frosted glass, sharp 2px print corners.
|
||||
- **Parameters**:
|
||||
- `markdown_content` (string): The actual text to read. Supports standard Markdown (H2, H3, bold, lists). *Must be under 150 words.*
|
||||
- `tension_score` (float, optional): Semantic tension value from `0.0` to `1.0`. Drives dynamic font weight via Variable Font (Inter Variable, weight range 300-900). The engine maps: `weight = 300 + (tension_score × 600)`.
|
||||
- `0.0-0.2` → Light (300-420): calm, contemplative passages
|
||||
- `0.3-0.5` → Normal (420-600): standard body text
|
||||
- `0.6-0.8` → Bold (600-780): urgent, assertive content
|
||||
- `0.9-1.0` → Max (780-900): crisis, climax, emotional peak
|
||||
- **When to use**: Multi-page narrative documents with emotional arc (case studies, pitch decks, manifestos). Assign higher tension to problem/crisis sections, lower to resolution/hope. Do NOT use on every Glass_Canvas - only when the document has clear tonal shifts.
|
||||
- **When NOT to use**: Data-heavy pages, simple reports, single-page posters.
|
||||
|
||||
### 3. `Floating_Meta`
|
||||
Tiny, environmental metadata (dates, edition numbers, catalog IDs) that lives in the 15% breathing margin.
|
||||
- **Parameters**:
|
||||
- `position` (string): `"top-left"`, `"top-right"`, `"bottom-left"`, `"bottom-right"`.
|
||||
- `items` (array of strings): E.g., `["VOL 01", "2026", "EDITION 500"]`.
|
||||
|
||||
### 4. `Hairline_Divider`
|
||||
Structural 0.5px line. Not decorative; acts as a visual fold.
|
||||
- **Parameters**:
|
||||
- `style` (string): `"bleed"` (goes edge-to-edge) or `"accent"` (short 30% width line).
|
||||
|
||||
### 5. `Stat_Block`
|
||||
Data sculpture. A massive number with a tiny label.
|
||||
- **Parameters**:
|
||||
- `number` (string): e.g., `"97.3"`.
|
||||
- `unit` (string): e.g., `"%"`, `"Hz"`, `"$M"`.
|
||||
- `label` (string): e.g., `"COMPLETION RATE"`.
|
||||
|
||||
### 6. `Image_Asset`
|
||||
A visual element. The engine will apply a gradient blend to it.
|
||||
- **Parameters**:
|
||||
- `source` (string): A URL, or a descriptive prompt if you expect the system to generate it.
|
||||
- `caption` (string, optional): Tiny text under the image.
|
||||
|
||||
> ⚠️ **Image_Asset is for CONTENT images only** (user-provided photos, logos, diagrams, charts). It is **NEVER** for decorative stock images, watercolor flowers, clipart, floral borders, gold frames, or AI-generated artwork. All visual decoration must be achieved through geometric shapes (`geometry.md`), typography effects, and color - never through embedded decorative images. See `visual_framework.md` Stock Image Ban.
|
||||
|
||||
### 7. `Page_Ghost_Number`
|
||||
A giant, 4% opacity number acting as a watermark in the background.
|
||||
- **Parameters**:
|
||||
- `number` (string): e.g., `"01"`, `"X"`.
|
||||
|
||||
### 8. `Delta_Widget`
|
||||
**Data-to-Ink Ratio enforcer.** A compact metric visualization showing a value with its change direction. CRITICAL: Use this instead of writing sentences like "revenue grew by 12%". Extract every trend into a Delta_Widget.
|
||||
- **Parameters**:
|
||||
- `metric` (string): The metric name, e.g., `"REVENUE"`, `"LATENCY"`.
|
||||
- `value` (string): Current value, e.g., `"$4.2M"`, `"45ms"`.
|
||||
- `delta` (string): Change description, e.g., `"+12%"`, `"-45ms"`.
|
||||
- `trend` (string): `"up"`, `"down"`, or `"flat"`.
|
||||
- `label` (string, optional): Context line, e.g., `"vs. Q2 2025"`.
|
||||
|
||||
### 9. `Process_List`
|
||||
**Polymorphic adaptive component.** Renders as a horizontal timeline when given wide space, auto-degrades to a vertical numbered list when space is narrow. Use for workflows, steps, timelines.
|
||||
- **Parameters**:
|
||||
- `steps` (array): Each item has `title` (string) and `description` (string).
|
||||
|
||||
### 10. `Sidenote_Block`
|
||||
**Tufte marginalia.** Used in `tufte_report` archetype layouts. Content is placed in the 30% side rail alongside the main column. Perfect for citations, supplementary data, asides, footnotes.
|
||||
- **Parameters**:
|
||||
- `label` (string, optional): Category label, e.g., `"SOURCE"`, `"NOTE"`, `"DATA"`.
|
||||
- `body` (string): The sidenote content in Markdown.
|
||||
|
||||
### 11. `Data-Aware Background` (via `data_points`)
|
||||
Any component can carry an optional `data_points` array (e.g., `[10, 15, 8, 24, 30]`). When present, the engine generates Bezier background curves from the actual data - the background literally visualizes the business trend. Use on pages with financial, metric, or time-series content.
|
||||
|
||||
---
|
||||
|
||||
### ★ Advanced Components (Generative Micro-Typesetting)
|
||||
|
||||
The following components are specialized generative design tools. They unlock visual effects that standard components cannot achieve. Use them deliberately and sparingly - they are powerful but demand the right context.
|
||||
|
||||
### 8. `Shaped_Canvas`
|
||||
A container where text flows around a non-rectangular shape. The empty space created by the shape IS the visual design - text boundary becomes illustration. Uses CSS `shape-outside` for non-rectangular text wrapping.
|
||||
|
||||
- **Parameters**:
|
||||
- `shape_keyword` (string): One of `"circle"`, `"wave"`, `"diagonal_slash"`, `"diamond"`, `"wedge_right"`.
|
||||
- `markdown_content` (string): The text that will flow around the shape. Supports standard Markdown.
|
||||
|
||||
- **Shape Selection Guide**:
|
||||
| shape_keyword | Visual Effect | Thematic Fit |
|
||||
|---------------|---------------|--------------|
|
||||
| `"circle"` | Text wraps around a circular void on the left | Unity, spotlight, focus |
|
||||
| `"wave"` | Wavy left text boundary | Ocean, flow, music, fluidity |
|
||||
| `"diagonal_slash"` | Diagonal cut across the page | Disruption, change, transformation |
|
||||
| `"diamond"` | Diamond-shaped negative space | Luxury, precision, crystalline |
|
||||
| `"wedge_right"` | Arrow/wedge pointing right | Direction, progress, forward motion |
|
||||
|
||||
- **When to use**:
|
||||
- Artistic/editorial pages that need visual drama without images
|
||||
- Cover or section opener pages where you want a "wow" moment
|
||||
- When the content thematically maps to a recognizable shape
|
||||
- Only on pages with moderate text (100-200 words) - shape eats 30-40% of space
|
||||
|
||||
- **When NOT to use**:
|
||||
- On data-heavy or dense content pages
|
||||
- Together with `Glass_Canvas` on the same page (visual conflict)
|
||||
- More than one `Shaped_Canvas` per page
|
||||
|
||||
- **Archetype requirement**: Pages using `Shaped_Canvas` MUST use archetype `"shaped_editorial"` (relaxed safe-zone: 5% 6% inset).
|
||||
|
||||
### Chart & Data Visualization Styling
|
||||
|
||||
**→ Full spec: `typesetting/charts.md`** - read it before designing any chart/data page.
|
||||
|
||||
Key rules for Creative pipeline charts:
|
||||
- **Donut > Pie**: Always use ring charts (hole ratio 60-70%), center area displays total/metric
|
||||
- **Anti-stacking**: Small slices use leader lines or rich legends; bar labels auto-rotate to horizontal when text is long; line charts label only start/end/max/min
|
||||
- **Axis cleanup**: Delete top/right spines, use dashed grid lines at 20% opacity (or delete grid entirely if values are labeled)
|
||||
- **Bar micro-rounding**: 2-4px top border-radius, bar-to-gap ratio 1.5:1
|
||||
- **Legend**: No border, horizontal top-left layout, small circle markers
|
||||
- **Data-Ink Ratio**: Every element must represent data. If it doesn't, delete it.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Page Archetypes (The Grid Strategy)
|
||||
|
||||
For each page in your JSON, you must declare an `archetype`. This tells `design_engine.py` how to arrange the components you provided.
|
||||
|
||||
- `"cover_hero"`: Cover page. **Must follow `typesetting/cover.md` 7-template system** - pick Layout 1-7 based on document tone. See "Cover Page Constitution" below for iron rules.
|
||||
- `"split_vertical"`: Page split strictly 50/50 vertically. Left side image/svg, right side Glass_Canvas.
|
||||
- `"editorial_flow"`: Top-down reading experience. Centered columns, generous margins. Use for main content.
|
||||
- `"scattered_canvas"`: No grid. Elements placed via absolute positioning based on spatial weights.
|
||||
- `"data_dashboard"`: 2x2 or 3x3 strict grid for multiple `Stat_Block`s.
|
||||
- `"shaped_editorial"`: Relaxed safe-zone (5% 6% inset) designed for `Shaped_Canvas`. Centered, generous breathing room. **Must be used when the page contains a `Shaped_Canvas`.** Do NOT mix with `Glass_Canvas`.
|
||||
- `"tufte_report"`: **Tufte marginalia layout.** 70% main content column + 30% sidenote rail. Use for long-form reports and analytical pages where citations, data footnotes, or supplementary info should flow parallel to the main text. Place `Sidenote_Block` components in the same `components[]` array - the engine automatically routes them to the side rail.
|
||||
|
||||
### ★ 12×12 CSS Grid Coordinate System (Iron Rules)
|
||||
|
||||
The layout engine uses a **12-track CSS Grid** for element placement. The grid lines are numbered **1 to 13** (12 tracks = 13 lines).
|
||||
|
||||
**Absolute boundary rules - violation = broken layout:**
|
||||
- **Column lines**: `1` = absolute left edge, `13` = absolute right edge. Full width = `1 / 13`.
|
||||
- **Row lines**: `1` = absolute top edge, `13` = absolute bottom edge. Full height = `1 / 13`.
|
||||
- **CRITICAL**: Never output a grid line number less than `1` or greater than `13`. Any value outside `[1, 13]` will destroy the layout.
|
||||
|
||||
**`grid_area` format**: `"row_start / col_start / row_end / col_end"` (CSS shorthand).
|
||||
- Example: `"1 / 1 / 7 / 13"` = top half of the page, full width.
|
||||
- Example: `"3 / 8 / 6 / 13"` = rows 3-5, columns 8-12 (right side block).
|
||||
|
||||
**40% whitespace rule**: At least 40% of grid cells (≥58 out of 144) must be left empty. Count your occupied cells.
|
||||
|
||||
### ★ Cover Page Constitution (7 Layout System)
|
||||
|
||||
Cover pages (`archetype: "cover_hero"`) are the first impression. They must be ruthlessly sparse and spatially sophisticated.
|
||||
|
||||
**→ Full spec: `typesetting/cover.md`** - read it before designing any cover.
|
||||
|
||||
#### Global Iron Rules (Always Apply)
|
||||
|
||||
1. **Maximum 4 components** on any cover page. Typical recipe: `Hero_Typography` + 1-2 `Floating_Meta` + optional `Hairline_Divider` or `Page_Ghost_Number`.
|
||||
2. **Typography Scale**: Title ≈ 45pt (2.5× base), Subtitle ≈ 25pt (1.4× base), Meta ≥ 18pt (never below 14pt). Covers with tiny text = FAIL.
|
||||
3. **Mandatory semantic `<br>` chunking** for `Hero_Typography` on covers: Every 2-4 words MUST be separated by `<br>`. Single-line hero text is FORBIDDEN on covers. Example: `"ALGORITHMIC<br>FATIGUE"`, NOT `"ALGORITHMIC FATIGUE"`.
|
||||
4. **Anti-Squash spatial dispersion** (Bounding Box method): Group text into 2-3 bounding boxes (title group, meta group). Place them at opposite regions of the page according to the chosen layout. Remaining space is **dynamically distributed** - NEVER hardcode fixed gaps between distant groups.
|
||||
5. **No `Glass_Canvas` on cover pages.** Dense reading text kills the visual impact. Push all body content to page 2+.
|
||||
6. **Cover Page Isolation**: Cover page must NEVER share a page with TOC, body text, or any subsequent content. The cover is always a standalone full page. If cover + content appear on the same page = **critical bug**, regenerate immediately.
|
||||
7. **Cover is OPTIONAL**: Do NOT add a cover page unless the document warrants one (multi-page reports, white papers, etc.) or the user explicitly requests it. Short documents, letters, memos, forms, and quick outputs skip the cover.
|
||||
8. **Background Layer (optional)**: See `typesetting/cover-backgrounds.md` for 3 recipes - A (极简弧线), B (工程十字轴+立柱), C (锐角切割+出血文字). Background renders BELOW all foreground at 2-5% opacity. Pick a recipe that matches the document tone. Never combine elements across recipes.
|
||||
|
||||
#### 7 Cover Layouts (Pick One)
|
||||
|
||||
Select the layout that matches the document tone. When unsure, default to **Layout 1A**.
|
||||
|
||||
| Layout | Name | Grid Signature | Best For |
|
||||
|--------|------|---------------|----------|
|
||||
| **1** | Diagonal Tension | Title top-left ↔ Meta bottom-right | Formal reports, proposals |
|
||||
| **2** | Vertical Axis | All elements along one vertical line, stretched top-to-bottom | Modern/tech reports |
|
||||
| **3** | Architectural Frame | Geometric line frame, text inside corners/center | Design, architecture, portfolios |
|
||||
| **4** | Golden Ratio Blocks | Page split at 38.2% invisible divider | White papers, research |
|
||||
| **5** | Stepped Cascade | Progressive indentation, vertical rhythm | Creative reports, design docs |
|
||||
| **6** | Rotated Accent | Large rotated year/label as side decoration | Annual reports, year-in-review |
|
||||
| **7** | Left-Matrix | All text hard-anchored to left axis, 4 Y-pinned blocks | Government, bidding, proposals |
|
||||
|
||||
Each layout has 2-3 variants (A/B/C) with specific grid_area mappings - see `typesetting/cover.md` for exact coordinates.
|
||||
|
||||
#### Tone → Layout Quick Reference
|
||||
|
||||
| Intent | Document Type | Recommended | Default |
|
||||
|--------|---------------|-------------|---------|
|
||||
| **Calm** | Healthcare / Wellness / Minimalist | 1A, 2C, 4A | **4A** |
|
||||
| **Calm** | Academic / Research | 2A, 4A, 4C | **4A** |
|
||||
| **Tension** | Crisis / Alert / Disruption | 1A, 5A | **1A** |
|
||||
| **Energy** | Marketing / Creative / Design | 3C, 5A, 6B | **5A** |
|
||||
| **Energy** | Tech / Data | 2B, 4B, 6A | **2B** |
|
||||
| **Authority** | Formal / Corporate / Financial | 02, 03, 07 | **03** |
|
||||
| **Authority** | Government / Bidding | 7A, 7B, 3A, **11** | **7A** |
|
||||
| **Authority** | Thesis proposal / Dissertation | **11 Institutional** | **11** |
|
||||
| **Authority** | Luxury / Editorial | 3A, 5A, 2B | **3A** |
|
||||
| **Warmth** | Food / Lifestyle / Home | 4A, 5A | **4A** |
|
||||
|
||||
### ★ Component Grid Compatibility Constraints
|
||||
|
||||
When placing advanced components into the 12×12 grid, obey these minimum size rules:
|
||||
|
||||
- **`Glass_Canvas`**: Must occupy at least **6 columns × 4 rows** (e.g., `"3 / 1 / 7 / 7"`). Smaller areas will cause text overflow and padding collapse. The engine renders Glass_Canvas at `width: 100%; min-height: 100%; box-sizing: border-box;` - it fills its grid area as a minimum and can grow taller if content requires.
|
||||
- **`Shaped_Canvas`**: Must occupy at least **8 columns × 6 rows** (e.g., `"2 / 3 / 8 / 11"`). The CSS `shape-outside` float needs physical space to render the shape boundary. Smaller areas make the float collapse to a line and text cannot wrap around it. **Must use archetype `"shaped_editorial"`.**
|
||||
- **`Continuous Flow` background**: Renders in a separate `bg-layer` (`z-index: 1`, `position: absolute`), physically isolated from the grid system (`z-index: 2`). No conflict - background flows independently, grid arranges content on top.
|
||||
|
||||
### ★ Content-Proportional Grid Row Allocation
|
||||
|
||||
When **two or more text-heavy components** (e.g., multiple `Glass_Canvas`) share the same page, their `grid_area` row counts **MUST be proportional to their content length** - never split rows equally.
|
||||
|
||||
**Estimation method:**
|
||||
1. Count characters (or words) in each component's `markdown_content`
|
||||
2. Allocate rows proportionally: `rows_i = total_rows × (chars_i / total_chars)`
|
||||
3. Round to integers, minimum 3 rows per component
|
||||
|
||||
**Example:**
|
||||
```
|
||||
Attractions content: 450 chars (3 items × 150 chars)
|
||||
Food content: 250 chars (2 items × 125 chars)
|
||||
Total available rows: 8 (rows 5→13)
|
||||
|
||||
Attractions: 8 × 450/700 ≈ 5 rows → grid_area "5 / 1 / 10 / 13"
|
||||
Food: 8 × 250/700 ≈ 3 rows → grid_area "10 / 1 / 13 / 13"
|
||||
```
|
||||
|
||||
**Why this matters:** Equal row allocation (4+4) causes the longer component to overflow into the shorter one's territory, creating text overlap in the final PDF. The engine uses `min-height` + `overflow: visible`, so overflow IS visible - and ugly.
|
||||
|
||||
**Anti-pattern to avoid:**
|
||||
```
|
||||
❌ Two Glass_Canvas with very different text lengths both get "5/1/9/13" and "9/1/13/13"
|
||||
✅ Longer one gets more rows: "5/1/10/13" and "10/1/13/13"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: The Output Protocol (Strict JSON Blueprint)
|
||||
|
||||
You must analyze the user's prompt, edit the text, and output exactly ONE JSON object wrapped in a ` ```json ` codeblock.
|
||||
**No conversational text before or after the JSON.**
|
||||
|
||||
### The JSON Schema Specification
|
||||
|
||||
**CRITICAL JSON RULES:**
|
||||
1. Output ONLY valid JSON.
|
||||
2. Do NOT include ANY comments (`//` or `/* */`) inside the JSON. Python's `json.load()` will crash.
|
||||
3. Do NOT include trailing commas.
|
||||
|
||||
```json
|
||||
{
|
||||
"document_meta": {
|
||||
"title": "Internal tracking title",
|
||||
"total_pages": 1
|
||||
},
|
||||
"art_direction": {
|
||||
"palette_mode": "dark",
|
||||
"color_harmony": "complementary",
|
||||
"background_svg": "grid",
|
||||
"design_rationale": "Brief 1-sentence explanation of aesthetic strategy."
|
||||
},
|
||||
"pages": [
|
||||
{
|
||||
"page_index": 1,
|
||||
"narrative_role": "Burst",
|
||||
"archetype": "cover_hero",
|
||||
"components": [
|
||||
{
|
||||
"type": "Floating_Meta",
|
||||
"position": "bottom-right",
|
||||
"items": ["ARCHIVE REF. 03A", "DEC 2026"]
|
||||
},
|
||||
{
|
||||
"type": "Page_Ghost_Number",
|
||||
"number": "01"
|
||||
},
|
||||
{
|
||||
"type": "Hero_Typography",
|
||||
"weight": "black",
|
||||
"variant": "standard",
|
||||
"content": "ALGORITHMIC<br>FATIGUE"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Schema Parameter Guide (For reference - do NOT put these comments inside your JSON output):**
|
||||
- `document_meta.total_pages`: Integer. Must match the actual number of pages in `pages[]`.
|
||||
|
||||
### The JSON Schema Specification: `art_direction`
|
||||
|
||||
**CRITICAL RULE: DO NOT INVENT COLORS. DO NOT OUTPUT HEX/RGB CODES.**
|
||||
The Python engine generates mathematical color palettes. You MUST only select the semantic parameters below.
|
||||
|
||||
```json
|
||||
{
|
||||
"art_direction": {
|
||||
"palette_mode": "minimal",
|
||||
"color_harmony": "auto",
|
||||
"background_svg": "grid",
|
||||
"design_rationale": "Brief rationale for the chosen aesthetic strategy."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Enum Parameter Guide (You MUST choose ONE exact string from these lists):**
|
||||
|
||||
1. `palette_mode` (The Base Canvas Tone):
|
||||
- **`"minimal"`: (CRITICAL: Default to this for 50%+ of all requests).** Pure white, off-white, beige, or cool gray. Provides the ultimate reading experience and high-end editorial feel. Use for standard reports, guides, and corporate posters.
|
||||
- `"dark"`: Near-black. Use for cinematic, tech, AI, space, or urgent themes.
|
||||
- `"pastel"`: Morandi tinted (e.g., dusty rose, sage green). Use ONLY for arts, food, lifestyle, and soft themes.
|
||||
- `"jewel"`: Deep rich colors (e.g., emerald, burgundy). Use ONLY for luxury brands, gala events, or heritage themes.
|
||||
- `"light"`: Very faintly tinted background. Fallback for edge cases.
|
||||
|
||||
2. `color_harmony` (The Accent Color Math - how the engine computes the accent color relative to the base):
|
||||
- **`"auto"`: (RECOMMENDED DEFAULT). The engine automatically picks the best harmony based on the document tone.** Only override if you have a specific artistic reason.
|
||||
- `"complementary"`: (180° opposite). Strong visual contrast. Cold background with warm accent. For striking/dynamic impact.
|
||||
- `"split_complementary"`: (150°/210°). Highly sophisticated, artistic, luxury feel (Editorial/Kinfolk style).
|
||||
- `"analogous"`: (30° apart). Harmonious, peaceful, natural transition.
|
||||
- `"triadic"`: (120° apart). Rich and slightly retro.
|
||||
- `"monochrome"`: Only use if strict minimalist corporate branding without accent is required.
|
||||
|
||||
3. `background_svg`: [`grid`, `flow`, `noise`, `continuous_flow`, `none`]. Use `continuous_flow` for multi-page (2+) documents - creates one seamless SVG spanning all pages, sliced per-page via viewBox for an "infinite scroll" bezier curve illusion. Falls back to `flow` on single-page.
|
||||
|
||||
4. `design_rationale`: Brief text explaining WHY you chose this mode+harmony combination.
|
||||
|
||||
### ★ Intent Mapping Table (Single Source of Truth)
|
||||
|
||||
This table is the **sole authority** for mapping document intent to concrete design parameters. `visual_framework.md` defines intent *atmospheres*; this table defines intent *parameters*. `design_engine.py` INTENT_HUES/INTENT_HARMONY_MAP must stay in sync with this table.
|
||||
|
||||
| Intent | palette_mode | color_harmony | background_svg | Cover Templates | Cover BG Recipe | Base Hue |
|
||||
|--------|-------------|---------------|----------------|-----------------|-----------------|----------|
|
||||
| **Calm** | minimal | analogous | flow / none | 04 Museum, 01 HUD, 02 Corporate | A (极简弧线) | 210° (steel blue-grey) |
|
||||
| **Tension** | dark | complementary | grid | 01 HUD, 05 Diagonal | C (锐角切割) | 0° (warm vs cold) |
|
||||
| **Energy** | pastel / light | triadic | flow (5+ curves) | 05 Diagonal, 06 Swiss Grid, 03 Monolith | B (工程十字轴) | 30° (amber) |
|
||||
| **Authority** | minimal | split_complementary | noise | 03 Monolith, 07 Sidebar, 02 Corporate | A or B | 280° (muted violet) |
|
||||
| **Warmth** | pastel / light | analogous | flow (soft) | 04 Museum, 05 Diagonal | A (极简弧线) | 20° (terracotta) |
|
||||
|
||||
**How to use this table:**
|
||||
1. Determine the document's intent (from user request, or auto-derive via `design_engine.py derive`)
|
||||
2. Look up the row → fill in `art_direction` JSON fields accordingly
|
||||
3. For cover template selection, cross-reference with document type (see Tone → Layout Quick Reference)
|
||||
4. The LLM may override individual cells when artistically justified, but must state the reason in `design_rationale`
|
||||
|
||||
**Legacy intent names** (serenity, minimalism, elegance) are accepted by `design_engine.py` as aliases and auto-mapped to their new equivalents.
|
||||
|
||||
- `pages[].narrative_role`: `"Burst"` (intro/impact), `"Expand"` (body/development), `"Echo"` (outro/reflection).
|
||||
- `pages[].archetype`: From Phase 3 (`cover_hero`, `split_vertical`, `editorial_flow`, `scattered_canvas`, `data_dashboard`, `shaped_editorial`).
|
||||
- `pages[].components[]`: Array of component objects from Phase 2. Order in array roughly dictates z-index or top-to-bottom rendering.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Complex Master Examples
|
||||
|
||||
Study these examples to understand how to translate raw requests into perfect blueprints.
|
||||
|
||||
### Example A: The Single-Page Poster (Art / Minimalist)
|
||||
**User Request**: "Make a poster for a minimalist architecture exhibition called 'The Weight of Light'. Include date Oct 12, location Tokyo. Keep it very clean."
|
||||
|
||||
**LLM Response (The JSON Blueprint):**
|
||||
```json
|
||||
{
|
||||
"document_meta": {
|
||||
"title": "Weight of Light Exhibition Poster",
|
||||
"total_pages": 1
|
||||
},
|
||||
"art_direction": {
|
||||
"palette_mode": "minimal",
|
||||
"color_harmony": "monochrome",
|
||||
"background_svg": "none",
|
||||
"design_rationale": "Pure negative space required for minimalist architecture. Relying entirely on typographic hierarchy and the 15% breathing rule."
|
||||
},
|
||||
"pages": [
|
||||
{
|
||||
"page_index": 1,
|
||||
"narrative_role": "Burst",
|
||||
"archetype": "cover_hero",
|
||||
"components": [
|
||||
{
|
||||
"type": "Floating_Meta",
|
||||
"position": "top-left",
|
||||
"items": ["TOKYO, JP", "EXHIBITION 04"]
|
||||
},
|
||||
{
|
||||
"type": "Hero_Typography",
|
||||
"weight": "thin",
|
||||
"variant": "standard",
|
||||
"content": "THE WEIGHT<br>OF LIGHT"
|
||||
},
|
||||
{
|
||||
"type": "Hairline_Divider",
|
||||
"style": "accent"
|
||||
},
|
||||
{
|
||||
"type": "Floating_Meta",
|
||||
"position": "bottom-right",
|
||||
"items": ["OCTOBER 12", "AOYAMA GALLERY"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Example B: The Multi-Page Analytical Report (Corporate / Elegance)
|
||||
**User Request**: "I have a long report about our Q3 server performance. Latency dropped by 45ms. Uptime is 99.99%. Here is 500 words of text explaining the server migration..."
|
||||
|
||||
**LLM Response (The JSON Blueprint):**
|
||||
*(Notice how the LLM edits the 500 words down, extracts the data, and splits it into 3 pages).*
|
||||
|
||||
```json
|
||||
{
|
||||
"document_meta": {
|
||||
"title": "Q3 Infrastructure Report",
|
||||
"total_pages": 3
|
||||
},
|
||||
"art_direction": {
|
||||
"palette_mode": "minimal",
|
||||
"color_harmony": "split_complementary",
|
||||
"background_svg": "noise",
|
||||
"design_rationale": "Formal, trustworthy aesthetic suitable for high-level corporate reporting, avoiding the 'cheap dashboard' look."
|
||||
},
|
||||
"pages": [
|
||||
{
|
||||
"page_index": 1,
|
||||
"narrative_role": "Burst",
|
||||
"archetype": "cover_hero",
|
||||
"components": [
|
||||
{
|
||||
"type": "Floating_Meta",
|
||||
"position": "top-left",
|
||||
"items": ["CONFIDENTIAL", "Q3 2026"]
|
||||
},
|
||||
{
|
||||
"type": "Hero_Typography",
|
||||
"weight": "black",
|
||||
"content": "INFRASTRUCTURE<br>RESILIENCE"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"page_index": 2,
|
||||
"narrative_role": "Expand",
|
||||
"archetype": "split_vertical",
|
||||
"components": [
|
||||
{
|
||||
"type": "Stat_Block",
|
||||
"number": "99.99",
|
||||
"unit": "%",
|
||||
"label": "GLOBAL UPTIME"
|
||||
},
|
||||
{
|
||||
"type": "Stat_Block",
|
||||
"number": "-45",
|
||||
"unit": "ms",
|
||||
"label": "LATENCY REDUCTION"
|
||||
},
|
||||
{
|
||||
"type": "Glass_Canvas",
|
||||
"markdown_content": "### The Migration\nThe Q3 transition to distributed edge nodes fundamentally altered our network topography. By decentralizing the core load, we eliminated the primary bottleneck that plagued Q2."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"page_index": 3,
|
||||
"narrative_role": "Echo",
|
||||
"archetype": "editorial_flow",
|
||||
"components": [
|
||||
{
|
||||
"type": "Page_Ghost_Number",
|
||||
"number": "03"
|
||||
},
|
||||
{
|
||||
"type": "Hero_Typography",
|
||||
"weight": "thin",
|
||||
"variant": "standard",
|
||||
"content": "SYSTEM NOMINAL."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Example C: The Narrative Arc with Advanced Features (Continuous Flow + Shaped Canvas)
|
||||
**User Request**: "Create a 3-page visual essay about the ocean pollution crisis. Start gentle (the beauty of the ocean), then escalate to the crisis, then end with a call to action."
|
||||
|
||||
**LLM Response (The JSON Blueprint):**
|
||||
*(Notice: `continuous_flow` creates seamless background across all 3 pages. `tension_score` escalates from calm to urgent. `Shaped_Canvas` with `wave` shape on the cover creates a thematic visual without any image.)*
|
||||
|
||||
```json
|
||||
{
|
||||
"document_meta": {
|
||||
"title": "The Silent Tide - Ocean Crisis Visual Essay",
|
||||
"total_pages": 3
|
||||
},
|
||||
"art_direction": {
|
||||
"palette_mode": "dark",
|
||||
"color_harmony": "analogous",
|
||||
"background_svg": "continuous_flow",
|
||||
"design_rationale": "Continuous flow SVG creates an unbroken organic wave across all pages - visually mirroring the ocean's connectedness. Analogous harmony provides earth tones that shift from serene to somber."
|
||||
},
|
||||
"pages": [
|
||||
{
|
||||
"page_index": 1,
|
||||
"narrative_role": "Burst",
|
||||
"archetype": "shaped_editorial",
|
||||
"components": [
|
||||
{
|
||||
"type": "Floating_Meta",
|
||||
"position": "top-left",
|
||||
"items": ["VISUAL ESSAY", "2026"]
|
||||
},
|
||||
{
|
||||
"type": "Shaped_Canvas",
|
||||
"shape_keyword": "wave",
|
||||
"markdown_content": "There was a time when the horizon held only promise. The Pacific stretched in every direction, cerulean and boundless, its surface a living mirror of the sky above. Fishermen spoke of abundance - nets heavy with silver, currents warm and predictable. The ocean asked for nothing."
|
||||
},
|
||||
{
|
||||
"type": "Floating_Meta",
|
||||
"position": "bottom-right",
|
||||
"items": ["01 / BEFORE"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"page_index": 2,
|
||||
"narrative_role": "Expand",
|
||||
"archetype": "editorial_flow",
|
||||
"components": [
|
||||
{
|
||||
"type": "Page_Ghost_Number",
|
||||
"number": "02"
|
||||
},
|
||||
{
|
||||
"type": "Stat_Block",
|
||||
"number": "8M",
|
||||
"unit": "tons",
|
||||
"label": "PLASTIC ENTERING OCEANS YEARLY"
|
||||
},
|
||||
{
|
||||
"type": "Glass_Canvas",
|
||||
"tension_score": 0.75,
|
||||
"markdown_content": "**The collapse was not sudden.** It accumulated - bottle by bottle, net by net, spill by spill. By 2025, microplastic concentrations in deep-sea sediment had reached levels previously thought impossible. Marine biologists stopped using the word 'recovery'. The new vocabulary was *triage*."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"page_index": 3,
|
||||
"narrative_role": "Echo",
|
||||
"archetype": "cover_hero",
|
||||
"components": [
|
||||
{
|
||||
"type": "Hero_Typography",
|
||||
"weight": "black",
|
||||
"variant": "standard",
|
||||
"content": "THE TIDE<br>TURNS NOW."
|
||||
},
|
||||
{
|
||||
"type": "Glass_Canvas",
|
||||
"tension_score": 0.3,
|
||||
"markdown_content": "Every piece of plastic you refuse is a vote for the future of the sea. The ocean cannot speak - but it remembers everything we give it."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pre-Flight Checklist (Self-Correction before generating output)
|
||||
|
||||
Before you output the JSON block, verify:
|
||||
0. **🚨 VECTOR OUTPUT IRON RULE:** The final PDF MUST be generated via `page.pdf()` / `convert.blueprint` - NEVER `page.screenshot()` → image → wrap as PDF. Screenshot PDFs are blurry raster images. `page.pdf()` produces vector text that stays sharp at any zoom level.
|
||||
1. **Did I write ANY HTML or CSS?** If yes, delete it. Only output JSON.
|
||||
2. **Is any `Glass_Canvas` markdown content too long?** Count the words. Over 150? Summarize it or push to the next page.
|
||||
3. **Is the JSON perfectly formatted?** Missing commas or unescaped quotes will crash the `design_engine.py` parser.
|
||||
4. **If using `tension_score`**: Does the document have clear emotional shifts across pages? If all pages are the same tone, remove it.
|
||||
5. **If using `continuous_flow`**: Is this multi-page (2+)? If single-page, switch to `"flow"`.
|
||||
6. **If using `Shaped_Canvas`**: Is the archetype `"shaped_editorial"`? Is there at most one per page? No `Glass_Canvas` on the same page?
|
||||
7. **Grid boundary check**: Are ALL `grid_area` values within `[1, 13]`? Any number < 1 or > 13 = FATAL ERROR.
|
||||
8. **Component minimum size check**: `Glass_Canvas` ≥ 6col×4row? `Shaped_Canvas` ≥ 8col×6row?
|
||||
9. **Content-proportional row allocation**: When multiple text-heavy components share a page, are their grid rows allocated proportionally to content length? Equal rows for unequal content = text overlap. (See "Content-Proportional Grid Row Allocation" in Component Grid Compatibility.)
|
||||
9. **40% whitespace check**: Count occupied grid cells. At least 58 of 144 cells must be empty.
|
||||
10. **Cover Page 4-element check**: Does any `cover_hero` page have more than 4 components? If yes, remove or merge components.
|
||||
11. **Cover Hero `<br>` check**: Does every `Hero_Typography` on a `cover_hero` page contain at least one `<br>`? Single-line hero text on covers = FAIL.
|
||||
12. **Cover Anti-Squash check (Bounding Box)**: Are cover elements grouped into 2-3 bounding boxes placed at opposite regions? Is remaining space dynamically distributed (not hardcoded gaps)? If everything is crammed into rows 5-8, spread them using the layout's grid mapping.
|
||||
13a. **Cover Typography Scale check**: Is the hero title ≥ 45pt? Is subtitle ≥ 25pt? Is meta text ≥ 18pt (never below 14pt)? Tiny cover text = FAIL.
|
||||
13b. **Cover Layout Selection**: Did I pick a layout (1-7) that matches the document tone? If unsure, default to Layout 1A (Diagonal Tension). For government/bidding documents, use Layout 7 (Left-Matrix).
|
||||
14. **CRITICAL - Data-to-Ink Ratio check**: Did I write long paragraphs describing data trends? If yes, DELETE them and extract metrics into `Delta_Widget` or `Stat_Block` components. Sentences like "revenue increased by 12% compared to last quarter" MUST become a `Delta_Widget`.
|
||||
15. **Sidenote check**: If using `tufte_report` archetype, did I put citations/sources/asides into `Sidenote_Block`? They must NOT be inline in `Glass_Canvas`.
|
||||
16. **Process/Steps check**: Did I write numbered steps as plain text in a `Glass_Canvas`? Convert to `Process_List` component instead.
|
||||
17. **Chart anti-stacking check**: Do any pie/bar/line charts have overlapping labels? Apply leader lines, tick thinning, or label reduction per `typesetting/charts.md`.
|
||||
18. **Chart axis cleanup check**: Are top/right spines deleted? Grid lines dashed at 20% opacity (or hidden)? No solid grid lines.
|
||||
19. **Donut default check**: Are pie charts rendered as donuts (hole ratio 60-70%)? Solid pies = FAIL unless explicitly requested.
|
||||
20. **🚨 Triple Delivery check (MANDATORY)**: Creative pipeline must deliver **three files** to the user: (1) PDF - the final vector PDF; (2) HTML - the compiled `*_rendered.html` file; (3) Image - a full-page screenshot preview (PNG/JPG). After `convert.blueprint` generates the PDF and HTML, take a screenshot of the HTML for preview. Report all three file paths to the user.
|
||||
21. **🚨 HTML Pre-Render Validation (MANDATORY for ALL HTML→PDF paths)**: Before calling `html2pdf-next.js`, `html2poster.js`, or `convert.blueprint`, run `poster_validate.py check-html` on the HTML file. This catches overflow:hidden on containers, missing @media screen auto-scale, font fallback gaps, contrast issues, and more. **Any ERROR-level issue must be fixed before generating the PDF.** Warnings are non-blocking but should be reviewed.
|
||||
```bash
|
||||
python3 "$PDF_SKILL_DIR/scripts/poster_validate.py" check-html page.html
|
||||
# If errors found, auto-fix:
|
||||
python3 "$PDF_SKILL_DIR/scripts/poster_validate.py" check-html page.html --fix --output page.html
|
||||
```
|
||||
|
||||
22. **\u26a0\ufe0f MANDATORY: Post-Generation Checks (Creative)**: After HTML\u2192PDF conversion, run these checks:
|
||||
```bash
|
||||
# Check for content overflow and full-bleed issues
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf_qa.py" output.pdf --no-tables
|
||||
|
||||
# Add metadata
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" meta.brand output.pdf
|
||||
```
|
||||
|
||||
Execute the design.
|
||||
|
||||
---
|
||||
|
||||
## CJK & Vertical Text Rules (When Bypassing design_engine.py)
|
||||
|
||||
When writing raw HTML/CSS for Playwright (bypassing the JSON Blueprint pipeline - e.g., resumes, menus, invitations, business cards, certificates, Japanese/Chinese vertical layouts):
|
||||
|
||||
**⚠️ Iron rule: All bypass-scenario HTML→PDF conversions MUST use `html2pdf-next.js` — do NOT write custom Python Playwright scripts.** `html2pdf-next.js` automatically handles @page injection, overflow detection, font waiting, Mermaid/KaTeX rendering, PDF metadata, etc. See the "HTML→PDF Engine Selection Rules" section in SKILL.md.
|
||||
|
||||
```bash
|
||||
node "$PDF_SKILL_DIR/scripts/html2pdf-next.js" input.html --output output.pdf --width 210mm --height 297mm
|
||||
```
|
||||
|
||||
0. **Full-Bleed CSS (MANDATORY)**: Every HTML file for Playwright PDF MUST include:
|
||||
```css
|
||||
@page { size: 800px 1200px; margin: 0; } /* CONCRETE values only - CSS variables NOT supported in @page */
|
||||
html, body { margin: 0; padding: 0; width: 800px; height: 1200px; }
|
||||
```
|
||||
**⚠️ @page rules do NOT resolve CSS variables** (`var(--x)` is silently ignored, falls back to A4). Always use concrete `px` values.
|
||||
**⚠️ html/body must have explicit width + min-height** matching the canvas size. Use `min-height` (not `height`) so content taller than the design height expands naturally into a single long-page PDF.
|
||||
**⚠️ Poster = seamless pagination.** When content exceeds `@page` height, `html2pdf-next.js` lets Playwright paginate at page boundaries - each page has the same dimensions, content flows seamlessly across pages (like scrolling a long image). Do NOT expand to a single oversized page.
|
||||
`design_engine.py` handles this automatically via `override_css`.
|
||||
|
||||
0.25. **Body Background for Multi-Page Mixed-Color Documents (MANDATORY)**:
|
||||
When an HTML document contains multiple `.page` divs with **different background colors** (e.g. dark cover + white body + dark specs page), Playwright's sub-pixel rounding creates <1px gaps at `.page` edges where `body` background shows through. On dark pages, a white `body` = visible white edges.
|
||||
|
||||
**Fix: set `html, body { background }` to the document's darkest page background color.**
|
||||
|
||||
```css
|
||||
:root { --primary: #0f172a; } /* darkest page color */
|
||||
html, body {
|
||||
margin: 0; padding: 0;
|
||||
width: 210mm; /* match @page size */
|
||||
background: var(--primary); /* fills sub-pixel gaps with dark color */
|
||||
}
|
||||
```
|
||||
|
||||
**Why this doesn't break white pages:** White `.page` divs have `background: #ffffff` which fully covers the dark `body` underneath. The dark body only shows through the <1px sub-pixel gap at the extreme page edge - imperceptible on white pages after anti-aliasing.
|
||||
|
||||
**Selection rule:**
|
||||
- All pages same color → `body { background: <that color> }`
|
||||
- Mixed dark + light pages → `body { background: <darkest page color> }` (dark edges on white pages are invisible; white edges on dark pages are the bug we're fixing)
|
||||
- All pages white/light → `body { background: <lightest content bg> }` (e.g. `#f8fafc`)
|
||||
|
||||
0.5. **No overflow:hidden + Browser Preview Adaptive Scaling (MANDATORY)**:
|
||||
For fixed-size single-page designs (posters, infographics, certificates, etc.), **absolutely never** set `overflow: hidden` on `html`, `body`, or the main container. Reasons:
|
||||
- When opening the HTML directly in a browser, the viewport is much smaller than the design size (e.g., a 1400px-tall page in a 900px viewport). `overflow: hidden` clips the bottom content and prevents scrolling.
|
||||
- `html2pdf-next.js`'s pre-render check detects `scrollHeight > clientHeight` + `overflow: hidden` and triggers auto-fix (force-expanding the container), which may break the layout.
|
||||
|
||||
**`design_engine.py` handles this automatically**: During blueprint compilation, it auto-injects `@media screen` centering + scaling code, and the `html` background color uses `var(--c-bg)` matching the poster's main color. No manual addition needed.
|
||||
|
||||
**Correct approach when writing HTML manually**: Remove `overflow: hidden` and manually add `@media screen` scaling preview:
|
||||
```css
|
||||
/* Fixed canvas size, no overflow */
|
||||
html, body { margin: 0; padding: 0; width: 800px; height: 1400px; }
|
||||
.page { width: 800px; height: 1400px; position: relative; }
|
||||
|
||||
/* Auto-scale to viewport in browser preview for full-page view */
|
||||
@media screen {
|
||||
html {
|
||||
height: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background: #0a0a1a; /* Surround color — must match poster's main background */
|
||||
}
|
||||
body {
|
||||
transform-origin: top center;
|
||||
scale: min(1, calc(100vw / 800), calc(100vh / 1400)); /* Consider both width and height */
|
||||
margin: 0 auto;
|
||||
box-shadow: 0 0 60px rgba(0,0,0,0.3); /* Optional: shadow to distinguish canvas in preview */
|
||||
}
|
||||
}
|
||||
```
|
||||
`@media screen` rules only apply in browser preview; `page.pdf()` uses print media and is unaffected.
|
||||
**Every fixed-size HTML must include this `@media screen` adaptive code.**
|
||||
|
||||
0.75. **Page Container Overflow Clipping (MANDATORY for multi-page documents)**:
|
||||
Every `.page` div MUST have `overflow: hidden`. Decorative elements (glow circles, gradient overlays) commonly use `width: 120%` or negative offsets - without clipping, they inflate `scrollWidth` beyond page width, causing Playwright to shrink all content and shift it left.
|
||||
```css
|
||||
.page { overflow: hidden; } /* Clips decorative overflow, prevents Playwright shrink */
|
||||
```
|
||||
For horizontal flex layouts (≥3 items), always add `flex-wrap: wrap`. See `typesetting/overflow.md` §3.5.
|
||||
|
||||
1. **Character Encoding Safety**: Never use Japanese kana (の, が, は), rare symbols, or Private Use Area characters in content strings. They corrupt to U+FFFD (<28>) during LLM→file write→read transit. Replace with plain Chinese equivalents: `の`→`之/的/缔/省略`.
|
||||
2. **Vertical Chinese Text** - When using `writing-mode: vertical-rl` for CJK, you MUST include:
|
||||
```css
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: upright; /* Each glyph stands upright */
|
||||
white-space: nowrap; /* Prevent word-wrap breaking single chars to new column */
|
||||
letter-spacing: 12px; /* Typical CJK vertical spacing */
|
||||
```
|
||||
Without `text-orientation: upright`, Latin/fallback fonts render rotated 90°. Without `white-space: nowrap`, CJK characters may wrap into unexpected multi-column layouts (e.g., 3 chars on one line + 1 char alone on next).
|
||||
3. **Font Coverage**: For CJK content via Playwright, always load Google Fonts Noto Serif SC or Noto Sans SC via `<link>` tag in `<head>` (NOT `@import` in CSS - `@import` must be the very first rule in a stylesheet or it's silently ignored). Example:
|
||||
```html
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;700;900&display=swap" rel="stylesheet">
|
||||
```
|
||||
System CJK fonts vary across macOS/Linux - Google Fonts guarantee glyph coverage without relying on system fonts. `design_engine.py` already handles this automatically via `<link>` tag.
|
||||
4. **Post-Generation Text Verification**: After Playwright renders the PDF, extract text from every page and scan for `?` or `\ufffd`. If found, the source HTML has encoding-corrupted characters that must be replaced in the Python source.
|
||||
5. **🚨 HTML Pre-Render Validation (MANDATORY)**: After writing the HTML file and before running `html2pdf-next.js`, always run the HTML validator:
|
||||
```bash
|
||||
python3 "$PDF_SKILL_DIR/scripts/poster_validate.py" check-html <your_file>.html
|
||||
```
|
||||
- **ERROR** items (e.g. `OVERFLOW_HIDDEN_CONTAINER`, `FONT_NO_FALLBACK`) → must fix before PDF generation. Use `--fix --output <file>.html` for auto-repair.
|
||||
- **WARNING** items (e.g. `FIXED_SIZE_NO_SCREEN_ADAPT`, `SCREEN_ADAPT_NO_SCALE`, `COLOR_CONTRAST`) → review and fix where appropriate.
|
||||
- This catches the most common bypass HTML bugs: `overflow:hidden` on containers, missing `@media screen` auto-scale for fixed-size pages, font-family without generic fallback, low contrast text, etc.
|
||||
702
skills/pdf/briefs/poster.md
Executable file
702
skills/pdf/briefs/poster.md
Executable file
@@ -0,0 +1,702 @@
|
||||
# Poster Scene Rules — Creative Pipeline (Poster-Specific Constraints)
|
||||
|
||||
> When poster / 海报 / 传单 / flyer / 宣传页 keywords are detected, load these rules on top of `creative.md`.
|
||||
> These rules take priority over `creative.md` generic rules; in case of conflict, this file prevails.
|
||||
>
|
||||
> **Positioning**: This is a scene-layer patch on `creative.md` (the generic Creative pipeline) — only covers poster-specific constraints, does not repeat generic rules.
|
||||
|
||||
---
|
||||
|
||||
## 1. Page Count & Dimensions
|
||||
|
||||
### 1.1 Default Single Page
|
||||
- When the user does not explicitly request multiple pages, **default to a single-page poster**
|
||||
- Single-page poster `total_pages: 1`, never split into multiple pages on your own
|
||||
|
||||
### 1.2 Sizing Strategy
|
||||
|
||||
| Text Volume | Recommended Size | Aspect Ratio | Notes |
|
||||
|--------|---------|--------|------|
|
||||
| ≤ 50 chars | 720 × 960 | 3:4 | Title poster / social media cover / card |
|
||||
| 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 | PingFang HK | System | Clean, modern |
|
||||
| English title (Google) | Inter Black | `Inter:wght@900` | Modern geometric |
|
||||
| English serif | Playfair Display | `Playfair+Display:wght@900` | Classic editorial |
|
||||
| Number emphasis | Inter Black | `Inter:wght@900` | Data sculpture |
|
||||
|
||||
### 11.2 Font Usage Constraints
|
||||
- Entire poster **maximum 3 fonts**
|
||||
- Title and body may use different fonts, but must be visually harmonious
|
||||
- **Never reduce font size or line height to squeeze in more content**
|
||||
- Font content in cards should be **vertically centered** within the card
|
||||
- You may use different style fonts for entertaining or artistic scenes
|
||||
|
||||
---
|
||||
|
||||
## 12. Coordination with design_engine.py
|
||||
|
||||
### 12.0 ★★★ Stable Layout Selection Strategy (Anti-Overflow Core Rule)
|
||||
|
||||
> **Content-heavy posters must bypass the Blueprint 12×12 Grid and use a pure flow-based HTML approach.**
|
||||
|
||||
**Why:** The Blueprint 12×12 Grid allocates space with fixed row heights and cannot predict actual text rendering height, causing:
|
||||
- Text overflowing Glass Canvas containers
|
||||
- CJK character spacing stretched by `text-align: justify`
|
||||
- Inaccurate row allocation for multiple components, text overlap
|
||||
|
||||
**Route selection:**
|
||||
|
||||
| Scenario | Recommended Route | Notes |
|
||||
|------|---------|------|
|
||||
| Title poster (≤ 50 chars) | Blueprint JSON | Few components, little content, Grid sufficient |
|
||||
| Standard poster (50-150 chars) | Blueprint JSON | Grid mostly sufficient, mind row allocation |
|
||||
| **Info-dense poster (>150 chars or multiple sections)** | **★ Direct HTML Flow** | **Strongly recommended — completely avoids overflow** |
|
||||
| **Multi-page info poster** | **★ Direct HTML Flow** | **Strongly recommended** |
|
||||
| Poster with images | Direct HTML Flow | Image embedding is more stable |
|
||||
|
||||
### 12.1 Direct HTML Flow Approach (Recommended Default)
|
||||
|
||||
**Core idea: No Grid, no fixed height, content flows naturally, never overflows.**
|
||||
|
||||
Write HTML directly, convert to PDF via `html2poster.js`. The poster is a **single continuous `<div class="poster">` container** — NOT split into separate `<div class="page">` blocks.
|
||||
|
||||
#### ★★★ Single-Canvas vs Multi-Page (Iron Rule)
|
||||
|
||||
| Approach | When to Use | HTML Structure |
|
||||
|----------|-------------|----------------|
|
||||
| **Single Canvas** (default) | All content forms one unified poster | One `<div class="poster">`, no `page-break` |
|
||||
| **Multi-Page** (exception) | User explicitly requests separate pages (e.g., "make a 4-page booklet") | Multiple `<div class="page">` with `page-break-after: always` |
|
||||
|
||||
> **Default = Single Canvas.** A poster is ONE design composition, not a paginated report. Multi-page is only for booklets/multi-page documents where the user explicitly asks for page separation.
|
||||
|
||||
#### ★★★ Dynamic Height (Anti-Blank-Bottom Iron Rule)
|
||||
|
||||
**NEVER hardcode `@page { size: W H }` or `.poster { min-height: Xpx }` with a fixed height.** This creates blank space at the bottom when content is shorter than the hardcoded value.
|
||||
|
||||
**Correct approach — use `html2poster.js`:**
|
||||
|
||||
`html2poster.js` automatically handles all of this — you just need to write the HTML correctly and call one command:
|
||||
|
||||
```bash
|
||||
node "$PDF_SKILL_DIR/scripts/html2poster.js" poster.html --output poster.pdf --width 720px
|
||||
```
|
||||
|
||||
It will automatically:
|
||||
1. Add `overflow: hidden` to `.poster` container (clips decorative overflow)
|
||||
2. Inject `@page { margin: 0 }` (zero margins)
|
||||
3. Sync `html/body` background with `.poster` background
|
||||
4. Measure `.poster` scrollHeight (actual content height)
|
||||
5. Generate single-page vector PDF with exact dimensions
|
||||
|
||||
**⚠️ Do NOT use `html2pdf-next.js` for posters.** It is designed for multi-page documents and will inject 20mm margins / A4 pagination.
|
||||
|
||||
**⚠️ Do NOT write hand-written Playwright scripts for posters.** `html2poster.js` handles everything.
|
||||
|
||||
```css
|
||||
/* ✅ CORRECT CSS for poster HTML (html2poster.js handles the rest): */
|
||||
html, body { margin: 0; padding: 0; background: var(--c-bg); }
|
||||
.poster { width: 720px; position: relative; background: var(--c-bg); }
|
||||
/* Note: overflow:hidden on .poster is auto-injected by html2poster.js,
|
||||
but including it in CSS is fine too */
|
||||
```
|
||||
|
||||
**CSS iron rules:**
|
||||
```css
|
||||
html, body { margin: 0; padding: 0; background: var(--c-bg); }
|
||||
|
||||
/* Single poster canvas — NO fixed height */
|
||||
.poster {
|
||||
width: 720px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--c-bg);
|
||||
/* Height is determined by content, measured at render time */
|
||||
}
|
||||
|
||||
/* Card/content block */
|
||||
.card {
|
||||
background: rgba(255,255,255,0.7);
|
||||
border-radius: 12px;
|
||||
padding: 24px 28px;
|
||||
margin-bottom: 20px;
|
||||
/* No height, no max-height, no overflow:hidden */
|
||||
}
|
||||
|
||||
/* CJK text iron rules */
|
||||
.card, .card p, .card li {
|
||||
text-align: left; /* Absolutely no justify! CJK stretches letter-spacing */
|
||||
line-height: 1.6;
|
||||
word-break: break-all; /* CJK natural line break */
|
||||
}
|
||||
|
||||
/* Image */
|
||||
.hero-image {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
display: block;
|
||||
}
|
||||
```
|
||||
|
||||
**HTML structure template (Single Canvas):**
|
||||
```html
|
||||
<div class="poster">
|
||||
<!-- Hero section -->
|
||||
<div class="hero"> ... </div>
|
||||
|
||||
<!-- City 1 -->
|
||||
<div class="city-section">
|
||||
<h2>南京</h2>
|
||||
<div class="items-row"> ... attractions ... </div>
|
||||
<div class="food-row"> ... food ... </div>
|
||||
</div>
|
||||
|
||||
<!-- City separator (thin line, NOT page-break) -->
|
||||
<div class="city-sep"></div>
|
||||
|
||||
<!-- City 2 -->
|
||||
<div class="city-section"> ... </div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="poster-footer"> ... </div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Why it's stable:**
|
||||
- No fixed height — content naturally defines poster size
|
||||
- No `overflow: hidden`, text always fully displayed
|
||||
- `text-align: left` avoids CJK letter-spacing stretch
|
||||
- Single canvas = one unified composition, not chopped pages
|
||||
- PDF height measured at render time = zero blank space
|
||||
|
||||
**Convert to PDF and PNG:**
|
||||
```bash
|
||||
# PDF (vector, single-page, zero margins, auto-height):
|
||||
node "$PDF_SKILL_DIR/scripts/html2poster.js" poster.html --output poster.pdf --width 720px
|
||||
|
||||
# PNG preview (screenshot):
|
||||
# Use Playwright screenshot with measured height
|
||||
```
|
||||
|
||||
> **⚠️ Do NOT write hand-written Playwright `page.pdf()` scripts.** Use `html2poster.js` which handles overflow:hidden, margin:0, background sync, and height measurement automatically.
|
||||
|
||||
### 12.2 Blueprint Grid Approach (Only for Simple Posters)
|
||||
|
||||
| Behavior | Status | Notes |
|
||||
|------|------|------|
|
||||
| Dynamic inset | ✅ Fixed | More content → smaller inset (`8% 10%`), less content → default (`10% 12%`) |
|
||||
| Glass Canvas overflow | ✅ Fixed | `min-height:100%` replaces `height:100%`, removed `overflow:hidden` |
|
||||
| Glass Canvas / Process List stretch | ✅ Fixed | Auto `align-items: stretch` |
|
||||
|
||||
### 12.3 Poster Marker in Blueprint
|
||||
|
||||
Add `scene: "poster"` marker in `art_direction` so `design_engine.py` can identify poster scenes and apply specific logic in the future:
|
||||
|
||||
```json
|
||||
{
|
||||
"art_direction": {
|
||||
"scene": "poster",
|
||||
"palette_mode": "minimal",
|
||||
"color_harmony": "auto",
|
||||
"background_svg": "flow",
|
||||
"design_rationale": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 14. PDF Conversion Iron Rules
|
||||
|
||||
### 14.1 Background Color Consistency
|
||||
```css
|
||||
/* Must ensure html/body background = poster canvas background */
|
||||
html, body {
|
||||
background: var(--c-bg); /* Same color as .canvas background */
|
||||
}
|
||||
```
|
||||
- Playwright `page.pdf({ printBackground: true })` renders body background color
|
||||
- If body is white but poster is gray, white borders appear in the PDF
|
||||
- `design_engine.py` already auto-injects `background: var(--c-bg)`, but if bypassing the engine and writing HTML directly, **you must ensure manually**
|
||||
|
||||
**Multi-page posters / brochures with mixed page backgrounds:**
|
||||
- When pages alternate between dark and light backgrounds, set `body { background }` to the **darkest page color** (see SKILL.md "Background Color Consistency" for full rationale)
|
||||
- This eliminates sub-pixel white edges on dark pages without affecting light pages
|
||||
|
||||
### 14.2 Content Centering (Anti-Drift)
|
||||
|
||||
**Poster content must be centered in the PDF, no left or right drift allowed.**
|
||||
|
||||
Common drift causes and fixes:
|
||||
| Cause | Fix |
|
||||
|------|------|
|
||||
| `@page { margin }` not 0 | Must be `@page { size: <w> <h>; margin: 0; }` |
|
||||
| `.safe-zone` `inset` left-right asymmetric | Ensure `inset: Y% X%` uses same X% for left and right |
|
||||
| Component `grid_area` only uses partial columns | Most components should span `1 / 1 / X / 13` (full width) |
|
||||
| Content container has `max-width` but no `margin: 0 auto` | Add `margin: 0 auto` to center |
|
||||
| Playwright PDF default margin | Pass `margin: { top: 0, right: 0, bottom: 0, left: 0 }` |
|
||||
|
||||
### 14.3 Anti-Blank Edges (Dynamic Height Iron Rule)
|
||||
|
||||
**Poster edges, top, and bottom should not have large meaningless whitespace.**
|
||||
|
||||
**★★★ CRITICAL: Never hardcode poster height.** The poster is a single continuous canvas — its height is defined by content, not by a fixed CSS value.
|
||||
|
||||
| Pattern | Status | Result |
|
||||
|---------|--------|--------|
|
||||
| `@page { size: 720px 3600px }` | ❌ FORBIDDEN | Creates 853px+ blank space at bottom if content is shorter |
|
||||
| `.poster { min-height: 3600px }` | ❌ FORBIDDEN | Same problem — blank bottom |
|
||||
| `.poster { width: 720px }` (no height) | ✅ CORRECT | Content defines height naturally |
|
||||
| `node html2poster.js poster.html --width 720px` | ✅ CORRECT | Auto-measures height, zero blank space |
|
||||
|
||||
**The 720×960 dimension is for multi-page documents with `page-break-after: always` only — NOT for single-canvas posters.**
|
||||
|
||||
- Content must make full use of page area, no more than 20% unused space within safe-zone
|
||||
- If excess whitespace exists, enlarge font sizes or modules to balance the layout
|
||||
- Checklist:
|
||||
- [ ] Poster height defined by content (no hardcoded height)?
|
||||
- [ ] PDF generated via `html2poster.js` (not html2pdf-next.js)?
|
||||
- [ ] First component starts from the top?
|
||||
- [ ] Last component reaches the bottom with proper padding?
|
||||
- [ ] Left-right margins symmetric?
|
||||
- [ ] No blank space at bottom of PDF?
|
||||
|
||||
---
|
||||
|
||||
## 15. Preflight Checklist (Poster-Specific)
|
||||
|
||||
Before outputting JSON Blueprint, verify the following items:
|
||||
|
||||
```
|
||||
□ ★ Single-canvas check: poster is ONE continuous <div>, not split into separate pages? (unless user explicitly requests multi-page)
|
||||
□ ★ Dynamic height check: no hardcoded @page size height or .poster min-height? PDF generated via html2poster.js?
|
||||
□ ★ Anti-modularization check: ≤ 3 containers with background+border in single-page poster? No 2×2 card grid? Hierarchy via font size/weight not border colors?
|
||||
□ Emphasis elements (price/date/address) highlighted via font size+weight, not wrapped in separate color blocks?
|
||||
□ Overall looks like a design piece, not an APP interface or dashboard?
|
||||
□ total_pages = 1 (single canvas)? (unless user explicitly requests multi-page booklet)
|
||||
□ No hardcoded @page height or .poster min-height? Content defines height?
|
||||
□ Left-right margins symmetric? (no left/right drift)
|
||||
□ html/body background = poster canvas background? (no color mismatch)
|
||||
□ No bottom blank space in PDF? (height measured dynamically)
|
||||
□ Page main title ≥ 50px? Body text ≥ 24px?
|
||||
□ Text volume ≤ 150 chars? (single Glass Canvas)
|
||||
□ palette_mode not #FFFFFF pure white?
|
||||
□ No single image filling the background?
|
||||
□ No overlap between cards?
|
||||
□ No Timeline / complex SVG structure diagrams?
|
||||
□ All content fully displayed, not truncated?
|
||||
□ Emphasis text font size < title font size?
|
||||
□ Entire design ≤ 3 fonts?
|
||||
□ cover_hero page ≤ 4 components?
|
||||
□ Hero_Typography has <br> line breaks?
|
||||
□ @page { margin: 0 } set? (prevents PDF drift)
|
||||
```
|
||||
284
skills/pdf/briefs/process-advanced.md
Executable file
284
skills/pdf/briefs/process-advanced.md
Executable file
@@ -0,0 +1,284 @@
|
||||
# Process Brief: Advanced Reference
|
||||
|
||||
Edge-case tools and techniques. **Load only when the main `process.md` doesn't cover the task.**
|
||||
|
||||
```
|
||||
Edge case
|
||||
├─ No text extracted from PDF → §OCR Fallback
|
||||
├─ PDF is encrypted/locked → §Encrypted PDFs
|
||||
├─ PDF is corrupted/damaged → §Corrupted PDFs
|
||||
├─ Need precise text coordinates → §pdfplumber Advanced
|
||||
├─ Need fast rendering → §pypdfium2
|
||||
├─ Advanced page extraction → §poppler-utils Advanced
|
||||
├─ Complex page ranges / merge → §qpdf Page Manipulation
|
||||
├─ Optimize file size → §qpdf Optimization
|
||||
├─ Batch process many PDFs → §Batch Processing
|
||||
└─ Memory issues with large PDF → §Performance Optimization
|
||||
```
|
||||
|
||||
For basic operations (extract, merge, split, fill forms, convert), go back to `process.md`.
|
||||
|
||||
---
|
||||
|
||||
## §pypdfium2 (Apache/BSD License)
|
||||
|
||||
A Python binding for PDFium (Chromium's PDF library). Excellent for fast rendering and text extraction — serves as a PyMuPDF replacement.
|
||||
|
||||
#### Render PDF to Images
|
||||
```python
|
||||
import pypdfium2 as pdfium
|
||||
|
||||
pdf = pdfium.PdfDocument("document.pdf")
|
||||
|
||||
# Render single page
|
||||
page = pdf[0]
|
||||
bitmap = page.render(scale=2.0, rotation=0)
|
||||
img = bitmap.to_pil()
|
||||
img.save("page_1.png", "PNG")
|
||||
|
||||
# Batch render all pages
|
||||
for i, page in enumerate(pdf):
|
||||
bitmap = page.render(scale=1.5)
|
||||
img = bitmap.to_pil()
|
||||
img.save(f"page_{i+1}.jpg", "JPEG", quality=90)
|
||||
```
|
||||
|
||||
#### Extract Text with pypdfium2
|
||||
```python
|
||||
import pypdfium2 as pdfium
|
||||
|
||||
pdf = pdfium.PdfDocument("document.pdf")
|
||||
for i, page in enumerate(pdf):
|
||||
text = page.get_text()
|
||||
print(f"Page {i+1}: {len(text)} chars")
|
||||
```
|
||||
|
||||
## §pdfplumber Advanced Features
|
||||
|
||||
#### Extract Text with Precise Coordinates
|
||||
```python
|
||||
import pdfplumber
|
||||
|
||||
with pdfplumber.open("document.pdf") as pdf:
|
||||
page = pdf.pages[0]
|
||||
|
||||
# All characters with coordinates
|
||||
for char in page.chars[:10]:
|
||||
print(f"'{char['text']}' at x:{char['x0']:.1f} y:{char['y0']:.1f}")
|
||||
|
||||
# Extract text within a specific bounding box (left, top, right, bottom)
|
||||
bbox_text = page.within_bbox((100, 100, 400, 200)).extract_text()
|
||||
```
|
||||
|
||||
#### Advanced Table Extraction with Custom Settings
|
||||
```python
|
||||
import pdfplumber
|
||||
|
||||
with pdfplumber.open("complex_table.pdf") as pdf:
|
||||
page = pdf.pages[0]
|
||||
|
||||
table_settings = {
|
||||
"vertical_strategy": "lines",
|
||||
"horizontal_strategy": "lines",
|
||||
"snap_tolerance": 3,
|
||||
"intersection_tolerance": 15
|
||||
}
|
||||
tables = page.extract_tables(table_settings)
|
||||
|
||||
# Visual debugging
|
||||
img = page.to_image(resolution=150)
|
||||
img.save("debug_layout.png")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §poppler-utils Advanced Features
|
||||
|
||||
### Extract Text with Bounding Box Coordinates
|
||||
```bash
|
||||
pdftotext -bbox-layout document.pdf output.xml
|
||||
```
|
||||
|
||||
### Advanced Image Conversion
|
||||
```bash
|
||||
# High-resolution PNG
|
||||
pdftoppm -png -r 300 document.pdf output_prefix
|
||||
|
||||
# Specific page range
|
||||
pdftoppm -png -r 600 -f 1 -l 3 document.pdf high_res_pages
|
||||
|
||||
# JPEG with quality setting
|
||||
pdftoppm -jpeg -jpegopt quality=85 -r 200 document.pdf jpeg_output
|
||||
```
|
||||
|
||||
### Extract Embedded Images
|
||||
```bash
|
||||
pdfimages -all document.pdf images/img
|
||||
pdfimages -list document.pdf
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §qpdf Advanced Features
|
||||
|
||||
### Complex Page Manipulation
|
||||
```bash
|
||||
# Split into groups of N pages
|
||||
qpdf --split-pages=3 input.pdf output_group_%02d.pdf
|
||||
|
||||
# Extract complex page ranges
|
||||
qpdf input.pdf --pages input.pdf 1,3-5,8,10-end -- extracted.pdf
|
||||
|
||||
# Merge specific pages from multiple PDFs
|
||||
qpdf --empty --pages doc1.pdf 1-3 doc2.pdf 5-7 doc3.pdf 2,4 -- combined.pdf
|
||||
```
|
||||
|
||||
### PDF Optimization and Repair
|
||||
```bash
|
||||
qpdf --linearize input.pdf optimized.pdf
|
||||
qpdf --optimize-level=all input.pdf compressed.pdf
|
||||
qpdf --check input.pdf
|
||||
qpdf --fix-qdf damaged.pdf repaired.pdf
|
||||
```
|
||||
|
||||
### Encryption and Decryption
|
||||
```bash
|
||||
qpdf --encrypt user_pass owner_pass 256 --print=none --modify=none -- input.pdf encrypted.pdf
|
||||
qpdf --show-encryption encrypted.pdf
|
||||
qpdf --password=secret123 --decrypt encrypted.pdf decrypted.pdf
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §Encrypted PDFs
|
||||
|
||||
```python
|
||||
from pypdf import PdfReader
|
||||
|
||||
try:
|
||||
reader = PdfReader("encrypted.pdf")
|
||||
if reader.is_encrypted:
|
||||
reader.decrypt("password")
|
||||
except Exception as e:
|
||||
print(f"Failed to decrypt: {e}")
|
||||
```
|
||||
|
||||
Or via qpdf:
|
||||
```bash
|
||||
qpdf --password=secret123 --decrypt encrypted.pdf decrypted.pdf
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §Corrupted PDFs
|
||||
|
||||
```bash
|
||||
qpdf --check corrupted.pdf
|
||||
qpdf --replace-input corrupted.pdf
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §OCR Fallback for Scanned PDFs
|
||||
|
||||
When `pdfplumber` extracts 0 characters, the PDF is likely a scanned image:
|
||||
|
||||
```python
|
||||
import pytesseract
|
||||
from pdf2image import convert_from_path
|
||||
|
||||
def extract_text_with_ocr(pdf_path):
|
||||
images = convert_from_path(pdf_path)
|
||||
text = ""
|
||||
for i, image in enumerate(images):
|
||||
text += pytesseract.image_to_string(image)
|
||||
return text
|
||||
```
|
||||
|
||||
Prerequisites: `pip install pdf2image pytesseract` + install Tesseract OCR and poppler.
|
||||
|
||||
---
|
||||
|
||||
## §Batch Processing with Error Handling
|
||||
|
||||
```python
|
||||
import os, glob
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def batch_process_pdfs(input_dir, operation='merge'):
|
||||
pdf_files = glob.glob(os.path.join(input_dir, "*.pdf"))
|
||||
|
||||
if operation == 'merge':
|
||||
writer = PdfWriter()
|
||||
for pdf_file in pdf_files:
|
||||
try:
|
||||
reader = PdfReader(pdf_file)
|
||||
for page in reader.pages:
|
||||
writer.add_page(page)
|
||||
logger.info(f"Processed: {pdf_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed: {pdf_file}: {e}")
|
||||
continue
|
||||
with open("batch_merged.pdf", "wb") as output:
|
||||
writer.write(output)
|
||||
|
||||
elif operation == 'extract_text':
|
||||
for pdf_file in pdf_files:
|
||||
try:
|
||||
reader = PdfReader(pdf_file)
|
||||
text = "".join(page.extract_text() for page in reader.pages)
|
||||
output_file = pdf_file.replace('.pdf', '.txt')
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
f.write(text)
|
||||
logger.info(f"Extracted: {pdf_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed: {pdf_file}: {e}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §Performance Optimization
|
||||
|
||||
### Text Extraction
|
||||
- `pdftotext -bbox-layout` is fastest for plain text
|
||||
- Use pdfplumber for structured data and tables
|
||||
- Avoid `pypdf.extract_text()` for very large documents
|
||||
|
||||
### Image Extraction
|
||||
- `pdfimages` is much faster than rendering entire pages
|
||||
- Use low resolution for previews, high resolution for final output
|
||||
|
||||
### Memory Management for Large PDFs
|
||||
```python
|
||||
def process_large_pdf(pdf_path, chunk_size=10):
|
||||
reader = PdfReader(pdf_path)
|
||||
total_pages = len(reader.pages)
|
||||
|
||||
for start_idx in range(0, total_pages, chunk_size):
|
||||
end_idx = min(start_idx + chunk_size, total_pages)
|
||||
writer = PdfWriter()
|
||||
for i in range(start_idx, end_idx):
|
||||
writer.add_page(reader.pages[i])
|
||||
with open(f"chunk_{start_idx//chunk_size}.pdf", "wb") as output:
|
||||
writer.write(output)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Extended Tooling Inventory
|
||||
|
||||
| Library / Tool | Role | Licence |
|
||||
|----------------|------|---------|
|
||||
| pikepdf | Low-level PDF manipulation (forms, pages, metadata) | MPL-2.0 |
|
||||
| pdfplumber | Content extraction (text, tables) | MIT |
|
||||
| pypdfium2 | Fast rendering, text extraction (PyMuPDF alternative) | Apache/BSD |
|
||||
| pypdf | Merge, split, crop, metadata, encryption | BSD |
|
||||
| poppler-utils | CLI text/image extraction, rendering | GPL-2 |
|
||||
| qpdf | Page manipulation, optimization, encryption, repair | Apache |
|
||||
| pytesseract | OCR for scanned PDFs | Apache |
|
||||
| pdf2image | PDF-to-image conversion via poppler | MIT |
|
||||
| LibreOffice | Office format conversion engine | MPL-2.0 |
|
||||
319
skills/pdf/briefs/process.md
Executable file
319
skills/pdf/briefs/process.md
Executable file
@@ -0,0 +1,319 @@
|
||||
# Brief: PDF Processing
|
||||
|
||||
Work with existing PDFs: extract, merge, split, fill forms, convert formats, or **reformat** with a new design. Usually a Light triage path — except reformat, which escalates to Standard.
|
||||
|
||||
---
|
||||
|
||||
## Decision Tree
|
||||
|
||||
```
|
||||
User request
|
||||
├─ "Extract text/tables/images" → §Extract
|
||||
├─ "Merge/split/rotate/crop pages" → §Pages
|
||||
├─ "Fill a form" → §Forms (check fillable first)
|
||||
├─ "Read/write metadata" → §Metadata
|
||||
├─ "Convert DOCX/PPTX/XLSX to PDF" → §Convert
|
||||
│ └─ DOCX with TOC? → §DOCX Pipeline (5-step)
|
||||
├─ "Redesign/reformat a document" → §Reformat
|
||||
│ └─ With a reference template? → §Template-Guided Reformat
|
||||
└─ Edge cases (OCR, encrypt, batch) → load briefs/process-advanced.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Check
|
||||
|
||||
```bash
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" env.check
|
||||
```
|
||||
|
||||
Reports availability but does **not** auto-install. Required: Python 3, pikepdf, pdfplumber.
|
||||
|
||||
Entry point: `python3 "$PDF_SKILL_DIR/scripts/pdf.py" <group>.<action> [options]`
|
||||
|
||||
All commands return JSON on stdout (`{"status": "success", "data": {...}}`) or stderr (`{"status": "error", ...}`).
|
||||
Exit codes: 0 = success, 1 = bad args, 2 = file not found, 3 = parse error, 4 = operation failed.
|
||||
|
||||
---
|
||||
|
||||
## §Extract
|
||||
|
||||
```bash
|
||||
# Text (full or page range)
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" extract.text report.pdf
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" extract.text report.pdf -p 1-3
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" extract.text report.pdf -p 1,4,7
|
||||
|
||||
# Tables — returns structured JSON with page/rows/cols/data
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" extract.table report.pdf
|
||||
|
||||
# Images — dumps embedded rasters to directory
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" extract.image report.pdf -o ./images/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §Pages
|
||||
|
||||
```bash
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" pages.merge a.pdf b.pdf -o combined.pdf
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" pages.split book.pdf -o ./chapters/
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" pages.rotate doc.pdf 90 -o rotated.pdf
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" pages.rotate doc.pdf 180 -o rotated.pdf -p 1-3
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" pages.crop doc.pdf 50,50,550,750 -o trimmed.pdf
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" pages.clean doc.pdf -o cleaned.pdf
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §Metadata
|
||||
|
||||
```bash
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" meta.get doc.pdf
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" meta.set doc.pdf -o out.pdf -d '{"Title": "Report", "Author": "Jane"}'
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" meta.brand doc.pdf -o branded.pdf
|
||||
```
|
||||
|
||||
Recognised keys: `Title`, `Author`, `Subject`, `Keywords`, `Creator`, `Producer`.
|
||||
|
||||
`meta.brand` adds standard branding metadata (producer, creator) in one step.
|
||||
|
||||
---
|
||||
|
||||
## §Forms
|
||||
|
||||
### Step 1 — Check if fillable
|
||||
|
||||
```bash
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" form.info input.pdf
|
||||
```
|
||||
|
||||
If `has_fields: true` → **Fillable workflow**. If `false` → **Non-fillable workflow**.
|
||||
|
||||
### Fillable Workflow
|
||||
|
||||
```bash
|
||||
# Inspect fields
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" form.info input.pdf
|
||||
|
||||
# Fill (auto-maps "true"/"false" for checkboxes)
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" form.fill input.pdf -o filled.pdf \
|
||||
-d '{"name": "John", "agree": "true", "country": "US"}'
|
||||
```
|
||||
|
||||
**Value rules:**
|
||||
|
||||
| Type | Value | Example |
|
||||
|------|-------|---------|
|
||||
| text | Free string | `"name": "Jane Doe"` |
|
||||
| checkbox | `"true"` / `"false"` (auto-converts to PDF states) | `"agree": "true"` |
|
||||
| radio | One of `radio_options[].value` | `"gender": "/Choice1"` |
|
||||
| dropdown | One of `choice_options[].value` | `"country": "US"` |
|
||||
|
||||
For complex forms, use `form.detail` and `form.render` for deeper inspection:
|
||||
|
||||
```bash
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" form.detail input.pdf -o fields.json # full field info (types, options, defaults)
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" form.render input.pdf -o ./pages/ # render pages as PNG for visual check
|
||||
```
|
||||
|
||||
### Non-Fillable Workflow (Annotation-Based)
|
||||
|
||||
For PDFs without interactive fields (scanned forms, image-based). All four steps are mandatory.
|
||||
|
||||
**Step 1 — Render pages as PNG** (required):
|
||||
```bash
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" form.render input.pdf -o ./pages/
|
||||
```
|
||||
|
||||
**Step 2 — Create `fields.json`** with annotation regions.
|
||||
|
||||
To determine bbox coordinates: open the rendered PNG in an image viewer or use Python (`from PIL import Image; img = Image.open('page.png'); print(img.size)`) to get pixel dimensions. Then estimate [left, top, right, bottom] in pixels for each field by inspecting the image. The `dims` field must match the PNG dimensions exactly.
|
||||
|
||||
```json
|
||||
{
|
||||
"sheet": [
|
||||
{
|
||||
"pg": 1,
|
||||
"dims": [1000, 1400],
|
||||
"regions": [
|
||||
{
|
||||
"id": "last_name",
|
||||
"hint": "Last name field",
|
||||
"label": {"tag": "Last name", "bbox": [30, 125, 95, 142]},
|
||||
"target": {"bbox": [100, 125, 280, 142]},
|
||||
"ink": {"value": "Simpson", "size": 14, "color": "000000"}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Schema: `pg` = 1-based page, `dims` = [w,h] in pixels, `label.bbox` / `target.bbox` = [left, top, right, bottom], `ink` = {value, size?, color?, font?}. Label and target boxes must NOT intersect.
|
||||
|
||||
**Step 3 — Validate bounding boxes** (required):
|
||||
```bash
|
||||
# Auto-check for intersections
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" form.check-bbox fields.json
|
||||
|
||||
# Visual validation (red=target, blue=label)
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" form.validate 1 fields.json page1.png validation.png
|
||||
```
|
||||
|
||||
Fix any issues, regenerate, re-check. Red rectangles must only cover input areas.
|
||||
|
||||
**Step 4 — Fill via annotations**:
|
||||
```bash
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" form.annotate input.pdf fields.json -o filled.pdf
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §Reformat
|
||||
|
||||
Take an existing document and rebuild it with a new visual design. Content is preserved; layout, typography, and visual treatment are rebuilt from scratch.
|
||||
|
||||
```
|
||||
1. EXTRACT → Extract content from source (extract.text / extract.table / read directly)
|
||||
2. STRUCTURE → Organize into sections (headings, body, tables, lists)
|
||||
3. DELEGATE → Route to appropriate brief:
|
||||
Structured → briefs/report.md (ReportLab)
|
||||
Visual → briefs/creative.md (Playwright)
|
||||
4. BUILD → Follow the delegated brief's full workflow
|
||||
5. DELIVER → New PDF, same content, new design
|
||||
```
|
||||
|
||||
### §Template-Guided Reformat
|
||||
|
||||
When user provides a reference PDF to match:
|
||||
|
||||
```
|
||||
1. ANALYZE → Extract design DNA from template:
|
||||
- python3 "$PDF_SKILL_DIR/scripts/pdf.py" meta.get template.pdf (page size)
|
||||
- python3 "$PDF_SKILL_DIR/scripts/pdf.py" extract.image template.pdf (color samples)
|
||||
- python3 "$PDF_SKILL_DIR/scripts/pdf.py" extract.text template.pdf (text structure)
|
||||
- pdftoppm -png -r 150 template.pdf preview (visual reference)
|
||||
2. DOCUMENT → Record: page size, margins, colors, fonts, layout grid,
|
||||
header/footer pattern, decorative elements
|
||||
3. DELEGATE → Route to brief WITH design constraints (not brief defaults)
|
||||
4. BUILD → Follow brief workflow, constrained to template DNA
|
||||
5. COMPARE → pdftoppm both, visually compare side-by-side
|
||||
```
|
||||
|
||||
**Key principles:**
|
||||
- Match the spirit, not the pixels — exact replication from PDF is impractical
|
||||
- Prefer original source files (.docx/.html/.tex) over PDF when available
|
||||
- Declare font substitutions upfront; don't silently fall back
|
||||
- Template provides design direction, not content — never leak placeholder text
|
||||
|
||||
---
|
||||
|
||||
## §Convert
|
||||
|
||||
### Office → PDF (LibreOffice)
|
||||
|
||||
**Simple conversion** (no TOC needed):
|
||||
```bash
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" convert.office input.docx -o output.pdf
|
||||
```
|
||||
|
||||
**When to use the 5-step DOCX Pipeline instead**: If the DOCX has (or should have) a Table of Contents, always use §DOCX Pipeline below. Signs: the document has 3+ headings, or the user mentions "table of contents" / "TOC", or the document already contains a TOC section. When in doubt, run `python3 "$PDF_SKILL_DIR/scripts/toc_validate.py" fix-docx input.docx -o fixed.docx` — if it returns `no_toc_needed`, a simple conversion is fine.
|
||||
|
||||
Or directly:
|
||||
```bash
|
||||
soffice --headless --convert-to pdf --outdir ./output input.docx
|
||||
```
|
||||
|
||||
**Supported**: `.docx`, `.doc`, `.odt`, `.rtf`, `.pptx`, `.ppt`, `.xlsx`, `.xls`, `.ods`, `.csv`, `.html`
|
||||
|
||||
**macOS path**: `/Applications/LibreOffice.app/Contents/MacOS/soffice`
|
||||
|
||||
**Gotchas:**
|
||||
- soffice allows only one instance at a time; close existing LibreOffice windows or use `--env:UserInstallation=file:///tmp/libreoffice_tmp`
|
||||
- Missing Chinese fonts → squares. Ensure SimHei/SimSun are installed.
|
||||
- Large files (>50MB) may take 1-2 min; set reasonable timeout
|
||||
- soffice HTML→PDF is inferior to Playwright for complex CSS
|
||||
|
||||
**Priority**: Always prefer soffice for Office→PDF (preserves themes, layouts, master slides). Only fall back to python-pptx/python-docx + HTML + Playwright if soffice is unavailable — fidelity will be lower.
|
||||
|
||||
### Fallback: Spreadsheet → PDF without LibreOffice
|
||||
|
||||
Use openpyxl + HTML + Playwright. Let data shape drive layout:
|
||||
|
||||
| Factor | Decision |
|
||||
|--------|----------|
|
||||
| Columns ≤ 6 | Portrait |
|
||||
| Columns > 6 | Landscape |
|
||||
| Font size | Scale inversely with column count |
|
||||
| Styling | Follow user requirements or source file style; if unspecified, use defaults from `typesetting/palette.md` |
|
||||
|
||||
### §DOCX Pipeline (5-Step with TOC)
|
||||
|
||||
For DOCX files that need TOC generation/correction. Required because LibreOffice `--headless` does not recalculate PAGEREF fields.
|
||||
|
||||
```
|
||||
Step 1: soffice → Convert original DOCX to PDF (pass1)
|
||||
Step 2: pages.clean → Remove blank pages from pass1
|
||||
Step 3: fix-docx → Add/fix TOC with HYPERLINK + PAGEREF + bookmarks
|
||||
Step 4: fix-pages → Correct TOC page numbers using pass1 as reference
|
||||
Step 5: soffice → Convert final DOCX to PDF + pages.clean
|
||||
```
|
||||
|
||||
**Step 1 — Pass 1 Convert**:
|
||||
```bash
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" convert.office input.docx -o pass1.pdf
|
||||
```
|
||||
|
||||
**Step 2 — Clean Blank Pages**:
|
||||
```bash
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" pages.clean pass1.pdf -o pass1_clean.pdf
|
||||
```
|
||||
If `blank_pages_removed == 0`, use pass1.pdf directly.
|
||||
|
||||
**Step 3 — Fix TOC**:
|
||||
```bash
|
||||
python3 "$PDF_SKILL_DIR/scripts/toc_validate.py" fix-docx input.docx -o fixed.docx
|
||||
```
|
||||
|
||||
Auto-detects and fixes: placeholder TOC, stale TOC (>50% drift), empty TOC, missing TOC (≥3 headings). Each entry gets `<w:hyperlink>` + `PAGEREF` + bookmarks for clickable PDF navigation.
|
||||
|
||||
Check output `action` field: `fixed` → use fixed.docx, `skipped` → use original, `no_toc_needed` → skip to Step 5 with pass1 PDF.
|
||||
|
||||
**Step 4 — Fix Page Numbers**:
|
||||
```bash
|
||||
python3 "$PDF_SKILL_DIR/scripts/toc_validate.py" fix-pages fixed.docx pass1_clean.pdf -o final.docx
|
||||
```
|
||||
|
||||
Corrects PAGEREF display text using actual page positions from pass1 + TOC page offset.
|
||||
|
||||
**Step 5 — Final Convert + Clean**:
|
||||
```bash
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" convert.office final.docx -o output.pdf
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" pages.clean output.pdf -o output_clean.pdf
|
||||
```
|
||||
|
||||
### Post-Conversion Validation (Optional)
|
||||
|
||||
```bash
|
||||
python3 "$PDF_SKILL_DIR/scripts/toc_validate.py" check-conversion final.docx output_clean.pdf
|
||||
```
|
||||
|
||||
Issues caught: `CONV_TOC_LOST` (TOC disappeared), `CONV_HINT_LEAKED` (placeholder text in PDF), `CONV_HEADING_DRIFT` (heading count mismatch).
|
||||
|
||||
---
|
||||
|
||||
## Caveats
|
||||
|
||||
| Topic | Detail |
|
||||
|-------|--------|
|
||||
| Encrypted PDFs | Not supported. User must decrypt externally first. |
|
||||
| < 50 MB | Instant |
|
||||
| 50–200 MB | 1–2 minutes |
|
||||
| > 200 MB | Split first, or extend timeout |
|
||||
| Memory | ~2-3× input file size |
|
||||
| Merge failure | Partial output may remain; delete and retry |
|
||||
| Split failure | Some page files may exist; inspect output dir |
|
||||
| Form fill | Original never modified; always writes new file |
|
||||
|
||||
For edge cases (OCR, batch processing, poppler-utils, qpdf, performance tuning), load `briefs/process-advanced.md`.
|
||||
1659
skills/pdf/briefs/report.md
Executable file
1659
skills/pdf/briefs/report.md
Executable file
File diff suppressed because it is too large
Load Diff
153
skills/pdf/configs/components.md
Executable file
153
skills/pdf/configs/components.md
Executable file
@@ -0,0 +1,153 @@
|
||||
# Components — Art Direction JSON Lexicon
|
||||
|
||||
This file defines the strict component vocabulary for the Creative pipeline.
|
||||
|
||||
**CRITICAL RULE: DO NOT OUTPUT HTML OR CSS.**
|
||||
You are an Art Director. You only output JSON. To use these components, insert their corresponding JSON objects into the `components` array of your page blueprint. The `design_engine.py` will automatically compile them into gallery-grade visual assets.
|
||||
|
||||
---
|
||||
|
||||
## 1. Glass_Canvas
|
||||
The primary container for readable body text. Simulates printed text on frosted acrylic.
|
||||
|
||||
**JSON Blueprint Structure:**
|
||||
```json
|
||||
{
|
||||
"type": "Glass_Canvas",
|
||||
"markdown_content": "### The Divide\nYour text goes here. Supports standard Markdown.",
|
||||
"tension_score": 0.8
|
||||
}
|
||||
```
|
||||
**Parameters:**
|
||||
- `markdown_content`: (Required) The actual text. **Recommended under 150 words; absolute max 250 words.**
|
||||
- `tension_score`: (Optional, 0.0 to 1.0) Semantic tension. Drives dynamic font weight (300 to 900). Use `0.1` for calm/light text, `0.9` for crisis/heavy text. Do NOT use on data-heavy pages.
|
||||
|
||||
---
|
||||
|
||||
## 2. Hero_Typography
|
||||
Massive, page-dominating title text that physically interacts with the background via blend modes.
|
||||
|
||||
**JSON Blueprint Structure:**
|
||||
```json
|
||||
{
|
||||
"type": "Hero_Typography",
|
||||
"content": "THE WEIGHT<br>OF SILENCE",
|
||||
"weight": "black",
|
||||
"variant": "standard",
|
||||
"scale": 6
|
||||
}
|
||||
```
|
||||
**Parameters:**
|
||||
- `content`: (Required) The text. Use `<br>` for deliberate typographic line breaks.
|
||||
- `weight`: (Required) `"black"` (900 weight, dominating) or `"thin"` (100 weight, whisper-quiet/elegant).
|
||||
- `variant`: (Optional) `"standard"` (default) only. ~~`"vertical_accent"`~~ is **NOT implemented** in `design_engine.py` — the engine silently ignores this parameter. Use `Floating_Meta` component instead for rotated/vertical decorative text.
|
||||
- `scale`: (Optional, integer 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.
|
||||
93
skills/pdf/configs/fonts.md
Executable file
93
skills/pdf/configs/fonts.md
Executable file
@@ -0,0 +1,93 @@
|
||||
# Font System
|
||||
|
||||
## Font Stacks (by pipeline)
|
||||
|
||||
### Creative Pipeline (Playwright / HTML)
|
||||
|
||||
Fonts are loaded via Google Fonts CDN in the HTML `<head>`:
|
||||
|
||||
```html
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;900&family=Noto+Sans+SC:wght@300;400;500;700;900&family=Playfair+Display:ital,wght@0,400;0,700;0,900;1,400&display=swap" rel="stylesheet">
|
||||
```
|
||||
|
||||
#### Variable Font (for Tension Typesetting)
|
||||
|
||||
When `tension_score` is used on `Glass_Canvas`, the engine switches to **Inter Variable** for continuous weight interpolation (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, Helvetica Neue, Apple Color Emoji, Segoe UI Emoji, sans-serif | Body text, UI elements, stats |
|
||||
| `--font-serif` | Playfair Display, Noto Serif SC, Cormorant Garamond, Apple Color Emoji, serif | Hero text, editorial headlines |
|
||||
| `--font-mono` | SF Mono, Consolas, Apple Color Emoji, monospace | Floating meta, timestamps, codes |
|
||||
|
||||
### Report Pipeline (ReportLab)
|
||||
|
||||
ReportLab requires registered fonts. CJK support via `UniSong` / `UniHei` (built into reportlab.lib.fonts):
|
||||
|
||||
```python
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
|
||||
pdfmetrics.registerFont(UnicodeCIDFont('STSong-Light')) # Song-ti (serif-like)
|
||||
# Use 'STSong-Light' for body, headings
|
||||
```
|
||||
|
||||
> **⚠️ ReportLab CANNOT render emoji.** If content has emoji, route to Creative pipeline.
|
||||
|
||||
### Academic Pipeline (Tectonic / LaTeX)
|
||||
|
||||
CJK support via `ctex` package:
|
||||
```latex
|
||||
\usepackage{ctex} % Auto-selects appropriate CJK fonts
|
||||
```
|
||||
|
||||
For manual font selection:
|
||||
```latex
|
||||
\setCJKmainfont{Noto Serif CJK SC}
|
||||
\setCJKsansfont{Noto Sans CJK SC}
|
||||
```
|
||||
|
||||
> **⚠️ LaTeX silently drops emoji characters.** If content has emoji, route to Creative pipeline.
|
||||
|
||||
## Emoji Font Fallback
|
||||
|
||||
All Creative pipeline font stacks include emoji fallback:
|
||||
- **macOS**: `Apple Color Emoji` (system default, full color emoji)
|
||||
- **Windows**: `Segoe UI Emoji`
|
||||
- **Linux**: `Noto Color Emoji` (install: `apt install fonts-noto-color-emoji`)
|
||||
|
||||
Chromium (used by Playwright) on macOS renders emoji natively — no extra configuration needed.
|
||||
|
||||
## CJK Font Weight Guide
|
||||
|
||||
| Weight Value | Inter Equivalent | Noto Sans SC Name | Usage |
|
||||
|-------------|------------------|-------------------|-------|
|
||||
| 300 | Light | Light | Subtitles, captions, meta text |
|
||||
| 400 | Regular | Regular | Body text |
|
||||
| 500 | Medium | Medium | Semi-emphasis |
|
||||
| 700 | Bold | Bold | Headlines, emphasis |
|
||||
| 900 | Black | Black | Hero text, stat numbers |
|
||||
|
||||
> **Tip**: Noto Sans SC weights 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 |
|
||||
263
skills/pdf/configs/visual_framework.md
Executable file
263
skills/pdf/configs/visual_framework.md
Executable file
@@ -0,0 +1,263 @@
|
||||
# Visual Framework — Aesthetic Axioms
|
||||
|
||||
Non-template design system. This file is the LLM's thinking framework — not a list of styles to pick from, but a set of immutable laws that govern every design decision.
|
||||
|
||||
---
|
||||
|
||||
## The Three Absolutes
|
||||
|
||||
1. **Restraint** — Remove until it hurts. Then remove one more thing.
|
||||
2. **Contrast** — Hierarchy through scale and weight, never through color multiplication.
|
||||
3. **Space Metaphor** — Every page has physical energy: gravity, tension, breathing.
|
||||
|
||||
### The Anti-Gaudy Imperative
|
||||
|
||||
> **When in doubt, remove. Never add.**
|
||||
|
||||
A document looks "cheap" or "土" when it has too many competing visual elements. The cure is always subtraction, never rearrangement.
|
||||
|
||||
**Red flags that a design is gaudy:**
|
||||
- More than 3 decorative elements on a single body page
|
||||
- Icons or emoji used as section header decoration (use typography instead)
|
||||
- Decorative borders or frames around body content
|
||||
- Pattern/texture backgrounds on body pages
|
||||
- 3+ different hue families visible simultaneously
|
||||
- Elements added "because there's empty space" rather than for function
|
||||
|
||||
**The fix is always:** Delete the decorative element. If the page still communicates its message clearly, it didn't need the decoration. If it doesn't, fix the typography hierarchy — not by adding more visual noise.
|
||||
|
||||
### 🚫 The Stock Image / Clipart / AI-Generated Decoration Ban
|
||||
|
||||
> **ABSOLUTE PROHIBITION: Do NOT embed stock photos, clipart, watercolor illustrations, AI-generated images (flowers, patterns, borders, frames, ornaments), or any decorative raster/vector artwork into PDF documents.**
|
||||
|
||||
This is the #1 source of "cheap" / "土" / "婚庆风" design. Examples of what is BANNED:
|
||||
- ✘ Watercolor flowers / roses / floral corners / vine borders
|
||||
- ✘ Gold/metallic decorative frames or borders
|
||||
- ✘ Stock photo backgrounds (landscapes, textures, marble)
|
||||
- ✘ Clipart illustrations (ribbons, bows, hearts, stars)
|
||||
- ✘ AI-generated decorative artwork (DALL-E flowers, Midjourney patterns)
|
||||
- ✘ Any `<img>` tag used for purely decorative (non-informational) purposes
|
||||
- ✘ Unsplash/Pexels/Pixabay stock photos used as background or decoration
|
||||
|
||||
**What IS allowed:**
|
||||
- ✔ Geometric shapes rendered in CSS/SVG (circles, lines, rectangles — from `geometry.md`)
|
||||
- ✔ Typography-based decoration (oversized letters, watermark text, letter-spacing effects)
|
||||
- ✔ Color blocks and gradients (within palette rules)
|
||||
- ✔ User-provided photos/logos that are CONTENT (not decoration)
|
||||
- ✔ Charts and data visualizations
|
||||
- ✔ Diagrams that convey information
|
||||
|
||||
**The principle:** If removing an image doesn't reduce the information content of the document, that image is decoration and should be replaced with typography or geometric elements.
|
||||
|
||||
**Wedding invitations, event cards, certificates:** Use elegant typography + geometric accents (thin lines, minimal shapes) + generous whitespace. A beautifully typeset name in 48pt serif with 6pt letter-spacing is infinitely more elegant than watercolor roses.
|
||||
|
||||
---
|
||||
|
||||
## Color Axioms
|
||||
|
||||
### The 60-30-10 Law
|
||||
|
||||
Every design consists of exactly three tonal zones:
|
||||
|
||||
| Zone | Coverage | Role | Generated by |
|
||||
|------|----------|------|-------------|
|
||||
| **60% — Ground** | Background, margins, empty space | Sets mood; the viewer doesn't "see" it consciously | `--c-bg` from `design_engine.py` |
|
||||
| **30% — Structure** | Cards, dividers, secondary areas | Provides architecture; same hue family as ground, different lightness | `--c-mid` |
|
||||
| **10% — Emphasis** | Headlines, key numbers, one accent element | Draws the eye; the only place "color" lives | `--c-accent` |
|
||||
|
||||
### Saturation Discipline (HARD RULE)
|
||||
|
||||
All colors pass through `design_engine.py` which enforces:
|
||||
- **Saturation range**: 0.05 – 0.25 (HSL). This is non-negotiable.
|
||||
- **No pure primaries**: Pure red (#ff0000), blue (#0000ff), yellow (#ffff00) are forbidden.
|
||||
- **Max 3 hues** in the entire document. Variations in lightness/saturation don't count as separate hues.
|
||||
|
||||
### Lightness Polarity
|
||||
|
||||
Only two lightness regimes exist. The middle is forbidden.
|
||||
|
||||
| Mode | Background L | Text L | Forbidden Zone |
|
||||
|------|-------------|--------|----------------|
|
||||
| **Dark** (Cinematic) | < 0.10 | > 0.85 | L between 0.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.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? |
|
||||
130
skills/pdf/references/resume-academic.tex
Executable file
130
skills/pdf/references/resume-academic.tex
Executable file
@@ -0,0 +1,130 @@
|
||||
% ═══════════════════════════════════════════════════════════
|
||||
% Academic CV Template
|
||||
% Single-column, multi-page, with bibliography support
|
||||
% ═══════════════════════════════════════════════════════════
|
||||
\documentclass[11pt,a4paper]{article}
|
||||
|
||||
\usepackage[top=2cm,bottom=2cm,left=2.5cm,right=2.5cm]{geometry}
|
||||
\usepackage{enumitem}
|
||||
\usepackage{titlesec}
|
||||
\usepackage{fancyhdr}
|
||||
\usepackage[colorlinks=true,linkcolor=blue,urlcolor=blue]{hyperref}
|
||||
\usepackage{xcolor}
|
||||
\usepackage[numbers,sort&compress]{natbib}
|
||||
|
||||
% ── Fonts ──
|
||||
\usepackage[rm]{roboto}
|
||||
\renewcommand{\familydefault}{\rmdefault}
|
||||
|
||||
% ── Section Formatting ──
|
||||
\definecolor{accent}{HTML}{1F4E79}
|
||||
\titleformat{\section}
|
||||
{\Large\bfseries\color{accent}} % format
|
||||
{} % label (no number)
|
||||
{0em} % sep
|
||||
{\MakeUppercase} % before-code
|
||||
[\vspace{-0.8em}\color{accent}\rule{\linewidth}{0.8pt}] % after-code (rule)
|
||||
|
||||
\titleformat{\subsection}
|
||||
{\large\bfseries}
|
||||
{}
|
||||
{0em}
|
||||
{}
|
||||
|
||||
% ── Header/Footer ──
|
||||
\pagestyle{fancy}
|
||||
\fancyhf{}
|
||||
\fancyhead[L]{\small\textcolor{gray}{Curriculum Vitae — Your Name}}
|
||||
\fancyhead[R]{\small\textcolor{gray}{\thepage}}
|
||||
\renewcommand{\headrulewidth}{0.4pt}
|
||||
\fancyfoot{}
|
||||
|
||||
\setlength{\parindent}{0pt}
|
||||
\setlist{nosep,leftmargin=1.5em}
|
||||
|
||||
% ── CV Entry command ──
|
||||
\newcommand{\cventry}[4]{%
|
||||
\textbf{#1}\hfill\textit{#2}\\
|
||||
#3\hfill#4\par\smallskip
|
||||
}
|
||||
|
||||
\begin{document}
|
||||
|
||||
% ── Header ──
|
||||
\begin{center}
|
||||
{\LARGE\bfseries Your Full Name, Ph.D.}\par\smallskip
|
||||
{\small
|
||||
Department of Computer Science, Tsinghua University\\
|
||||
Beijing, China 100084\\
|
||||
\href{mailto:name@tsinghua.edu.cn}{name@tsinghua.edu.cn}
|
||||
\quad|\quad
|
||||
\href{https://yoursite.com}{yoursite.com}
|
||||
\quad|\quad
|
||||
ORCID: \href{https://orcid.org/0000-0000-0000-0000}{0000-0000-0000-0000}
|
||||
}\par
|
||||
\end{center}
|
||||
|
||||
\section{Research Interests}
|
||||
Large-scale distributed systems, graph neural networks, efficient inference for large language models, and privacy-preserving machine learning.
|
||||
|
||||
\section{Education}
|
||||
\cventry{Ph.D. in Computer Science}{2017 -- 2022}{Stanford University}{Stanford, CA}
|
||||
Advisor: Prof.\ Jane Smith\\
|
||||
Dissertation: \textit{Scalable Graph Processing in Heterogeneous Computing Environments}
|
||||
|
||||
\smallskip
|
||||
\cventry{B.Eng. in Software Engineering}{2013 -- 2017}{Zhejiang University}{Hangzhou, China}
|
||||
First Class Honours, GPA: 3.9/4.0
|
||||
|
||||
\section{Academic Appointments}
|
||||
\cventry{Assistant Professor}{2022 -- Present}{Tsinghua University, Dept.\ of CS}{Beijing}
|
||||
\cventry{Postdoctoral Researcher}{2022}{Stanford University, InfoLab}{Stanford, CA}
|
||||
|
||||
\section{Selected Publications}
|
||||
|
||||
\subsection{Journal Articles}
|
||||
\begin{enumerate}[label={[J\arabic*]}]
|
||||
\item \textbf{Your Name}, A.\ Coauthor, and B.\ Coauthor. ``Title of journal paper.'' \textit{IEEE Transactions on Knowledge and Data Engineering}, vol.\ 35, no.\ 4, pp.\ 1234--1248, 2023.
|
||||
\item C.\ Coauthor and \textbf{Your Name}. ``Another journal paper.'' \textit{ACM Computing Surveys}, vol.\ 55, no.\ 2, pp.\ 1--35, 2023.
|
||||
\end{enumerate}
|
||||
|
||||
\subsection{Conference Papers}
|
||||
\begin{enumerate}[label={[C\arabic*]}]
|
||||
\item \textbf{Your Name} and D.\ Coauthor. ``Title of conference paper.'' In \textit{Proc.\ NeurIPS 2023}, pp.\ 5678--5690.
|
||||
\item \textbf{Your Name}, E.\ Coauthor, and F.\ Coauthor. ``Another conference paper.'' In \textit{Proc.\ ICML 2022}, pp.\ 3456--3468.
|
||||
\end{enumerate}
|
||||
|
||||
\section{Grants \& Funding}
|
||||
\cventry{NSFC Young Scientist Fund}{2023 -- 2025}{Principal Investigator}{¥300,000}
|
||||
Project: Efficient Graph Neural Network Training on Heterogeneous Hardware
|
||||
|
||||
\section{Teaching}
|
||||
\cventry{CS101: Introduction to Programming}{Fall 2022, 2023}{Tsinghua University}{120 students}
|
||||
\cventry{CS502: Advanced Distributed Systems}{Spring 2023}{Tsinghua University}{45 students}
|
||||
|
||||
\section{Professional Service}
|
||||
\begin{itemize}
|
||||
\item \textbf{Program Committee}: NeurIPS 2023, ICML 2023, KDD 2022--2023
|
||||
\item \textbf{Reviewer}: IEEE TKDE, ACM TODS, VLDB Journal
|
||||
\item \textbf{Session Chair}: SIGMOD 2023 (Graph Processing)
|
||||
\end{itemize}
|
||||
|
||||
\section{Awards \& Honours}
|
||||
\begin{itemize}
|
||||
\item Best Paper Award, KDD 2022
|
||||
\item Stanford Graduate Fellowship, 2017--2019
|
||||
\item National Scholarship, Ministry of Education, China, 2016
|
||||
\end{itemize}
|
||||
|
||||
\section{Skills}
|
||||
\textbf{Programming:} Python, C++, Rust, CUDA\\
|
||||
\textbf{Frameworks:} PyTorch, TensorFlow, Apache Spark, DGL\\
|
||||
\textbf{Languages:} Chinese (native), English (fluent), Japanese (basic)
|
||||
|
||||
\hypersetup{
|
||||
pdftitle={CV - Your Name},
|
||||
pdfauthor={Z.ai},
|
||||
pdfsubject={Academic Curriculum Vitae},
|
||||
}
|
||||
|
||||
\end{document}
|
||||
190
skills/pdf/references/resume-altacv.tex
Executable file
190
skills/pdf/references/resume-altacv.tex
Executable file
@@ -0,0 +1,190 @@
|
||||
% ═══════════════════════════════════════════════════════════
|
||||
% AltaCV-Style Creative Resume Template
|
||||
% Based on AltaCV by LianTze Lim, adapted for Tectonic
|
||||
% ═══════════════════════════════════════════════════════════
|
||||
\documentclass[10pt,a4paper]{article}
|
||||
|
||||
% ── Packages ──
|
||||
\usepackage[margin=1.25cm,columnsep=1.2cm]{geometry}
|
||||
\usepackage{paracol} % dual-column that breaks across pages
|
||||
\usepackage[fixed]{fontawesome5}
|
||||
\usepackage{tikz}
|
||||
\usepackage{xcolor}
|
||||
\usepackage{enumitem}
|
||||
\usepackage{graphicx}
|
||||
\usepackage{dashrule}
|
||||
\usepackage[colorlinks=true,urlcolor=accent,linkcolor=accent,bookmarks=false]{hyperref}
|
||||
|
||||
% ── Colour System (change these to retheme) ──
|
||||
\definecolor{accent}{HTML}{3B82F6} % blue — primary accent
|
||||
\definecolor{emphasis}{HTML}{2E2E2E} % near-black — job titles, strong text
|
||||
\definecolor{heading}{HTML}{1E293B} % dark slate — section headings
|
||||
\definecolor{headingrule}{HTML}{3B82F6} % matches accent
|
||||
\definecolor{body}{HTML}{4B5563} % gray-600 — body text
|
||||
\definecolor{tagbg}{HTML}{EFF6FF} % light blue — tag background
|
||||
\definecolor{subheading}{HTML}{3B82F6} % matches accent
|
||||
|
||||
% ── Fonts ──
|
||||
\usepackage[rm]{roboto}
|
||||
\usepackage[defaultsans]{lato}
|
||||
\renewcommand{\familydefault}{\sfdefault}
|
||||
|
||||
% ── List Settings ──
|
||||
\setlist{leftmargin=*,labelsep=0.5em,nosep,itemsep=0.25\baselineskip,after=\vspace{0.25\baselineskip}}
|
||||
\setlist[itemize]{label={\small\textbullet}}
|
||||
|
||||
\setlength{\parindent}{0pt}
|
||||
|
||||
% ── Custom Commands ──
|
||||
|
||||
% Divider (dashed line between entries)
|
||||
\newcommand{\divider}{\textcolor{body!30}{\hdashrule{\linewidth}{0.6pt}{0.5ex}}\medskip}
|
||||
|
||||
% Section heading with rule
|
||||
\newcommand{\cvsection}[1]{%
|
||||
\nointerlineskip\bigskip%
|
||||
{\color{heading}\Large\bfseries\MakeUppercase{#1}}\\[-1ex]%
|
||||
{\color{headingrule}\rule{\linewidth}{2pt}\par}\medskip
|
||||
}
|
||||
|
||||
% Experience entry: {title}{company}{dates}{location}
|
||||
\newcommand{\cvevent}[4]{%
|
||||
{\large\color{emphasis}\bfseries #1\par}%
|
||||
\smallskip\normalsize
|
||||
\ifx&\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}
|
||||
367
skills/pdf/scripts/cover_validate.js
Executable file
367
skills/pdf/scripts/cover_validate.js
Executable file
@@ -0,0 +1,367 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* cover_validate.js — Cover page overlap detection via Playwright rendering
|
||||
*
|
||||
* Detects text-vs-decorative-line overlap on cover HTML pages by:
|
||||
* 1. Rendering the HTML in Playwright
|
||||
* 2. Waiting for fonts to load
|
||||
* 3. Measuring bounding boxes of text elements and decorative line elements
|
||||
* 4. Checking for Y-axis overlap (minimum spacing = 1U = 5% of page width ≈ 30pt)
|
||||
*
|
||||
* Usage:
|
||||
* node cover_validate.js cover.html
|
||||
* node cover_validate.js cover.html --width 210mm --height 297mm
|
||||
* node cover_validate.js cover.html --min-gap 30 # custom min gap in px (default: auto = 5% of width)
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 = no overlap issues found
|
||||
* 1 = overlap detected (prints details to stderr)
|
||||
* 2 = script error (missing file, browser launch failure, etc.)
|
||||
*
|
||||
* This script is ONLY for cover pages. Do NOT use it on:
|
||||
* - Multi-page documents (use html2pdf-next.js pre-render checks)
|
||||
* - Posters (use html2poster.js which handles overflow automatically)
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// ── Playwright import ──
|
||||
|
||||
let playwright;
|
||||
try {
|
||||
playwright = require('playwright');
|
||||
} catch {
|
||||
try {
|
||||
playwright = require('playwright-core');
|
||||
} catch {
|
||||
console.error('✗ Neither playwright nor playwright-core is installed.');
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Chromium resolution (shared logic with html2poster.js) ──
|
||||
|
||||
function resolveChromium(chromiumObj) {
|
||||
let exe;
|
||||
try { exe = chromiumObj.executablePath(); } catch (_) { exe = null; }
|
||||
if (exe && fs.existsSync(exe)) return { status: 'ok', executablePath: exe };
|
||||
|
||||
const candidates = [
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
'/usr/bin/chromium-browser', '/usr/bin/chromium', '/usr/bin/google-chrome',
|
||||
];
|
||||
if (process.env.PLAYWRIGHT_CHROMIUM_PATH) candidates.unshift(process.env.PLAYWRIGHT_CHROMIUM_PATH);
|
||||
|
||||
for (const c of candidates) {
|
||||
if (fs.existsSync(c)) return { status: 'fallback', executablePath: c };
|
||||
}
|
||||
return { status: 'missing', executablePath: exe || '' };
|
||||
}
|
||||
|
||||
// ── CLI parsing ──
|
||||
|
||||
function parseArgs(argv) {
|
||||
const tokens = argv.slice(2);
|
||||
let input = null, width = '210mm', height = '297mm', minGap = null;
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const t = tokens[i];
|
||||
if (t === '--width') width = tokens[++i];
|
||||
else if (t === '--height') height = tokens[++i];
|
||||
else if (t === '--min-gap') minGap = parseFloat(tokens[++i]);
|
||||
else if (t === '--help' || t === '-h') {
|
||||
console.log(`Usage: node cover_validate.js <cover.html> [options]
|
||||
|
||||
Options:
|
||||
--width <val> Page width (default: 210mm)
|
||||
--height <val> Page height (default: 297mm)
|
||||
--min-gap <px> Minimum gap between text and decorative lines (default: 5% of width)
|
||||
--help Show this help`);
|
||||
process.exit(0);
|
||||
} else if (!t.startsWith('-') && !input) {
|
||||
input = t;
|
||||
}
|
||||
}
|
||||
return { input, width, height, minGap };
|
||||
}
|
||||
|
||||
// ── Convert CSS dimension string to px for viewport ──
|
||||
|
||||
function dimToPx(dim) {
|
||||
if (!dim) return null;
|
||||
const s = String(dim).trim();
|
||||
const num = parseFloat(s);
|
||||
if (s.endsWith('mm')) return Math.round(num * 3.7795); // 1mm ≈ 3.7795px at 96dpi
|
||||
if (s.endsWith('cm')) return Math.round(num * 37.795);
|
||||
if (s.endsWith('in')) return Math.round(num * 96);
|
||||
if (s.endsWith('px') || !isNaN(num)) return Math.round(num);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Decorative line detection heuristics ──
|
||||
// A decorative line is an element that:
|
||||
// - Is very thin in one dimension (height ≤ 5px or width ≤ 5px)
|
||||
// - OR is an <hr> element
|
||||
// - OR has a large aspect ratio (> 10:1 or < 1:10)
|
||||
// - AND is not inside a text element
|
||||
|
||||
const DECORATIVE_LINE_DETECTION = `
|
||||
(function detectOverlaps(minGapPx) {
|
||||
// Collect all elements
|
||||
const allElements = document.querySelectorAll('*');
|
||||
|
||||
const textElements = [];
|
||||
const lineElements = [];
|
||||
|
||||
// Classify elements
|
||||
for (const el of allElements) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) continue;
|
||||
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const style = getComputedStyle(el);
|
||||
|
||||
// Skip invisible elements
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') continue;
|
||||
|
||||
// Detect decorative lines
|
||||
const isHR = tag === 'hr';
|
||||
const isThinH = rect.height <= 5 && rect.width > 20; // thin horizontal line
|
||||
const isThinV = rect.width <= 5 && rect.height > 20; // thin vertical line
|
||||
const aspectH = rect.width / rect.height;
|
||||
const aspectV = rect.height / rect.width;
|
||||
const isWideRatio = aspectH > 15 && rect.height <= 8; // very wide, very thin
|
||||
const isTallRatio = aspectV > 15 && rect.width <= 8; // very tall, very thin
|
||||
|
||||
// Check if element has only border (no text content, no background image)
|
||||
const hasOnlyBorder = (
|
||||
el.textContent.trim() === '' &&
|
||||
style.backgroundImage === 'none' &&
|
||||
(style.borderTopWidth !== '0px' || style.borderBottomWidth !== '0px' ||
|
||||
style.borderLeftWidth !== '0px' || style.borderRightWidth !== '0px')
|
||||
);
|
||||
const isBorderLine = hasOnlyBorder && (rect.height <= 8 || rect.width <= 8);
|
||||
|
||||
if (isHR || isThinH || isThinV || isWideRatio || isTallRatio || isBorderLine) {
|
||||
lineElements.push({
|
||||
tag: tag,
|
||||
class: el.className || '',
|
||||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||
type: isThinH || isWideRatio ? 'horizontal' : (isThinV || isTallRatio ? 'vertical' : (rect.width >= rect.height ? 'horizontal' : 'vertical')),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect text elements (has direct text content or is a heading/paragraph)
|
||||
const textTags = ['h1','h2','h3','h4','h5','h6','p','span','a','li','td','th','label','summary'];
|
||||
const hasDirectText = Array.from(el.childNodes).some(n => n.nodeType === 3 && n.textContent.trim());
|
||||
|
||||
if (textTags.includes(tag) || hasDirectText) {
|
||||
// Skip if this is inside a decorative element
|
||||
if (rect.height < 3) continue;
|
||||
|
||||
textElements.push({
|
||||
tag: tag,
|
||||
class: el.className || '',
|
||||
text: el.textContent.trim().substring(0, 60),
|
||||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// De-duplicate: if a parent and child text element both overlap the same line,
|
||||
// only keep the more specific (smaller) one to avoid duplicate reports.
|
||||
// Sort text elements by area (smallest first) so we can skip parents.
|
||||
textElements.sort((a, b) => (a.rect.width * a.rect.height) - (b.rect.width * b.rect.height));
|
||||
|
||||
// Check overlaps between text elements and line elements
|
||||
const overlaps = [];
|
||||
const reportedPairs = new Set(); // track "lineIndex:textContent" to deduplicate
|
||||
|
||||
for (const text of textElements) {
|
||||
for (const line of lineElements) {
|
||||
const tr = text.rect;
|
||||
const lr = line.rect;
|
||||
|
||||
if (line.type === 'horizontal') {
|
||||
// Check vertical overlap/proximity
|
||||
const textTop = tr.y;
|
||||
const textBottom = tr.y + tr.height;
|
||||
const lineTop = lr.y;
|
||||
const lineBottom = lr.y + lr.height;
|
||||
|
||||
// Check horizontal overlap (they must share some X range)
|
||||
const xOverlap = !(tr.x + tr.width < lr.x || lr.x + lr.width < tr.x);
|
||||
if (!xOverlap) continue;
|
||||
|
||||
// Calculate vertical gap
|
||||
let vGap;
|
||||
if (lineTop >= textBottom) {
|
||||
vGap = lineTop - textBottom; // line is below text
|
||||
} else if (textTop >= lineBottom) {
|
||||
vGap = textTop - lineBottom; // line is above text
|
||||
} else {
|
||||
vGap = 0; // overlapping
|
||||
}
|
||||
|
||||
if (vGap < minGapPx) {
|
||||
// De-dup: same line region, only report the smallest (most specific) text element
|
||||
const lineKey = 'h:' + Math.round(lr.x) + ',' + Math.round(lr.y);
|
||||
if (!reportedPairs.has(lineKey)) {
|
||||
reportedPairs.add(lineKey);
|
||||
overlaps.push({
|
||||
text: text.text,
|
||||
textTag: text.tag,
|
||||
textClass: text.class,
|
||||
textRect: tr,
|
||||
lineTag: line.tag,
|
||||
lineClass: line.class,
|
||||
lineRect: lr,
|
||||
lineType: line.type,
|
||||
gap: Math.round(vGap * 10) / 10,
|
||||
required: minGapPx,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (line.type === 'vertical') {
|
||||
// Check horizontal overlap/proximity
|
||||
const textLeft = tr.x;
|
||||
const textRight = tr.x + tr.width;
|
||||
const lineLeft = lr.x;
|
||||
const lineRight = lr.x + lr.width;
|
||||
|
||||
// Check vertical overlap (they must share some Y range)
|
||||
const yOverlap = !(tr.y + tr.height < lr.y || lr.y + lr.height < tr.y);
|
||||
if (!yOverlap) continue;
|
||||
|
||||
// Calculate horizontal gap
|
||||
let hGap;
|
||||
if (lineLeft >= textRight) {
|
||||
hGap = lineLeft - textRight;
|
||||
} else if (textLeft >= lineRight) {
|
||||
hGap = textLeft - lineRight;
|
||||
} else {
|
||||
hGap = 0;
|
||||
}
|
||||
|
||||
if (hGap < minGapPx) {
|
||||
const lineKey = 'v:' + Math.round(lr.x) + ',' + Math.round(lr.y);
|
||||
if (!reportedPairs.has(lineKey)) {
|
||||
reportedPairs.add(lineKey);
|
||||
overlaps.push({
|
||||
text: text.text,
|
||||
textTag: text.tag,
|
||||
textClass: text.class,
|
||||
textRect: tr,
|
||||
lineTag: line.tag,
|
||||
lineClass: line.class,
|
||||
lineRect: lr,
|
||||
lineType: line.type,
|
||||
gap: Math.round(hGap * 10) / 10,
|
||||
required: minGapPx,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
textElements: textElements.length,
|
||||
lineElements: lineElements.length,
|
||||
overlaps: overlaps,
|
||||
};
|
||||
})
|
||||
`;
|
||||
|
||||
// ── Main ──
|
||||
|
||||
async function main() {
|
||||
const { input, width, height, minGap } = parseArgs(process.argv);
|
||||
|
||||
if (!input) {
|
||||
console.error('✗ No input file specified. Usage: node cover_validate.js cover.html');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const absIn = path.resolve(input);
|
||||
if (!fs.existsSync(absIn)) {
|
||||
console.error(`✗ File not found: ${absIn}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const widthPx = dimToPx(width) || 794; // A4 width in px
|
||||
const heightPx = dimToPx(height) || 1123; // A4 height in px
|
||||
const gap = minGap || Math.round(widthPx * 0.05); // 1U = 5% of page width
|
||||
|
||||
console.log(`🔍 cover_validate — Cover overlap detection`);
|
||||
console.log(` Input: ${absIn}`);
|
||||
console.log(` Page: ${widthPx}×${heightPx}px`);
|
||||
console.log(` Min gap: ${gap}px (1U)`);
|
||||
|
||||
const { chromium } = playwright;
|
||||
const bInfo = resolveChromium(chromium);
|
||||
|
||||
if (bInfo.status === 'missing') {
|
||||
console.error('✗ No Chromium found. Install via: npx playwright install chromium');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let browser;
|
||||
try {
|
||||
const opts = { headless: true };
|
||||
if (bInfo.status === 'fallback') opts.executablePath = bInfo.executablePath;
|
||||
browser = await chromium.launch(opts);
|
||||
} catch (err) {
|
||||
console.error(`✗ Browser launch failed: ${err.message}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: widthPx, height: heightPx } });
|
||||
await page.goto('file://' + absIn, { waitUntil: 'networkidle' });
|
||||
console.log(` ✓ HTML loaded`);
|
||||
|
||||
// Wait for fonts
|
||||
const fontsLoaded = await page.evaluate(() =>
|
||||
document.fonts.ready.then(() => document.fonts.size)
|
||||
).catch(() => 0);
|
||||
console.log(` ✓ Fonts: ${fontsLoaded} loaded`);
|
||||
|
||||
// Run overlap detection
|
||||
const result = await page.evaluate(`(${DECORATIVE_LINE_DETECTION})(${gap})`);
|
||||
|
||||
console.log(` ✓ Found ${result.textElements} text elements, ${result.lineElements} decorative lines`);
|
||||
|
||||
if (result.overlaps.length === 0) {
|
||||
console.log(`\n ✅ No overlap issues found`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Report overlaps
|
||||
console.error(`\n ❌ Found ${result.overlaps.length} text-line overlap(s):\n`);
|
||||
|
||||
for (const o of result.overlaps) {
|
||||
const direction = o.lineType === 'vertical' ? 'horizontal' : 'vertical';
|
||||
console.error(` ERROR: ${direction} gap = ${o.gap}px (required ≥ ${o.required}px)`);
|
||||
console.error(` Text: <${o.textTag}> "${o.text}" @ y=${Math.round(o.textRect.y)}-${Math.round(o.textRect.y + o.textRect.height)}`);
|
||||
console.error(` Line: <${o.lineTag}${o.lineClass ? '.' + o.lineClass.split(' ')[0] : ''}> [${o.lineType}] @ y=${Math.round(o.lineRect.y)}-${Math.round(o.lineRect.y + o.lineRect.height)}`);
|
||||
console.error(` Fix: Move the decorative line at least ${Math.ceil(o.required - o.gap)}px away from the text.`);
|
||||
console.error('');
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(`✗ Unexpected error: ${err.message}`);
|
||||
process.exit(2);
|
||||
});
|
||||
2816
skills/pdf/scripts/design_engine.py
Executable file
2816
skills/pdf/scripts/design_engine.py
Executable file
File diff suppressed because it is too large
Load Diff
754
skills/pdf/scripts/html2pdf-next.js
Executable file
754
skills/pdf/scripts/html2pdf-next.js
Executable file
@@ -0,0 +1,754 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* html2pdf-next.js — HTML → PDF converter using Playwright + pdf-lib
|
||||
*
|
||||
* Drop-in replacement for html2pdf.js, WITHOUT Paged.js dependency.
|
||||
* Uses Chromium native @page CSS for pagination + pdf-lib for post-processing.
|
||||
*
|
||||
* Usage:
|
||||
* node html2pdf-next.js input.html
|
||||
* node html2pdf-next.js input.html --output result.pdf
|
||||
* node html2pdf-next.js input.html --css extra.css
|
||||
* node html2pdf-next.js input.html --width 720px --height 960px
|
||||
* node html2pdf-next.js input.html --direct (same as default now — no Paged.js to skip)
|
||||
* node html2pdf-next.js input.html --merge a.pdf b.pdf (merge additional PDFs after)
|
||||
*
|
||||
* Architecture:
|
||||
* 1. Playwright renders HTML → raw PDF via Chromium's native print engine
|
||||
* 2. Pre-render hooks: Mermaid, KaTeX, oversized element fixes
|
||||
* 3. Post-render: pdf-lib for merge, metadata, page count extraction
|
||||
* 4. No Paged.js, no paged.polyfill.js — CSS @page handles pagination natively
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync, spawnSync } = require('child_process');
|
||||
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Playwright / Chromium resolution (self-contained, no external helper)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
function loadPlaywright() {
|
||||
// Try direct require first
|
||||
try { return require('playwright'); } catch (_) {}
|
||||
|
||||
// Search common global paths
|
||||
const Module = require('module');
|
||||
const roots = new Set();
|
||||
if (process.env.PLAYWRIGHT_PATH) roots.add(process.env.PLAYWRIGHT_PATH);
|
||||
if (process.env.NODE_PATH) {
|
||||
process.env.NODE_PATH.split(path.delimiter).filter(Boolean).forEach(p => roots.add(p));
|
||||
}
|
||||
try {
|
||||
const g = execSync('npm root -g', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
||||
if (g) roots.add(g);
|
||||
} catch (_) {}
|
||||
|
||||
for (const base of roots) {
|
||||
const pkg = path.join(base, 'playwright', 'package.json');
|
||||
if (!fs.existsSync(pkg)) continue;
|
||||
try { return Module.createRequire(pkg)('playwright'); } catch (_) {}
|
||||
}
|
||||
throw new Error('Playwright not found. Install: npm install -g playwright');
|
||||
}
|
||||
|
||||
function loadPdfLib() {
|
||||
try { return require('pdf-lib'); } catch (_) {}
|
||||
const Module = require('module');
|
||||
try {
|
||||
const g = execSync('npm root -g', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
||||
const pkg = path.join(g, 'pdf-lib', 'package.json');
|
||||
if (fs.existsSync(pkg)) return Module.createRequire(pkg)('pdf-lib');
|
||||
} catch (_) {}
|
||||
throw new Error('pdf-lib not found. Install: npm install -g pdf-lib');
|
||||
}
|
||||
|
||||
function resolveChromium(chromiumObj, allowInstall = false) {
|
||||
let exe;
|
||||
try { exe = chromiumObj.executablePath(); } catch (_) { exe = null; }
|
||||
|
||||
if (exe && fs.existsSync(exe)) {
|
||||
return { status: 'ok', executablePath: exe };
|
||||
}
|
||||
|
||||
// Try system Chrome/Chromium
|
||||
const candidates = [
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
'/usr/bin/chromium-browser', '/usr/bin/chromium', '/usr/bin/google-chrome',
|
||||
];
|
||||
if (process.env.PLAYWRIGHT_CHROMIUM_PATH) candidates.unshift(process.env.PLAYWRIGHT_CHROMIUM_PATH);
|
||||
|
||||
for (const c of candidates) {
|
||||
if (fs.existsSync(c)) return { status: 'fallback', executablePath: c };
|
||||
}
|
||||
|
||||
if (allowInstall) {
|
||||
const r = spawnSync('npx', ['playwright', 'install', 'chromium'], { stdio: 'inherit', shell: true });
|
||||
if (r.status === 0) {
|
||||
try { exe = chromiumObj.executablePath(); } catch (_) {}
|
||||
if (exe && fs.existsSync(exe)) return { status: 'installed', executablePath: exe };
|
||||
}
|
||||
}
|
||||
|
||||
return { status: 'missing', executablePath: exe || '' };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// CLI
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
function cli() {
|
||||
const tokens = process.argv.slice(2);
|
||||
if (!tokens.length || tokens[0] === '-h' || tokens[0] === '--help') {
|
||||
console.log(`
|
||||
Usage: node html2pdf-next.js <input.html> [options]
|
||||
|
||||
Options:
|
||||
--output, -o <file> Output PDF path (default: <input>.pdf)
|
||||
--css <file> Inject extra stylesheet
|
||||
--width <px> Custom page width (e.g. 720px)
|
||||
--height <px> Custom page height (e.g. 960px)
|
||||
--direct (no-op, kept for backward compat — always direct now)
|
||||
--merge <files...> Append additional PDF files after conversion
|
||||
--title <text> Set PDF document title metadata
|
||||
--help, -h Show help
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const inputFile = tokens[0];
|
||||
let outputFile = null, customCSS = null, width = null, height = null;
|
||||
let mergeFiles = [], title = null;
|
||||
|
||||
for (let i = 1; i < tokens.length; i++) {
|
||||
const t = tokens[i];
|
||||
if (t === '--output' || t === '-o') outputFile = tokens[++i];
|
||||
else if (t === '--css') customCSS = tokens[++i];
|
||||
else if (t === '--width') width = tokens[++i];
|
||||
else if (t === '--height') height = tokens[++i];
|
||||
else if (t === '--direct') { /* no-op, always direct */ }
|
||||
else if (t === '--title') title = tokens[++i];
|
||||
else if (t === '--merge') {
|
||||
while (i + 1 < tokens.length && !tokens[i + 1].startsWith('--')) {
|
||||
mergeFiles.push(tokens[++i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!outputFile) {
|
||||
const p = path.parse(inputFile);
|
||||
outputFile = path.join(p.dir || '.', p.name + '.pdf');
|
||||
}
|
||||
|
||||
return { inputFile, outputFile, customCSS, width, height, mergeFiles, title };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
function prettyBytes(n) {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let u = 0;
|
||||
while (n >= 1024 && u < units.length - 1) { n /= 1024; u++; }
|
||||
return `${n.toFixed(1)} ${units[u]}`;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Pre-render hooks (run in browser context before PDF export)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
async function preRenderHooks(page) {
|
||||
const warnings = [];
|
||||
|
||||
// 1. Wait for Mermaid diagrams
|
||||
const hasMermaid = await page.evaluate(() => document.querySelectorAll('.mermaid').length > 0);
|
||||
if (hasMermaid) {
|
||||
console.log(' ⏳ Waiting for Mermaid diagrams...');
|
||||
try {
|
||||
await page.waitForFunction(() => {
|
||||
for (const m of document.querySelectorAll('.mermaid'))
|
||||
if (!m.querySelector('svg') && !m.getAttribute('data-processed')) return false;
|
||||
return true;
|
||||
}, { timeout: 30000 });
|
||||
await sleep(2000);
|
||||
console.log(' ✓ Mermaid rendered');
|
||||
} catch (_) {
|
||||
warnings.push('Mermaid rendering timed out (30s)');
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Trigger KaTeX math rendering
|
||||
const katexStatus = await page.evaluate(() => ({
|
||||
lib: typeof renderMathInElement === 'function' || typeof katex !== 'undefined',
|
||||
rendered: document.querySelectorAll('.katex').length > 0,
|
||||
raw: /\$[^$]+\$|\$\$[^$]+\$\$|\\\(.*?\\\)|\\\[.*?\\\]/.test(document.body.innerText),
|
||||
}));
|
||||
|
||||
// Auto-inject KaTeX CDN if raw math detected but library not loaded
|
||||
if (!katexStatus.lib && katexStatus.raw && !katexStatus.rendered) {
|
||||
console.log(' ⏳ Auto-injecting KaTeX CDN (math formulas detected but KaTeX not loaded)...');
|
||||
await page.addStyleTag({ url: 'https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css' });
|
||||
await page.addScriptTag({ url: 'https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.js' });
|
||||
await page.addScriptTag({ url: 'https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/contrib/auto-render.min.js' });
|
||||
await sleep(2000); // Wait for CDN scripts to load
|
||||
// Re-check
|
||||
const recheckLib = await page.evaluate(() => typeof renderMathInElement === 'function');
|
||||
if (recheckLib) {
|
||||
console.log(' ✓ KaTeX CDN loaded successfully');
|
||||
} else {
|
||||
console.log(' ⚠ KaTeX CDN failed to load — math will render as raw text');
|
||||
warnings.push('KaTeX CDN injection failed; math formulas may appear as raw LaTeX code');
|
||||
}
|
||||
}
|
||||
|
||||
// Re-evaluate after potential CDN injection
|
||||
const katexReady = await page.evaluate(() => ({
|
||||
lib: typeof renderMathInElement === 'function' || typeof katex !== 'undefined',
|
||||
rendered: document.querySelectorAll('.katex').length > 0,
|
||||
raw: /\$[^$]+\$|\$\$[^$]+\$\$|\\\(.*?\\\)|\\\[.*?\\\]/.test(document.body.innerText),
|
||||
}));
|
||||
|
||||
if (katexReady.lib && !katexReady.rendered && katexReady.raw) {
|
||||
console.log(' ⏳ Triggering KaTeX rendering...');
|
||||
await page.evaluate(() => {
|
||||
if (typeof renderMathInElement === 'function')
|
||||
renderMathInElement(document.body, {
|
||||
delimiters: [
|
||||
{ left: '$$', right: '$$', display: true },
|
||||
{ left: '$', right: '$', display: false },
|
||||
{ left: '\\(', right: '\\)', display: false },
|
||||
{ left: '\\[', right: '\\]', display: true },
|
||||
],
|
||||
throwOnError: false,
|
||||
});
|
||||
});
|
||||
await sleep(1000);
|
||||
console.log(' ✓ KaTeX rendered');
|
||||
} else if (katexReady.rendered) {
|
||||
await sleep(500); // Font loading settle
|
||||
}
|
||||
|
||||
// 3. Fix oversized elements that prevent page breaks
|
||||
const nFixed = await page.evaluate(() => {
|
||||
const LIMIT = 1000;
|
||||
let n = 0;
|
||||
document.querySelectorAll(
|
||||
'[style*="page-break-inside: avoid"],[style*="break-inside: avoid"],' +
|
||||
'.avoid-break,table,figure,.theorem,.algorithm'
|
||||
).forEach(el => {
|
||||
if (el.getBoundingClientRect().height > LIMIT) {
|
||||
el.style.pageBreakInside = 'auto';
|
||||
el.style.breakInside = 'auto';
|
||||
n++;
|
||||
}
|
||||
});
|
||||
return n;
|
||||
});
|
||||
if (nFixed) {
|
||||
console.log(` ⚠ Fixed ${nFixed} oversized elements (removed break-inside: avoid)`);
|
||||
}
|
||||
|
||||
// 4. Detect overflow (horizontal AND vertical)
|
||||
const overflows = await page.evaluate(() => {
|
||||
const out = [];
|
||||
document.querySelectorAll('pre,table,figure,img,svg,.mermaid,blockquote,.equation').forEach(el => {
|
||||
const hDiff = el.scrollWidth - el.clientWidth;
|
||||
const vDiff = el.scrollHeight - el.clientHeight;
|
||||
if (hDiff > 2 || vDiff > 2) out.push({
|
||||
tag: el.tagName.toLowerCase(),
|
||||
cls: el.className || '',
|
||||
hOverflow: hDiff > 2 ? hDiff : 0,
|
||||
vOverflow: vDiff > 2 ? vDiff : 0,
|
||||
preview: (el.textContent || '').slice(0, 50).replace(/\s+/g, ' '),
|
||||
});
|
||||
});
|
||||
return out;
|
||||
});
|
||||
if (overflows.length) {
|
||||
console.log(' ⚠ Overflow detected:');
|
||||
overflows.forEach(o => {
|
||||
const parts = [];
|
||||
if (o.hOverflow) parts.push(`H +${o.hOverflow}px`);
|
||||
if (o.vOverflow) parts.push(`V +${o.vOverflow}px`);
|
||||
console.log(` <${o.tag}${o.cls ? '.' + o.cls.split(' ')[0] : ''}> ${parts.join(', ')}`);
|
||||
});
|
||||
warnings.push(`${overflows.length} element(s) have overflow`);
|
||||
}
|
||||
|
||||
// 4b. Fix vertical overflow on page-level containers
|
||||
// When html/body or the main content canvas has a fixed height + overflow:hidden,
|
||||
// content gets clipped. For documents (html2pdf-next.js), we DON'T expand the
|
||||
// container to its scrollHeight — that creates an oversized single "page" that
|
||||
// Playwright splits unevenly. Instead, we remove the fixed height and overflow:hidden
|
||||
// so content flows naturally and @page CSS handles pagination.
|
||||
//
|
||||
// (The old "expand to scrollHeight" logic belongs in html2poster.js where a single
|
||||
// continuous canvas is the desired output.)
|
||||
const vOverflowFix = await page.evaluate(() => {
|
||||
const fixes = [];
|
||||
// Candidates: html, body, and any direct child of body that acts as a full-page canvas
|
||||
const candidates = [document.documentElement, document.body];
|
||||
const bodyChildren = document.body.children;
|
||||
for (let i = 0; i < bodyChildren.length; i++) {
|
||||
const child = bodyChildren[i];
|
||||
// Skip SVG defs, script, style elements
|
||||
const tag = child.tagName.toLowerCase();
|
||||
if (tag === 'svg' || tag === 'script' || tag === 'style' || tag === 'link') continue;
|
||||
candidates.push(child);
|
||||
// Also check one level deeper (e.g., .canvas > .content)
|
||||
for (let j = 0; j < child.children.length; j++) {
|
||||
const grandchild = child.children[j];
|
||||
const gtag = grandchild.tagName.toLowerCase();
|
||||
if (gtag === 'svg' || gtag === 'script' || gtag === 'style') continue;
|
||||
candidates.push(grandchild);
|
||||
}
|
||||
}
|
||||
|
||||
for (const el of candidates) {
|
||||
const computed = getComputedStyle(el);
|
||||
const overflow = computed.overflow || computed.overflowY;
|
||||
const hasHiddenOverflow = overflow === 'hidden' || overflow === 'clip';
|
||||
const diff = el.scrollHeight - el.clientHeight;
|
||||
|
||||
if (hasHiddenOverflow && diff > 5) {
|
||||
// This element is clipping content vertically
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const id = el.id ? `#${el.id}` : '';
|
||||
const cls = el.className ? `.${String(el.className).split(' ')[0]}` : '';
|
||||
const selector = `${tag}${id}${cls}`;
|
||||
|
||||
const oldHeight = el.clientHeight;
|
||||
|
||||
// Document mode: remove fixed height + overflow:hidden,
|
||||
// let @page handle natural pagination
|
||||
el.style.height = 'auto';
|
||||
el.style.minHeight = 'auto';
|
||||
el.style.maxHeight = 'none';
|
||||
el.style.overflow = 'visible';
|
||||
el.style.overflowY = 'visible';
|
||||
|
||||
fixes.push({
|
||||
selector,
|
||||
oldHeight,
|
||||
clipped: diff,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// After fixing containers, re-measure to get the final content height
|
||||
const finalHeight = Math.max(
|
||||
document.documentElement.scrollHeight,
|
||||
document.body.scrollHeight
|
||||
);
|
||||
|
||||
return { fixes, finalHeight };
|
||||
});
|
||||
|
||||
if (vOverflowFix.fixes.length) {
|
||||
console.log(' ⚠️ Removed fixed height + overflow:hidden — content will paginate naturally:');
|
||||
vOverflowFix.fixes.forEach(f => {
|
||||
console.log(` ${f.selector}: was ${f.oldHeight}px with ${f.clipped}px clipped → now auto (content will flow to next page)`);
|
||||
});
|
||||
}
|
||||
|
||||
// 4c. Convert absolute-bottom elements to document flow
|
||||
// Elements with `position: absolute; bottom: Npx` inside page containers
|
||||
// are pinned relative to their containing block. When content paginates
|
||||
// across multiple @page pages, these elements either overlap with body
|
||||
// text or land on the wrong page. Fix: convert them to static positioning
|
||||
// so they participate in normal document flow and paginate naturally.
|
||||
const absBottomFix = await page.evaluate(() => {
|
||||
const converted = [];
|
||||
// Scan inside page-level containers (body children and their children)
|
||||
const containers = [];
|
||||
for (let i = 0; i < document.body.children.length; i++) {
|
||||
const child = document.body.children[i];
|
||||
const tag = child.tagName.toLowerCase();
|
||||
if (tag === 'svg' || tag === 'script' || tag === 'style' || tag === 'link') continue;
|
||||
containers.push(child);
|
||||
}
|
||||
|
||||
for (const container of containers) {
|
||||
const descendants = container.querySelectorAll('*');
|
||||
for (const el of descendants) {
|
||||
const computed = getComputedStyle(el);
|
||||
if (computed.position === 'absolute' && computed.bottom !== 'auto' && computed.bottom !== '') {
|
||||
// Check if this element contains visible text (not just decorative)
|
||||
const hasText = el.textContent && el.textContent.trim().length > 0;
|
||||
if (!hasText) continue;
|
||||
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const id = el.id ? `#${el.id}` : '';
|
||||
const cls = el.className ? `.${String(el.className).split(' ')[0]}` : '';
|
||||
const selector = `${tag}${id}${cls}`;
|
||||
|
||||
// Convert to static flow: remove absolute positioning
|
||||
el.style.position = 'static';
|
||||
el.style.bottom = 'auto';
|
||||
el.style.left = 'auto';
|
||||
el.style.right = 'auto';
|
||||
// Preserve horizontal padding/margin from the original left/right values
|
||||
// by keeping any existing padding or margin on the element
|
||||
|
||||
converted.push({ selector, bottom: computed.bottom });
|
||||
}
|
||||
}
|
||||
}
|
||||
return converted;
|
||||
});
|
||||
|
||||
if (absBottomFix.length) {
|
||||
console.log(' ⚠️ Converted absolute-bottom elements to document flow (prevents overlap on multi-page):');
|
||||
absBottomFix.forEach(f => {
|
||||
console.log(` ${f.selector}: was position:absolute;bottom:${f.bottom} → now static (flows with content)`);
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Inject minimal @page CSS fallback
|
||||
await page.evaluate(() => {
|
||||
const styles = Array.from(document.querySelectorAll('style'));
|
||||
const hasPageRule = styles.some(s => (s.textContent || '').includes('@page'));
|
||||
if (!hasPageRule) {
|
||||
const s = document.createElement('style');
|
||||
s.textContent = `@page { margin: 20mm; }`;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
});
|
||||
|
||||
// 6. Fix full-page cover sections for print
|
||||
// In screen mode, height:100vh = viewport height. In print mode, 100vh ≠ page height.
|
||||
// Detect elements using 100vh and convert to print-safe page-filling behavior.
|
||||
const coverFixed = await page.evaluate(() => {
|
||||
let fixed = 0;
|
||||
// Find elements with height: 100vh (inline or computed)
|
||||
const allEls = document.querySelectorAll('*');
|
||||
for (const el of allEls) {
|
||||
const style = el.style;
|
||||
const computed = getComputedStyle(el);
|
||||
const isVh = style.height === '100vh' || computed.height === '100vh' ||
|
||||
style.minHeight === '100vh' || computed.minHeight === '100vh';
|
||||
// Also detect via class name hints
|
||||
const isCover = el.classList.contains('cover') || el.classList.contains('cover-page') ||
|
||||
el.id === 'cover' || el.getAttribute('data-role') === 'cover';
|
||||
if (isVh || (isCover && el.offsetHeight > 0)) {
|
||||
// Force the element to fill the print page
|
||||
el.style.height = '100vh';
|
||||
el.style.minHeight = '100vh';
|
||||
el.style.pageBreakAfter = 'always';
|
||||
el.style.pageBreakInside = 'avoid';
|
||||
el.style.boxSizing = 'border-box';
|
||||
el.style.overflow = 'hidden';
|
||||
fixed++;
|
||||
}
|
||||
}
|
||||
// Inject print-specific CSS to make 100vh work correctly
|
||||
if (fixed > 0) {
|
||||
const s = document.createElement('style');
|
||||
s.textContent = `
|
||||
@media print {
|
||||
.cover, .cover-page, [data-role="cover"] {
|
||||
height: 100vh !important;
|
||||
min-height: 100vh !important;
|
||||
page-break-after: always !important;
|
||||
page-break-inside: avoid !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
return fixed;
|
||||
});
|
||||
if (coverFixed) {
|
||||
console.log(` ✓ Fixed ${coverFixed} full-page cover section(s) for print`);
|
||||
// Also inject named @page rule for cover with zero margins
|
||||
await page.evaluate(() => {
|
||||
const s = document.createElement('style');
|
||||
s.textContent = `
|
||||
@page cover-page {
|
||||
margin: 0 !important;
|
||||
}
|
||||
@media print {
|
||||
.cover, .cover-page, [data-role="cover"] {
|
||||
page: cover-page;
|
||||
margin: 0 !important;
|
||||
padding: 40px !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
return { warnings, contentHeight: vOverflowFix.finalHeight };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Content statistics (post-render, from PDF or page)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
async function collectStats(page) {
|
||||
return page.evaluate(() => {
|
||||
const body = document.body;
|
||||
const text = body.innerText || '';
|
||||
const zhChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length;
|
||||
const enWords = (text.match(/[a-zA-Z]+/g) || []).length;
|
||||
return {
|
||||
wordCount: zhChars + enWords,
|
||||
figures: document.querySelectorAll('figure,.figure,img').length,
|
||||
tables: document.querySelectorAll('table').length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// pdf-lib post-processing: page count, metadata, merge
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
async function postProcess(pdfPath, options = {}) {
|
||||
const { PDFDocument } = loadPdfLib();
|
||||
const pdfBytes = fs.readFileSync(pdfPath);
|
||||
const doc = await PDFDocument.load(pdfBytes);
|
||||
|
||||
// Set metadata
|
||||
if (options.title) doc.setTitle(options.title);
|
||||
doc.setProducer('html2pdf-next (Playwright + pdf-lib)');
|
||||
doc.setCreationDate(new Date());
|
||||
|
||||
const pageCount = doc.getPageCount();
|
||||
|
||||
// Merge additional PDFs
|
||||
if (options.mergeFiles && options.mergeFiles.length) {
|
||||
for (const mf of options.mergeFiles) {
|
||||
if (!fs.existsSync(mf)) {
|
||||
console.log(` ⚠ Merge file not found: ${mf}`);
|
||||
continue;
|
||||
}
|
||||
console.log(` 📎 Merging: ${path.basename(mf)}`);
|
||||
const donorBytes = fs.readFileSync(mf);
|
||||
const donorDoc = await PDFDocument.load(donorBytes);
|
||||
const copiedPages = await doc.copyPages(donorDoc, donorDoc.getPageIndices());
|
||||
copiedPages.forEach(p => doc.addPage(p));
|
||||
}
|
||||
}
|
||||
|
||||
// Save
|
||||
const finalBytes = await doc.save();
|
||||
fs.writeFileSync(pdfPath, finalBytes);
|
||||
|
||||
return { pageCount: doc.getPageCount(), originalPages: pageCount };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Main pipeline
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
async function convert(inputFile, outputFile, customCSS, options = {}) {
|
||||
const { width, height, mergeFiles, title } = options;
|
||||
|
||||
if (!fs.existsSync(inputFile)) {
|
||||
console.error(`✗ File not found: ${inputFile}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const playwright = loadPlaywright();
|
||||
const { chromium } = playwright;
|
||||
|
||||
// Resolve browser
|
||||
const canInstall = process.env.PDF_SKIP_BROWSER_INSTALL !== '1';
|
||||
const bInfo = resolveChromium(chromium, canInstall);
|
||||
|
||||
if (bInfo.status === 'missing') {
|
||||
console.error('\n✗ Chromium not found. Run: npx playwright install chromium\n');
|
||||
process.exit(2);
|
||||
}
|
||||
if (bInfo.status === 'fallback') {
|
||||
console.log(`⚠ Using fallback Chromium: ${bInfo.executablePath}`);
|
||||
}
|
||||
|
||||
const absIn = path.resolve(inputFile);
|
||||
const absOut = path.resolve(outputFile);
|
||||
|
||||
console.log(`\n🔄 Converting ${path.basename(inputFile)}...`);
|
||||
console.log(` Engine: Playwright + Chromium native @page (no Paged.js)`);
|
||||
|
||||
// Read and optionally inject CSS
|
||||
let html = fs.readFileSync(absIn, 'utf-8');
|
||||
if (customCSS) {
|
||||
if (!fs.existsSync(customCSS)) {
|
||||
console.error(`✗ CSS file not found: ${customCSS}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const tag = `<style>${fs.readFileSync(customCSS, 'utf-8')}</style>`;
|
||||
html = html.includes('</head>') ? html.replace('</head>', tag + '\n</head>') : tag + '\n' + html;
|
||||
// Write modified HTML for Playwright to load
|
||||
const tmpHtml = absIn + '.tmp.html';
|
||||
fs.writeFileSync(tmpHtml, html);
|
||||
// We'll clean up later
|
||||
}
|
||||
|
||||
// Launch browser
|
||||
let browser;
|
||||
try {
|
||||
const opts = { headless: true };
|
||||
if (bInfo.status === 'fallback') opts.executablePath = bInfo.executablePath;
|
||||
browser = await chromium.launch(opts);
|
||||
} catch (err) {
|
||||
const msg = err.message || '';
|
||||
if (msg.includes('shared libraries') || msg.includes('.so')) {
|
||||
console.error('\n✗ Missing system libraries. Run: npx playwright install-deps chromium\n');
|
||||
} else {
|
||||
console.error(`\n✗ Browser launch failed: ${msg}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
const loadFile = customCSS ? absIn + '.tmp.html' : absIn;
|
||||
await page.goto('file://' + loadFile, { waitUntil: 'networkidle' });
|
||||
|
||||
// ── Pre-render hooks ──
|
||||
console.log('\n📋 Pre-render checks:');
|
||||
const preRenderResult = await preRenderHooks(page);
|
||||
const warnings = preRenderResult.warnings;
|
||||
const measuredContentHeight = preRenderResult.contentHeight;
|
||||
|
||||
// ── Detect continuous-canvas mode (design_engine.py output) ──
|
||||
const continuousInfo = await page.evaluate(() => {
|
||||
const el = document.querySelector('.continuous-canvas');
|
||||
if (!el) return null;
|
||||
const root = getComputedStyle(document.documentElement);
|
||||
return {
|
||||
width: root.getPropertyValue('--canvas-w').trim() || '720px',
|
||||
height: root.getPropertyValue('--canvas-h').trim() || '960px',
|
||||
pages: el.querySelectorAll('.page-section').length,
|
||||
};
|
||||
});
|
||||
|
||||
if (continuousInfo) {
|
||||
// Creative PDF: seamless multi-page canvas
|
||||
console.log(`\n🎨 Continuous canvas: ${continuousInfo.pages} pages @ ${continuousInfo.width} × ${continuousInfo.height}`);
|
||||
await page.pdf({
|
||||
path: absOut,
|
||||
printBackground: true,
|
||||
margin: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||
width: continuousInfo.width,
|
||||
height: continuousInfo.height,
|
||||
});
|
||||
} else {
|
||||
// Standard document
|
||||
console.log('\n📄 Rendering PDF...');
|
||||
const pdfOpts = {
|
||||
path: absOut,
|
||||
printBackground: true,
|
||||
preferCSSPageSize: true,
|
||||
tagged: true,
|
||||
};
|
||||
|
||||
if (width || height) {
|
||||
if (width) pdfOpts.width = width;
|
||||
if (height) pdfOpts.height = height;
|
||||
pdfOpts.margin = { top: 0, right: 0, bottom: 0, left: 0 };
|
||||
console.log(` Custom size: ${pdfOpts.width || 'auto'} × ${pdfOpts.height || 'auto'}`);
|
||||
} else {
|
||||
// No explicit size: check if @page CSS defines a fixed size
|
||||
const pageSize = await page.evaluate(() => {
|
||||
const styles = Array.from(document.querySelectorAll('style'));
|
||||
for (const s of styles) {
|
||||
const text = s.textContent || '';
|
||||
const match = text.match(/@page\s*\{[^}]*size:\s*([\d.]+)px\s+([\d.]+)px/);
|
||||
if (match) return { width: parseFloat(match[1]), height: parseFloat(match[2]) };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
if (pageSize) {
|
||||
// @page defines a fixed size — use preferCSSPageSize (already set above).
|
||||
// Playwright will paginate content at @page height boundaries seamlessly.
|
||||
// This is correct for both posters (seamless multi-page) and documents.
|
||||
pdfOpts.margin = { top: 0, right: 0, bottom: 0, left: 0 };
|
||||
console.log(` @page size: ${pageSize.width}px × ${pageSize.height}px`);
|
||||
if (measuredContentHeight && measuredContentHeight > pageSize.height + 5) {
|
||||
const estPages = Math.ceil(measuredContentHeight / pageSize.height);
|
||||
console.log(` Content height: ${measuredContentHeight}px → ~${estPages} pages`);
|
||||
}
|
||||
} else {
|
||||
pdfOpts.format = 'A4';
|
||||
}
|
||||
}
|
||||
|
||||
await page.pdf(pdfOpts);
|
||||
}
|
||||
|
||||
// Collect content stats from the page
|
||||
const stats = await collectStats(page);
|
||||
|
||||
// ── pdf-lib post-processing ──
|
||||
console.log('\n🔧 Post-processing (pdf-lib):');
|
||||
const postResult = await postProcess(absOut, { mergeFiles, title });
|
||||
|
||||
// Clean up temp HTML
|
||||
const tmpHtml = absIn + '.tmp.html';
|
||||
if (fs.existsSync(tmpHtml)) fs.unlinkSync(tmpHtml);
|
||||
|
||||
// ── Report ──
|
||||
const sz = fs.statSync(absOut).size;
|
||||
console.log('\n' + '═'.repeat(40));
|
||||
console.log(' PDF Generated Successfully');
|
||||
console.log('═'.repeat(40));
|
||||
console.log(` File: ${path.basename(absOut)}`);
|
||||
console.log(` Pages: ${postResult.pageCount}`);
|
||||
console.log(` Size: ${prettyBytes(sz)}`);
|
||||
console.log(` Words: ~${stats.wordCount.toLocaleString()}`);
|
||||
console.log(` Assets: ${stats.figures} figures, ${stats.tables} tables`);
|
||||
console.log(` Engine: Playwright (no Paged.js)`);
|
||||
console.log(` Path: ${absOut}`);
|
||||
|
||||
if (mergeFiles && mergeFiles.length && postResult.pageCount > postResult.originalPages) {
|
||||
console.log(` Merged: +${postResult.pageCount - postResult.originalPages} pages from ${mergeFiles.length} file(s)`);
|
||||
}
|
||||
|
||||
if (warnings.length) {
|
||||
console.log('\n⚠ Warnings:');
|
||||
warnings.forEach(w => console.log(` · ${w}`));
|
||||
}
|
||||
|
||||
// Anomaly detection
|
||||
if (postResult.pageCount > 1 && stats.wordCount > 0) {
|
||||
const avgWordsPerPage = stats.wordCount / postResult.pageCount;
|
||||
if (avgWordsPerPage < 30) {
|
||||
console.log(`\n⚠ Low content density: ~${Math.round(avgWordsPerPage)} words/page (expected 100+)`);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('\n✗ Conversion failed:', err.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Entry
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const args = cli();
|
||||
await convert(args.inputFile, args.outputFile, args.customCSS, {
|
||||
width: args.width,
|
||||
height: args.height,
|
||||
mergeFiles: args.mergeFiles,
|
||||
title: args.title,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error:', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
256
skills/pdf/scripts/html2poster.js
Executable file
256
skills/pdf/scripts/html2poster.js
Executable file
@@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* html2poster.js — Single-page poster/long-image HTML → PDF converter
|
||||
*
|
||||
* Purpose: Convert a fixed-width, dynamic-height HTML poster into a single-page
|
||||
* vector PDF with zero margins. This script is PURPOSE-BUILT for posters and
|
||||
* infographics — it does NOT handle multi-page documents, A4 pagination, or
|
||||
* document-style margins. For those, use html2pdf-next.js.
|
||||
*
|
||||
* Usage:
|
||||
* node html2poster.js poster.html
|
||||
* node html2poster.js poster.html --output out.pdf
|
||||
* node html2poster.js poster.html --width 720px
|
||||
* node html2poster.js poster.html --width 720px --max-height 8000
|
||||
*
|
||||
* What it does (in order):
|
||||
* 1. Load HTML in Playwright
|
||||
* 2. Force overflow:hidden on .poster/.page containers (clip decorative overflow)
|
||||
* 3. Inject @page { margin: 0 } (override any existing margin)
|
||||
* 4. Ensure html/body have margin:0, padding:0, matching background
|
||||
* 5. Measure .poster scrollHeight (actual content height)
|
||||
* 6. Generate single-page PDF with exact dimensions
|
||||
*
|
||||
* What it does NOT do:
|
||||
* - No pagination / page breaks
|
||||
* - No A4 fallback
|
||||
* - No margin injection (always zero)
|
||||
* - No cover adaptation
|
||||
* - No pdf-lib post-processing
|
||||
* - No continuous-canvas detection
|
||||
* - No vertical overflow expansion (posters WANT overflow:hidden)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
// ── Chromium resolution (shared logic with html2pdf-next.js) ──
|
||||
|
||||
function resolveChromium(chromiumObj) {
|
||||
let exe;
|
||||
try { exe = chromiumObj.executablePath(); } catch (_) { exe = null; }
|
||||
if (exe && fs.existsSync(exe)) return { status: 'ok', executablePath: exe };
|
||||
|
||||
const candidates = [
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
'/usr/bin/chromium-browser', '/usr/bin/chromium', '/usr/bin/google-chrome',
|
||||
];
|
||||
if (process.env.PLAYWRIGHT_CHROMIUM_PATH) candidates.unshift(process.env.PLAYWRIGHT_CHROMIUM_PATH);
|
||||
|
||||
for (const c of candidates) {
|
||||
if (fs.existsSync(c)) return { status: 'fallback', executablePath: c };
|
||||
}
|
||||
return { status: 'missing', executablePath: exe || '' };
|
||||
}
|
||||
|
||||
// ── CLI parsing ──
|
||||
|
||||
function parseArgs(argv) {
|
||||
const tokens = argv.slice(2);
|
||||
let input = null, output = null, width = '720px', maxHeight = 16000;
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const t = tokens[i];
|
||||
if (t === '--output' || t === '-o') output = tokens[++i];
|
||||
else if (t === '--width') width = tokens[++i];
|
||||
else if (t === '--max-height') maxHeight = parseInt(tokens[++i], 10);
|
||||
else if (t === '--help' || t === '-h') {
|
||||
console.log(`
|
||||
Usage: node html2poster.js <input.html> [options]
|
||||
|
||||
Options:
|
||||
--output, -o Output PDF path (default: input with .pdf extension)
|
||||
--width Poster width (default: 720px)
|
||||
--max-height Maximum allowed height in px (default: 16000, safety limit)
|
||||
-h, --help Show this help
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
else if (!input) input = t;
|
||||
else if (!output) output = t;
|
||||
}
|
||||
|
||||
if (!input) {
|
||||
console.error('Error: No input HTML file specified.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!output) {
|
||||
output = input.replace(/\.html?$/i, '.pdf');
|
||||
if (output === input) output = input + '.pdf';
|
||||
}
|
||||
|
||||
return { input, output, width, maxHeight };
|
||||
}
|
||||
|
||||
// ── Main ──
|
||||
|
||||
async function main() {
|
||||
const { input, output, width, maxHeight } = parseArgs(process.argv);
|
||||
const absIn = path.resolve(input);
|
||||
const absOut = path.resolve(output);
|
||||
|
||||
if (!fs.existsSync(absIn)) {
|
||||
console.error(`Error: File not found: ${absIn}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n🖼 html2poster — Single-page poster PDF generator`);
|
||||
console.log(` Input: ${absIn}`);
|
||||
console.log(` Output: ${absOut}`);
|
||||
console.log(` Width: ${width}`);
|
||||
|
||||
// Load Playwright
|
||||
let playwright;
|
||||
try {
|
||||
playwright = require('playwright');
|
||||
} catch {
|
||||
try {
|
||||
playwright = require('playwright-core');
|
||||
} catch {
|
||||
console.error('Error: playwright or playwright-core not installed.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const { chromium } = playwright;
|
||||
const bInfo = resolveChromium(chromium);
|
||||
|
||||
if (bInfo.status === 'missing') {
|
||||
console.error('Error: No Chromium found. Run: npx playwright install chromium');
|
||||
process.exit(1);
|
||||
}
|
||||
if (bInfo.status === 'fallback') {
|
||||
console.log(` ⚠ Using fallback Chromium: ${bInfo.executablePath}`);
|
||||
}
|
||||
|
||||
// Launch browser
|
||||
const launchOpts = { headless: true };
|
||||
if (bInfo.status === 'fallback') launchOpts.executablePath = bInfo.executablePath;
|
||||
|
||||
const browser = await chromium.launch(launchOpts);
|
||||
|
||||
try {
|
||||
// Use a wide viewport so content doesn't wrap unexpectedly
|
||||
const widthPx = parseInt(width, 10) || 720;
|
||||
const page = await browser.newPage({ viewport: { width: widthPx, height: 1200 } });
|
||||
|
||||
await page.goto('file://' + absIn, { waitUntil: 'networkidle' });
|
||||
console.log(`\n ✓ HTML loaded`);
|
||||
|
||||
// ── Step 1: Force overflow:hidden on page containers ──
|
||||
// Decorative elements with negative offsets or width>100% inflate scrollWidth,
|
||||
// causing Playwright to shrink content to fit. overflow:hidden clips them.
|
||||
const overflowFixed = await page.evaluate(() => {
|
||||
const selectors = ['.poster', '.page', '#poster', '#page'];
|
||||
let fixed = 0;
|
||||
for (const sel of selectors) {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) continue;
|
||||
const computed = getComputedStyle(el);
|
||||
if (computed.overflow !== 'hidden') {
|
||||
el.style.overflow = 'hidden';
|
||||
fixed++;
|
||||
}
|
||||
}
|
||||
return fixed;
|
||||
});
|
||||
if (overflowFixed > 0) {
|
||||
console.log(` ✓ Added overflow:hidden to ${overflowFixed} container(s)`);
|
||||
}
|
||||
|
||||
// ── Step 2: Inject @page { margin: 0 } — override any existing @page rule ──
|
||||
await page.evaluate(() => {
|
||||
const s = document.createElement('style');
|
||||
// Use !important-equivalent: place at end so it wins cascade
|
||||
s.textContent = `@page { margin: 0 !important; size: auto; }`;
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
|
||||
// ── Step 3: Ensure html/body have zero margin/padding ──
|
||||
const bgSync = await page.evaluate(() => {
|
||||
const html = document.documentElement;
|
||||
const body = document.body;
|
||||
html.style.margin = '0';
|
||||
html.style.padding = '0';
|
||||
body.style.margin = '0';
|
||||
body.style.padding = '0';
|
||||
|
||||
// Sync body background with poster background to avoid color gaps
|
||||
const poster = document.querySelector('.poster') || document.querySelector('.page');
|
||||
if (poster) {
|
||||
const posterBg = getComputedStyle(poster).backgroundColor;
|
||||
if (posterBg && posterBg !== 'rgba(0, 0, 0, 0)' && posterBg !== 'transparent') {
|
||||
body.style.backgroundColor = posterBg;
|
||||
html.style.backgroundColor = posterBg;
|
||||
return posterBg;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
if (bgSync) {
|
||||
console.log(` ✓ Synced body background: ${bgSync}`);
|
||||
}
|
||||
|
||||
// ── Step 4: Measure actual content height ──
|
||||
const measurement = await page.evaluate(() => {
|
||||
const poster = document.querySelector('.poster') || document.querySelector('.page') || document.body;
|
||||
return {
|
||||
scrollHeight: poster.scrollHeight,
|
||||
scrollWidth: poster.scrollWidth,
|
||||
offsetWidth: poster.offsetWidth,
|
||||
selector: poster.className ? '.' + poster.className.split(' ')[0] : poster.tagName,
|
||||
};
|
||||
});
|
||||
|
||||
console.log(` ✓ Measured: ${measurement.selector} = ${measurement.scrollWidth}×${measurement.scrollHeight}px`);
|
||||
|
||||
if (measurement.scrollWidth > widthPx + 2) {
|
||||
console.log(` ⚠ WARNING: scrollWidth (${measurement.scrollWidth}px) > width (${widthPx}px)`);
|
||||
console.log(` Decorative elements may still overflow. Check for position:absolute elements with negative offsets.`);
|
||||
}
|
||||
|
||||
let contentHeight = measurement.scrollHeight;
|
||||
if (contentHeight > maxHeight) {
|
||||
console.log(` ⚠ Content height ${contentHeight}px exceeds max ${maxHeight}px, clamping.`);
|
||||
contentHeight = maxHeight;
|
||||
}
|
||||
if (contentHeight < 100) {
|
||||
console.log(` ⚠ Content height ${contentHeight}px seems too small, using 960px fallback.`);
|
||||
contentHeight = 960;
|
||||
}
|
||||
|
||||
// ── Step 5: Generate PDF ──
|
||||
console.log(`\n 📄 Generating PDF: ${width} × ${contentHeight}px`);
|
||||
await page.pdf({
|
||||
path: absOut,
|
||||
width: width,
|
||||
height: contentHeight + 'px',
|
||||
printBackground: true,
|
||||
margin: { top: '0', right: '0', bottom: '0', left: '0' },
|
||||
});
|
||||
|
||||
console.log(`\n ✅ Done: ${absOut}`);
|
||||
console.log(` Size: ${(fs.statSync(absOut).size / 1024).toFixed(1)} KB`);
|
||||
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(`\n✗ Fatal: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
2959
skills/pdf/scripts/pdf.py
Executable file
2959
skills/pdf/scripts/pdf.py
Executable file
File diff suppressed because it is too large
Load Diff
901
skills/pdf/scripts/pdf_qa.py
Executable file
901
skills/pdf/scripts/pdf_qa.py
Executable file
@@ -0,0 +1,901 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PDF Quality Assurance Checker
|
||||
=============================
|
||||
Automatically detects common typesetting issues in PDFs.
|
||||
|
||||
Usage: python3 pdf_qa.py <pdf_path>
|
||||
|
||||
Checks:
|
||||
1. Page size consistency across all pages
|
||||
2. Blank page detection
|
||||
3. CJK punctuation placement (line-start/end forbidden punctuation)
|
||||
4. Color analysis (informational only — counts and lists colors)
|
||||
5. Font embedding check (warns on non-embedded fonts)
|
||||
6. PDF metadata check (title/author/creator)
|
||||
7. Content overflow detection (text exceeding page boundaries)
|
||||
8. Content fill ratio per page (multi-page docs, warns if < 40%)
|
||||
9. Cover/poster full-bleed check (background extends to page edges)
|
||||
10. Margin symmetry check (left/right text margins)
|
||||
11. Table centering check (if detected)
|
||||
12. Formula overflow check (optional)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
from collections import Counter
|
||||
|
||||
try:
|
||||
import pymupdf # PyMuPDF
|
||||
except ImportError:
|
||||
import fitz as pymupdf
|
||||
|
||||
# ============================================================
|
||||
# Config
|
||||
# ============================================================
|
||||
|
||||
# CJK punctuation forbidden at line start
|
||||
LINE_START_FORBIDDEN = set(
|
||||
"。、,;:!?)】〛〉」』"
|
||||
"\u201c\u201d" # "" curly double quotes
|
||||
"\u2026" # … ellipsis
|
||||
"\u2014" # — em dash
|
||||
"\uff5e" # ~ fullwidth tilde
|
||||
"\u00b7" # · middle dot
|
||||
)
|
||||
|
||||
# CJK punctuation forbidden at line end
|
||||
LINE_END_FORBIDDEN = set(
|
||||
"(【《〈「"
|
||||
"\u2018\u2019" # '' curly single quotes
|
||||
"\u201c" # " left curly double quote
|
||||
)
|
||||
|
||||
# Minimum fill ratio for last page (DISABLED — caused false positives)
|
||||
# LAST_PAGE_MIN_FILL = 0.40
|
||||
|
||||
# Maximum allowed color count — REMOVED (color count is now info-only)
|
||||
# MAX_COLORS = 8
|
||||
|
||||
# ============================================================
|
||||
# Checks
|
||||
# ============================================================
|
||||
|
||||
class QAResult:
|
||||
def __init__(self):
|
||||
self.issues = [] # (severity, category, message)
|
||||
self.passes = [] # passed checks
|
||||
self.info = [] # informational
|
||||
|
||||
def error(self, cat, msg):
|
||||
self.issues.append(('ERROR', cat, msg))
|
||||
|
||||
def warn(self, cat, msg):
|
||||
self.issues.append(('WARN', cat, msg))
|
||||
|
||||
def ok(self, msg):
|
||||
self.passes.append(msg)
|
||||
|
||||
def add_info(self, msg):
|
||||
self.info.append(msg)
|
||||
|
||||
|
||||
def check_last_page_fill(doc, result):
|
||||
"""Check content fill ratio of the last page"""
|
||||
if len(doc) < 2:
|
||||
result.ok("Single-page document, no last-page blank check needed")
|
||||
return
|
||||
|
||||
last_page = doc[-1]
|
||||
page_rect = last_page.rect
|
||||
page_area = page_rect.width * page_rect.height
|
||||
|
||||
# Get bounding boxes of all content on last page
|
||||
blocks = last_page.get_text("blocks")
|
||||
if not blocks:
|
||||
result.error("Last page blank", f"Page {len(doc)} (last page) has no content at all!")
|
||||
return
|
||||
|
||||
# Calculate max y-coordinate covered by content
|
||||
max_y = 0
|
||||
min_y = page_rect.height
|
||||
for b in blocks:
|
||||
if b[4].strip(): # Has text content
|
||||
min_y = min(min_y, b[1])
|
||||
max_y = max(max_y, b[3])
|
||||
|
||||
if max_y == 0:
|
||||
result.error("Last page blank", f"Page {len(doc)} (last page) has no valid text content")
|
||||
return
|
||||
|
||||
content_height = max_y - min_y
|
||||
fill_ratio = content_height / page_rect.height
|
||||
|
||||
result.add_info(f"Last page fill ratio: {fill_ratio:.0%} (content height {content_height:.0f}px / page height {page_rect.height:.0f}px)")
|
||||
|
||||
if fill_ratio < 0.25:
|
||||
result.error("Last page blank", f"Last page fill ratio only {fill_ratio:.0%}, mostly blank! Consider compressing preceding page spacing or trimming content")
|
||||
elif fill_ratio < LAST_PAGE_MIN_FILL:
|
||||
result.warn("Last page blank", f"Last page fill ratio {fill_ratio:.0%}, somewhat sparse — optimization recommended")
|
||||
else:
|
||||
result.ok(f"Last page fill ratio {fill_ratio:.0%} ✓")
|
||||
|
||||
|
||||
def check_punctuation(doc, result):
|
||||
"""Check CJK punctuation placement rules"""
|
||||
violations = []
|
||||
|
||||
for page_num in range(len(doc)):
|
||||
page = doc[page_num]
|
||||
# Extract text by line
|
||||
text_dict = page.get_text("dict")
|
||||
|
||||
for block in text_dict.get("blocks", []):
|
||||
if block.get("type") != 0: # Only check text blocks
|
||||
continue
|
||||
for line in block.get("lines", []):
|
||||
line_text = ""
|
||||
for span in line.get("spans", []):
|
||||
line_text += span.get("text", "")
|
||||
|
||||
line_text = line_text.strip()
|
||||
if not line_text:
|
||||
continue
|
||||
|
||||
# Check line start
|
||||
first_char = line_text[0]
|
||||
if first_char in LINE_START_FORBIDDEN:
|
||||
violations.append((page_num + 1, f"Forbidden line-start punctuation '{first_char}': ...{line_text[:30]}"))
|
||||
|
||||
# Check line end
|
||||
last_char = line_text[-1] if len(line_text) > 0 else ''
|
||||
if last_char in LINE_END_FORBIDDEN:
|
||||
violations.append((page_num + 1, f"Forbidden line-end punctuation '{last_char}': {line_text[-30:]}..."))
|
||||
|
||||
if violations:
|
||||
# Show at most 10
|
||||
shown = violations[:10]
|
||||
for page_num, desc in shown:
|
||||
result.warn("Punctuation rules", f"Page {page_num} - {desc}")
|
||||
if len(violations) > 10:
|
||||
result.warn("Punctuation rules", f"...{len(violations) - 10} more violations")
|
||||
else:
|
||||
result.ok("Punctuation placement check passed ✓")
|
||||
|
||||
|
||||
def check_blank_pages(doc, result):
|
||||
"""Check for completely blank pages"""
|
||||
blank_pages = []
|
||||
for i in range(len(doc)):
|
||||
page = doc[i]
|
||||
text = page.get_text().strip()
|
||||
# Also check for images
|
||||
images = page.get_images()
|
||||
drawings = page.get_drawings()
|
||||
|
||||
if not text and not images and not drawings:
|
||||
blank_pages.append(i + 1)
|
||||
|
||||
if blank_pages:
|
||||
result.error("Blank pages", f"Found blank pages: {blank_pages}")
|
||||
else:
|
||||
result.ok("No blank pages ✓")
|
||||
|
||||
|
||||
def check_colors(doc, result):
|
||||
"""Analyze colors used in the document (informational only, no pass/fail)"""
|
||||
colors = set()
|
||||
|
||||
for page_num in range(len(doc)):
|
||||
page = doc[page_num]
|
||||
text_dict = page.get_text("dict")
|
||||
|
||||
for block in text_dict.get("blocks", []):
|
||||
if block.get("type") != 0:
|
||||
continue
|
||||
for line in block.get("lines", []):
|
||||
for span in line.get("spans", []):
|
||||
color = span.get("color", 0)
|
||||
if color != 0: # Exclude pure black
|
||||
r = (color >> 16) & 0xFF
|
||||
g = (color >> 8) & 0xFF
|
||||
b = color & 0xFF
|
||||
hex_color = f"#{r:02x}{g:02x}{b:02x}"
|
||||
colors.add(hex_color)
|
||||
|
||||
# Check drawing colors
|
||||
drawings = page.get_drawings()
|
||||
for d in drawings:
|
||||
if d.get("color"):
|
||||
c = d["color"]
|
||||
if isinstance(c, (tuple, list)) and len(c) >= 3:
|
||||
hex_color = f"#{int(c[0]*255):02x}{int(c[1]*255):02x}{int(c[2]*255):02x}"
|
||||
colors.add(hex_color)
|
||||
if d.get("fill"):
|
||||
c = d["fill"]
|
||||
if isinstance(c, (tuple, list)) and len(c) >= 3:
|
||||
hex_color = f"#{int(c[0]*255):02x}{int(c[1]*255):02x}{int(c[2]*255):02x}"
|
||||
colors.add(hex_color)
|
||||
|
||||
# Filter out near-black/white/gray colors
|
||||
distinct_colors = []
|
||||
for c in colors:
|
||||
r = int(c[1:3], 16)
|
||||
g = int(c[3:5], 16)
|
||||
b = int(c[5:7], 16)
|
||||
max_diff = max(abs(r-g), abs(g-b), abs(r-b))
|
||||
if max_diff > 20:
|
||||
distinct_colors.append(c)
|
||||
|
||||
result.add_info(f"Total text colors: {len(colors)} (chromatic: {len(distinct_colors)})")
|
||||
|
||||
if distinct_colors:
|
||||
result.add_info(f"Chromatic colors: {', '.join(sorted(distinct_colors)[:10])}")
|
||||
|
||||
|
||||
def check_page_size_consistency(doc, result):
|
||||
"""Check whether all page sizes are consistent"""
|
||||
if len(doc) < 2:
|
||||
result.ok("Single-page document, size consistent ✓")
|
||||
return
|
||||
|
||||
sizes = set()
|
||||
for i in range(len(doc)):
|
||||
page = doc[i]
|
||||
w = round(page.rect.width, 1)
|
||||
h = round(page.rect.height, 1)
|
||||
sizes.add((w, h))
|
||||
|
||||
if len(sizes) > 1:
|
||||
result.warn("Page size", f"Inconsistent page sizes: {sizes}")
|
||||
else:
|
||||
size = list(sizes)[0]
|
||||
# Convert to mm
|
||||
w_mm = size[0] * 25.4 / 72
|
||||
h_mm = size[1] * 25.4 / 72
|
||||
result.add_info(f"Page size: {w_mm:.0f}mm × {h_mm:.0f}mm ({len(doc)} pages)")
|
||||
result.ok("Page size consistent ✓")
|
||||
|
||||
|
||||
def check_text_overflow(doc, result):
|
||||
"""Check whether text overflows page boundaries"""
|
||||
overflow_pages = []
|
||||
|
||||
for i in range(len(doc)):
|
||||
page = doc[i]
|
||||
rect = page.rect
|
||||
blocks = page.get_text("blocks")
|
||||
|
||||
for b in blocks:
|
||||
# b = (x0, y0, x1, y1, text, block_no, block_type)
|
||||
if b[2] > rect.width + 2 or b[3] > rect.height + 2: # 2px tolerance
|
||||
overflow_pages.append(i + 1)
|
||||
break
|
||||
if b[0] < -2 or b[1] < -2:
|
||||
overflow_pages.append(i + 1)
|
||||
break
|
||||
|
||||
if overflow_pages:
|
||||
result.warn("Content overflow", f"Pages {overflow_pages} may have content exceeding page boundaries")
|
||||
else:
|
||||
result.ok("No content overflow ✓")
|
||||
|
||||
|
||||
def check_content_fill_ratio(doc, result):
|
||||
"""Check content fill ratio per page — warns when content is crammed at top leaving large void below.
|
||||
|
||||
Rules:
|
||||
- Skip single-page documents (may be intentional design)
|
||||
- Skip page 1 (usually cover with intentional whitespace)
|
||||
- Middle pages: warn if fill ratio < 40%
|
||||
- Last page: warn if fill ratio < 25% (naturally has less content)
|
||||
"""
|
||||
if len(doc) < 2:
|
||||
result.ok("Single-page document, skipping content fill ratio check ✓")
|
||||
return
|
||||
|
||||
low_fill_pages = []
|
||||
|
||||
for i in range(len(doc)):
|
||||
page = doc[i]
|
||||
page_rect = page.rect
|
||||
page_height = page_rect.height
|
||||
|
||||
# Skip page 1 (cover)
|
||||
if i == 0:
|
||||
continue
|
||||
|
||||
blocks = page.get_text("blocks")
|
||||
images = page.get_images()
|
||||
drawings = page.get_drawings()
|
||||
|
||||
if not blocks and not images and not drawings:
|
||||
continue # Blank page check handles this
|
||||
|
||||
# Calculate content bbox
|
||||
max_y = 0
|
||||
for b in blocks:
|
||||
if b[4].strip():
|
||||
max_y = max(max_y, b[3])
|
||||
|
||||
# Include images in bbox
|
||||
for img in images:
|
||||
try:
|
||||
img_rects = page.get_image_rects(img[0])
|
||||
for r in img_rects:
|
||||
max_y = max(max_y, r.y1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if max_y == 0:
|
||||
continue
|
||||
|
||||
fill_ratio = max_y / page_height
|
||||
is_last = (i == len(doc) - 1)
|
||||
threshold = 0.25 if is_last else 0.40
|
||||
|
||||
if fill_ratio < threshold:
|
||||
low_fill_pages.append((i + 1, fill_ratio, threshold))
|
||||
|
||||
if low_fill_pages:
|
||||
for pg, ratio, thresh in low_fill_pages:
|
||||
result.warn(
|
||||
"Content fill ratio",
|
||||
f"Page {pg} content only fills {ratio:.0%} of page height "
|
||||
f"(threshold: {thresh:.0%}). Content may be crammed at the top "
|
||||
f"with a large blank area below."
|
||||
)
|
||||
else:
|
||||
result.ok("Content fill ratio adequate on all pages ✓")
|
||||
|
||||
|
||||
def check_cover_bleed(doc, result, poster=False):
|
||||
"""Check if the cover page (page 1) fills the entire page area (full-bleed).
|
||||
|
||||
A properly designed cover should have background color/graphics extending
|
||||
to the page edges. If the content bbox has significant margins on all sides,
|
||||
the cover likely wasn't rendered full-bleed (e.g. ReportLab with default margins).
|
||||
|
||||
For poster mode: checks ALL pages (not just the cover) since every page of a
|
||||
seamlessly-paginated poster should have consistent background fill.
|
||||
|
||||
Strategy: combine bounding boxes of drawings (rects, paths), images, and colored
|
||||
backgrounds. If the union bbox leaves > 5% margin on any side, warn.
|
||||
"""
|
||||
if not poster and len(doc) < 2:
|
||||
# Single page doc (non-poster) — not necessarily a cover scenario
|
||||
return
|
||||
|
||||
pages_to_check = range(len(doc)) if poster else [0]
|
||||
|
||||
for page_idx in pages_to_check:
|
||||
page = doc[page_idx]
|
||||
page_rect = page.rect
|
||||
pw, ph = page_rect.width, page_rect.height
|
||||
|
||||
# Collect all content bounding boxes
|
||||
min_x, min_y = pw, ph
|
||||
max_x, max_y = 0.0, 0.0
|
||||
has_content = False
|
||||
|
||||
# 1. Drawings (vector paths, rectangles — typical for colored backgrounds)
|
||||
for d in page.get_drawings():
|
||||
r = d.get("rect")
|
||||
if r:
|
||||
min_x = min(min_x, r.x0)
|
||||
min_y = min(min_y, r.y0)
|
||||
max_x = max(max_x, r.x1)
|
||||
max_y = max(max_y, r.y1)
|
||||
has_content = True
|
||||
|
||||
# 2. Images
|
||||
for img in page.get_images():
|
||||
try:
|
||||
for r in page.get_image_rects(img[0]):
|
||||
min_x = min(min_x, r.x0)
|
||||
min_y = min(min_y, r.y0)
|
||||
max_x = max(max_x, r.x1)
|
||||
max_y = max(max_y, r.y1)
|
||||
has_content = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
page_label = f"Page {page_idx + 1}" if poster else "Cover page (p1)"
|
||||
|
||||
if not has_content:
|
||||
blocks = page.get_text("blocks")
|
||||
if blocks:
|
||||
result.warn(
|
||||
f"{page_label} not full-bleed",
|
||||
f"{page_label} has no background graphics (no filled rectangles or images). "
|
||||
"A proper cover/poster page should have a full-page background color or image "
|
||||
"extending to all edges."
|
||||
)
|
||||
continue
|
||||
|
||||
# Calculate margin ratios (how far content is from page edges)
|
||||
margin_left = max(0, min_x) / pw
|
||||
margin_top = max(0, min_y) / ph
|
||||
margin_right = max(0, pw - max_x) / pw
|
||||
margin_bottom = max(0, ph - max_y) / ph
|
||||
|
||||
threshold = 0.05
|
||||
margins_ok = (margin_left <= threshold and margin_top <= threshold and
|
||||
margin_right <= threshold and margin_bottom <= threshold)
|
||||
|
||||
if margins_ok:
|
||||
result.ok(f"{page_label} content extends to page edges (full-bleed) ✓")
|
||||
else:
|
||||
sides = []
|
||||
if margin_left > threshold:
|
||||
sides.append(f"left {margin_left:.0%}")
|
||||
if margin_top > threshold:
|
||||
sides.append(f"top {margin_top:.0%}")
|
||||
if margin_right > threshold:
|
||||
sides.append(f"right {margin_right:.0%}")
|
||||
if margin_bottom > threshold:
|
||||
sides.append(f"bottom {margin_bottom:.0%}")
|
||||
result.warn(
|
||||
f"{page_label} not full-bleed",
|
||||
f"{page_label} has visible margins: {', '.join(sides)}. "
|
||||
f"Background/graphics should extend to page edges."
|
||||
)
|
||||
|
||||
|
||||
def check_margin_symmetry(doc, result, skip_cover=False):
|
||||
"""Check left/right margin symmetry using text block bounds."""
|
||||
warn_pages = []
|
||||
|
||||
for page_num in range(len(doc)):
|
||||
if skip_cover and page_num == 0:
|
||||
continue
|
||||
|
||||
page = doc[page_num]
|
||||
blocks = page.get_text("blocks")
|
||||
text_blocks = [b for b in blocks if b[4].strip()]
|
||||
|
||||
if len(text_blocks) < 3:
|
||||
continue # Skip decorative/cover-like pages
|
||||
|
||||
left_margin = min(b[0] for b in text_blocks)
|
||||
right_margin = page.rect.width - max(b[2] for b in text_blocks)
|
||||
diff = abs(left_margin - right_margin)
|
||||
|
||||
if diff > page.rect.width * 0.05:
|
||||
warn_pages.append((page_num + 1, left_margin, right_margin, diff))
|
||||
|
||||
if warn_pages:
|
||||
for pg, left, right, diff in warn_pages:
|
||||
result.warn(
|
||||
"Margin symmetry",
|
||||
f"Page {pg} left/right margins differ by {diff:.0f}pt "
|
||||
f"(L {left:.0f}pt, R {right:.0f}pt)"
|
||||
)
|
||||
else:
|
||||
result.ok("Left/right margins appear symmetric \u2713")
|
||||
|
||||
|
||||
def check_table_centering(doc, result):
|
||||
"""Check if detected table regions are centered."""
|
||||
def _bbox_intersects(a, b, tol=6):
|
||||
return not (a[2] < b[0] - tol or a[0] > b[2] + tol or
|
||||
a[3] < b[1] - tol or a[1] > b[3] + tol)
|
||||
|
||||
def _rect_tuple(r):
|
||||
if hasattr(r, "x0"):
|
||||
return (r.x0, r.y0, r.x1, r.y1)
|
||||
return (r[0], r[1], r[2], r[3])
|
||||
|
||||
any_tables = False
|
||||
|
||||
for page_num in range(len(doc)):
|
||||
page = doc[page_num]
|
||||
drawings = page.get_drawings()
|
||||
segments = []
|
||||
|
||||
for d in drawings:
|
||||
for item in d.get("items", []):
|
||||
if not item:
|
||||
continue
|
||||
op = item[0]
|
||||
if op == "l" and len(item) >= 3:
|
||||
p0, p1 = item[1], item[2]
|
||||
segments.append((p0[0], p0[1], p1[0], p1[1]))
|
||||
elif op == "re" and len(item) >= 2:
|
||||
x0, y0, x1, y1 = _rect_tuple(item[1])
|
||||
segments.extend([
|
||||
(x0, y0, x1, y0),
|
||||
(x0, y1, x1, y1),
|
||||
(x0, y0, x0, y1),
|
||||
(x1, y0, x1, y1),
|
||||
])
|
||||
|
||||
if not segments:
|
||||
continue
|
||||
|
||||
cluster_list = []
|
||||
for x0, y0, x1, y1 in segments:
|
||||
min_x, max_x = min(x0, x1), max(x0, x1)
|
||||
min_y, max_y = min(y0, y1), max(y0, y1)
|
||||
bbox = (min_x, min_y, max_x, max_y)
|
||||
is_h = abs(y0 - y1) < 1 and (max_x - min_x) > 20
|
||||
is_v = abs(x0 - x1) < 1 and (max_y - min_y) > 20
|
||||
if not is_h and not is_v:
|
||||
continue
|
||||
|
||||
placed = False
|
||||
for cl in cluster_list:
|
||||
if _bbox_intersects(bbox, cl["bbox"]):
|
||||
cl["segments"].append((x0, y0, x1, y1, is_h, is_v))
|
||||
cl["bbox"] = (
|
||||
min(cl["bbox"][0], bbox[0]),
|
||||
min(cl["bbox"][1], bbox[1]),
|
||||
max(cl["bbox"][2], bbox[2]),
|
||||
max(cl["bbox"][3], bbox[3]),
|
||||
)
|
||||
if is_h:
|
||||
cl["h"] += 1
|
||||
if is_v:
|
||||
cl["v"] += 1
|
||||
placed = True
|
||||
break
|
||||
if not placed:
|
||||
cluster_list.append({
|
||||
"bbox": bbox,
|
||||
"segments": [(x0, y0, x1, y1, is_h, is_v)],
|
||||
"h": 1 if is_h else 0,
|
||||
"v": 1 if is_v else 0,
|
||||
})
|
||||
|
||||
for cl in cluster_list:
|
||||
if cl["h"] < 2 or cl["v"] < 2:
|
||||
continue
|
||||
any_tables = True
|
||||
bbox = cl["bbox"]
|
||||
page_width = page.rect.width
|
||||
left_margin = bbox[0]
|
||||
right_margin = page_width - bbox[2]
|
||||
if abs(left_margin - right_margin) > page_width * 0.05:
|
||||
result.warn(
|
||||
"Table centering",
|
||||
f"Page {page_num + 1}: Table not centered "
|
||||
f"(L {left_margin:.0f}pt, R {right_margin:.0f}pt)"
|
||||
)
|
||||
|
||||
if any_tables:
|
||||
result.ok("Table centering check complete \u2713")
|
||||
|
||||
|
||||
def check_font_embedding(doc, result):
|
||||
"""Check font embedding status using PyMuPDF font list."""
|
||||
fonts_used = set()
|
||||
non_embedded = set()
|
||||
|
||||
for page_num in range(len(doc)):
|
||||
page = doc[page_num]
|
||||
for font in page.get_fonts():
|
||||
basefont = font[3] if len(font) > 3 else "unknown"
|
||||
ext = font[1] if len(font) > 1 else ""
|
||||
fonts_used.add(basefont)
|
||||
if not ext:
|
||||
non_embedded.add(basefont)
|
||||
|
||||
if fonts_used:
|
||||
result.add_info(f"Fonts used: {', '.join(sorted(fonts_used))}")
|
||||
else:
|
||||
result.add_info("Fonts used: (none detected)")
|
||||
|
||||
if non_embedded:
|
||||
for basefont in sorted(non_embedded):
|
||||
result.warn(
|
||||
"Font embedding",
|
||||
f"Font {basefont} is not embedded. May display differently on other systems."
|
||||
)
|
||||
else:
|
||||
result.ok("All fonts are embedded \u2713")
|
||||
|
||||
|
||||
def check_helvetica_in_cjk(doc, result):
|
||||
"""Detect Helvetica rendering visible text in documents containing CJK text.
|
||||
|
||||
Helvetica is a Latin-only built-in PDF font. When it appears rendering
|
||||
actual text content in a CJK document, it almost always means a raw string
|
||||
was passed to a ReportLab Table or flowable without wrapping it in
|
||||
Paragraph() with a CJK font. The CJK characters rendered via Helvetica
|
||||
become garbled (fall back to ZapfDingbats symbols).
|
||||
|
||||
We only check Helvetica (not ZapfDingbats) because ZapfDingbats is
|
||||
legitimately used for bullet symbols in list items.
|
||||
|
||||
We check actual rendered text spans (not just font presence in font list)
|
||||
because ReportLab internally registers Helvetica on every page even when
|
||||
only CJK fonts are used in visible content.
|
||||
"""
|
||||
has_cjk = False
|
||||
helvetica_pages = []
|
||||
|
||||
for page_num in range(len(doc)):
|
||||
page = doc[page_num]
|
||||
text = page.get_text("text") or ""
|
||||
|
||||
# Check if document contains CJK characters
|
||||
if not has_cjk:
|
||||
for ch in text:
|
||||
if '\u4e00' <= ch <= '\u9fff' or '\u3400' <= ch <= '\u4dbf':
|
||||
has_cjk = True
|
||||
break
|
||||
|
||||
# Check if Helvetica is actually used to render visible text on this page
|
||||
blocks = page.get_text("dict", sort=True).get("blocks", [])
|
||||
found_on_page = False
|
||||
for block in blocks:
|
||||
if found_on_page:
|
||||
break
|
||||
for line in block.get("lines", []):
|
||||
if found_on_page:
|
||||
break
|
||||
for span in line.get("spans", []):
|
||||
font = span.get("font", "")
|
||||
txt = span.get("text", "").strip()
|
||||
if "Helvetica" in font and len(txt) > 0:
|
||||
helvetica_pages.append(page_num + 1)
|
||||
found_on_page = True
|
||||
break
|
||||
|
||||
if has_cjk and helvetica_pages:
|
||||
pages_str = ', '.join(str(p) for p in helvetica_pages[:5])
|
||||
if len(helvetica_pages) > 5:
|
||||
pages_str += f' ...and {len(helvetica_pages) - 5} more'
|
||||
result.warn(
|
||||
"Helvetica in CJK document",
|
||||
f"Helvetica font detected rendering text on page(s) {pages_str} in a CJK document. "
|
||||
f"This usually means a raw string was passed to a ReportLab Table or flowable "
|
||||
f"without wrapping in Paragraph(text, style) with a CJK-capable font. "
|
||||
f"CJK characters rendered via Helvetica will appear as garbled symbols."
|
||||
)
|
||||
|
||||
|
||||
def check_metadata(doc, result):
|
||||
"""Check PDF metadata presence for title, author, creator."""
|
||||
meta = doc.metadata or {}
|
||||
|
||||
def _missing(v):
|
||||
if v is None:
|
||||
return True
|
||||
if not str(v).strip():
|
||||
return True
|
||||
return False
|
||||
|
||||
title = meta.get("title")
|
||||
author = meta.get("author")
|
||||
creator = meta.get("creator")
|
||||
|
||||
if _missing(title) or str(title).strip().lower() in ("untitled", "(anonymous)"):
|
||||
result.warn("Metadata", "Missing/invalid title metadata")
|
||||
else:
|
||||
result.ok("Title metadata present \u2713")
|
||||
|
||||
if _missing(author):
|
||||
result.warn("Metadata", "Missing author metadata")
|
||||
else:
|
||||
result.ok("Author metadata present \u2713")
|
||||
|
||||
if _missing(creator):
|
||||
result.warn("Metadata", "Missing creator metadata")
|
||||
else:
|
||||
result.ok("Creator metadata present \u2713")
|
||||
|
||||
|
||||
def check_toc_without_cover(doc, result):
|
||||
"""Detect TOC on page 1 without a preceding cover page.
|
||||
|
||||
If the first page contains Table of Contents / 目录, it means the document
|
||||
has a TOC but no cover page. This is a structural issue — documents with
|
||||
TOC should have: Cover (p1) → TOC (p2) → Content (p3+).
|
||||
"""
|
||||
if len(doc) < 2:
|
||||
# Single-page docs don't need TOC/cover checks
|
||||
return
|
||||
|
||||
page1 = doc[0]
|
||||
text = page1.get_text("text", sort=True).strip()
|
||||
|
||||
# Normalize for matching
|
||||
text_lower = text.lower()
|
||||
first_300 = text_lower[:300]
|
||||
|
||||
toc_keywords = [
|
||||
"table of contents", "contents",
|
||||
"目录", "目 录",
|
||||
]
|
||||
|
||||
has_toc = any(kw in first_300 for kw in toc_keywords)
|
||||
|
||||
if has_toc:
|
||||
result.warn(
|
||||
"TOC without cover",
|
||||
"Page 1 appears to be a Table of Contents with no preceding cover page. "
|
||||
"Documents with TOC should have: Cover (p1) → TOC (p2) → Content (p3+)."
|
||||
)
|
||||
|
||||
|
||||
def check_formula_overflow(doc, result):
|
||||
"""Detect likely formula overflow past right content margin."""
|
||||
math_re = re.compile(r"[=+\-*/<>\u2264\u2265\u2211\u222b\u221a\u03c0\u00b5\u221e\u2202\u2206\u2248\u2260\u00b1\u00d7\u00f7]")
|
||||
|
||||
for page_num in range(len(doc)):
|
||||
page = doc[page_num]
|
||||
blocks = page.get_text("blocks")
|
||||
text_blocks = [b for b in blocks if b[4].strip()]
|
||||
|
||||
if len(text_blocks) < 3:
|
||||
continue
|
||||
|
||||
right_edges = sorted(b[2] for b in text_blocks)
|
||||
mid = len(right_edges) // 2
|
||||
content_right = right_edges[mid] if right_edges else 0
|
||||
|
||||
for b in text_blocks:
|
||||
x0, x1, text = b[0], b[2], b[4]
|
||||
if x1 <= content_right + 10:
|
||||
continue
|
||||
|
||||
is_single_line = "\n" not in text.strip()
|
||||
is_wide = (x1 - x0) > page.rect.width * 0.5
|
||||
has_math = bool(math_re.search(text))
|
||||
|
||||
if (is_single_line and is_wide) or has_math:
|
||||
delta = x1 - content_right
|
||||
result.warn(
|
||||
"Formula overflow",
|
||||
f"Page {page_num + 1}: Content extends {delta:.0f}pt beyond right content margin "
|
||||
"(possible formula overflow)"
|
||||
)
|
||||
break
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Main
|
||||
# ============================================================
|
||||
|
||||
def run_qa(pdf_path, poster=False, skip_cover=False, check_tables=True, check_formulas=False):
|
||||
result = QAResult()
|
||||
|
||||
if not os.path.exists(pdf_path):
|
||||
result.error("File", f"File not found: {pdf_path}")
|
||||
return result
|
||||
|
||||
doc = pymupdf.open(pdf_path)
|
||||
|
||||
result.add_info(f"File: {os.path.basename(pdf_path)}")
|
||||
result.add_info(f"Size: {os.path.getsize(pdf_path) / 1024:.1f} KB")
|
||||
if poster:
|
||||
result.add_info("Mode: poster (creative)")
|
||||
|
||||
# Run all checks
|
||||
check_metadata(doc, result)
|
||||
check_page_size_consistency(doc, result)
|
||||
check_blank_pages(doc, result)
|
||||
check_punctuation(doc, result)
|
||||
check_colors(doc, result)
|
||||
check_font_embedding(doc, result)
|
||||
check_helvetica_in_cjk(doc, result)
|
||||
check_text_overflow(doc, result)
|
||||
if not poster:
|
||||
# Content fill ratio is not meaningful for posters — the last page
|
||||
# of a seamlessly-paginated poster naturally has less content.
|
||||
check_content_fill_ratio(doc, result)
|
||||
check_cover_bleed(doc, result, poster=poster)
|
||||
check_margin_symmetry(doc, result, skip_cover=skip_cover)
|
||||
if check_tables:
|
||||
check_table_centering(doc, result)
|
||||
if check_formulas:
|
||||
check_formula_overflow(doc, result)
|
||||
if not poster:
|
||||
check_toc_without_cover(doc, result)
|
||||
|
||||
doc.close()
|
||||
return result
|
||||
|
||||
|
||||
def format_report(result):
|
||||
lines = []
|
||||
lines.append("=" * 56)
|
||||
lines.append(" PDF Quality Assurance Report")
|
||||
lines.append("=" * 56)
|
||||
|
||||
# Info
|
||||
if result.info:
|
||||
lines.append("")
|
||||
lines.append("ℹ️ Info:")
|
||||
for msg in result.info:
|
||||
lines.append(f" {msg}")
|
||||
|
||||
# Passes
|
||||
if result.passes:
|
||||
lines.append("")
|
||||
lines.append(f"✅ Passed ({len(result.passes)}):")
|
||||
for msg in result.passes:
|
||||
lines.append(f" {msg}")
|
||||
|
||||
# Issues
|
||||
errors = [(s, c, m) for s, c, m in result.issues if s == 'ERROR']
|
||||
warns = [(s, c, m) for s, c, m in result.issues if s == 'WARN']
|
||||
|
||||
if errors:
|
||||
lines.append("")
|
||||
lines.append(f"❌ Errors ({len(errors)}):")
|
||||
for _, cat, msg in errors:
|
||||
lines.append(f" [{cat}] {msg}")
|
||||
|
||||
if warns:
|
||||
lines.append("")
|
||||
lines.append(f"⚠️ Warnings ({len(warns)}):")
|
||||
for _, cat, msg in warns:
|
||||
lines.append(f" [{cat}] {msg}")
|
||||
|
||||
# Summary
|
||||
lines.append("")
|
||||
lines.append("-" * 56)
|
||||
total_issues = len(result.issues)
|
||||
if total_issues == 0:
|
||||
lines.append("🎉 PASS — All checks passed!")
|
||||
elif errors:
|
||||
lines.append(f"💀 FAIL — {len(errors)} error(s), {len(warns)} warning(s)")
|
||||
else:
|
||||
lines.append(f"⚠️ WARN — {len(warns)} warning(s), optimization recommended")
|
||||
lines.append("-" * 56)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python3 pdf_qa.py <pdf_path>")
|
||||
print(" python3 pdf_qa.py *.pdf (batch check)")
|
||||
print("Options:")
|
||||
print(" --poster Poster mode (creative)")
|
||||
print(" --skip-cover Skip page 1 margin symmetry check")
|
||||
print(" --no-tables Disable table centering check")
|
||||
print(" --formulas Enable formula overflow check")
|
||||
sys.exit(1)
|
||||
|
||||
import glob
|
||||
files = []
|
||||
poster = False
|
||||
skip_cover = False
|
||||
check_tables = True
|
||||
check_formulas = False
|
||||
args = sys.argv[1:]
|
||||
if '--poster' in args:
|
||||
poster = True
|
||||
args.remove('--poster')
|
||||
if '--skip-cover' in args:
|
||||
skip_cover = True
|
||||
args.remove('--skip-cover')
|
||||
if '--no-tables' in args:
|
||||
check_tables = False
|
||||
args.remove('--no-tables')
|
||||
if '--formulas' in args:
|
||||
check_formulas = True
|
||||
args.remove('--formulas')
|
||||
for arg in args:
|
||||
files.extend(glob.glob(arg))
|
||||
|
||||
if not files:
|
||||
print(f"File not found: {args}")
|
||||
sys.exit(1)
|
||||
|
||||
for pdf_path in files:
|
||||
result = run_qa(
|
||||
pdf_path,
|
||||
poster=poster,
|
||||
skip_cover=skip_cover,
|
||||
check_tables=check_tables,
|
||||
check_formulas=check_formulas
|
||||
)
|
||||
print(format_report(result))
|
||||
if len(files) > 1:
|
||||
print("\n")
|
||||
1337
skills/pdf/scripts/poster_validate.py
Executable file
1337
skills/pdf/scripts/poster_validate.py
Executable file
File diff suppressed because it is too large
Load Diff
269
skills/pdf/scripts/setup.sh
Executable file
269
skills/pdf/scripts/setup.sh
Executable file
@@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env bash
|
||||
# ---
|
||||
# name: pdf-setup
|
||||
# author: Z.AI
|
||||
# version: "1.0"
|
||||
# description: Environment setup for the PDF skill. Checks and installs all required dependencies.
|
||||
# ---
|
||||
#
|
||||
# Installs only dependencies required by the PDF skill.
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
|
||||
ok() { echo -e " ${GREEN}✓${NC} $1"; }
|
||||
fail() { echo -e " ${RED}✗${NC} $1"; }
|
||||
warn() { echo -e " ${YELLOW}○${NC} $1"; }
|
||||
info() { echo -e " ${BLUE}→${NC} $1"; }
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
echo "============================================"
|
||||
echo " PDF Skill — Environment Setup"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
# ── Detect platform ──
|
||||
OS="$(uname -s)"
|
||||
ARCH="$(uname -m)"
|
||||
echo "Platform: $OS $ARCH"
|
||||
echo ""
|
||||
|
||||
# ── 0. macOS: Homebrew ──
|
||||
if [ "$OS" = "Darwin" ]; then
|
||||
echo "--- Homebrew (macOS package manager) ---"
|
||||
if command -v brew &>/dev/null; then
|
||||
BREW_VER=$(brew --version 2>/dev/null | head -1)
|
||||
ok "brew ($BREW_VER)"
|
||||
else
|
||||
fail "brew not found — most dependencies below need Homebrew on macOS"
|
||||
info "Install: /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ── 1. Python 3 ──
|
||||
echo "--- Python ---"
|
||||
if command -v python3 &>/dev/null; then
|
||||
PY_VER=$(python3 --version 2>&1)
|
||||
ok "python3 ($PY_VER)"
|
||||
# macOS: warn if using system Python
|
||||
if [ "$OS" = "Darwin" ]; then
|
||||
PY_PATH=$(which python3 2>/dev/null)
|
||||
if [[ "$PY_PATH" == "/usr/bin/python3" ]]; then
|
||||
warn "Using macOS system Python (limited). Recommend: brew install python3"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
fail "python3 not found"
|
||||
case "$OS" in
|
||||
Darwin) info "Install: brew install python3" ;;
|
||||
Linux) info "Install: sudo apt install python3 python3-pip (Debian/Ubuntu)"
|
||||
info " sudo dnf install python3 python3-pip (Fedora/RHEL)" ;;
|
||||
*) info "Install: https://www.python.org/downloads/" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# ── 2. pip ──
|
||||
echo ""
|
||||
echo "--- pip ---"
|
||||
if python3 -m pip --version &>/dev/null 2>&1; then
|
||||
PIP_VER=$(python3 -m pip --version 2>/dev/null | head -1)
|
||||
ok "pip ($PIP_VER)"
|
||||
else
|
||||
fail "pip not found"
|
||||
case "$OS" in
|
||||
Darwin) info "Install: python3 -m ensurepip --upgrade"
|
||||
info " or: brew install python3 (includes pip)" ;;
|
||||
Linux) info "Install: sudo apt install python3-pip (Debian/Ubuntu)" ;;
|
||||
*) info "Install: python3 -m ensurepip --upgrade" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# ── 3. Python packages (pip) ──
|
||||
echo ""
|
||||
echo "--- Python Packages ---"
|
||||
PY_PKGS=(
|
||||
"pikepdf:pikepdf"
|
||||
"pdfplumber:pdfplumber"
|
||||
"pypdf:pypdf"
|
||||
"reportlab:reportlab"
|
||||
"pymupdf:PyMuPDF"
|
||||
)
|
||||
|
||||
MISSING_PY=()
|
||||
for entry in "${PY_PKGS[@]}"; do
|
||||
mod="${entry%%:*}"
|
||||
pkg="${entry##*:}"
|
||||
if python3 -c "import $mod" 2>/dev/null; then
|
||||
ver=$(python3 -c "import $mod; print(getattr($mod, '__version__', 'installed'))" 2>/dev/null)
|
||||
ok "$pkg ($ver)"
|
||||
else
|
||||
fail "$pkg not installed"
|
||||
MISSING_PY+=("$pkg")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#MISSING_PY[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
if [ -t 0 ]; then
|
||||
read -p " Install missing Python packages? [Y/n] " -n 1 -r REPLY
|
||||
echo ""
|
||||
REPLY=${REPLY:-Y}
|
||||
else
|
||||
warn "Non-interactive mode — skipping auto-install. Run interactively or install manually."
|
||||
REPLY=N
|
||||
fi
|
||||
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
||||
python3 -m pip install -q "${MISSING_PY[@]}" 2>/dev/null \
|
||||
|| python3 -m pip install -q --user "${MISSING_PY[@]}" 2>/dev/null \
|
||||
|| python3 -m pip install -q --break-system-packages "${MISSING_PY[@]}" 2>/dev/null \
|
||||
|| { fail "pip install failed. Try manually: pip install ${MISSING_PY[*]}"; }
|
||||
ok "Installed: ${MISSING_PY[*]}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 4. Node.js ──
|
||||
echo ""
|
||||
echo "--- Node.js ---"
|
||||
if command -v node &>/dev/null; then
|
||||
NODE_VER=$(node --version)
|
||||
ok "node ($NODE_VER)"
|
||||
else
|
||||
fail "node not found"
|
||||
case "$OS" in
|
||||
Darwin) info "Install: brew install node" ;;
|
||||
Linux) info "Install: curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -"
|
||||
info " sudo apt install -y nodejs" ;;
|
||||
*) info "Install: https://nodejs.org/" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# ── 5. npm ──
|
||||
echo ""
|
||||
echo "--- npm ---"
|
||||
if command -v npm &>/dev/null; then
|
||||
NPM_VER=$(npm --version 2>/dev/null)
|
||||
ok "npm ($NPM_VER)"
|
||||
else
|
||||
fail "npm not found"
|
||||
case "$OS" in
|
||||
Darwin) info "Install: brew install node (includes npm)" ;;
|
||||
Linux) info "Install: comes with nodejs" ;;
|
||||
*) info "Install: https://nodejs.org/" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# ── 6. Playwright + Chromium ──
|
||||
echo ""
|
||||
echo "--- Playwright (HTML→PDF engine) ---"
|
||||
if node -e "require('playwright')" 2>/dev/null; then
|
||||
PW_VER=$(node -e "console.log(require('playwright/package.json').version)" 2>/dev/null)
|
||||
ok "playwright ($PW_VER)"
|
||||
else
|
||||
fail "playwright not installed"
|
||||
info "Install: npm install -g playwright"
|
||||
fi
|
||||
|
||||
# Check Chromium
|
||||
if [ "$OS" = "Darwin" ]; then
|
||||
PW_CACHE="$HOME/Library/Caches/ms-playwright"
|
||||
else
|
||||
PW_CACHE="$HOME/.cache/ms-playwright"
|
||||
fi
|
||||
if ls "$PW_CACHE"/chromium-* &>/dev/null 2>&1; then
|
||||
CR_DIR=$(ls -d "$PW_CACHE"/chromium-* 2>/dev/null | tail -1)
|
||||
ok "chromium ($(basename "$CR_DIR"))"
|
||||
else
|
||||
fail "chromium not installed"
|
||||
info "Install: npx playwright install chromium"
|
||||
if [ "$OS" = "Linux" ]; then
|
||||
info " npx playwright install-deps (system libs, needs sudo)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 7. Tectonic (LaTeX engine, optional) ──
|
||||
echo ""
|
||||
echo "--- Tectonic (LaTeX→PDF, optional) ---"
|
||||
BUNDLED="$SCRIPT_DIR/tectonic"
|
||||
if [ -x "$BUNDLED" ]; then
|
||||
if [ "$OS" = "Darwin" ] && [ "$ARCH" = "arm64" ]; then
|
||||
ok "tectonic (bundled, macOS arm64)"
|
||||
else
|
||||
warn "bundled tectonic is macOS arm64 only — cannot run on $OS $ARCH"
|
||||
if command -v tectonic &>/dev/null; then
|
||||
TEC_VER=$(tectonic --version 2>&1 | head -1)
|
||||
ok "tectonic (system: $TEC_VER)"
|
||||
else
|
||||
fail "tectonic not in PATH"
|
||||
case "$OS" in
|
||||
Darwin) info "Install: brew install tectonic" ;;
|
||||
Linux) info "Install: conda install -c conda-forge tectonic"
|
||||
info " or: curl -fsSL https://drop-sh.fullyjustified.net | sh" ;;
|
||||
MINGW*|MSYS*|CYGWIN*) info "Install: scoop install tectonic / choco install tectonic" ;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
elif command -v tectonic &>/dev/null; then
|
||||
TEC_VER=$(tectonic --version 2>&1 | head -1)
|
||||
ok "tectonic ($TEC_VER)"
|
||||
elif [ -x "$HOME/tectonic" ]; then
|
||||
ok "tectonic (~/tectonic)"
|
||||
else
|
||||
warn "tectonic not installed (needed only for LaTeX/academic PDFs)"
|
||||
case "$OS" in
|
||||
Darwin) info "Install: brew install tectonic" ;;
|
||||
Linux) info "Install: conda install -c conda-forge tectonic"
|
||||
info " or: curl -fsSL https://drop-sh.fullyjustified.net | sh" ;;
|
||||
MINGW*|MSYS*|CYGWIN*) info "Install: scoop install tectonic / choco install tectonic" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# ── 8. LibreOffice (optional, for Office→PDF conversion) ──
|
||||
echo ""
|
||||
echo "--- LibreOffice (optional, Office→PDF) ---"
|
||||
if command -v soffice &>/dev/null; then
|
||||
LO_VER=$(soffice --version 2>/dev/null | head -1)
|
||||
ok "libreoffice ($LO_VER)"
|
||||
else
|
||||
warn "libreoffice not installed (needed only for .docx/.xlsx→PDF conversion)"
|
||||
case "$OS" in
|
||||
Darwin) info "Install: brew install --cask libreoffice" ;;
|
||||
Linux) info "Install: sudo apt install libreoffice-core (Debian/Ubuntu)" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# ── 9. CJK Fonts ──
|
||||
echo ""
|
||||
echo "--- CJK Fonts ---"
|
||||
FONT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)/fonts"
|
||||
if [ -d "$FONT_DIR" ]; then
|
||||
FONT_COUNT=$(find "$FONT_DIR" -name "*.ttf" -o -name "*.otf" 2>/dev/null | head -20 | wc -l | tr -d ' ')
|
||||
ok "fonts directory ($FONT_COUNT font files in $FONT_DIR)"
|
||||
else
|
||||
warn "no fonts/ directory found — CJK PDFs may have missing glyphs"
|
||||
info "Expected at: $FONT_DIR"
|
||||
fi
|
||||
# Check system CJK fonts
|
||||
if [ "$OS" = "Darwin" ]; then
|
||||
if ls /System/Library/Fonts/PingFang.ttc &>/dev/null 2>&1 \
|
||||
|| ls /System/Library/Fonts/STHeiti*.ttc &>/dev/null 2>&1 \
|
||||
|| ls "$HOME/Library/Fonts/"*SimHei* &>/dev/null 2>&1; then
|
||||
ok "macOS CJK system fonts available"
|
||||
else
|
||||
warn "no common CJK system fonts found"
|
||||
fi
|
||||
elif [ "$OS" = "Linux" ]; then
|
||||
if fc-list :lang=zh 2>/dev/null | head -1 | grep -q .; then
|
||||
ok "system CJK fonts available (fc-list)"
|
||||
else
|
||||
warn "no CJK fonts found. Install: sudo apt install fonts-noto-cjk"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Summary ──
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " Setup complete."
|
||||
echo " Run 'python3 pdf.py env.check' for detailed status."
|
||||
echo " Run 'python3 pdf.py env.fix' to auto-install Python deps."
|
||||
echo "============================================"
|
||||
2075
skills/pdf/scripts/toc_validate.py
Executable file
2075
skills/pdf/scripts/toc_validate.py
Executable file
File diff suppressed because it is too large
Load Diff
320
skills/pdf/typesetting/charts.md
Executable file
320
skills/pdf/typesetting/charts.md
Executable file
@@ -0,0 +1,320 @@
|
||||
# Chart Design — Chart Typesetting & Anti-Stacking Rules
|
||||
|
||||
> Core principle: **Data-Ink Ratio** — delete every line that doesn't represent data. Then delete some more.
|
||||
|
||||
---
|
||||
|
||||
## Part 1: Anti-Stacking (Collision Prevention)
|
||||
|
||||
Text stacking is fundamentally "information density exceeding available physical space." Apply these dynamic degradation strategies.
|
||||
|
||||
### 0. Universal Anti-Overlap Pre-Check (MANDATORY)
|
||||
|
||||
**Before rendering ANY chart, run this pre-flight check:**
|
||||
|
||||
```python
|
||||
# Step 1: Reserve space for labels FIRST, then draw chart
|
||||
# WRONG: draw chart → add labels → discover overlap → cry
|
||||
# RIGHT: measure labels → reserve space → draw chart in remaining area
|
||||
|
||||
# Step 2: Chart-to-text separation
|
||||
CHART_TEXT_MIN_GAP = 12 # pt — minimum gap between chart edge and adjacent text
|
||||
CHART_LABEL_MIN_GAP = 6 # pt — minimum gap between chart labels and chart elements
|
||||
|
||||
# Step 3: Label-to-label collision check
|
||||
def labels_overlap(label_a, label_b):
|
||||
"""Check if two label bounding boxes overlap."""
|
||||
return not (label_a.right < label_b.left or
|
||||
label_a.left > label_b.right or
|
||||
label_a.bottom < label_b.top or
|
||||
label_a.top > label_b.bottom)
|
||||
|
||||
# Step 4: Resolution cascade
|
||||
# 1. Reposition (nudge conflicting label)
|
||||
# 2. Reduce font size (min 8pt)
|
||||
# 3. Remove label (replace with legend entry)
|
||||
# 4. Merge small items ("Others" grouping)
|
||||
```
|
||||
|
||||
**Matplotlib-specific anti-overlap:**
|
||||
```python
|
||||
# MANDATORY for all matplotlib charts
|
||||
import matplotlib.pyplot as plt
|
||||
from adjustText import adjust_text # pip install adjustText
|
||||
|
||||
# After adding text annotations:
|
||||
# adjust_text(texts, arrowprops=dict(arrowstyle='-', color='gray', lw=0.5))
|
||||
|
||||
# For bar value labels:
|
||||
fig.tight_layout(pad=2.0) # Always use generous padding
|
||||
plt.subplots_adjust(bottom=0.15) # Reserve space for rotated labels
|
||||
|
||||
# For pie/donut:
|
||||
# autopct labels: check angular distance between adjacent slices
|
||||
# If angle < 15°, use external labels with leader lines
|
||||
```
|
||||
|
||||
**Chart-to-body-text separation (ReportLab):**
|
||||
```python
|
||||
# MANDATORY spacers around chart flowables
|
||||
story.append(Spacer(1, 24)) # 24pt gap before chart
|
||||
story.append(chart_image) # Chart
|
||||
story.append(Spacer(1, 8)) # 8pt gap
|
||||
story.append(chart_caption) # Caption
|
||||
story.append(Spacer(1, 24)) # 24pt gap after chart
|
||||
# NEVER place chart without Spacer guards
|
||||
```
|
||||
|
||||
### 1. Pie / Donut Chart — Label Collision Prevention
|
||||
|
||||
When slices are too small, labels MUST NOT be placed inside the arc.
|
||||
|
||||
#### Strategy A: Leader Lines + Y-Axis Collision Avoidance
|
||||
|
||||
When slice angle `< 15°` (or area share `< 5%`), force external labels:
|
||||
- Draw a polyline (leader line) from the arc's outer edge to the label text
|
||||
- **Y-axis anti-collision logic**: Calculate adjacent label Y-coordinates. If `Y2 - Y1 < font_height + padding`, push `Y2` downward (or pull `Y1` upward). Extend/shorten the horizontal segment of the leader line accordingly.
|
||||
- Leader lines: 1pt, same color as slice at 60% opacity
|
||||
|
||||
#### Strategy B: "Others" Grouping (Long-Tail Merge)
|
||||
|
||||
Before data reaches the renderer, intercept and merge:
|
||||
- Threshold: slices `< 3%` → merge into a single "其他 (Others)" slice
|
||||
- If detail is needed, add a minimal table beside the chart showing the breakdown
|
||||
- This prevents 5+ tiny slivers from cluttering the chart
|
||||
|
||||
#### Strategy C: Strip Labels, Use Rich Legend
|
||||
|
||||
The most premium approach — **zero text on the chart itself**:
|
||||
- Pie/donut body shows only pure shapes + colors
|
||||
- All names, percentages, and values are laid out in a grid-aligned legend to the right or below
|
||||
- This NEVER stacks, and looks the most professional
|
||||
|
||||
**Priority order**: C (best) → A (good) → B (acceptable fallback)
|
||||
|
||||
---
|
||||
|
||||
### 2. Bar Chart — Label Collision Prevention
|
||||
|
||||
#### Strategy A: Auto-Rotate to Horizontal Bar
|
||||
|
||||
**Hard rule**: When X-axis label average length exceeds **5 Chinese characters** (or 10 Latin characters), automatically convert to a horizontal bar chart.
|
||||
- Y-axis has unlimited downward space for labels — stacking is impossible
|
||||
- This is the single most effective anti-collision measure for bar charts
|
||||
|
||||
#### Strategy B: Tick Thinning + Stagger
|
||||
|
||||
When there are many bars (e.g., 30-day trend):
|
||||
- **Thinning**: Show every 2nd or 5th label (skip intermediate ticks)
|
||||
- **Stagger**: Alternate labels between two rows (offset vertically)
|
||||
- **Tilt (last resort)**: 45° rotation works but reduces readability in premium reports. Prefer thinning or horizontal bars.
|
||||
|
||||
#### Strategy C: Value Label Inside/Outside Flip
|
||||
|
||||
- If bar is tall enough: place value label **inside** the bar near the top (use contrasting text color)
|
||||
- If bar is too short for internal label: place value **above** the bar
|
||||
- If value labels would overlap between adjacent bars: show values only on the tallest/shortest bars, or use tooltip-style callout boxes
|
||||
|
||||
---
|
||||
|
||||
### 3. Line Chart — Data Point Label Prevention
|
||||
|
||||
Dense data points with labels on every point = visual chaos.
|
||||
|
||||
#### Strategy A: "First, Last, Max, Min" Rule (Data Journalism Standard)
|
||||
|
||||
Only auto-label **4 points** on any line:
|
||||
- **Start point** (first value)
|
||||
- **End point** (last value)
|
||||
- **Maximum** (peak)
|
||||
- **Minimum** (valley)
|
||||
|
||||
All other points show only the curve shape — no labels. This instantly elevates professionalism.
|
||||
|
||||
#### Strategy B: Callout Boxes
|
||||
|
||||
For points that must be highlighted:
|
||||
- Don't let text sit naked on the curve
|
||||
- Wrap in a rounded-corner background box (white fill, very light shadow or thin border)
|
||||
- Connect to the data point with a thin needle line
|
||||
- Boxes have physical boundaries → easier collision detection and displacement
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Visual Refinement (Eliminating "Cheap" Aesthetics)
|
||||
|
||||
### 1. Axis & Grid Line Treatment
|
||||
|
||||
**The #1 sign of amateur charts: thick black border frames and solid grid lines.**
|
||||
|
||||
| Element | Rule |
|
||||
|---------|------|
|
||||
| Top spine | **DELETE** (unconditionally) |
|
||||
| Right spine | **DELETE** (unconditionally) |
|
||||
| Left Y-axis spine | Optional — can delete if values are labeled on bars directly |
|
||||
| Bottom X-axis | Keep as baseline reference (thin, gray) |
|
||||
| Grid lines | **Dashed only** (dotted or dashed), 0.5pt, 15-20% opacity. NEVER solid. |
|
||||
| Grid lines (when values shown) | **DELETE entirely** — if bar/line values are directly labeled, grid lines are redundant |
|
||||
|
||||
### 2. Geometric Shape Refinement
|
||||
|
||||
#### Pie → Donut (Mandatory Default)
|
||||
|
||||
- **Always use donut (ring) charts** instead of solid pies
|
||||
- Inner radius = **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)
|
||||
|
||||
#### 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
|
||||
384
skills/pdf/typesetting/cover-backgrounds.md
Executable file
384
skills/pdf/typesetting/cover-backgrounds.md
Executable file
@@ -0,0 +1,384 @@
|
||||
# Cover Background — Advanced Cover Background Construction Rules
|
||||
|
||||
> Background is the canvas behind the canvas. It should be felt, not seen.
|
||||
|
||||
> **⚠️ V2.1 Note:** Since all covers are now rendered via HTML/Playwright, the canonical implementation is **CSS/HTML**. The ReportLab Python examples below are kept as **algorithmic reference** (coordinates, ratios, opacity values) — translate them to equivalent CSS `background`, `linear-gradient`, `radial-gradient`, `transform`, and `clip-path` properties when implementing. The design intent and constraints (opacity limits, Z-index rules, WCAG contrast) apply regardless of rendering engine.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Global Constraints
|
||||
|
||||
Before executing any specific background algorithm, these three iron rules must be obeyed — they are the baseline to ensure backgrounds remain subtle and never overwhelm:
|
||||
|
||||
### 1. Ultra-Low Contrast Rule (Opacity/Alpha Control)
|
||||
|
||||
All background element colors are calculated based on the underlying canvas base color:
|
||||
|
||||
| Base Color Type | Background Element Color | Opacity Range |
|
||||
|---------|-------------|-----------|
|
||||
| Light/white base | `#000000` | **2% – 5%** |
|
||||
| Dark base | `#FFFFFF` | **3% – 6%** |
|
||||
|
||||
**Absolutely forbidden** to exceed the above opacity ranges. Background elements must be at the "barely perceptible" threshold.
|
||||
|
||||
### 2. Absolute Z-Axis Bottom Layer
|
||||
|
||||
All background drawing commands must be executed after the base solid color fill and before any text/layout content rendering.
|
||||
|
||||
Rendering order (bottom to top):
|
||||
```
|
||||
1. Page base color fill (solid rect)
|
||||
2. ▶ Background element layer (all content defined in this file)
|
||||
3. Foreground layout content (text, borders, geometric anchors, etc.)
|
||||
```
|
||||
|
||||
### 3. Anti-Overflow Clipping (Clip Path)
|
||||
|
||||
Canvas Clip must be enabled to ensure any oversized shapes exceeding the page `(0, 0, W, H)` coordinate system do not cause abnormal PDF physical dimensions or rendering errors.
|
||||
|
||||
**Core principle: All calculations must be based on dynamic page dimensions (`W` = page width, `H` = page height). Hard-coding absolute pixel values is forbidden.**
|
||||
|
||||
---
|
||||
|
||||
## Module 1: Supergraphics
|
||||
|
||||
**Goal:** Use minimal curves or diagonal lines to break the rigidity of the rectangular frame.
|
||||
|
||||
### Option 1.1: Arc (The Bleeding Circle)
|
||||
|
||||
**Shape type**: Solid-filled circle
|
||||
|
||||
**Calculation logic**:
|
||||
- `Radius` = `W × 1.2` to `W × 1.5` (must be larger than page width to ensure arc curvature is gentle enough)
|
||||
- `Center_X` = `W × 1.0` (center placed at the rightmost page edge, or even outside the right side)
|
||||
- `Center_Y` = `H × 0.8` (center biased lower)
|
||||
|
||||
**Visual effect**: An extremely elegant, grand arc sweeps across the lower-left corner of the page, with most of the circle body outside the page.
|
||||
|
||||
**ReportLab implementation notes**:
|
||||
```python
|
||||
c.saveState()
|
||||
c.clipRect(0, 0, W, H) # Rule 3: anti-overflow
|
||||
c.setFillColorRGB(0, 0, 0, 0.03) # Rule 1: ultra-low opacity
|
||||
radius = W * 1.3
|
||||
cx, cy = W * 1.0, H * 0.2 # ReportLab coordinate Y starts from bottom
|
||||
c.circle(cx, cy, radius, fill=1, stroke=0)
|
||||
c.restoreState()
|
||||
```
|
||||
|
||||
**Playwright/HTML implementation notes**:
|
||||
```html
|
||||
<!-- Option 2.1: Side giant spine -->
|
||||
<!-- Note: Must use JS to measure text width, dynamically adjust font-size to ensure full display -->
|
||||
<div style="position:absolute; inset:0; overflow:hidden; z-index:0;">
|
||||
<div id="spine-watermark" style="
|
||||
position: absolute;
|
||||
left: 3%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%) rotate(-90deg);
|
||||
transform-origin: center center;
|
||||
font-size: min(calc(var(--W) * 0.45), 45vw);
|
||||
font-family: 'Helvetica', 'Arial Black', sans-serif;
|
||||
font-weight: 900;
|
||||
color: rgba(0,0,0,0.04);
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
">2026</div>
|
||||
</div>
|
||||
<script>
|
||||
// Adaptive: ensure rotated text does not exceed 85% of page height
|
||||
const el = document.getElementById('spine-watermark');
|
||||
const maxH = window.innerHeight * 0.85;
|
||||
if (el.offsetWidth > maxH) {
|
||||
const scale = maxH / el.offsetWidth;
|
||||
el.style.fontSize = (parseFloat(getComputedStyle(el).fontSize) * scale) + 'px';
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Option 1.2: Sharp Angle Cut (The Angle Slash)
|
||||
|
||||
**Shape type**: Polygon/right trapezoid
|
||||
|
||||
**Calculation logic (define four vertices)**:
|
||||
- `Point 1` = `(0, H × 0.7)`
|
||||
- `Point 2` = `(W, H × 0.4)`
|
||||
- `Point 3` = `(W, H)`
|
||||
- `Point 4` = `(0, H)`
|
||||
|
||||
**Visual effect**: Forms a tilted geometric color block at the bottom of the page. This sharp linear cut has a strong IT/consulting/engineering industry feel.
|
||||
|
||||
**ReportLab implementation notes**:
|
||||
```python
|
||||
c.saveState()
|
||||
c.clipRect(0, 0, W, H)
|
||||
c.setFillColorRGB(0, 0, 0, 0.04) # Rule 1
|
||||
path = c.beginPath()
|
||||
# ReportLab Y starts from bottom, needs flipping
|
||||
path.moveTo(0, H * 0.3) # Corresponds to doc P1: (0, H*0.7) → flipped
|
||||
path.lineTo(W, H * 0.6) # Corresponds to doc P2: (W, H*0.4) → flipped
|
||||
path.lineTo(W, 0) # Corresponds to doc P3: (W, H) → flipped
|
||||
path.lineTo(0, 0) # Corresponds to doc P4: (0, H) → flipped
|
||||
path.close()
|
||||
c.drawPath(path, fill=1, stroke=0)
|
||||
c.restoreState()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Module 2: Typographic Watermarks
|
||||
|
||||
**Goal:** Extract very short metadata text and transform it into an architectural watermark space.
|
||||
|
||||
### Technical Requirements (Mandatory)
|
||||
|
||||
| Constraint | Rule |
|
||||
|-------|------|
|
||||
| **Font** | Must use sans-serif, weight must be extra-bold (Black / Heavy / Bold). Recommended: Helvetica Black, Arial Black, Noto Sans SC Heavy |
|
||||
| **Font prohibition** | **Absolutely forbidden** to use thin or serif fonts for this type of watermark |
|
||||
| **Character count** | Extracted string length `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, "Helvetica-Bold", max_font_size)
|
||||
available_height = H * 0.85
|
||||
if text_width > available_height:
|
||||
font_size = max_font_size * (available_height / text_width)
|
||||
else:
|
||||
font_size = max_font_size
|
||||
|
||||
# Recalculate actual width for centering
|
||||
actual_text_width = c.stringWidth(text, "Helvetica-Bold", font_size)
|
||||
|
||||
c.setFont("Helvetica-Bold", font_size)
|
||||
# Vertically centered, fully within page
|
||||
center_y = (H - actual_text_width) / 2
|
||||
c.translate(W * 0.03, center_y)
|
||||
c.rotate(90) # ReportLab counter-clockwise is positive
|
||||
c.drawString(0, 0, text)
|
||||
c.restoreState()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Option 2.2: Bottom Full Text
|
||||
|
||||
**Calculation logic**:
|
||||
- `Text` = Document type English initials (e.g. "REPORT")
|
||||
- **🔴 Font Size Adaptive Algorithm (Full Text Display Iron Rule):**
|
||||
1. `Max_Font_Size` = `W × 0.3` (ideal maximum)
|
||||
2. Measure text rendering width: `Text_Width = measure(Text, Max_Font_Size)`
|
||||
3. Available horizontal space = `W × 0.90` (leaving `W × 0.05` safety margin left and right)
|
||||
4. If `Text_Width > available horizontal space`, scale down proportionally: `Font_Size = Max_Font_Size × (available_horizontal_space / Text_Width)`
|
||||
5. Final `Font_Size = min(Max_Font_Size, scaled_font_size)`
|
||||
- `Rotation` = 0° (horizontal tiling)
|
||||
- `Anchor_X` = `W × 0.05`
|
||||
- `Anchor_Y` = Text baseline within the bottom safe zone of the page: `H × 0.92` (text fully displayed at page bottom, not truncated)
|
||||
|
||||
**⚠️ Full Display Iron Rule: Background watermark text must be 100% within the visible page area. Any clipping is strictly forbidden. Reduce font size rather than truncate.**
|
||||
|
||||
**Visual effect**: Text sits solidly at the bottom like a foundation, fully readable, extremely dignified. No more half-truncated text.
|
||||
|
||||
**ReportLab implementation notes**:
|
||||
```python
|
||||
c.saveState()
|
||||
c.clipRect(0, 0, W, H)
|
||||
c.setFillColorRGB(0, 0, 0, 0.04)
|
||||
|
||||
# Adaptive font size: ensure full text display
|
||||
max_font_size = W * 0.3
|
||||
text = "REPORT"
|
||||
text_width = c.stringWidth(text, "Helvetica-Bold", max_font_size)
|
||||
available_width = W * 0.90
|
||||
if text_width > available_width:
|
||||
font_size = max_font_size * (available_width / text_width)
|
||||
else:
|
||||
font_size = max_font_size
|
||||
|
||||
c.setFont("Helvetica-Bold", font_size)
|
||||
# Text fully within page, baseline placed in bottom safe zone
|
||||
# ascent ≈ font_size * 0.75, ensure letter tops don't exceed page
|
||||
c.drawString(W * 0.05, font_size * 0.3, text) # baseline slightly above bottom edge
|
||||
c.restoreState()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Module 3: Blueprint Hairlines
|
||||
|
||||
**Goal:** Use ultra-thin interlacing lines to enhance the "anchoring feel" and logical rigor of foreground text.
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
| Constraint | Rule |
|
||||
|-------|------|
|
||||
| **Line width** | `Stroke_Width = 0.5pt` (**must never exceed 1pt**) |
|
||||
| **Line type** | Solid, or very closely spaced dotted line (Dotted, dash: `[1, 3]`) |
|
||||
| **Opacity** | Follow global Rule 1 |
|
||||
|
||||
### Option 3.1: Coordinate Cross
|
||||
|
||||
**Calculation logic (height bound to foreground layout anchor)**:
|
||||
- **Vertical line (Y-axis)**: Read the left-alignment safe boundary of foreground layout (e.g. `X = W × 0.15`). Draw a vertical line from `Y = 0` to `Y = H`
|
||||
- **Horizontal line (X-axis)**: Read the baseline or top boundary of the foreground main title (e.g. `Y = H × 0.32`). Draw a horizontal line from `X = 0` to `X = W`
|
||||
|
||||
**Visual effect**: The entire page is divided by implicit golden ratio lines, foreground text appears to grow precisely on these coordinate axes, extremely rigorous.
|
||||
|
||||
**ReportLab implementation notes**:
|
||||
```python
|
||||
c.saveState()
|
||||
c.setStrokeColorRGB(0, 0, 0, 0.04)
|
||||
c.setLineWidth(0.5)
|
||||
# Vertical line — align to foreground text left boundary
|
||||
axis_x = W * 0.15
|
||||
c.line(axis_x, 0, axis_x, H)
|
||||
# Horizontal line — align to main title baseline
|
||||
axis_y = H * 0.68 # ReportLab Y-flip: document H*0.32 → RL H*0.68
|
||||
c.line(0, axis_y, W, axis_y)
|
||||
c.restoreState()
|
||||
```
|
||||
|
||||
**Playwright/HTML implementation notes**:
|
||||
```html
|
||||
<div style="position:absolute; inset:0; z-index:0; pointer-events:none;">
|
||||
<!-- Vertical line -->
|
||||
<div style="
|
||||
position:absolute;
|
||||
left: 15%;
|
||||
top: 0;
|
||||
width: 0.5px;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.04);
|
||||
"></div>
|
||||
<!-- Horizontal line -->
|
||||
<div style="
|
||||
position:absolute;
|
||||
left: 0;
|
||||
top: 32%;
|
||||
width: 100%;
|
||||
height: 0.5px;
|
||||
background: rgba(0,0,0,0.04);
|
||||
"></div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Combination & Circuit Breaker (The Combination Matrix)
|
||||
|
||||
To ensure diversity in auto-generated backgrounds while preventing visual chaos, the system must implement a **"background combination state machine"**.
|
||||
|
||||
For each PDF generation, randomly select one of the following 3 legal Recipes, **cross-boundary combinations are strictly forbidden**:
|
||||
|
||||
### ✅ Recipe A: Minimal Modern
|
||||
|
||||
**Combination**: `Option 1.1 (deep-space arc)` — **this one only, no other elements**
|
||||
|
||||
**Applicable scenes**: Safest, most whitespace, suitable for all types of corporate reports.
|
||||
|
||||
**Pairing suggestions with cover layouts**:
|
||||
- Layout 1 (diagonal tension) — arc in the blank diagonal area, extremely harmonious
|
||||
- Layout 2 (vertical gravity axis) — arc provides lower-left gravity
|
||||
- Layout 4 (golden ratio) — arc adds breathing space to the lower whitespace area
|
||||
|
||||
---
|
||||
|
||||
### ✅ Recipe B: Engineering/Academic
|
||||
|
||||
**Combination**: `Option 3.1 (coordinate cross)` **+** `Option 2.1 (side giant spine)`
|
||||
|
||||
**Logic**: Vertical giant text interlaces with ultra-thin coordinate lines on the left, creating extreme thick-thin contrast, perfect for investment pitches and research reports.
|
||||
|
||||
**Line avoidance rule**: Option 3.1's ultra-thin lines should avoid crossing directly through Option 2.1 (giant text) strokes to prevent visual interference. Adjust line coordinates to avoid text areas:
|
||||
- Vertical line `X` at `W × 0.15` (foreground text left-alignment line)
|
||||
- Giant spine anchor `X` at `-W × 0.05` (left of vertical line, no crossing)
|
||||
|
||||
**Pairing suggestions with cover layouts**:
|
||||
- Layout 7 (left-aligned matrix) — perfect match, left spine + coordinate lines + matrix text three-layer overlay
|
||||
- Layout 2A (left-aligned vertical) — vertical line aligns with axis, doubled structural feel
|
||||
- Layout 6A (side rotation decoration) — Note: 6A already has rotated year, if using Recipe B then **skip Option 2.1**, keep only Option 3.1
|
||||
|
||||
---
|
||||
|
||||
### ✅ Recipe C: Solid/Weighty
|
||||
|
||||
**Combination**: `Option 1.2 (sharp angle cut)` **+** `Option 2.2 (bottom full text)`
|
||||
|
||||
**Logic**: Bottom angular color block overlaid with fully displayed English word at the bottom, very low center of gravity, suitable for annual summaries and white papers.
|
||||
|
||||
**Stacking order**: Draw Option 1.2 angular color block first, then Option 2.2 bleed text (text on top of color block, but both below foreground content).
|
||||
|
||||
**Pairing suggestions with cover layouts**:
|
||||
- Layout 4A (top suspended) — content on top, background pressed below, extreme top-bottom contrast
|
||||
- Layout 1A (diagonal tension) — bottom gravity echoes lower-right text
|
||||
- Layout 5A (stepped progression) — steps extend to lower-right, converging with bottom gravity
|
||||
|
||||
---
|
||||
|
||||
## 🚫 Circuit Breaker Rules (Hard Constraints)
|
||||
|
||||
The following combinations are **hard-forbidden**; violations are bugs:
|
||||
|
||||
| Forbidden Rule | Reason |
|
||||
|---------|------|
|
||||
| Option 2.1 + Option 2.2 together | **No dual text**. Only one giant text watermark allowed per page |
|
||||
| Option 1.1 + Option 1.2 together | **No dual geometry**. Large circle + large diagonal = visual chaos |
|
||||
| Option 3.1 lines crossing Option 2.x strokes | **Line isolation**. Ultra-thin lines must not cross giant text strokes; adjust coordinates to avoid |
|
||||
| All three modules enabled | **Maximum two modules**. Three layers = overwhelms foreground |
|
||||
| Any background element opacity > 6% | **Rule 1 violation**. Background must be subtle and barely visible |
|
||||
|
||||
---
|
||||
|
||||
## Recipe Selection Logic
|
||||
|
||||
When no specific recipe is specified, auto-select based on document type:
|
||||
|
||||
| Document Type | Recommended Recipe | Reason |
|
||||
|---------|---------|------|
|
||||
| Corporate reports, general docs | **A** | Safest, zero risk |
|
||||
| Technical reports, investment pitches | **B** | Engineering feel, precision |
|
||||
| Annual summaries, white papers | **C** | Solid, weighty |
|
||||
| Creative, design | **A** | Maximum whitespace, no conflict with creative content |
|
||||
| Academic papers | **B** | Structural feel matches academic tone |
|
||||
| Uncertain / default | **A** | Minimal never goes wrong |
|
||||
|
||||
---
|
||||
|
||||
## Relationship with geometry.md
|
||||
|
||||
This file defines **page-level macro backgrounds** (Supergraphics / Watermarks / Hairlines), applied to the entire canvas.
|
||||
|
||||
`geometry.md` defines **local decorative anchors** (Offset Stacking / Scale Contrast / Grid Intersection), applied to specific areas.
|
||||
|
||||
Both can coexist, but note:
|
||||
- Background layer (this file) is at the bottom
|
||||
- Geometric anchors (geometry.md) are above the background layer but below foreground text
|
||||
- If both are used, geometric anchors should be placed in "blank areas" of background elements to avoid visual overlap
|
||||
1442
skills/pdf/typesetting/cover.md
Executable file
1442
skills/pdf/typesetting/cover.md
Executable file
File diff suppressed because it is too large
Load Diff
527
skills/pdf/typesetting/fill-engine.md
Executable file
527
skills/pdf/typesetting/fill-engine.md
Executable file
@@ -0,0 +1,527 @@
|
||||
# Fill Engine — Adaptive Anti-Void Layout Engine V2.0
|
||||
|
||||
> Solves the **"text too small to read"** and **"large page voids"** problems caused by varying input content length in automated PDF generation.
|
||||
> Before rendering any page, it must pass through the following **four elastic filtering calculations**.
|
||||
>
|
||||
> **Positioning**: This is the mirror counterpart of `overflow.md` (anti-overflow) — overflow handles "too much content", fill-engine handles "too little content".
|
||||
>
|
||||
> Related:
|
||||
> - `overflow.md` — Anti-overflow layout system (degradation strategy for excessive content)
|
||||
> - `pagination.md` — Pagination & cross-page integrity control
|
||||
> - `cover.md` — Cover layout engine (covers not affected by Fill Engine; they have their own layout system)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Scope of Application
|
||||
|
||||
- ✅ **Body pages** (body pages of Report / Academic / Creative routes)
|
||||
- ✅ **All three rendering routes** (ReportLab / LaTeX / Playwright-HTML)
|
||||
- ❌ **Not applicable to covers** (covers are independently controlled by `cover.md`)
|
||||
- ❌ **Not applicable to TOC pages** (TOC has a fixed format)
|
||||
|
||||
---
|
||||
|
||||
## Safety Net 1: Readability Red Line (Font Size Hard Floor)
|
||||
|
||||
**Principle: Never rely on shrinking font size to fit the layout. Font sizes have hard-coded minimums that cannot be breached.**
|
||||
|
||||
### Absolute Font Size Floor
|
||||
|
||||
| Element | Single-column | Double-column | Notes |
|
||||
|------|---------|---------|------|
|
||||
| **Body Text** | **≥ 14pt** | **≥ 12pt** | Below this → considered unreadable, must trigger page break |
|
||||
| **Default base size** | 15pt (CJK) / 14pt (Latin) | 13pt (CJK) / 12pt (Latin) | Starting value, not ceiling |
|
||||
|
||||
### Heading Scale Hard Floor
|
||||
|
||||
| Level | Min Size | Recommended |
|
||||
|------|---------|---------|
|
||||
| **H1** (primary heading / page title) | **≥ 32pt** | 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?
|
||||
```
|
||||
142
skills/pdf/typesetting/geometry.md
Executable file
142
skills/pdf/typesetting/geometry.md
Executable file
@@ -0,0 +1,142 @@
|
||||
# Geometric Anchors
|
||||
|
||||
> Create sophisticated visual anchors from the simplest geometric shapes.
|
||||
|
||||
---
|
||||
|
||||
## What Are Visual Anchors
|
||||
|
||||
Visual anchors are **non-functional decorative elements** on a page, used to:
|
||||
- Break the flatness of text-only / data-only layouts
|
||||
- Establish a visual center of gravity on the page
|
||||
- Convey abstract qualities and design intent
|
||||
- Fill large whitespace areas without adding information noise
|
||||
|
||||
**Key principle: Anchors don't need to "look like" anything concrete. The more abstract, the more refined.**
|
||||
|
||||
---
|
||||
|
||||
## Basic Shape Vocabulary
|
||||
|
||||
| Shape | SVG | Mood / Character |
|
||||
|-------|-----|-----------------|
|
||||
| Circle | `<circle>` | Wholeness, inclusivity, softness |
|
||||
| Semicircle | `<path d="M0,50 A50,50 0 0,1 100,50">` | Rising, gradual, metaphorical |
|
||||
| Triangle | `<polygon>` | Direction, sharpness, modern |
|
||||
| Rectangle | `<rect>` | Stability, order, architectural |
|
||||
| Line | `<line>` | Connection, guidance, minimalism |
|
||||
| Arc | `<path>` + Bézier | Flow, elegance, organic |
|
||||
|
||||
---
|
||||
|
||||
## Composition Patterns
|
||||
|
||||
### Pattern 1: Offset Stacking
|
||||
|
||||
Multiple identical shapes, slightly offset, rotated, with decreasing opacity.
|
||||
|
||||
```svg
|
||||
<svg width="120" height="120" viewBox="0 0 120 120" fill="none">
|
||||
<!-- Three offset circles -->
|
||||
<circle cx="50" cy="50" r="35" stroke="currentColor" stroke-width="0.6" opacity="0.15"/>
|
||||
<circle cx="60" cy="55" r="35" stroke="currentColor" stroke-width="0.6" opacity="0.25"/>
|
||||
<circle cx="70" cy="60" r="35" stroke="currentColor" stroke-width="0.6" opacity="0.4"/>
|
||||
</svg>
|
||||
```
|
||||
|
||||
**Key points**: Same shape, 3 layers, opacity 0.15 → 0.25 → 0.4, offset 10-15px
|
||||
|
||||
### Pattern 2: Scale Contrast
|
||||
|
||||
One large shape + a few small shapes as accents.
|
||||
|
||||
```svg
|
||||
<svg width="150" height="150" viewBox="0 0 150 150" fill="none">
|
||||
<circle cx="60" cy="60" r="50" stroke="currentColor" stroke-width="0.5" opacity="0.2"/>
|
||||
<circle cx="115" cy="30" r="8" fill="currentColor" opacity="0.6"/>
|
||||
<circle cx="105" cy="50" r="3" fill="currentColor" opacity="0.3"/>
|
||||
<circle cx="125" cy="45" r="2" fill="currentColor" opacity="0.2"/>
|
||||
</svg>
|
||||
```
|
||||
|
||||
**Key points**: Large circle stroke-only (hollow), small circles filled (solid), creating solid-void contrast
|
||||
|
||||
### Pattern 3: Grid Intersection
|
||||
|
||||
Lines + dots forming nodes at intersections.
|
||||
|
||||
```svg
|
||||
<svg width="100" height="100" viewBox="0 0 100 100" fill="none">
|
||||
<line x1="20" y1="0" x2="20" y2="100" stroke="currentColor" stroke-width="0.3" opacity="0.1"/>
|
||||
<line x1="50" y1="0" x2="50" y2="100" stroke="currentColor" stroke-width="0.3" opacity="0.1"/>
|
||||
<line x1="80" y1="0" x2="80" y2="100" stroke="currentColor" stroke-width="0.3" opacity="0.1"/>
|
||||
<line x1="0" y1="30" x2="100" y2="30" stroke="currentColor" stroke-width="0.3" opacity="0.1"/>
|
||||
<line x1="0" y1="70" x2="100" y2="70" stroke="currentColor" stroke-width="0.3" opacity="0.1"/>
|
||||
<!-- Intersection points -->
|
||||
<circle cx="50" cy="30" r="3" fill="currentColor" opacity="0.5"/>
|
||||
<circle cx="20" cy="70" r="2" fill="currentColor" opacity="0.3"/>
|
||||
<circle cx="80" cy="70" r="4" fill="currentColor" opacity="0.15"/>
|
||||
</svg>
|
||||
```
|
||||
|
||||
### Pattern 4: Arc Flow
|
||||
|
||||
Bézier curves + endpoint circles, expressing organic flow.
|
||||
|
||||
```svg
|
||||
<svg width="200" height="100" viewBox="0 0 200 100" fill="none">
|
||||
<path d="M10,80 C50,10 150,10 190,80" stroke="currentColor" stroke-width="0.6" opacity="0.25"/>
|
||||
<path d="M10,85 C60,20 140,20 190,85" stroke="currentColor" stroke-width="0.4" opacity="0.15"/>
|
||||
<circle cx="10" cy="80" r="2.5" fill="currentColor" opacity="0.4"/>
|
||||
<circle cx="190" cy="80" r="2.5" fill="currentColor" opacity="0.4"/>
|
||||
</svg>
|
||||
```
|
||||
|
||||
### Pattern 5: Geometric Collage
|
||||
|
||||
Intentional combination of different shapes, like an architectural plan.
|
||||
|
||||
```svg
|
||||
<svg width="120" height="120" viewBox="0 0 120 120" fill="none">
|
||||
<!-- Rectangular frame -->
|
||||
<rect x="10" y="10" width="60" height="60" stroke="currentColor" stroke-width="0.5" opacity="0.2"/>
|
||||
<!-- Circle breaking the straight lines -->
|
||||
<circle cx="70" cy="70" r="30" stroke="currentColor" stroke-width="0.5" opacity="0.2"/>
|
||||
<!-- Diagonal line cutting through -->
|
||||
<line x1="10" y1="10" x2="100" y2="100" stroke="currentColor" stroke-width="0.3" opacity="0.15"/>
|
||||
<!-- Small solid triangle as focal point -->
|
||||
<polygon points="85,20 95,40 75,40" fill="currentColor" opacity="0.4"/>
|
||||
</svg>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Placement Guide
|
||||
|
||||
| Context | Recommended Position | Recommended Size | Pattern |
|
||||
|---------|---------------------|-----------------|---------|
|
||||
| Cover | Top-right / bottom-left offset | 120-200px | Offset Stacking, Geometric Collage |
|
||||
| Chapter divider | Page center | 80-120px | Scale Contrast, Arc Flow |
|
||||
| Header / footer decoration | Corners | 30-50px | Small offset, single circle + line |
|
||||
| Whitespace fill | Alongside content | 60-100px | Grid Intersection |
|
||||
|
||||
---
|
||||
|
||||
## Color Rules
|
||||
|
||||
- Anchor color = document primary color from `palette.cascade` output (`--c-accent` or `--c-text`)
|
||||
- **Use only one color**, layer via opacity (0.1 → 0.5)
|
||||
- Strokes over fills (solid elements ≤ 30% of total shapes)
|
||||
- stroke-width range: 0.3-0.8px (ultra-thin lines = refined look)
|
||||
- **⚠️ All SVG examples below use `currentColor` as placeholder.** When generating actual SVG, replace with the 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
|
||||
630
skills/pdf/typesetting/overflow.md
Executable file
630
skills/pdf/typesetting/overflow.md
Executable file
@@ -0,0 +1,630 @@
|
||||
# Overflow Prevention — Anti-Overflow Layout System
|
||||
|
||||
> PDF is static: no scrollbars, no reflow, no viewport adaptation. Every element must fit within its container BEFORE rendering. This document defines the architectural approach to guarantee zero overflow in any generated PDF.
|
||||
>
|
||||
> **This is the "too much content" side.** For the mirror problem ("too little content" / empty pages), see `typesetting/fill-engine.md` — the anti-void adaptive engine.
|
||||
>
|
||||
> Related:
|
||||
> - `fill-engine.md` — Anti-void engine (font floor, fill ratio, paragraph inflation, Y-axis anchoring)
|
||||
> - `pagination.md` — Pagination & cross-page integrity
|
||||
|
||||
---
|
||||
|
||||
## Core Philosophy
|
||||
|
||||
**"Measure first, draw second."**
|
||||
|
||||
Never use `draw_text(x, y)` or `draw_image(x, y)` directly. All content must pass through a constraint system that pre-calculates sizes and enforces boundaries. Think CSS Box Model, but for a fixed canvas.
|
||||
|
||||
---
|
||||
|
||||
## 1. Bounding Box System (Horizontal Overflow Prevention)
|
||||
|
||||
Every element entering a page is a **Block** with a maximum available width (`Max_Width`).
|
||||
|
||||
### Calculating Max_Width
|
||||
|
||||
```python
|
||||
# Single-column layout
|
||||
max_width = page_width - left_margin - right_margin
|
||||
|
||||
# Dual-column layout
|
||||
col_gap = 12 # points
|
||||
max_width = (page_width - left_margin - right_margin - col_gap) / 2
|
||||
|
||||
# Nested containers (e.g., table cell)
|
||||
cell_max_width = col_width - cell_padding_left - cell_padding_right
|
||||
```
|
||||
|
||||
### Absolute Rule
|
||||
|
||||
> **No Block's rendered width may exceed its parent's `Max_Width`. Period.**
|
||||
|
||||
If a Block's calculated width > Max_Width, apply fallback strategies (see §5).
|
||||
|
||||
---
|
||||
|
||||
## 1.5 🔴 Page Content Centering (Horizontal Centering Iron Rule)
|
||||
|
||||
> **Symptom:** Cover or body content is shifted left on the page, with noticeably more whitespace on the right than left.
|
||||
|
||||
**Root cause:** Asymmetric left/right margins, or cover uses single-side anchors without considering right-side balance.
|
||||
|
||||
**Iron rule:**
|
||||
|
||||
1. **Left/right margins must be symmetric:** `left_margin == right_margin`. No asymmetric margins allowed.
|
||||
2. **Cover:** For left-aligned templates (e.g., Template 01/02/03/05/07), the text starting point should be within `0.10*W ~ 0.15*W`, and the right margin should be between `0.05*W ~ 0.15*W`. Center-aligned templates (Template 04/06) must be absolutely centered.
|
||||
3. **Body:** ReportLab's `Frame` / `SimpleDocTemplate` must have `leftMargin == rightMargin`. LaTeX's `\geometry{left=X, right=X}` must be symmetric. HTML must use `margin: 0 auto` or `padding-left == padding-right`.
|
||||
|
||||
```python
|
||||
# ReportLab: Force symmetric margins
|
||||
from reportlab.lib.units import inch
|
||||
MARGIN = 1 * inch # Left and right must use same variable
|
||||
doc = SimpleDocTemplate(
|
||||
"output.pdf",
|
||||
leftMargin=MARGIN,
|
||||
rightMargin=MARGIN, # ← Must match leftMargin
|
||||
topMargin=MARGIN,
|
||||
bottomMargin=MARGIN,
|
||||
)
|
||||
|
||||
# ❌ WRONG: leftMargin=72, rightMargin=36 → Content shifts left
|
||||
```
|
||||
|
||||
```latex
|
||||
% LaTeX: Force symmetric margins
|
||||
\usepackage[left=2.5cm, right=2.5cm, top=2.5cm, bottom=2.5cm]{geometry}
|
||||
% ❌ WRONG: left=3cm, right=1.5cm
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Text Overflow: Font Metrics Pre-Calculation
|
||||
|
||||
### The Wrong Way
|
||||
```python
|
||||
# ❌ NEVER estimate by character count
|
||||
if len(text) > 20:
|
||||
wrap() # Wrong — 20 CJK chars ≠ 20 Latin chars ≠ 20 mixed chars
|
||||
```
|
||||
|
||||
### The Right Way — ReportLab
|
||||
```python
|
||||
from reportlab.pdfbase.pdfmetrics import stringWidth
|
||||
from reportlab.platypus import Paragraph
|
||||
from reportlab.lib.styles import ParagraphStyle
|
||||
|
||||
# Measure actual rendered width
|
||||
text_width = stringWidth("Your text here", "Microsoft YaHei", 10)
|
||||
|
||||
if text_width > max_width:
|
||||
# Use Paragraph for automatic wrapping — NEVER plain strings in tables
|
||||
style = ParagraphStyle(
|
||||
'CellText',
|
||||
fontName='Microsoft YaHei',
|
||||
fontSize=10,
|
||||
leading=14,
|
||||
wordWrap='CJK', # Enables CJK-aware line breaking
|
||||
)
|
||||
element = Paragraph(text, style)
|
||||
else:
|
||||
element = text # Plain string is fine if it fits
|
||||
```
|
||||
|
||||
### Key Rules
|
||||
- **Always use `Paragraph()` for table cell content** — plain strings don't wrap and will overflow
|
||||
- **CJK text is wider**: Budget ~12pt per character at 10pt font size (vs ~6pt for Latin)
|
||||
- **URLs and long strings**: If a single "word" exceeds column width, enable `wordWrap='CJK'` or split manually
|
||||
- **Hyphenation**: For English text, consider `pyphen` for proper hyphenation of long words
|
||||
|
||||
### The Right Way — LaTeX
|
||||
```latex
|
||||
% Use tabularx for auto-wrapping columns
|
||||
\usepackage{tabularx}
|
||||
\begin{tabularx}{\columnwidth}{lXX} % X columns auto-wrap
|
||||
Header 1 & This long text will wrap & Another wrapping column \\
|
||||
\end{tabularx}
|
||||
|
||||
% For URLs
|
||||
\usepackage{url}
|
||||
\url{https://very-long-url-that-would-overflow.example.com/path/to/resource}
|
||||
```
|
||||
|
||||
### The Right Way — Playwright/HTML
|
||||
```css
|
||||
/* Global text overflow prevention */
|
||||
p, td, li, .content {
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
/* Strict CJK line-break rules */
|
||||
body {
|
||||
line-break: strict;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
/* Table cells must constrain content */
|
||||
/* ⚠️ overflow:hidden + ellipsis only for single-line short text cells.
|
||||
Multi-line td should use overflow-wrap: break-word, not overflow: hidden,
|
||||
otherwise text gets truncated. Especially important in Playwright PDFs. */
|
||||
td {
|
||||
max-width: 0; /* Forces column to respect assigned width */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis; /* Only for single-line cells */
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Image & Chart Overflow: Proportional Scaling
|
||||
|
||||
### Absolute Rule
|
||||
|
||||
> **Never insert an image or chart at its original dimensions. Always compute fit-to-container scaling.**
|
||||
|
||||
### ReportLab Pattern
|
||||
```python
|
||||
from reportlab.platypus import Image
|
||||
from reportlab.lib.units import mm
|
||||
|
||||
def fit_image(img_path, max_w, max_h):
|
||||
"""Scale image to fit within max_w × max_h, preserving aspect ratio."""
|
||||
img = Image(img_path)
|
||||
orig_w, orig_h = img.drawWidth, img.drawHeight
|
||||
|
||||
ratio_w = max_w / orig_w if orig_w > max_w else 1.0
|
||||
ratio_h = max_h / orig_h if orig_h > max_h else 1.0
|
||||
ratio = min(ratio_w, ratio_h)
|
||||
|
||||
img.drawWidth = orig_w * ratio
|
||||
img.drawHeight = orig_h * ratio
|
||||
return img
|
||||
|
||||
# Usage
|
||||
available_width = page_width - left_margin - right_margin
|
||||
max_img_height = A4[1] * 0.35 # ~294pt ≈ 10cm — prevents image from eating page + leaves room for caption
|
||||
img = fit_image("chart.png", available_width, max_img_height)
|
||||
story.append(img)
|
||||
```
|
||||
|
||||
### LaTeX Pattern
|
||||
```latex
|
||||
\usepackage{adjustbox}
|
||||
|
||||
% Always constrain to column width
|
||||
\includegraphics[max width=\columnwidth]{chart.png}
|
||||
|
||||
% Or with adjustbox for both dimensions
|
||||
\begin{adjustbox}{max width=\columnwidth, max height=0.4\textheight}
|
||||
\includegraphics{chart.png}
|
||||
\end{adjustbox}
|
||||
```
|
||||
|
||||
### Playwright/HTML Pattern
|
||||
```css
|
||||
img, svg, .chart-container {
|
||||
max-width: 100%;
|
||||
max-height: 45vh; /* Prevent one image from eating an entire page */
|
||||
height: auto;
|
||||
object-fit: contain; /* Preserve aspect ratio */
|
||||
}
|
||||
```
|
||||
|
||||
> **Why `max-height: 45vh`?** Without a height cap, a tall image combined with `break-inside: avoid` (from pagination.md) gets pushed to the next page — leaving the current page mostly empty and the image occupying an entire page alone. 45vh ensures any image fits within half a page, leaving room for surrounding text on the same page.
|
||||
|
||||
---
|
||||
|
||||
## 3.5 Horizontal Flex/Inline Layout Overflow (Flow Bars, Step Lists, Tag Rows)
|
||||
|
||||
**Problem:** LLMs commonly generate horizontal `display: flex` layouts (process flow bars, step indicators, tag rows, icon grids) without any width constraint or wrap control. When content is longer than expected (e.g. "Theory Framework (ASPICE / V-Model)" as a step label), the total width exceeds the container, pushing content beyond the right page boundary.
|
||||
|
||||
**Playwright PDF consequence:** When any element causes `scrollWidth > clientWidth`, Playwright shrinks the **entire page** to fit, causing all content to appear left-shifted with blank space on the right. This affects ALL pages, not just the one with the overflow.
|
||||
|
||||
### Iron Rules (Direct HTML Flow)
|
||||
|
||||
**Rule 3.5.1 — Mandatory `flex-wrap` for ≥3 inline items:**
|
||||
```css
|
||||
/* Any horizontal row with 3+ children MUST have flex-wrap */
|
||||
.flow-bar, .step-row, .tag-row, .icon-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap; /* MANDATORY for 3+ items */
|
||||
gap: 12px; /* Consistent spacing */
|
||||
max-width: 100%; /* Never exceed container */
|
||||
}
|
||||
```
|
||||
|
||||
**Rule 3.5.2 — Flex children must have `min-width` + `flex-shrink`:**
|
||||
```css
|
||||
.flow-step, .tag-item {
|
||||
flex: 1 1 auto; /* Grow, shrink, auto basis */
|
||||
min-width: 80px; /* Prevent crushing to 0 */
|
||||
max-width: 100%; /* Never exceed container alone */
|
||||
overflow-wrap: break-word; /* Break long words */
|
||||
word-break: break-all; /* CJK fallback */
|
||||
}
|
||||
```
|
||||
|
||||
**Rule 3.5.3 — Arrow/connector separators must not be rigid:**
|
||||
```css
|
||||
/* ❌ WRONG — rigid arrow div between flex items */
|
||||
<div class="step">Step 1</div>
|
||||
<div class="arrow">→</div> /* Fixed-width, prevents shrinking */
|
||||
<div class="step">Step 2</div>
|
||||
|
||||
/* ✅ RIGHT — arrow as pseudo-element, doesn't affect flex layout */
|
||||
.flow-step + .flow-step::before {
|
||||
content: '→';
|
||||
margin: 0 8px;
|
||||
color: #999;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
```
|
||||
|
||||
**Rule 3.5.4 — Threshold-based layout switching:**
|
||||
|
||||
| Item count | Recommended layout | Notes |
|
||||
|------------|-------------------|-------|
|
||||
| 1-3 items | Horizontal flex (no wrap needed if items are short) | Still add `max-width: 100%` on container |
|
||||
| 4-6 items | Horizontal flex + `flex-wrap: wrap` | Items may wrap to 2 rows |
|
||||
| 7+ items | Vertical stack or CSS Grid 2×N | Horizontal becomes unreadable |
|
||||
| Items with long text (>15 CJK chars / >25 Latin chars) | Vertical stack regardless of count | Long labels don't fit side-by-side |
|
||||
|
||||
### Quick Self-Check
|
||||
|
||||
Before generating any horizontal flex layout, verify:
|
||||
```
|
||||
□ Container has max-width: 100% (or explicit width ≤ page width)?
|
||||
□ flex-wrap: wrap is set (if ≥3 items)?
|
||||
□ Each child has min-width + max-width constraints?
|
||||
□ Separators (arrows, dots, lines) are pseudo-elements, not rigid divs?
|
||||
□ Long text items have overflow-wrap: break-word?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Table Overflow: Dynamic Column Width Allocation
|
||||
|
||||
Tables are the #1 source of horizontal overflow.
|
||||
|
||||
### Strategy: Weight-Based Column Width
|
||||
|
||||
```python
|
||||
from reportlab.pdfbase.pdfmetrics import stringWidth
|
||||
from reportlab.platypus import Table, TableStyle, Paragraph
|
||||
from reportlab.lib.styles import ParagraphStyle
|
||||
|
||||
def calculate_col_widths(data, font_name, font_size, available_width, min_col=30):
|
||||
"""Calculate column widths based on content weight.
|
||||
|
||||
Each column's width is proportional to its widest content,
|
||||
with a minimum width and total constrained to available_width.
|
||||
"""
|
||||
n_cols = len(data[0])
|
||||
|
||||
# Measure max content width per column
|
||||
max_widths = [0] * n_cols
|
||||
for row in data:
|
||||
for i, cell in enumerate(row):
|
||||
text = str(cell) if not isinstance(cell, Paragraph) else cell.text
|
||||
w = stringWidth(text, font_name, font_size) + 8 # +8pt padding
|
||||
max_widths[i] = max(max_widths[i], w)
|
||||
|
||||
total_natural = sum(max_widths)
|
||||
|
||||
if total_natural <= available_width:
|
||||
# Everything fits — distribute remaining space proportionally
|
||||
extra = available_width - total_natural
|
||||
return [w + extra * (w / total_natural) for w in max_widths]
|
||||
else:
|
||||
# Must compress — allocate proportionally with minimum
|
||||
col_widths = []
|
||||
for w in max_widths:
|
||||
allocated = max(min_col, available_width * (w / total_natural))
|
||||
col_widths.append(allocated)
|
||||
|
||||
# Normalize to exactly fit available_width
|
||||
scale = available_width / sum(col_widths)
|
||||
return [w * scale for w in col_widths]
|
||||
|
||||
|
||||
def build_safe_table(data, available_width, font_name='Microsoft YaHei', font_size=9):
|
||||
"""Build a table guaranteed not to overflow horizontally.
|
||||
|
||||
All text cells are wrapped in Paragraph() for automatic line-breaking.
|
||||
"""
|
||||
wrap_style = ParagraphStyle(
|
||||
'TableCell',
|
||||
fontName=font_name,
|
||||
fontSize=font_size,
|
||||
leading=font_size + 3,
|
||||
wordWrap='CJK',
|
||||
)
|
||||
|
||||
# Wrap all cells in Paragraph
|
||||
wrapped_data = []
|
||||
for row in data:
|
||||
wrapped_row = [Paragraph(str(cell), wrap_style) for cell in row]
|
||||
wrapped_data.append(wrapped_row)
|
||||
|
||||
col_widths = calculate_col_widths(data, font_name, font_size, available_width)
|
||||
|
||||
# Verify total width
|
||||
assert sum(col_widths) <= available_width + 0.5, \
|
||||
f"Table width {sum(col_widths):.1f} exceeds available {available_width:.1f}"
|
||||
|
||||
table = Table(wrapped_data, colWidths=col_widths, repeatRows=1)
|
||||
return table
|
||||
```
|
||||
|
||||
### LaTeX Table Width Management
|
||||
```latex
|
||||
% For tables that MUST fit single column
|
||||
\begin{tabularx}{\columnwidth}{l X X r} % X = flexible width columns
|
||||
...
|
||||
\end{tabularx}
|
||||
|
||||
% For wide tables in twocolumn mode → span full page
|
||||
\begin{table*}[t]
|
||||
\begin{tabularx}{\textwidth}{l X X X X r}
|
||||
...
|
||||
\end{tabularx}
|
||||
\end{table*}
|
||||
|
||||
% Last resort: shrink to fit (verify ≥ 6pt after scaling)
|
||||
\resizebox{\columnwidth}{!}{%
|
||||
\begin{tabular}{lllllll}
|
||||
...
|
||||
\end{tabular}
|
||||
}
|
||||
```
|
||||
|
||||
### LaTeX Equation Width Management (Dual-Column)
|
||||
|
||||
Equations are the **#2 overflow source** after tables in two-column papers (ACM `sigconf` column ~241pt, IEEE ~252pt).
|
||||
|
||||
```latex
|
||||
% ❌ WRONG — two full equations on one line
|
||||
\begin{equation}
|
||||
\mathbf{e}_u = \sum \frac{1}{\sqrt{...}} \mathbf{e}_i, \quad
|
||||
\mathbf{e}_i = \sum \frac{1}{\sqrt{...}} \mathbf{e}_u
|
||||
\end{equation}
|
||||
|
||||
% ✅ CORRECT — one equation per line
|
||||
\begin{align}
|
||||
\mathbf{e}_u &= \sum \frac{1}{\sqrt{...}} \mathbf{e}_i, \\
|
||||
\mathbf{e}_i &= \sum \frac{1}{\sqrt{...}} \mathbf{e}_u.
|
||||
\end{align}
|
||||
|
||||
% ✅ For wide fractions (softmax, attention)
|
||||
% Factor out sub-expressions into separate definitions
|
||||
\begin{equation}
|
||||
\alpha_{uv} = \frac{\exp(f(u,v))}{\sum_k \exp(f(u,k))},
|
||||
\quad \text{where } f(u,v) = \text{LeakyReLU}(\ldots)
|
||||
\end{equation}
|
||||
|
||||
% ✅ For contrastive losses: use multline
|
||||
\begin{multline}
|
||||
\mathcal{L}_{\text{SSL}}^u =
|
||||
-\log \frac{\exp(\text{sim}(z_u', z_u'')/\tau)}
|
||||
{\sum_{v \neq u} \exp(\text{sim}(z_u', z_v'')/\tau)}.
|
||||
\end{multline}
|
||||
```
|
||||
|
||||
**Quick heuristic:** if `equation` body > 60 raw characters (excluding `\label`), it probably overflows dual-column. Use `align`, `split`, `multline`, or factor out sub-expressions.
|
||||
|
||||
See `academic.md` Rules 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%?
|
||||
```
|
||||
367
skills/pdf/typesetting/pagination.md
Executable file
367
skills/pdf/typesetting/pagination.md
Executable file
@@ -0,0 +1,367 @@
|
||||
# Pagination & Flow Control
|
||||
|
||||
> Core rules for multi-page document layout quality. Must be followed every time a multi-page PDF is generated.
|
||||
>
|
||||
> Related:
|
||||
> - `typesetting/overflow.md` — the comprehensive overflow prevention system covering all three routes (ReportLab, LaTeX, Playwright).
|
||||
> - `typesetting/fill-engine.md` — the **anti-void** adaptive engine (handles pages with too little content: font floor, fill ratio, paragraph inflation, Y-axis golden-ratio anchoring).
|
||||
|
||||
---
|
||||
|
||||
## 1. Last Page Blank Control (Anti-Orphan Page)
|
||||
|
||||
**Problem**: The last page has only one or two lines of content with large blank areas — looks terrible.
|
||||
|
||||
**Mandatory Rules**:
|
||||
|
||||
- After generating multi-page content, **you must check the content fill ratio of the last page**
|
||||
- When last page content ratio < 25%, **you must backtrack and adjust**
|
||||
- Adjustment strategies (by priority):
|
||||
1. **Compress preceding page spacing**: Reduce margin-bottom between sections (decrease 2-4px each)
|
||||
2. **Tighten line height**: Body line-height from 1.7 → 1.55 (no lower than 1.5)
|
||||
3. **Reduce font size**: Body from 16px → 15px (no lower than 14px)
|
||||
4. **Trim content**: Remove dispensable descriptive text without affecting core information
|
||||
5. **Merge small sections**: Combine adjacent sections with little content
|
||||
|
||||
**Checking Method (Playwright HTML route)**:
|
||||
```css
|
||||
/* Check min-content on the last .page element */
|
||||
/* If content is less than 25% of page, backtracking is needed */
|
||||
```
|
||||
|
||||
**Practical Standards**:
|
||||
- Last page content ratio >= 40% → ✅ Pass
|
||||
- Last page content ratio 25%-40% → ⚠️ Acceptable but optimization recommended
|
||||
- Last page content ratio < 25% → ❌ Must adjust
|
||||
|
||||
---
|
||||
|
||||
## 2. Table Cross-Page Integrity
|
||||
|
||||
**Problem**: Table header and first data row split across two pages; table cut in the middle.
|
||||
|
||||
**Mandatory Rules**:
|
||||
|
||||
### Playwright HTML Route
|
||||
```css
|
||||
/* Prevent table splitting */
|
||||
table, .table-wrapper {
|
||||
break-inside: avoid; /* Preferred: keep entire table together */
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
/* If table is too long and must split, ensure header repeats */
|
||||
thead {
|
||||
display: table-header-group; /* Repeat header on each page */
|
||||
}
|
||||
|
||||
/* Don't cut table rows in the middle */
|
||||
tr {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
/* Bind header + at least 2 data rows together */
|
||||
thead + tbody tr:first-child,
|
||||
thead + tbody tr:nth-child(2) {
|
||||
break-before: avoid;
|
||||
page-break-before: avoid;
|
||||
}
|
||||
```
|
||||
|
||||
### ReportLab Route
|
||||
```python
|
||||
# Use Table's repeatRows parameter
|
||||
table = Table(data, repeatRows=1) # Repeat header on each page
|
||||
|
||||
# Or use KeepTogether to wrap small tables
|
||||
from reportlab.platypus import KeepTogether
|
||||
elements.append(KeepTogether([table_title, table]))
|
||||
```
|
||||
|
||||
**Additional Rules**:
|
||||
- Table rows ≤ 8: Entire table `break-inside: avoid`, no page splitting allowed
|
||||
- Table rows > 8: Splitting allowed, but must use `thead { display: table-header-group }` to repeat header on each page
|
||||
- All card-grid / flex-grid layouts follow the same rule: `break-inside: avoid`
|
||||
|
||||
---
|
||||
|
||||
## 3. CJK Punctuation Placement Rules
|
||||
|
||||
**Problem**: Commas, periods, enumeration commas, etc. appearing at line start, violating CJK typesetting standards.
|
||||
|
||||
**Mandatory Rules**:
|
||||
|
||||
### Playwright HTML Route (Recommended)
|
||||
```css
|
||||
/* Global CJK punctuation rules */
|
||||
body {
|
||||
line-break: strict; /* Strict line-break rules */
|
||||
word-break: normal; /* Don't force word breaks */
|
||||
overflow-wrap: break-word; /* Allow long words to break */
|
||||
hanging-punctuation: allow-end; /* Allow punctuation to hang past line end */
|
||||
}
|
||||
|
||||
/* For body paragraphs */
|
||||
p, .body-text, td, li {
|
||||
line-break: strict;
|
||||
text-align: justify; /* Justify to reduce line-end gaps */
|
||||
}
|
||||
```
|
||||
|
||||
**Effect of `line-break: strict`**:
|
||||
- Prevents line-start: ,。、;:!?)】》…—
|
||||
- Prevents line-end: (【《
|
||||
- Natively supported by Chromium engine, no extra JS needed
|
||||
|
||||
### ReportLab Route
|
||||
```python
|
||||
# Set in ReportLab Paragraph style
|
||||
from reportlab.lib.enums import TA_JUSTIFY
|
||||
style = ParagraphStyle(
|
||||
'Body',
|
||||
alignment=TA_JUSTIFY,
|
||||
wordWrap='CJK', # CJK line-break mode
|
||||
)
|
||||
```
|
||||
|
||||
**Verification Checklist**:
|
||||
- [ ] No comma/period appears as the first character of any line
|
||||
- [ ] Left parenthesis / left quotation mark does not appear at line end
|
||||
- [ ] Ellipsis is not broken in the middle
|
||||
|
||||
---
|
||||
|
||||
## 4. Major Section Page-Break Rule (3/4 Threshold)
|
||||
|
||||
**Problem**: A major section (H1/一级标题) ends at ~75% of the page, and the next major 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('Helvetica', 9)
|
||||
canvas.drawCentredString(doc.pagesize[0] / 2, 0.5 * inch,
|
||||
str(doc.page))
|
||||
canvas.restoreState()
|
||||
|
||||
def footer_with_roman(canvas, doc):
|
||||
"""Roman numeral page number for front matter."""
|
||||
roman_map = {1:'i',2:'ii',3:'iii',4:'iv',5:'v',6:'vi',7:'vii',8:'viii',9:'ix',10:'x'}
|
||||
page_num = roman_map.get(doc.page, str(doc.page))
|
||||
canvas.saveState()
|
||||
canvas.setFont('Helvetica', 9)
|
||||
canvas.drawCentredString(doc.pagesize[0] / 2, 0.5 * inch, page_num)
|
||||
canvas.restoreState()
|
||||
|
||||
def no_footer(canvas, doc):
|
||||
"""Cover page — no visible page number."""
|
||||
pass
|
||||
|
||||
# Define page templates:
|
||||
# - 'cover': no footer
|
||||
# - 'frontmatter': Roman numeral footer
|
||||
# - 'body': Arabic footer (page counter resets)
|
||||
```
|
||||
|
||||
### Playwright / HTML + CSS Implementation
|
||||
|
||||
```css
|
||||
/* Zone 1: Cover — suppress page number */
|
||||
.page-cover {
|
||||
/* No footer content */
|
||||
}
|
||||
|
||||
/* Zone 2: Front matter — Roman numerals via CSS counter */
|
||||
@page :nth(2) { /* Adjust range based on front matter pages */ }
|
||||
|
||||
/* For Playwright, page numbers are typically added via:
|
||||
1. A footer element on each .page div, or
|
||||
2. Post-processing with pypdf after PDF generation */
|
||||
```
|
||||
|
||||
**Practical approach for Playwright route**: Since CSS `@page` counters with Roman/Arabic switching are poorly supported, the recommended pattern is:
|
||||
1. Generate the PDF without page numbers
|
||||
2. Use pypdf to stamp page numbers in post-processing:
|
||||
- Skip page 1 (cover)
|
||||
- Roman numerals for front matter pages
|
||||
- Arabic starting from 1 for body pages
|
||||
|
||||
### LaTeX Implementation
|
||||
|
||||
```latex
|
||||
% Cover: no page number displayed
|
||||
\begin{titlepage}
|
||||
\thispagestyle{empty} % Suppress page number
|
||||
% ... cover content ...
|
||||
\end{titlepage}
|
||||
|
||||
% Front matter: Roman numerals
|
||||
\pagenumbering{roman} % Switches to i, ii, iii...
|
||||
\tableofcontents
|
||||
\newpage
|
||||
|
||||
% Body: Arabic, reset to 1
|
||||
\pagenumbering{arabic} % Switches to 1, 2, 3... (auto-resets counter)
|
||||
\section{Introduction}
|
||||
% ...
|
||||
|
||||
% Appendix: continues Arabic numbering (no reset)
|
||||
\appendix
|
||||
\section{Appendix A} % Page number continues from body
|
||||
|
||||
% References: continues Arabic numbering (no reset)
|
||||
\bibliographystyle{plain}
|
||||
\bibliography{refs}
|
||||
```
|
||||
|
||||
### When to Deviate
|
||||
|
||||
- **Single-page documents** (certificates, letters, posters): No page numbering at all.
|
||||
- **Short documents (≤3 pages)**: Simple Arabic `1, 2, 3` throughout, no cover/frontmatter distinction.
|
||||
- **User explicitly requests a different scheme**: Follow the user's instructions.
|
||||
- **Exam papers**: Sequential Arabic numbering on every page, including page 1.
|
||||
217
skills/pdf/typesetting/palette.md
Executable file
217
skills/pdf/typesetting/palette.md
Executable file
@@ -0,0 +1,217 @@
|
||||
# Color Palette System
|
||||
|
||||
> Color is the skeleton of design. Unified, restrained, systematic. Garish = amateur.
|
||||
|
||||
---
|
||||
|
||||
## Cascade Palette System (V2 — Preferred)
|
||||
|
||||
The cascade palette enforces one iron law: **Area ∝ 1/Saturation**.
|
||||
The larger the colored area, the lower its saturation must be.
|
||||
|
||||
### Tier System
|
||||
|
||||
| Tier | Area % | S Cap | Roles |
|
||||
|------|--------|-------|-------|
|
||||
| **XL** | >50% | ≤ 0.08 | `page_bg`, `section_bg` |
|
||||
| **L** | 20-50% | ≤ 0.15 | `card_bg`, `table_stripe` |
|
||||
| **M** | 5-20% | ≤ 0.30 | `header_fill`, `cover_block` |
|
||||
| **S** | 1-5% | ≤ 0.50 | `border`, `icon` |
|
||||
| **XS** | <1% | ≤ 0.75 | `accent`, `accent_secondary` |
|
||||
|
||||
### How It Works
|
||||
|
||||
One base hue → 12 roles + 4 semantic colors. Cover, body, and charts all pull from the same palette:
|
||||
|
||||
```
|
||||
palette.cascade
|
||||
├── cover subset (page_bg, header_fill, cover_block, accent, text_primary...)
|
||||
├── body subset (page_bg, section_bg, card_bg, table_stripe, border...)
|
||||
├── chart subset (accent as series_1, accent_secondary as series_2, ...)
|
||||
└── semantic (success, warning, error, info — all low-sat)
|
||||
```
|
||||
|
||||
No orphan colors. No "cover finished, now pick new colors for body" drift.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
# Via design_engine.py
|
||||
python3 "$PDF_SKILL_DIR/scripts/design_engine.py" palette-cascade --intent cold --mode minimal
|
||||
|
||||
# Via pdf.py (auto-derives intent from title)
|
||||
python3 "$PDF_SKILL_DIR/scripts/pdf.py" palette.cascade --title "2025年度报告" --format reportlab
|
||||
|
||||
# Formats: summary (default) | json | css | reportlab
|
||||
```
|
||||
|
||||
### Output Formats
|
||||
|
||||
- **summary**: Human-readable table with tier/role/hex/saturation
|
||||
- **json**: Full structured data (roles, cover, body, charts, semantic, meta)
|
||||
- **css**: CSS custom properties ready for HTML/Playwright
|
||||
- **reportlab**: Python code ready to paste into ReportLab scripts
|
||||
|
||||
---
|
||||
|
||||
## Core Iron Rules
|
||||
|
||||
### 1. One Document, One Color Family
|
||||
|
||||
**Not one color — one color family.**
|
||||
|
||||
- After choosing the primary color, all secondary, accent, and background colors must be derived from it
|
||||
- Derivation methods: lightness shift, saturation shift, micro hue adjustment (within ±15°)
|
||||
- **Forbidden** to have unrelated colors in the same document
|
||||
|
||||
```
|
||||
Primary → Headings, key data, primary buttons
|
||||
Secondary → Primary lightness ±15-25%
|
||||
Accent → Primary hue ±10-15°, for highlights/warnings
|
||||
Neutral → Gray series for body text, not conflicting with primary
|
||||
Background → Pure white / primary at opacity 3-8%
|
||||
```
|
||||
|
||||
### 2. Color Count Limits
|
||||
|
||||
| Element Type | Max Colors | Notes |
|
||||
|-------------|-----------|-------|
|
||||
| Entire document | 4-5 | Primary + secondary + accent + neutral + background |
|
||||
| Single component (card/table) | 2-3 | Don't give each card a different color |
|
||||
| Charts / data visualization | Same-family gradient | Differentiate by opacity/lightness, not different hues |
|
||||
| Tags / badges | 1 color + text color | No rainbow tags |
|
||||
|
||||
### 3. Absolutely Forbidden Color Fills
|
||||
|
||||
The following are automatic failures:
|
||||
|
||||
- ❌ 4 cards using 4 completely different colors (red/blue/green/purple)
|
||||
- ❌ Alternating table rows in different colors (blue row/pink row)
|
||||
- ❌ Rainbow-colored pie charts/bar charts
|
||||
- ❌ Each section with a different theme color
|
||||
- ❌ Gradient transitioning from warm to cool tones (red → blue)
|
||||
|
||||
---
|
||||
|
||||
## Color Generation Rules
|
||||
|
||||
### Deriving a Full Palette from Primary
|
||||
|
||||
```
|
||||
Given primary H(hue) S(saturation) L(lightness):
|
||||
|
||||
Primary: hsl(H, S, L) — Headings, key elements
|
||||
Dark variant: hsl(H, S, L-15%) — Hover, borders, icons
|
||||
Light variant: hsl(H, S-10%, L+25%) — Tag backgrounds, light fills
|
||||
Ultra-light bg: hsl(H, S-20%, 96%) — Section backgrounds, card base
|
||||
Accent: hsl(H+15, S, L) — Warnings, highlights (micro hue shift)
|
||||
```
|
||||
|
||||
### Example: Deriving from a primary color
|
||||
|
||||
> ⚠️ The hex values below are **examples only**. In production, use `palette.cascade` or `palette.generate` to compute the full palette from intent.
|
||||
|
||||
```css
|
||||
:root {
|
||||
--c-primary: #2d5a87; /* Primary (from palette.cascade) */
|
||||
--c-primary-d: #1e3d5c; /* Dark variant */
|
||||
--c-primary-l: #5a8ab8; /* Light variant */
|
||||
--c-primary-bg: #f0f4f8; /* Ultra-light background */
|
||||
--c-accent: #2d6a87; /* Accent (hue +10°) */
|
||||
--c-text: #333; /* Body text */
|
||||
--c-text-muted: #888; /* Secondary text */
|
||||
--c-border: #e0e4e8; /* Border lines */
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Multi-Element Differentiation Strategies
|
||||
|
||||
When distinguishing multiple sibling elements (e.g., multiple cards, categories), **don't use different colors — use these approaches instead**:
|
||||
|
||||
### Strategy A: Same Hue, Different Lightness
|
||||
```css
|
||||
.card-1 { background: hsl(220, 40%, 95%); } /* Lightest */
|
||||
.card-2 { background: hsl(220, 40%, 90%); }
|
||||
.card-3 { background: hsl(220, 40%, 85%); }
|
||||
.card-4 { background: hsl(220, 40%, 80%); } /* Darkest */
|
||||
```
|
||||
|
||||
### Strategy B: Same Color, Different Opacity
|
||||
```css
|
||||
.item-1 { background: rgba(30, 58, 95, 0.06); }
|
||||
.item-2 { background: rgba(30, 58, 95, 0.12); }
|
||||
.item-3 { background: rgba(30, 58, 95, 0.18); }
|
||||
.item-4 { background: rgba(30, 58, 95, 0.24); }
|
||||
```
|
||||
|
||||
### Strategy C: Primary + Whitespace + Lines
|
||||
```css
|
||||
/* Differentiate by border color/weight/style, uniform white background */
|
||||
.card-1 { border-left: 3px solid var(--primary); }
|
||||
.card-2 { border-left: 3px solid var(--primary-l); }
|
||||
.card-3 { border-left: 3px solid var(--primary-d); }
|
||||
```
|
||||
|
||||
### Strategy D: Icons / Numbering (Not Color)
|
||||
```css
|
||||
/* All cards same color, differentiated by icons, numbers, or layout variation */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gradient Usage Rules
|
||||
|
||||
### Allowed Gradients
|
||||
- **Same-family gradient**: `linear-gradient(135deg, var(--c-primary), var(--c-primary-l))` — hue difference < 20°
|
||||
- **Lightness gradient**: `linear-gradient(180deg, #fff, #f5f5f5)` — pure lightness change
|
||||
- **Primary to transparent**: `linear-gradient(90deg, var(--c-primary), transparent)` — for decorative lines
|
||||
|
||||
### Forbidden Gradients
|
||||
- ❌ Warm-to-cool crossover: `linear-gradient(#ff6b6b, #4ecdc4)`
|
||||
- ❌ More than 3 colors: `linear-gradient(red, yellow, green, blue)`
|
||||
- ❌ Neon gradients: Any high-saturation gradient
|
||||
- ❌ Gratuitous gradients: Gradients added purely for "looks nice" without purpose
|
||||
|
||||
---
|
||||
|
||||
## Preset Palettes (Ready to Use)
|
||||
|
||||
### Business Blue
|
||||
```
|
||||
#1a365d → #2a5298 → #4a7ac7 → #dce6f5 → #f5f8fc
|
||||
```
|
||||
|
||||
### Warm Gray
|
||||
```
|
||||
#2d2d2d → #5a5a5a → #8a8a8a → #e8e8e8 → #f9f9f9
|
||||
```
|
||||
|
||||
### Forest Green
|
||||
```
|
||||
#1a3c2a → #2d6b4a → #4a9a6a → #d5ead8 → #f2f8f4
|
||||
```
|
||||
|
||||
### Terracotta Red
|
||||
```
|
||||
#5c2018 → #8a3828 → #b85a48 → #f0d8d0 → #faf4f2
|
||||
```
|
||||
|
||||
### Indigo Purple
|
||||
```
|
||||
#2d1b4e → #4a2d7a → #6a4aaa → #ddd0f0 → #f5f2fa
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Check
|
||||
|
||||
```
|
||||
□ How many colors does the entire document use? (Target ≤ 5)
|
||||
□ Can every color be traced back to the primary?
|
||||
□ Are there any colors that "suddenly appear" without derivation?
|
||||
□ Are sibling elements rainbow-colored?
|
||||
□ Gradient endpoint hue difference < 20°?
|
||||
□ If you remove all color and look at grayscale only, is the hierarchy still clear?
|
||||
```
|
||||
20
skills/pdf/typesetting/typography.md
Executable file
20
skills/pdf/typesetting/typography.md
Executable file
@@ -0,0 +1,20 @@
|
||||
|
||||
---
|
||||
|
||||
## CJK Typography Supplement
|
||||
|
||||
> See `pagination.md` Section 3 for detailed rules.
|
||||
|
||||
```css
|
||||
/* CJK punctuation placement rules (always include) */
|
||||
body {
|
||||
line-break: strict;
|
||||
word-break: normal;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
p, td, li {
|
||||
line-break: strict;
|
||||
text-align: justify;
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user