Files
AOS/skills/pdf/briefs/report.md
T
Z User e45b68bb8b feat: IMAP mail sync with thread-to-contact matching (PRD §6.1)
Real IMAP connection via imapflow library:
- Connects to IMAP server (IMAPFlow, TLS on port 993)
- Fetches recent messages (configurable limit, default 20)
- Extracts: from, fromEmail, subject, date, snippet (200 chars)
- Thread-to-contact matching (PRD §6.1):
  1. Match sender email against persons table
  2. If no person match, extract domain and match against company.website
  3. Returns matchedCompany + matchedPerson for each email
- Audit-logged (action: crm.mailsync:imap)
- Zod validated (host, port, user, password, folder, limit)
- 502 on IMAP connection failure with detail
- 400 on validation failure

Tested: invalid creds → 502 'IMAP connection failed', missing host → 400
2026-10-27 22:50:50 +00:00

75 KiB
Executable File
Raw Blame History

Brief: Report Production

Structured documents via ReportLab: reports, proposals, contracts, white papers, financial analysis, data tables. Also covers ATS-friendly resumes.


EMOJI CHECK - STOP HERE IF CONTENT HAS EMOJI

ReportLab renders emoji (📊🎯🔥💡 etc.) as □ tofu squares. This is unfixable.

If the user's content contains intentional emoji → STOP. Do NOT use this brief. Route to briefs/creative-fixed-canvas.md instead (Playwright renders emoji natively).

This applies even if the document is a "report" - emoji + ReportLab = broken output.


Production Workflow

1. BRIEF    → Confirm document type, audience, page count, outline
2. DESIGN   → Run `pdf.py palette.cascade --title "..."` → copy-paste output into script
3. EDIT     → Content transformation (extract data, define typographic roles)
3.5 NUMBERING → ⚠️ Chapter Numbering Plan (see below) — MUST output mapping table before writing code
4. COVER    → ⚠️ MANDATORY READ: `typesetting/cover.md` — MUST read BEFORE writing any cover HTML
5. BUILD    → ⚠️ TOC GATE first (see below) → Write ReportLab code
6. PREFLIGHT → code.sanitize → execute → meta.brand → font.check → toc.check → pages.clean → pdf_qa.py
7. DELIVER  → Merge cover + body → final PDF

⚠️ Word (.docx) dual-delivery rule: If the user also requests a Word version, you MUST generate it through the docx skill (load docx/SKILL.md → read references/design-system.md → use a validated cover recipe R1-R7). Do NOT hand-write a python-docx script with freehand cover code — freehand covers produce blank pages, wrong heights, and broken layouts in MS Office/WPS. The docx skill's cover system exists to prevent these exact problems.

Typesetting assets (load when you reach that step):

  • typesetting/palette.md - color system, typography rules, anti-patterns
  • typesetting/cover.md - 5 cover layouts with variants, typography scale, bounding box rules
  • typesetting/charts.md - chart styling, anti-stacking rules, axis/grid/legend treatment

Cover is DEFAULT ON for reports. Skip cover only for: resumes, letters, memos, forms, checklists, or documents ≤ 2 pages.


⚠️ Step 4 COVER — MANDATORY Pre-Read Gate

You MUST read typesetting/cover.md BEFORE writing any cover HTML. Do NOT write cover HTML from memory or from pre-trained knowledge. The cover system has 5 specific templates with precise zone coordinates, layer structure, and bounding box rules that change over time.

# MANDATORY: Read cover.md first. Do NOT skip this.
cat "$PDF_SKILL_DIR/typesetting/cover.md"

After reading cover.md:

  1. Select a template (01-07) based on document tone and type
  2. Follow the template's HTML structure exactly
  3. Run poster_validate.py check-html on the result
  4. Run cover_validate.js on the result (checks text-text overlap + text-line overlap)
  5. Only then render via html2poster.js --width 794px

Common failure mode: Model reads report.md, skips cover.md, writes freehand HTML → broken layout, missing zones, overflow issues, decorative elements outside page bounds. The cover templates exist to prevent these exact problems.


⚠️ Step 5 BUILD — MANDATORY TOC Gate

Before writing ANY ReportLab code, answer this question:

Does this document have a Table of Contents?

Answer DocTemplate Build Method TOC Implementation
YES TocDocTemplate doc.multiBuild(story) TableOfContents() + heading bookmark_name/level/text/key attributes
NO SimpleDocTemplate doc.build(story) N/A

⚠️ If you answered YES, these are ALL mandatory — missing any one = broken TOC:

# 1. MUST define TocDocTemplate (NOT SimpleDocTemplate)
class TocDocTemplate(SimpleDocTemplate):
    def afterFlowable(self, flowable):
        if hasattr(flowable, 'bookmark_name'):
            level = getattr(flowable, 'bookmark_level', 0)
            text = getattr(flowable, 'bookmark_text', '')
            key = getattr(flowable, 'bookmark_key', '')
            self.notify('TOCEntry', (level, text, self.page, key))

# 2. MUST add TableOfContents to story
toc = TableOfContents()
toc.levelStyles = [toc_level0, toc_level1]  # define your styles
story.append(toc)
story.append(PageBreak())

# 3. MUST set bookmark attributes on every heading
def add_heading(text, style, level=0):
    key = f'h_{hashlib.md5(text.encode()).hexdigest()[:8]}'
    p = Paragraph(f'<a name="{key}"/>{text}', style)
    p.bookmark_name = key
    p.bookmark_level = level
    p.bookmark_text = text
    p.bookmark_key = key
    return p

# 4. MUST use multiBuild (NOT build)
doc = TocDocTemplate(output_path, pagesize=A4, ...)
doc.multiBuild(story)

🚫 FORBIDDEN patterns (these produce broken/fake TOC):

# ❌ Hard-coded TOC items with manual page numbers
toc_items = [('Chapter 1', '3'), ('Chapter 2', '5')]  # WRONG - not clickable, page numbers will be wrong

# ❌ SimpleDocTemplate + build() with TOC
doc = SimpleDocTemplate(...)  # WRONG if you have TOC
doc.build(story)              # WRONG - TOC entries won't populate

# ❌ TableOfContents without TocDocTemplate
toc = TableOfContents()
story.append(toc)
doc = SimpleDocTemplate(...)  # WRONG - no afterFlowable to feed TOC

Step 2: DESIGN - Palette & Font Plan

Before writing any ReportLab code, you MUST generate the color palette via the palette.generate command. This is not optional. Do NOT hardcode hex colors. Do NOT pick colors by feel.

2a. Generate Palette (MANDATORY)

Run this command FIRST. Copy-paste its output directly into your Python script:

python3 "$PDF_SKILL_DIR/scripts/pdf.py" palette.cascade --title "<document title>" --mode minimal --format reportlab

⚠️ Use palette.cascade (V2), NOT the old palette.generate. Cascade palette outputs unified color roles for cover, body, charts, and tables from one base hue.

The command auto-derives the design intent from the document title, computes a mathematically harmonious palette, and outputs ready-to-paste ReportLab Python code:

# ━━ Cascade Palette (auto-generated by design_engine.py palette-cascade) ━━
from reportlab.lib import colors

# XL tier: backgrounds (area > 50%, S ≤ 0.08)
PAGE_BG       = colors.HexColor('#f5f4f3')
SECTION_BG    = colors.HexColor('#f0efed')

# L tier: surfaces (area 20-50%, S ≤ 0.15)
CARD_BG       = colors.HexColor('#eae8e4')
TABLE_STRIPE  = colors.HexColor('#edebe8')

# M tier: structural fills (area 5-20%, S ≤ 0.30)
HEADER_FILL   = colors.HexColor('#3d5a6e')
COVER_BLOCK   = colors.HexColor('#4a6575')

# S tier: edges & icons (area 1-5%, S ≤ 0.50)
BORDER        = colors.HexColor('#b8c0c7')
ICON          = colors.HexColor('#4e6d7a')

# XS tier: emphasis (area < 1%, S ≤ 0.75)
ACCENT        = colors.HexColor('#2f97b9')
ACCENT_2      = colors.HexColor('#3a8a7d')

# Typography
TEXT_PRIMARY  = colors.HexColor('#252422')
TEXT_MUTED    = colors.HexColor('#8d8981')

# Table colors (derived from cascade tiers — NOT from ACCENT)
TABLE_HEADER_COLOR = HEADER_FILL          # M tier, low-sat — harmonious with body
TABLE_HEADER_TEXT  = colors.white          # White text on dark header
TABLE_ROW_EVEN     = colors.white          # Clean white
TABLE_ROW_ODD      = TABLE_STRIPE          # L tier, very subtle stripe

Options:

  • --mode minimal (default, recommended for 50%+ of documents) | dark | pastel | jewel | light
  • --harmony auto (default, recommended) | complementary | split_complementary | analogous | triadic | monochrome
  • --format reportlab (recommended) | json | css | summary
  • --seed <int> - for reproducible palettes across regenerations

⚠️ FORBIDDEN:

  • ❌ Writing colors.HexColor('#xxxxxx') with any hex value you chose yourself
  • ❌ Using colors.red, colors.blue, or any ReportLab named color for design elements
  • ❌ Skipping this step and picking colors "that look good"
  • ❌ Using different palettes for different sections of the same document
  • ❌ Using ACCENT (XS tier, high saturation) for table headers — use HEADER_FILL (M tier)
  • ❌ Using the old palette.generate — always use palette.cascade

✅ The ONLY acceptable way to get colors: Run palette.cascade, copy the output.

2b. Color Application Rules

