Initial commit
This commit is contained in:
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