Element Color Source Tier Notes
Table headers HEADER_FILL (bg) + white (text) M Low-sat, harmonious with body
Table odd rows TABLE_STRIPE L Very subtle same-hue stripe
Table even rows colors.white — Clean white
Section titles HEADER_FILL or TEXT_PRIMARY M/text Match heading hierarchy
Body text TEXT_PRIMARY text Never use pure #000000
Muted/meta text TEXT_MUTED text Dates, captions, footnotes
Horizontal rules BORDER at 30% opacity S Thin, unobtrusive
Chart colors ACCENT → ACCENT_2 → HEADER_FILL → ICON XS/M/S Series colors (see charts.md)
Badges / tags ACCENT XS Small area, high sat OK
Card backgrounds CARD_BG L Container fills

2c. Forbidden

  • ❌ Hardcoding hex colors like #1F4E79, #555555, #888888 in code
  • ❌ Using colors.red, colors.blue or any ReportLab named color for design elements
  • ❌ Choosing colors by "feel" without running palette first
  • ❌ Using different accent colors across tables/sections in the same document

Exception: Semantic colors for data visualization (green for positive, red for negative) are acceptable but should be muted variants derived from the palette when possible.


Step 3: EDIT - Content Transformation

Before writing any code, restructure raw text into visual roles. This is the most impactful quality step - skipping it produces "Word document with a PDF extension".

3a. Typographic Role Extraction

Parse raw text and categorize content into visual roles:

Role ReportLab Mapping Description
Hero / Display Cover title (36-42pt) or section opener 1-5 words, the emotional hook
Kicker / Eyebrow Subtitle or section intro (10-12pt, muted) Tiny context line above main heading
Data Sculpture CalloutBox or bold stat block Extract impactful numbers (97%, $4.2M, -45ms)
Pull Quote BlockQuote (italic + left indent 24pt + accent border) Most provocative sentence, standalone
Body Standard paragraph Everything else

Rule: Before writing any body paragraph, scan for numbers with units. Every metric like "revenue grew by 12%" or "latency dropped to 45ms" MUST be extracted into a CalloutBox or metrics table - never buried in paragraph prose. See §Data-to-Ink Ratio Rules below.

3b. Section Pacing

  • If any H1 section has >400 words of continuous body text without visual breaks, split into H2 subsections or insert a visual break (table, chart, callout)
  • Aim for at least 1 visual element (table/chart/callout) per 2-3 pages of body text
  • Dense text walls are the #1 sign of poor report design

3c. Content Sanitization for External Sources (MANDATORY when source is PDF/OCR/web)

When content is extracted from an external PDF, OCR output, or web scrape, it may contain invisible garbage characters that cause garbled rendering. You MUST sanitize the text content before writing it into any Paragraph() or canvas.drawString().

This is different from code.sanitize (which cleans Python source code). Content sanitization cleans the text data itself.

import re

def content_sanitize(text: str) -> str:
    """Strip invisible/control chars from externally-sourced text."""
    # Remove control chars (except \n \t)
    text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', text)
    # Remove zero-width chars
    text = re.sub(r'[\u200b-\u200f\u2028-\u202f\u2060\ufeff]', '', text)
    # Remove replacement char (upstream encoding error)
    text = text.replace('\ufffd', '')
    # Remove variation selectors
    text = re.sub(r'[\ufe00-\ufe0f]', '', text)
    # Remove private use area chars
    text = re.sub(r'[\ue000-\uf8ff]', '', text)
    return text

When to use:

Source Must sanitize?
User typed text in prompt No (usually clean)
Extracted from PDF via pdf.py extract.text YES
OCR output YES
Web scrape / HTML strip YES
LLM-generated content No (usually clean)

⚠️ Do NOT confuse code.sanitize (Step 6, cleans Python code) with content_sanitize() (Step 3, cleans text data). They solve different problems.


Step 3.5: NUMBERING — 正文才是第一章 (MANDATORY)

⚠️ 封面、目录、摘要、前言都不是章节。正文第一节 = 第一章/Chapter 1,永远。 Outline 的 index 是排序序号,不是章节号。混淆两者会导致文档从“第三章”或“第四章”开始。

写代码前必须输出编号映射表:

| Outline Index | Type    | Chapter # | Title            |
|---------------|---------|-----------|------------------|
| 1             | cover   | —         | 封面               |
| 2             | toc     | —         | 目录               |
| 3             | content | 第一章    | 概要               |
| 4             | content | 第二章    | 引言               |
| ...           | ...     | ...       | ...              |

铁律:

  • cover/toc/back_cover/摘要/前言 → 无编号
  • 第一个 content 节点 = 第一章,永远从 1 开始
  • page_title() 必须用此表的章节号,不是 Outline index


Character Safety Rule (MANDATORY)

Three rules for safe character handling in ReportLab PDFs:

a) Superscripts and subscripts: Use <super>/<sub> tags, never raw Unicode superscript/subscript characters (e.g., \u00b2, \u2082).

b) Emoji: ReportLab cannot render emoji. If content contains emoji, use Creative brief (HTML + Playwright).

c) Font fallback for mixed CJK/Latin text (MANDATORY): After registering fonts, you MUST call install_font_fallback() once. This automatically wraps missing-glyph characters in <font> tags inside every Paragraph(). No manual <font name="..."> wrapping needed for mixed Chinese-English text. Skipping this call is the #1 cause of Helvetica garbled text in CJK documents.

d) canvas.drawString / drawRightString CJK font rule (MANDATORY): install_font_fallback() only works on Paragraph() objects. It does NOT affect canvas.drawString() / canvas.drawRightString() / canvas.drawCentredString(). If the text passed to any canvas.draw*String() call contains CJK characters (Chinese/Japanese/Korean), you MUST call canvas.setFont('NotoSerifSC', size) (or another registered CJK font) before drawing. Using FreeSerif / Helvetica / Times for CJK canvas text = garbled output.

# ❌ WRONG — FreeSerif has no CJK glyphs, '科大讯飞' will be garbled
canvas.setFont('FreeSerif-Italic', 8)
canvas.drawRightString(x, y, 'SZSE: 002230  ·  科大讯飞')

# ✅ CORRECT — use CJK font for mixed text
canvas.setFont('NotoSerifSC', 8)
canvas.drawRightString(x, y, 'SZSE: 002230  ·  科大讯飞')

# ✅ ALSO CORRECT — pure English text can use FreeSerif
canvas.setFont('FreeSerif-Italic', 8)
canvas.drawString(x, y, 'Prepared by Strategy Advisory Team')

Mathematical/relational operators (×, ÷, ±, ≤, √, ∑, ≅, ∫, π, ∠, Δ, etc.) are safe to use as literal characters in Paragraph() - both Noto Sans SC and FreeSerif have these glyphs.

Need Correct Method Correct Example
Superscript <super> tag in Paragraph() Paragraph('10<super>2</super> × 10<super>3</super> = 10<super>5</super>', style)
Subscript <sub> tag in Paragraph() Paragraph('H<sub>2</sub>O', style)
Bold <b> tag in Paragraph() Paragraph('<b>Title</b>', style)
Math operators Literal char in Paragraph() Paragraph('AB ⊥ AC, ∠A = 90°, and ΔABC ≅ ΔDCF', style)
Scientific notation Combined tags in Paragraph() Paragraph('1.2 × 10<super>8</super> kg/m<super>3</super>', style)
from reportlab.platypus import Paragraph
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_JUSTIFY

body_style = ParagraphStyle(
    name="ENBodyStyle",
    fontName="FreeSerif",
    fontSize=10.5,
    leading=18,
    alignment=TA_JUSTIFY,
)

# CJK Body (Chinese documents)
cn_body_style = ParagraphStyle(
    name="CNBodyStyle",
    fontName="NotoSerifSC", fontSize=10.5, leading=18,
    alignment=TA_LEFT,      # ← NEVER TA_JUSTIFY for CJK
    wordWrap='CJK',         # ← MANDATORY for CJK
    firstLineIndent=2*4.5*mm,
)

# Superscript: area unit
Paragraph('Total area: 500 m<super>2</super>', body_style)

# Subscript: chemical formula
Paragraph('The reaction produces CO<sub>2</sub> and H<sub>2</sub>O', body_style)

# Scientific notation
Paragraph('Speed of light: 3.0 × 10<super>8</super> m/s', body_style)

# Combined
Paragraph('E<sub>k</sub> = mv<super>2</super>/2', body_style)

# Bold heading
Paragraph('<b>Chapter 1: Introduction</b>', header_style)

# Math symbols
Paragraph('When ∠ A = 90°, AB ⊥ AC and ΔABC ≅ ΔDEF', body_style)

Pre-generation check - before writing ANY string, ask:

"Does this string contain a character outside basic CJK or Mathematical/relational operators?" If YES → it MUST be inside a Paragraph() with the appropriate tag. If it is a superscript/subscript digit in raw unicode escape form → REPLACE with <super>/<sub> tag.

NEVER rely on post-generation scanning. Prevent at the point of writing.

Encoding safety - before writing ANY content text:

"Does this string contain Japanese kana (の, が, は etc.) or rare Unicode symbols?" If YES → REPLACE with safe plain Chinese equivalents. Japanese kana (Hiragana U+3040-U+309F, Katakana U+30A0-U+30FF) frequently corrupt to U+FFFD (�) when code passes through LLM output, heredoc, or terminal encoding layers. Common safe replacements: 活の真鲷→活缔真鲷, 盐烤の鲭鱼→盐烤鲭鱼, 烤の鸡串→炭烤鸡串. If the character is genuinely needed, verify it survives a full write→read round-trip with open(file, encoding='utf-8').


Font Setup (Guaranteed Success Method)

CRITICAL: Allowed Fonts Only

You MUST ONLY use the following registered fonts. Using ANY other font is STRICTLY FORBIDDEN.

Font Name Usage Path
NotoSerifSC Chinese body text (primary) /usr/share/fonts/truetype/noto-serif-sc/NotoSerifSC-Regular.ttf
NotoSerifSC-Bold Chinese headings (primary) /usr/share/fonts/truetype/noto-serif-sc/NotoSerifSC-Bold.ttf
Noto Sans SC Chinese fallback / sans-serif /usr/share/fonts/truetype/chinese/NotoSansSC-Regular.ttf
Noto Sans SC Bold Chinese headings (sans-serif) /usr/share/fonts/truetype/chinese/NotoSansSC-Bold.ttf
SarasaMonoSC Chinese code blocks /usr/share/fonts/truetype/chinese/SarasaMonoSC-Regular.ttf
FreeSerif English text, numbers, tables freefont/FreeSerif.ttf
DejaVuSans Formulas, symbols, code /usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf

FORBIDDEN fonts (DO NOT USE):

  • ❌ Arial, Arial-Bold, Arial-Italic
  • ❌ Helvetica, Helvetica-Bold, Helvetica-Oblique
  • ❌ Courier, Courier-Bold
  • ❌ Any font not listed in the table above

Font file locations note: On macOS, font paths may be under ~/.openclaw/workspace/fonts/ or ~/Library/Fonts/ instead of /usr/share/fonts/. The registration code below uses a single FONT_DIR variable — set it to the correct base path for your system. All font sub-paths (e.g. truetype/noto-serif-sc/NotoSerifSC-Regular.ttf) are appended to FONT_DIR.

Font Registration Template

from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfbase.pdfmetrics import registerFontFamily

import platform
_IS_MAC = platform.system() == 'Darwin'
if _IS_MAC:
    FONT_DIR = os.path.expanduser('~/.openclaw/workspace/fonts')
else:  # Linux
    FONT_DIR = '/usr/share/fonts'

# Chinese fonts — Noto Serif SC (primary, serif, high quality)
pdfmetrics.registerFont(TTFont('NotoSerifSC', f'{FONT_DIR}/truetype/noto-serif-sc/NotoSerifSC-Regular.ttf'))
pdfmetrics.registerFont(TTFont('NotoSerifSC-Bold', f'{FONT_DIR}/truetype/noto-serif-sc/NotoSerifSC-Bold.ttf'))
# Chinese fonts — fallback / sans-serif (static weight files, NOT variable font)
pdfmetrics.registerFont(TTFont('Noto Sans SC', f'{FONT_DIR}/truetype/chinese/NotoSansSC-Regular.ttf'))
pdfmetrics.registerFont(TTFont('Noto Sans SC Bold', f'{FONT_DIR}/truetype/chinese/NotoSansSC-Bold.ttf'))
pdfmetrics.registerFont(TTFont('SarasaMonoSC', f'{FONT_DIR}/truetype/chinese/SarasaMonoSC-Regular.ttf'))

# English fonts — FreeSerif (4 weights)
pdfmetrics.registerFont(TTFont('FreeSerif', f'{FONT_DIR}/truetype/freefont/FreeSerif.ttf'))
pdfmetrics.registerFont(TTFont('FreeSerif-Bold', f'{FONT_DIR}/truetype/freefont/FreeSerifBold.ttf'))
pdfmetrics.registerFont(TTFont('FreeSerif-Italic', f'{FONT_DIR}/truetype/freefont/FreeSerifItalic.ttf'))
pdfmetrics.registerFont(TTFont('FreeSerif-BoldItalic', f'{FONT_DIR}/truetype/freefont/FreeSerifBoldItalic.ttf'))

# Symbol/Formula font
pdfmetrics.registerFont(TTFont('DejaVuSans', f'{FONT_DIR}/truetype/dejavu/DejaVuSansMono.ttf'))

# CRITICAL: Register font families to enable <b>, <i>, <super>, <sub> tags
registerFontFamily('NotoSerifSC', normal='NotoSerifSC', bold='NotoSerifSC-Bold')
registerFontFamily('Noto Sans SC', normal='Noto Sans SC', bold='Noto Sans SC Bold')
registerFontFamily('FreeSerif', normal='FreeSerif', bold='FreeSerif-Bold', italic='FreeSerif-Italic', boldItalic='FreeSerif-BoldItalic')
registerFontFamily('DejaVuSans', normal='DejaVuSans', bold='DejaVuSans')

Font Configuration by Document Type

For Chinese PDFs:

  • Body text: NotoSerifSC (primary) — serif font with sharp, elegant strokes
  • Headings: NotoSerifSC-Bold (MUST use for Chinese headings) — same family, bolder weight for clear hierarchy
  • Code blocks: SarasaMonoSC
  • Formulas/symbols: DejaVuSans
  • In tables: ALL Chinese content MUST use NotoSerifSC, numbers use FreeSerif
  • Fallback: Noto Sans SC (if NotoSerifSC unavailable)

For English PDFs:

  • Body text: FreeSerif
  • Headings: FreeSerif (MUST use for English headings)
  • Code blocks: DejaVuSans
  • In tables: ALL English content and numbers MUST use FreeSerif

For Mixed Chinese-English PDFs:

  • Call install_font_fallback() once after registering fonts - it automatically wraps characters in <font> tags so you don't need to do it manually.
  • If you still want manual control, you can use <font name='...'> tags, but the automatic fallback handles most cases.
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'scripts'))
from pdf import install_font_fallback

# 1. Register fonts (as above)
# 2. Install fallback - one line, handles all mixed-language text
install_font_fallback()

# 3. Just write naturally - no manual <font> wrapping needed!
en_style = ParagraphStyle(name="EN", fontName="FreeSerif", fontSize=10.5, leading=18)
cn_style = ParagraphStyle(name="CN", fontName="NotoSerifSC", fontSize=10.5, leading=18, wordWrap='CJK')

Paragraph('MySQL、PostgreSQL、Redis', en_style)           # ✅ CJK comma auto-fallback to NotoSerifSC
Paragraph('Beijing (北京) has 21M people', en_style)       # ✅ CJK chars auto-fallback
Paragraph('价格:¥2,200~5,800/月', en_style)               # ✅ all CJK chars handled
Paragraph('《巴黎协定》签署于2015年', en_style)               # ✅ CJK book title marks handled

How it works: install_font_fallback() monkey-patches Paragraph.__init__ to scan each character against the font's charToGlyph table. Characters missing from the base font are automatically wrapped in <font name="FallbackFont">. The fallback chain is: English fonts → NotoSerifSC (primary) or Noto Sans SC (legacy), Chinese fonts → FreeSerif. For aesthetic optimization, Cyrillic text in NotoSerifSC/Noto Sans SC is automatically routed to FreeSerif (serif looks better for Cyrillic).

Chinese Plot PNG Method

import matplotlib
import matplotlib.pyplot as plt
matplotlib.font_manager.fontManager.addfont('/usr/share/fonts/truetype/chinese/NotoSansSC-Regular.ttf')
matplotlib.font_manager.fontManager.addfont('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf')
# Noto Sans SC for Chinese, DejaVu Sans catches symbols Noto Sans SC lacks (²³♠ etc.)
plt.rcParams['font.sans-serif'] = ['Noto Sans SC', 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False

Available Font Paths

Run fc-list to get more fonts. Font files are typically located under:

  • /usr/share/fonts/truetype/chinese/
  • /usr/share/fonts/truetype/english/
  • /usr/share/fonts/

Layout & Spacing Control

Page Breaks

Follow the document type strategy defined in SOUL.md Rule 1.

Structural breaks (always - MANDATORY):

  • Between cover page and TOC - cover must NEVER share a page with anything else
  • Between TOC and main content
  • Between main content and back cover

Content breaks (by document type):

  • Default (all document types): ❗ Absolutely NEVER force page breaks before H1/H2 — content flows naturally, do not insert PageBreak() before section headings. This is an iron rule.
  • Resume / contract / letter → No content page breaks
  • Short article → No content page breaks
  • Exception 1: Academic papers / textbooks / teaching plans → PageBreak() before each H1 is acceptable (only if user explicitly requests academic formatting)
  • Exception 2: User explicitly requests page breaks between chapters

Anti-tear (mandatory — but with height limit):

from reportlab.platypus import KeepTogether
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import inch

# Maximum height for KeepTogether blocks: 40% of page height
# Blocks taller than this should NOT be kept together — they cause
# the preceding page to be mostly empty when the block gets pushed down.
MAX_KEEP_HEIGHT = A4[1] * 0.4  # ~336pt ≈ 12cm

def safe_keep_together(elements):
    """Wrap elements in KeepTogether only if their total height is reasonable.
    If too tall, keep only the FIRST TWO elements together (e.g. title + table/image header)
    to prevent title-content separation, while letting the rest paginate naturally.
    """
    total_h = 0
    for el in elements:
        w, h = el.wrap(A4[0] - 2*inch, A4[1])
        total_h += h
    if total_h <= MAX_KEEP_HEIGHT:
        return [KeepTogether(elements)]
    elif len(elements) >= 2:
        # Keep at least title + first element together to prevent title orphaning
        return [KeepTogether(elements[:2])] + list(elements[2:])
    else:
        return list(elements)

# Heading stays with first paragraph
story.extend(safe_keep_together([
    heading_paragraph,
    first_body_paragraph,
]))

# Image + caption: keep together ONLY if image is small
story.extend(safe_keep_together([image, caption_paragraph]))

# Table title + table: keep together ONLY if table is short
if len(table_data) <= 15:
    story.extend(safe_keep_together([table_title, table]))
else:
    # Long table: just keep title with the table start
    story.extend(safe_keep_together([table_title]))
    story.append(table)

⚠️ KEY INSIGHT: KeepTogether is the #1 cause of “empty page with chart on next page”. When a KeepTogether block is taller than the remaining space on the current page, ReportLab pushes the entire block to the next page, leaving the current page mostly empty. Always use safe_keep_together() to cap the maximum block height.

⚠️ Note: Do NOT use KeepWithNext - it is unreliable in ReportLab 4.x.

Vertical Spacing Standards

  • Before tables: Spacer(1, 18) after preceding text
  • After tables: Spacer(1, 6) before table caption
  • After table captions: Spacer(1, 18) before next content
  • Between paragraphs: Spacer(1, 12) (~1 line)
  • Between H3 subsections: Spacer(1, 12)
  • Between H2 sections: Spacer(1, 18) (~1.5 lines)
  • Between H1 sections: Spacer(1, 24) (~2 lines)
  • NEVER use Spacer(1, X) where X > 24, except for H1 major breaks or cover page elements

Cover Page: HTML/Playwright Unified Cover System

⚠️ Report route covers are ALWAYS generated via HTML/Playwright, using the same 5-template system defined in typesetting/cover.md. This ensures visual consistency across all routes (Report, Creative, Academic) and avoids the limitations of ReportLab for complex cover layouts.

Pipeline (Report route):

  1. Generate body PDF via ReportLab (start with TOC or content - no cover in story[])
  2. Generate cover HTML following typesetting/cover.md 5-template system
  3. Run poster_validate.py check-html on cover HTML — fix any ERRORs before rendering (overflow:hidden, font fallback, etc.)
  4. Run cover_validate.js on cover HTML — detects text-vs-decorative-line overlaps. Non-zero exit = must fix before proceeding.
    node "$PDF_SKILL_DIR/scripts/cover_validate.js" cover.html
    
  5. Render cover HTML → single-page PDF via Playwright (html2poster.js) — NOT html2pdf-next.js (which converts absolute→static and destroys cover layout). ⚠️ MUST pass --width 794px for A4 documents (A4 = 210mm = 794px @96dpi). Without this, html2poster.js defaults to 720px (=540pt), which is narrower than A4 (595pt) and causes page size mismatch after merging with the body PDF.
  6. Merge: insert cover as page 0 of body PDF using pypdf → output single final PDF

Why not ReportLab covers? ReportLab is excellent for structured content (tables, paragraphs, flowables) but painful for visual design (geometric accents, precise absolute positioning, web fonts). HTML/CSS handles these natively. One cover system, one visual standard, zero inconsistency.

⚠️ Cover page is DEFAULT ON for all Report-route documents ≥ 3 pages.

The Report route handles formal, structured documents. Readers expect a professional cover. Always generate a cover unless the document type is explicitly excluded below.

ALWAYS add a cover page (default behavior):

  • Research reports, experiment reports, lab reports, analysis reports
  • White papers, industry analysis, market research
  • Business proposals, project plans, feasibility studies
  • Technical documentation, product manuals, user guides
  • Annual reports, financial summaries, quarterly reviews
  • Any formal document ≥ 3 pages that will be shared externally or submitted

NEVER add a cover page (explicit exclusions only):

  • Resumes / CVs - recruiters want content immediately
  • Letters / memos - single-page or short-form
  • Forms / checklists / invoices - functional documents
  • Internal notes / meeting minutes - brevity over presentation
  • Documents ≤ 2 pages - cover would be disproportionate
  • User explicitly said "no cover"

Rule of thumb: If it's a report, analysis, proposal, or any formal document ≥ 3 pages → add a cover. When in doubt, add the cover. It's easier to remove a cover the user didn't want than to miss one they expected.

Step 1: Generate Body PDF (ReportLab, no cover)

Build the ReportLab document starting directly with TOC or first section. Do NOT include any cover page in the story[] list.

# Body PDF - starts with TOC or first section, NO cover
story = []

# ⚠️ If document has TOC: use TocDocTemplate + multiBuild (see TOC Gate above)
# If no TOC: use SimpleDocTemplate + build

# WITH TOC (most reports):
# doc = TocDocTemplate(output, pagesize=A4, ...)
# story.append(toc)
# story.append(PageBreak())
# story.append(first_section_content)
# doc.multiBuild(story)

# WITHOUT TOC:
# doc = SimpleDocTemplate(output, pagesize=A4, ...)
# story.append(first_section_content)
# doc.build(story)

Step 2: Generate Cover PDF via html2poster.js

# ALWAYS use html2poster.js for cover rendering (NOT html2pdf-next.js)
# Cover pages use position:absolute layout — html2pdf-next.js pre-render hooks
# convert absolute→static and destroy the layout. html2poster.js preserves it.
node "$PDF_SKILL_DIR/scripts/html2poster.js" cover.html --output cover.pdf --width 794px

Or from Python:

import subprocess, os

def render_cover(html_path, pdf_path):
    """Render HTML cover to PDF via html2poster.js.
    
    ⚠️ ALWAYS use html2poster.js for covers (NOT html2pdf-next.js).
    Cover HTML uses position:absolute for layout. html2pdf-next.js pre-render
    hooks convert absolute→static to prevent multi-page overlap, which
    destroys cover layouts. html2poster.js preserves absolute positioning.
    """
    scripts_dir = os.path.expanduser('~/.openclaw/workspace/skills/pdf/scripts')
    subprocess.run([
        'node', os.path.join(scripts_dir, 'html2poster.js'),
        html_path, '--output', pdf_path,
        '--width', '794px',
    ], check=True)

Insertion script (single output PDF):

from pypdf import PdfReader, PdfWriter

A4_W, A4_H = 595.28, 841.89  # A4 in points

def normalize_page_to_a4(page):
    """Scale a page to A4 if its dimensions don't match."""
    box = page.mediabox
    w, h = float(box.width), float(box.height)
    if abs(w - A4_W) > 2 or abs(h - A4_H) > 2:
        page.scale_to(A4_W, A4_H)
    return page

def insert_cover(cover_pdf, body_pdf, output_pdf):
    """Insert cover as first page of body PDF → single output file."""
    writer = PdfWriter()
    # Cover as page 1
    cover_page = PdfReader(cover_pdf).pages[0]
    writer.add_page(normalize_page_to_a4(cover_page))
    # Body pages follow
    for page in PdfReader(body_pdf).pages:
        writer.add_page(normalize_page_to_a4(page))
    writer.add_metadata({'/Title': 'Report Title', '/Author': 'Z.ai', '/Creator': 'Z.ai'})
    with open(output_pdf, 'wb') as f:
        writer.write(f)

⚠️ Size pitfall: Playwright page.pdf(width='794px', height='1123px') output PDF dimensions may differ from A4 (595.28×841.89pt) by 1-2pt. Do not use PIL Image.save('x.pdf') for PNG→PDF conversion — DPI mapping causes severe size errors. You must call normalize_page_to_a4() to unify dimensions before insertion.

Cover HTML template reference: See typesetting/cover.md for the complete 5-Layout System with variants, typography scale, and bounding box rules.

→ MUST READ: typesetting/cover.md — read the complete 5-Layout System with variants, typography scale, and bounding box rules before writing any cover HTML.

Cover design rules (summary - see cover.md for details):

  • Page size: width: 794px; height: 1123px (A4 at 96dpi)
  • body { margin: 0; padding: 0; overflow: hidden; } - REQUIRED to avoid white borders
  • Load Google Fonts via <link href="..." rel="stylesheet"> in <head> (NOT @import url(...) in CSS — @import must be first rule or is silently ignored) — Playwright fetches them at render time
  • Background must be white or very light (#fff / #fafafa / #f5f8fb), no dark solid backgrounds or gradient backgrounds
  • Primary color used only for text, lines, geometric accents - never as large-area background
  • Sophistication = whitespace + typography + restrained geometric accents

Cover Constitution (5-Layout System):

  • Pick a layout (01/03/04/06/07) from typesetting/cover.md that matches the document tone. No global default - every selection must be a deliberate design decision.
  • Maximum 4 components on any cover. Typical recipe: Title + subtitle + 1 geometric accent + metadata.
  • Typography Scale: Title ≈ 45pt, Subtitle ≈ 25pt, Meta ≥ 18pt (never below 14pt). Tiny text = FAIL.
  • Background layer (optional): See typesetting/cover-backgrounds.md for 3 recipes (A=极简弧线, B=工程十字轴+立柱, C=锐角切割+出血文字). Background renders BELOW all foreground content at 2-5% opacity.
  • Mandatory <br> chunking: Title MUST break every 2-4 words (CJK) or 3-5 words (English). Single-line title = FAIL.
  • Bounding Box spatial dispersion: Group elements into 2-3 bounding boxes at opposite regions (e.g., top-left + bottom-right). Never cluster everything into middle 40%.
  • Safe Zone: 12% top/bottom, 14% left/right padding on cover pages.
  • No body text on cover: Cover = title + subtitle + date + optional geometric decoration. All content goes to page 2+.
  • Cover page isolation: Cover NEVER shares a page with TOC or body content. In Report route this is inherent (separate PDFs merged via pypdf). If output shows cover + TOC on same page = critical bug, regenerate immediately.

Alignment and Typography

  • CJK body: Use TA_LEFT + 2-char indent. Headings: no indent.
  • Font sizes: Body 11pt, subheadings 14pt, headings 18-20pt
  • Line height: 1.5-1.6 (leading at 1.4x font size minimum, recommended 1.5x for CJK)
    • CJK characters are taller than Latin - 1.2x causes cramped lines
    • Quick reference: 10pt→14pt min/18pt rec; 14pt→20pt min/22pt rec; 36pt→50pt min/54pt rec
  • ** Prohibited: fixed rowHeights in Table()**. Use TOPPADDING / BOTTOMPADDING to control row spacing. Fixed rowHeights cause content overflow clipping - the text renders but gets invisibly cut off.
  • CRITICAL: Alignment Selection Rule:
    • Use TA_JUSTIFY only when ALL of:
      • Text is predominantly English (≥ 90%)
      • Column width is sufficiently wide (A4 single-column body)
      • Font: Western fonts (FreeSerif)
      • Chinese content: None or negligible
    • Otherwise, always default to TA_LEFT
    • For Chinese text, always add wordWrap='CJK' to ParagraphStyle

Style Configuration

  • Normal paragraph: spaceBefore=0, spaceAfter=6-12
  • Headings: spaceBefore=12-18, spaceAfter=6-12
  • Headings must be bold: Use <b></b> tags in Paragraph
  • Table captions: spaceBefore=3, spaceAfter=6, alignment=TA_CENTER
  • CRITICAL: For Chinese text, always add wordWrap='CJK' to ParagraphStyle

Table Formatting

Standard Table Color Scheme (MUST USE for ALL tables)

Colors MUST come from the palette.cascade output (Step 2). The values below are variable names - they are populated by the cascade palette, never hardcoded.

# Colors from palette.cascade (Step 2) — NEVER hardcode hex values
# Table header uses HEADER_FILL (M tier, S ≤ 0.30) — NOT ACCENT (XS tier, too vivid)
TABLE_HEADER_COLOR = HEADER_FILL          # M tier — low-sat, blends with document body
TABLE_HEADER_TEXT  = colors.white          # White text for header (fixed)
TABLE_ROW_EVEN     = colors.white          # White for even rows
TABLE_ROW_ODD      = TABLE_STRIPE          # L tier — very subtle same-hue stripe

⚠️ Why NOT ACCENT for table headers? ACCENT is XS tier (S = 0.50-0.75), designed for tiny elements like badges and tags. Table headers occupy 5-20% of visual area (M tier). Using high-saturation ACCENT on table headers creates jarring color blocks that clash with the low-saturation body. HEADER_FILL (S ≤ 0.30) is specifically designed for this area ratio.

Table Rules

  • Caption must be centered, added immediately after the table
  • Entire table must be centered on the page
  • Header Row: Dark background (from palette HEADER_FILL, M tier), white bold text
  • Cell Margins: Left/Right at least 120-200 twips
  • Alignment: Each body element within the same table must be aligned the same method
  • Color consistency: All tables in one PDF must use the same HEADER_FILL + TABLE_STRIPE scheme
  • Spacing: Spacer(1, 18) → Table → Spacer(1, 6) → Caption → Spacer(1, 18)

Table Centering Enforcement (MANDATORY)

Every table MUST be horizontally centered on the page. No exceptions.

# ReportLab: ALWAYS set hAlign='CENTER'
table = Table(data, colWidths=col_widths, hAlign='CENTER')

# Width rule: table total width should be 85-100% of available content width
available_width = doc.width  # or page_width - left_margin - right_margin
table_width = sum(col_widths)
if table_width < available_width * 0.85:
    # Scale up columns proportionally to fill space
    scale = (available_width * 0.90) / table_width
    col_widths = [w * scale for w in col_widths]

# ❌ WRONG — no hAlign (defaults to LEFT, table drifts left)
table = Table(data, colWidths=[100, 200, 150])

# ✅ RIGHT — explicit centering
table = Table(data, colWidths=[100, 200, 150], hAlign='CENTER')

LaTeX equivalent:

\begin{table}[htbp]
  \centering  % MANDATORY
  \begin{tabular}{...}
  ...
  \end{tabular}
\end{table}

Table Cell Content Rule (MANDATORY - NON-NEGOTIABLE)

ALL text content in table cells MUST be wrapped in Paragraph(). NO EXCEPTIONS.

❌ PROHIBITED - Plain strings:

data = [
    ['<b>Header</b>', 'Value'],           # Bold won't render!
    ['Pressure', '1.01 × 10<super>5</super>'],  # Superscript won't work!
]

✅ REQUIRED - All text in Paragraph:

data = [
    [Paragraph('<b>Header</b>', header_style), Paragraph('Value', header_style)],
    [Paragraph('Pressure', cell_style), Paragraph('1.01 × 10<super>5</super>', cell_style)],
]

The ONLY exception: Image() objects can be placed directly in table cells.

Units with Exponents (CRITICAL)

  • PROHIBITED: W/m2, kg/m3, m/s2 (plain text exponents)
  • RIGHT: Paragraph('W/m<super>2</super>', style), Paragraph('kg/m<super>3</super>', style)

Numeric Values in Tables (CRITICAL)

  • Large numbers MUST use scientific notation: Paragraph('-1.246 × 10<super>8</super>', style) not -124600000
  • Small decimals MUST use scientific notation: Paragraph('2.5 × 10<super>-3</super>', style) not 0.0025
  • Threshold: Use scientific notation when |value| ≥ 10000 or |value| ≤ 0.001

Complete Table Example

from reportlab.platypus import Table, TableStyle, Paragraph, Image
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT, TA_JUSTIFY

header_style = ParagraphStyle(
    name='TableHeader', fontName='FreeSerif', fontSize=11,
    textColor=colors.white, alignment=TA_CENTER
)
cell_style = ParagraphStyle(
    name='TableCell', fontName='FreeSerif', fontSize=10,
    textColor=colors.black, alignment=TA_CENTER
)

data = [
    [Paragraph('<b>Parameter</b>', header_style),
     Paragraph('<b>Unit</b>', header_style),
     Paragraph('<b>Value</b>', header_style)],
    [Paragraph('Temperature', cell_style),
     Paragraph('°C', cell_style),
     Paragraph('25.5', cell_style)],
    [Paragraph('Pressure', cell_style),
     Paragraph('Pa', cell_style),
     Paragraph('1.01 × 10<super>5</super>', cell_style)],
    [Paragraph('Density', cell_style),
     Paragraph('kg/m<super>3</super>', cell_style),
     Paragraph('1.225', cell_style)],
]

table = Table(data, colWidths=[120, 80, 100])
# ⚠️ Above uses hardcoded widths - OK for this 3-col example (120+80+100=300 < available ~460pt).
# For real tables, ALWAYS calculate from available_width. See "Table Width Management" below.
table.setStyle(TableStyle([
    ('BACKGROUND', (0, 0), (-1, 0), ACCENT),           # Header: palette accent
    ('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
    ('BACKGROUND', (0, 1), (-1, 1), colors.white),
    ('BACKGROUND', (0, 2), (-1, 2), BG_SURFACE),       # Odd row: palette surface
    ('BACKGROUND', (0, 3), (-1, 3), colors.white),
    ('GRID', (0, 0), (-1, -1), 0.5, TEXT_MUTED),       # Grid: palette muted
    ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
    ('LEFTPADDING', (0, 0), (-1, -1), 8),
    ('RIGHTPADDING', (0, 0), (-1, -1), 8),
    ('TOPPADDING', (0, 0), (-1, -1), 6),
    ('BOTTOMPADDING', (0, 0), (-1, -1), 6),
]))

Table Width Management (⚠️ Prevent Table Overflow)

Most common table bug: columns overflow the right page margin because colWidths are hardcoded without checking available space.

Iron Rule: The sum of all colWidths MUST NOT exceed available_width.

from reportlab.lib.pagesizes import A4
from reportlab.lib.units import inch

page_width = A4[0]  # 595.28pt
left_margin = 1.0 * inch   # 72pt (adjust to your doc's actual margins)
right_margin = 1.0 * inch  # 72pt
available_width = page_width - left_margin - right_margin  # ≈ 451pt

# ─── Method 1: Proportional widths (RECOMMENDED) ───
# Define column ratios, auto-scale to fit available_width
col_ratios = [0.25, 0.40, 0.20, 0.15]  # must sum to 1.0
col_widths = [r * available_width for r in col_ratios]
table = Table(data, colWidths=col_widths)

# ─── Method 2: Fixed + flex columns ───
# Some columns have fixed width (numbers, dates), rest fills remaining space
fixed_cols = {0: 80, 3: 60}  # col 0 = 80pt, col 3 = 60pt
fixed_total = sum(fixed_cols.values())
flex_count = 4 - len(fixed_cols)  # total columns minus fixed
flex_width = (available_width - fixed_total) / flex_count
col_widths = []
for i in range(4):
    col_widths.append(fixed_cols.get(i, flex_width))
table = Table(data, colWidths=col_widths)

# ─── Method 3: Let ReportLab auto-calculate (simplest, less control) ───
# Pass colWidths=None - ReportLab auto-sizes based on content
# ⚠️ Risk: may still overflow if content is too wide
table = Table(data)  # no colWidths

Checklist before adding any table:

  1. Calculate available_width from your actual page size and margins
  2. Sum all colWidths - must be ≤ available_width
  3. For CJK text: characters are wider than Latin - budget ~12pt per CJK char at 10pt font
  4. If a column has long text (descriptions, policies), use Paragraph() wrapping inside cells (not plain strings) - plain strings don't wrap and will overflow
  5. Test with the longest expected content in each column

Common mistake - plain strings don't wrap:

# ❌ Plain string: will overflow if text is long
data = [['Policy Name', 'Very long description that keeps going and going']]
# ✅ Paragraph: wraps within column width
from reportlab.platypus import Paragraph
from reportlab.lib.styles import getSampleStyleSheet
styles = getSampleStyleSheet()
cell_style = styles['Normal']
data = [[Paragraph('Policy Name', cell_style),
         Paragraph('Very long description that keeps going and going', cell_style)]]
Do you need auto-TOC?
├─ YES → Use TocDocTemplate + doc.multiBuild(story)
└─ NO → Use SimpleDocTemplate + doc.build(story)
Requirement DocTemplate Build Method
Multi-page with TOC TocDocTemplate multiBuild()
Single-page or no TOC SimpleDocTemplate build()
With Cross-References (no TOC) SimpleDocTemplate build()
Both TOC + Cross-References TocDocTemplate multiBuild()

⚠️ CRITICAL:

  • multiBuild() is ONLY needed when using TableOfContents
  • Using build() with TocDocTemplate = TOC won't work
  • Using multiBuild() without TocDocTemplate = unnecessary overhead

Rich Text Formatting

Prerequisites

To use <b>, <super>, <sub> tags, you must:

  1. Register fonts via registerFont()
  2. Call registerFontFamily() to link normal/bold/italic variants
  3. Wrap all tagged text in Paragraph() objects

CRITICAL: These tags ONLY work inside Paragraph() objects. Plain strings will NOT render them.

Preventing Unwanted Line Breaks

# Use non-breaking space to prevent breaking
text = Paragraph("Professors (K.G.\u00A0Palepu) proposed...", style)

# Use wordWrap='CJK' for proper Chinese typography
styles.add(ParagraphStyle(name='BodyStyle', fontName='Noto Sans SC', fontSize=10.5,
    leading=18, alignment=TA_LEFT, wordWrap='CJK'))

# Use <br/> for intentional line breaks (NOT \n)
text = Paragraph("Line 1<br/>Line 2<br/>Line 3", style)

Auto-Generated Table of Contents

❌ FORBIDDEN: Manual Table of Contents

NEVER manually create TOC with hardcoded page numbers.

⚠️ MANDATORY: TOC requires a cover page

Unless the user explicitly requests no cover, if a document has a Table of Contents, it MUST have a cover page. Structure: Cover (page 1) → TOC (page 2) → Content (page 3+). Do not generate a TOC without a preceding cover page.

✅ ALWAYS use auto-generated TOC:

from reportlab.platypus import SimpleDocTemplate, Paragraph, PageBreak, Spacer
from reportlab.platypus.tableofcontents import TableOfContents
from reportlab.lib.utils import simpleSplit
import hashlib

class TocDocTemplate(SimpleDocTemplate):
    def afterFlowable(self, flowable):
        if hasattr(flowable, 'bookmark_name'):
            level = getattr(flowable, 'bookmark_level', 0)
            text = getattr(flowable, 'bookmark_text', '')
            key = getattr(flowable, 'bookmark_key', '')
            # MUST pass key as 4th element - without it, TOC entries won't have clickable links
            self.notify('TOCEntry', (level, text, self.page, key))

doc = TocDocTemplate("document.pdf", pagesize=letter)
story = []

toc = TableOfContents()
toc.levelStyles = [
    ParagraphStyle(name='TOCHeading1', fontSize=14, leftIndent=20, fontName='FreeSerif'),
    ParagraphStyle(name='TOCHeading2', fontSize=12, leftIndent=40, fontName='FreeSerif'),
]
story.append(Paragraph("<b>Table of Contents</b>", styles['Title']))
story.append(toc)
story.append(PageBreak())

def add_heading(text, style, level=0):
    # Generate a unique bookmark key for this heading
    key = 'h_%s' % hashlib.md5(text.encode()).hexdigest()[:8]
    p = Paragraph('<a name="%s"/>%s' % (key, text), style)
    p.bookmark_name = text
    p.bookmark_level = level
    p.bookmark_text = text
    p.bookmark_key = key  # This key enables clickable TOC links
    return p

# ⚠️ Orphan Heading Prevention (NOT a page break rule)
# If an H1 heading would appear in the bottom 15% of the page with no room
# for at least a few lines of content, push it to the next page.
# This is NOT "every H1 starts a new page" — it only triggers when the heading
# would be orphaned at the very bottom.
available_height = A4[1] - top_margin - bottom_margin
H1_ORPHAN_THRESHOLD = available_height * 0.15  # ~100pt ≈ 3.5cm — enough for heading + 2 lines

def add_major_section(text, style):
    """Add an H1-level heading with orphan prevention (NOT forced page break)."""
    return [
        CondPageBreak(H1_ORPHAN_THRESHOLD),  # Only break if heading would be orphaned at page bottom
        add_heading(text, style, level=0),
    ]

story.extend(add_major_section("Chapter 1: Introduction", styles['Heading1']))
# ... content ...

doc.multiBuild(story)  # MUST use multiBuild for TOC

⚠️ CRITICAL TOC LINK RULES:

  1. afterFlowable MUST pass key as the 4th element in the notify tuple - without it, TOC entries have no clickable links
  2. add_heading MUST set bookmark_key AND embed <a name="key"/> in the Paragraph text - this creates the link destination
  3. The key must be unique per heading - use a hash of the heading text

TOC with Leader Dots (Copy-Paste Ready)

⚠️ WARNING: This manual approach creates a visual-only TOC. It has NO clickable links and NO auto-updated page numbers. Use the auto-generated TOC above (TocDocTemplate + multiBuild) whenever possible. Only use this leader-dots approach if you need very specific visual styling that the auto TOC cannot provide - and even then, prefer the auto approach.

For a professional TOC with leader dots connecting titles to page numbers:

from reportlab.lib.pagesizes import A4
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, PageBreak
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib import colors
from reportlab.lib.units import inch

# Setup
doc = SimpleDocTemplate("report.pdf", pagesize=A4,
                       leftMargin=0.75*inch, rightMargin=0.75*inch)
styles = getSampleStyleSheet()
styles['Heading1'].fontName = 'FreeSerif'
styles['Heading1'].textColor = colors.black

story = []

# Calculate dimensions
page_width = A4[0]
available_width = page_width - 1.5*inch
page_num_width = 50  # Fixed width for page numbers
dots_column_width = available_width - 200 - page_num_width
optimal_dot_count = int(dots_column_width / 4.5)  # ~4.5pt per dot at 7pt font

# Define styles
toc_style = ParagraphStyle('TOCEntry', parent=styles['Normal'],
                          fontName='FreeSerif', fontSize=11, leading=16)
dots_style = ParagraphStyle('LeaderDots', parent=styles['Normal'],
                           fontName='FreeSerif', fontSize=7, leading=16)

# Build TOC
toc_data = [
    [Paragraph('<b>Table of Contents</b>', styles['Heading1']), '', ''],
    ['', '', ''],
]

entries = [('Section 1', '5'), ('Section 2', '10')]
for title, page in entries:
    toc_data.append([
        Paragraph(title, toc_style),
        Paragraph('.' * optimal_dot_count, dots_style),
        Paragraph(page, toc_style)
    ])

toc_table = Table(toc_data, colWidths=[None, dots_column_width, page_num_width])
toc_table.setStyle(TableStyle([
    ('GRID', (0, 0), (-1, -1), 0, colors.white),
    ('LINEBELOW', (0, 0), (0, 0), 1.5, colors.black),
    ('ALIGN', (0, 0), (0, -1), 'LEFT'),
    ('ALIGN', (1, 0), (1, -1), 'LEFT'),
    ('ALIGN', (2, 0), (2, -1), 'RIGHT'),
    ('VALIGN', (0, 0), (-1, -1), 'TOP'),
    ('LEFTPADDING', (0, 0), (-1, -1), 0),
    ('RIGHTPADDING', (0, 0), (-1, -1), 0),
    ('TOPPADDING', (0, 2), (-1, -1), 3),
    ('BOTTOMPADDING', (0, 2), (-1, -1), 3),
    ('TEXTCOLOR', (1, 2), (1, -1), TEXT_MUTED),  # From palette --c-muted
]))

story.append(toc_table)
story.append(PageBreak())
doc.build(story)

MANDATORY Leader Dots Rules:

Rule Requirement
Column widths ✅ MUST use fixed values. Percentage-based widths are STRICTLY FORBIDDEN.
Dot count ✅ MUST calculate dynamically: int(dots_column_width / 4.5). Hard-coded counts are STRICTLY FORBIDDEN.
Page number column ✅ MUST be at least 40pt wide.
Dot font size ✅ MUST NOT exceed 8pt.
Dot alignment ✅ MUST be LEFT-aligned (visual flow from title).
Padding ✅ MUST be exactly 0 between columns.
Bold text ✅ MUST use Paragraph('<b>Text</b>', style). Plain strings like '<b>Text</b>' are STRICTLY FORBIDDEN.
Indentation Use leading spaces for hierarchy (e.g., " 1.1 Subsection").

TOC Post-Generation Validation (MANDATORY)

After generating any PDF with a Table of Contents, run:

python3 "$PDF_SKILL_DIR/scripts/toc_validate.py" check-pdf output.pdf

If any errors are returned, fix the code and regenerate. Common issues:

  • TOC_ALL_SAME_PAGE → You used build() instead of multiBuild(), so all page numbers are stuck at 1.
  • TOC_NO_ENTRIES → Headings are missing bookmark_name/bookmark_level attributes.
  • TOC_PAGES_INVALID → A TOC entry references a page beyond the document's total page count.

Cross-References (Figures, Tables, Bibliography)

Pre-register all figures, tables, and references BEFORE using them in text.

class CrossReferenceDocument:
    def __init__(self):
        self.figures = {}
        self.tables = {}
        self.refs = {}
        self.figure_counter = 0
        self.table_counter = 0
        self.ref_counter = 0

    def add_figure(self, name):
        if name not in self.figures:
            self.figure_counter += 1
            self.figures[name] = self.figure_counter
        return self.figures[name]

    def add_table(self, name):
        if name not in self.tables:
            self.table_counter += 1
            self.tables[name] = self.table_counter
        return self.tables[name]

    def add_reference(self, name):
        if name not in self.refs:
            self.ref_counter += 1
            self.refs[name] = self.ref_counter
        return self.refs[name]

Image Handling

Diagram Embedding Rules (MANDATORY)

Figures are block-level Flowables. Every image/diagram in ReportLab MUST be appended to story as a standalone Image() or wrapped in KeepTogether([image, caption]). Never simulate floating layouts by placing images inside Paragraph text or multi-column Table cells alongside body text.

Complex diagrams (>12 nodes): Decompose into a ReportLab Table (details) + a simplified diagram image (overview). The diagram gives the big picture; the table carries precision. See SKILL.md "Complex Diagram Strategy".

Diagram quality: When generating flowcharts/diagrams for PDF embedding (via Playwright screenshot or ReportLab Drawing), follow the diagram content quality rules in SKILL.md § "Diagram Content Quality Rules". Key points: connectors must not pass through nodes, no high-saturation large fills, multi-arrow convergence must use merge pattern, font sizes must be legible at final embedded size (see context-specific minimums in SKILL.md).

Cross-brief diagram pipeline: For flowcharts/architecture diagrams, generate via Playwright+CSS (HTML nodes + CSS layout + SVG connectors), screenshot at 2× device scale factor for 300dpi print quality, then embed with Image(). See SKILL.md § "Diagram Generation Strategy" for the full pipeline.

🚫 FORBIDDEN: TikZ standalone → PNG pipeline in Report brief. Report uses ReportLab, not LaTeX. Going through TikZ requires a LaTeX compiler, adds compilation steps, and models frequently produce broken TikZ code. Use Playwright+CSS instead - it's native to the existing cover rendering engine.

# Playwright+CSS diagram → PNG → ReportLab embedding
# 1. LLM generates diagram.html (CSS grid/flexbox nodes + SVG arrows)
# 2. Playwright screenshots at 2× scale (300dpi equivalent)
# 3. Embed:
from reportlab.platypus import Image
img = Image('diagram.png', width=450)  # auto height via aspect ratio
story.append(img)                       # block-level Flowable

⚠️ CRITICAL: Preserve aspect ratio - NEVER hardcode both width and height without reading actual image dimensions.

⚠️ CRITICAL: Always constrain to available space - NEVER insert at original size if it exceeds container width/height.

Pie charts become ellipses, radar charts become diamonds, photos get stretched if you set arbitrary width/height.

→ MUST READ: typesetting/overflow.md — read the complete bounding box system before writing layout code.

from PIL import Image as PILImage
from reportlab.platypus import Image
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import inch

def embed_image(path: str, max_width: float = None, max_height: float = None) -> Image:
    """Embed image with preserved aspect ratio, constrained to container.

    ALWAYS use this pattern. Never hardcode both width and height.
    If max_width is None, defaults to available_width (page - margins).
    """
    if max_width is None:
        max_width = A4[0] - 2.0 * inch  # Default: A4 width minus 1" margins each side
    if max_height is None:
        max_height = A4[1] * 0.35  # ~294pt ≈ 10cm — leaves room for caption + surrounding text on same page

    pil_img = PILImage.open(path)
    orig_w, orig_h = pil_img.size

    # Scale to fit within BOTH max_width and max_height
    ratio_w = max_width / orig_w if orig_w > max_width else 1.0
    ratio_h = max_height / orig_h if orig_h > max_height else 1.0
    ratio = min(ratio_w, ratio_h)

    return Image(path, width=orig_w * ratio, height=orig_h * ratio)

# Usage:
available_width = A4[0] - left_margin - right_margin
img = embed_image('chart.png', max_width=available_width, max_height=300)
story.append(img)
# ❌ NEVER do this - distorts the image:
img = Image('chart.png', width=400, height=250)  # arbitrary height breaks aspect ratio

PDF Metadata

doc = SimpleDocTemplate(
    pdf_filename,
    pagesize=letter,
    title=os.path.splitext(pdf_filename)[0],
    author='Z.ai',
    creator='Z.ai',
    subject='Document purpose description'
)

⚠️ MANDATORY: Post-Generation Metadata

After doc.build(story) completes:

python3 "$PDF_SKILL_DIR/scripts/pdf.py" meta.brand output.pdf

⚠️ MANDATORY: Post-Generation Blank Page Cleanup

After metadata branding, remove any accidental blank pages:

python3 "$PDF_SKILL_DIR/scripts/pdf.py" pages.clean output.pdf -o output_clean.pdf

If blank pages were found, rename output_clean.pdf → output.pdf.


MANDATORY: Post-Generation Code Sanitization

After writing PDF generation code and BEFORE executing it, sanitize using:

# Step 0: Set skill root path (see SKILL.md § Script Path Setup)
PDF_SKILL_DIR="<skill_directory>"

# Step 1: Write code to .py file
cat > generate_pdf.py << 'PYEOF'
# ... your PDF generation code ...
PYEOF

# Step 2: Sanitize (MUST run before execution)
python3 "$PDF_SKILL_DIR/scripts/pdf.py" code.sanitize generate_pdf.py

# Step 3: Execute
python3 generate_pdf.py

# Step 4: Add metadata (MUST run after PDF creation)
python3 "$PDF_SKILL_DIR/scripts/pdf.py" meta.brand output.pdf

# Step 5: Check for missing glyphs (RECOMMENDED)
python3 "$PDF_SKILL_DIR/scripts/pdf.py" font.check output.pdf

# Step 6: Check TOC quality (if document has TOC)
python3 "$PDF_SKILL_DIR/scripts/pdf.py" toc.check output.pdf

# Step 7: PDF quality assurance scan (MUST run after all other checks)
python3 "$PDF_SKILL_DIR/scripts/pdf_qa.py" output.pdf

FORBIDDEN patterns:

# ❌ PROHIBITED: python -c with inline code
python -c "from reportlab... doc.build(story)"

# ❌ PROHIBITED: heredoc without saving to file first
python3 << 'EOF'
from reportlab...
EOF

# ❌ PROHIBITED: executing .py without sanitizing
python generate_pdf.py  # Missing sanitization!

Debug Tips for Layout Issues

→ MUST READ: typesetting/overflow.md — read the complete bounding box system, two-pass rendering, fallback strategies, and all three route implementations.

from reportlab.platypus import HRFlowable
from reportlab.lib.colors import red

# Visualize spacing during development - insert between elements
story.append(table)
story.append(HRFlowable(width="100%", color=red, thickness=0.5, spaceBefore=0, spaceAfter=0))
story.append(Spacer(1, 6))
story.append(HRFlowable(width="100%", color=red, thickness=0.5, spaceBefore=0, spaceAfter=0))
story.append(caption)
# Red lines create visual markers to see actual spacing - remove before final build

Resume / CV

Resume/CV content has been moved to a dedicated brief: briefs/resume.md

This brief is automatically loaded when the triage routes to Resume. For ATS-friendly single-column resumes, see that file.


Component Reference

Block types:

Type Description Notes
h1 22pt + accent underline rule KeepTogether with rule
h2 15pt, dark No rule, no accent
h3 11.5pt bold No accent
body 10.5pt justified, 17pt leading Supports <b> <i> <font>
bullet Body size with • prefix Unordered list
numbered Body size with N. prefix Counter auto-resets
callout Accent left border 4px + light tint bg Max one per section
table Accent header + alternating rows + outer box only Supports fractional col_widths
code Courier 8.5pt + accent left border Optional language label
divider Accent 1.2pt rule Use sparingly
caption 8.5pt muted, centered Below images/tables
chart matplotlib figure saved as PNG → Image() flowable Generate chart in-script, fig.savefig() → embed. Set plt.rcParams['font.sans-serif']=['Noto Sans SC', 'DejaVu Sans'] for CJK + symbol fallback. Always tight_layout(). Must follow typesetting/charts.md rules: delete top/right spines, dashed grid 20% opacity, donut default for pie, anti-stacking labels.
quote Body italic + left indent 24pt + muted accent left border 2px For blockquotes / testimonials
bibliography Hanging indent (firstLineIndent=-24pt, leftIndent=24pt) GB/T 7714 or APA format per language
math Rendered via <super> <sub> tags in Paragraph For inline math; complex equations → use academic brief instead
  • Header: document title (left, 7.5pt, muted) + accent rule (1.5pt, full width)
  • Footer: author (left, 7.5pt, muted) + page number (right, 7.5pt, muted) + light rule above

Custom flowables (preferred over Table hacks):

  • CalloutBox: accent 4px left border + light tint background - cleaner than Table simulation
  • BibliographyItem: hanging indent reference entry

Quick Reference

Task Best Tool Command/Code
Create PDF (ReportLab) reportlab Canvas or Platypus
Fill PDF forms Process brief python3 "$PDF_SKILL_DIR/scripts/pdf.py" form.fill or annotation workflow
Merge PDFs Process brief python3 "$PDF_SKILL_DIR/scripts/pdf.py" pages.merge
Extract text Process brief python3 "$PDF_SKILL_DIR/scripts/pdf.py" extract.text
Extract tables Process brief python3 "$PDF_SKILL_DIR/scripts/pdf.py" extract.table

Document-Type Quick Reference

📋 Invoice / Receipt

Key elements:

  • Company logo/name at top
  • Invoice number + date (right-aligned)
  • Seller/buyer info block (2-column layout)
  • Line items table: Item, Quantity, Unit Price, Amount
  • Total row with bold font
  • Tax info + payment terms at bottom
  • Stamp/seal placeholder (empty circle or rect with "Seal Here" text)
# Invoice table pattern
data = [['Item', 'Qty', 'Unit Price', 'Amount']]
data += [[item['name'], item['qty'], item['price'], item['total']] for item in items]
data.append(['', '', 'Total:', f'¥{total}'])

Key elements:

  • Title centered, bold, 18pt
  • Numbered clauses with auto-increment
  • Signature block at bottom: Party A / Party B with date line
  • Page numbers mandatory
# Signature block pattern
sig_data = [
    ['Party A (Seal): ________________', 'Party B (Seal): ________________'],
    ['Date: ____/____/________', 'Date: ____/____/________'],
]
sig_table = Table(sig_data, colWidths=[250, 250])

📊 Book / Long Document

For documents with 10+ pages:

  1. Generate TOC with TableOfContents() from reportlab.platypus
  2. Use CondPageBreak(H1_ORPHAN_THRESHOLD) before H1 headings (NOT PageBreak() — never force page breaks between chapters)
  3. Add running headers/footers with chapter title + page number
  4. Run python3 "$PDF_SKILL_DIR/scripts/toc_validate.py" output.pdf to verify TOC links
  5. Consider using bookmarks=True for PDF outline navigation

📝 Exam / Quiz / Test Paper / Worksheet

Exam papers have unique layout requirements. These rules are MANDATORY when generating any test/quiz/exam document.

Numbering & Structure

一、选择题(每小题 3 分,共 30 分)          ← Section header (宋体/黑体 14pt Bold)

1. 以下哪个不是 Python 内置数据类型?     ← Question stem (12pt)
   A. int                                   ← Options: indented 24pt (2em)
   B. float                                 ← Each option on new line or 2×2 grid
   C. array
   D. str

2. 以下表达式的值为?                     ← Next question
   A. True    B. False                      ← Short options: inline 2×2 grid OK
   C. None    D. Error

Option Indentation (MANDATORY)

# ReportLab styles for exam papers
question_style = ParagraphStyle(
    'Question', fontSize=12, leading=16,
    leftIndent=0, firstLineIndent=0,
    spaceBefore=12, spaceAfter=4,
)
option_style = ParagraphStyle(
    'Option', fontSize=12, leading=16,
    leftIndent=24,   # ← MANDATORY: 24pt indent from question stem
    firstLineIndent=0,
    spaceBefore=2, spaceAfter=2,
)

Option Layout Decision

def format_options(options):
    """
    Decide option layout based on content length.
    Short options (≤4 chars each, ≤4 options) → 2×2 grid table
    Long options or >4 options → vertical list, one per line
    """
    max_len = max(len(opt) for opt in options)
    if len(options) <= 4 and max_len <= 6:  # CJK: 4 chars; Latin: 6 chars
        # 2×2 grid using Table
        grid = Table(
            [[options[0], options[1]], [options[2], options[3]]],
            colWidths=[200, 200], hAlign='LEFT'
        )
        grid.setStyle(TableStyle([
            ('LEFTPADDING', (0,0), (-1,-1), 24),  # Match option indent
            ('VALIGN', (0,0), (-1,-1), 'TOP'),
        ]))
        return grid
    else:
        # Vertical list
        return [Paragraph(f'{opt}', option_style) for opt in options]

Answer Space Reservation (MANDATORY)

Every question MUST have adequate answer space. No exceptions.

Question Type Minimum Space Implementation
Multiple choice 0 extra (options are the answer) Just question + options
Fill-in-the-blank Inline underline, min 80pt width <u>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</u> or ReportLab HRFlowable
Short answer 40-60pt (2-3 lines) Spacer(1, 50)
Calculation / math work 120-200pt (6-10 lines) Spacer(1, 160) with light guide lines
Essay / long answer 200-400pt (10-20 lines) Spacer(1, 300) with light guide lines
Proof / derivation 160-250pt (8-12 lines) Spacer(1, 200)
# Answer line helper — light dashed lines for handwriting
def add_answer_lines(story, num_lines=8, line_spacing=20):
    """Add light guide lines for handwritten answers."""
    for i in range(num_lines):
        story.append(Spacer(1, line_spacing))
        story.append(HRFlowable(
            width='90%', thickness=0.3,
            color=colors.Color(0.8, 0.8, 0.8),  # Light gray
            dash=[4, 4],  # Dashed
            spaceAfter=0, spaceBefore=0
        ))

Page Density

  • spaceBefore=12pt between questions minimum
  • spaceBefore=24pt before section headers (一、二、三)
  • Score indicator after question number: 1. (分值: 5分) or 1. [5 pts]
  • Page header: exam title + time limit + total score
  • No cover page unless explicitly requested

Data-to-Ink Ratio Rules (MANDATORY for Reports)

CRITICAL: Do NOT write long paragraphs to describe data trends. You MUST extract metrics and trends into structured visual elements.

Pattern Detection Table

Before writing ANY body paragraph, scan the text. If you find any of these patterns, extract them:

Raw text pattern ❌ FORBIDDEN ✅ REQUIRED visual form
"revenue grew 12% to $4.2M" Bury in paragraph prose CalloutBox with bold +12% + $4.2M
"latency dropped from 120ms to 75ms" Long explanatory sentence CalloutBox or metrics table row
"Q1→Q2→Q3: 10%→25%→40%" Inline numbers in text Chart (matplotlib PNG → Image())
"first...second...third..." steps Paragraph with ordinal words Numbered Table or process list
"compared to last year / vs Q2" Nested comparisons in prose Side-by-side comparison table
"accounted for 60% of total" Percentage in paragraph Pie chart or stacked bar chart

ReportLab CalloutBox Template

Use this pattern for extracted metrics - it's visually clean and takes only 5 lines:

stat_style = ParagraphStyle(
    name='StatBig', fontName='FreeSerif', fontSize=22,
    leading=26, textColor=ACCENT, alignment=TA_CENTER  # From palette
)
label_style = ParagraphStyle(
    name='StatLabel', fontName='FreeSerif', fontSize=9,
    leading=12, textColor=TEXT_MUTED, alignment=TA_CENTER  # From palette
)

callout = Table(
    [[Paragraph('<b>+12%</b>', stat_style)],
     [Paragraph('Revenue Growth vs Q2', label_style)]],
    colWidths=[160]
)
callout.setStyle(TableStyle([
    ('BACKGROUND', (0,0), (-1,-1), BG_SURFACE),      # From palette --c-mid
    ('BOX', (0,0), (-1,-1), 1, ACCENT),               # From palette --c-accent
    ('TOPPADDING', (0,0), (-1,-1), 10),
    ('BOTTOMPADDING', (0,0), (-1,-1), 10),
    ('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
]))
story.append(KeepTogether(callout))

Final Checklist (Mandatory before delivery)

Code & Build

  • Font restriction: Only 6 registered fonts used
  • Font family registered: registerFontFamily() called for all used fonts
  • ⚠️ MANDATORY font fallback: install_font_fallback() called after font registration — #1 cause of Helvetica garbled text (乱码)
  • Rich text tags: Only inside Paragraph() objects
  • Table cells: ALL text wrapped in Paragraph() — no plain strings
  • Scientific notation: Large/small numbers use <super> tags
  • Chinese text: wordWrap='CJK' in ParagraphStyle
  • Line breaks: Using <br/> not \n
  • Headings: Bold with <b> tags
  • Sanitization: Code sanitized before execution

Pagination & Overflow

  • Last page fill ratio ≥ 40%: No large blank areas on final page
  • Major section 3/4 threshold: H1 headings must NOT start in bottom 25% of page. Use CondPageBreak(available_height * 0.25)
  • Tables don't split: Small tables break-inside: avoid. Large tables thead { display: table-header-group }
  • No orphan headings: Use break-after: avoid / KeepTogether
  • sum(colWidths) ≤ available_width: Verified in code
  • Images/charts scaled: Always fit_image() or max-width: 100%
  • Long tables repeatRows=1
  • CJK wordWrap='CJK'
  • URLs word-break: overflow-wrap: break-word

Color & Design

  • Entire document ≤ 5 colors: Primary + secondary + accent + neutral + background
  • All colors from palette: Secondary/accent derived via lightness/saturation shift
  • Table headers use HEADER_FILL (M tier, S ≤ 0.30) — NOT ACCENT
  • Table stripes use TABLE_STRIPE (L tier, S ≤ 0.15)
  • Decorative elements ≤ 3 per page (cover exempt)
  • No rainbow/multi-color schemes: Single-family palette only
  • No decorative borders/textures on body pages
  • 2-typeface maximum
  • 🚫 NO stock images / clipart / AI decorations

Cover

  • 正文编号从 1 开始: 封面/目录不是章节,Step 3.5 映射表已输出
  • Cover default ON for reports/proposals/analysis ≥ 3 pages
  • Single PDF output: Cover merged as page 0 via pypdf
  • Page isolation: Cover never shares page with TOC/body
  • 🚫 NEVER use ReportLab for covers — HTML/Playwright only
  • Z-Index Layers: Layer 0 (bg) → 1 (decorative, CLIPPED) → 2 (lines) → 3 (text)
  • No dark/gradient backgrounds
  • Cover whitespace ≥ 60%
  • Cover colors from palette.cascade :root CSS variables

Tables

  • ALL tables horizontally centered (hAlign='CENTER')
  • Table width 85-100% of content area
  • Table headers: White bold text on HEADER_FILL

Charts

  • No text overlap: Labels, values, legends collision-free
  • Chart-to-text separation: 24pt above/below, 8pt to caption
  • Legend outside chart area: bbox_to_anchor
  • Pie → Donut by default (hole_ratio 60-70%)
  • Axis cleanup: Top/right spines deleted

Layout

  • Margin symmetry: left_margin == right_margin
  • Full-bleed (Playwright): @page { margin: 0 }
  • Content centering: No left/right drift
  • Anti-void edges: Content fills ≥ 60%

Post-Build

  • Metadata: pdf.py meta.brand — Title matches filename, Author/Creator = "Z.ai"
  • Blank page cleanup: pdf.py pages.clean
  • Glyph check: pdf.py font.check
  • TOC check: pdf.py toc.check (if document has TOC)
  • PDF QA scan: pdf_qa.py