# Design System — DOCX Skill

## Color Philosophy

GLM uses a **mood-driven dynamic color system** instead of fixed named palettes. Colors are constructed from three dimensions:

### Three Dimensions of Document Color

| Dimension | Description | Range |
|-----------|-------------|-------|
| **Temperature** | Warm ↔ Cool | Warm (consulting, education) ↔ Cool (tech, medical) |
| **Weight** | Light ↔ Heavy | Light (resume, proposal) ↔ Heavy (legal, academic) |
| **Energy** | Calm ↔ Active | Calm (official, contract) ↔ Active (report, presentation) |

### Color Token System

Every document uses 5 color tokens. These are **computed** based on the document's mood, not selected from a fixed list.

| Token | Role | Guidance |
|-------|------|----------|
| `primary` | Headings, cover title | Dark, authoritative. Derived from Temperature + Weight. |
| `body` | Body text | Near-black with subtle warmth/coolness. Always high contrast. |
| `secondary` | Captions, footnotes | Mid-tone gray. Legible but visually recessive. |
| `accent` | Table headers, lines, links | The "personality" color. Reflects document Energy. |
| `surface` | Table alternating rows, card backgrounds | Very light tint of accent or neutral. |

### Mood Recipes

Instead of 10 fixed palettes, combine dimensions to generate colors dynamically:

**Cool + Heavy + Calm** → Deep Sea Academic (Academic / Research)
```js
const academic = {
  primary: "#162032", body: "#1C2A3D", secondary: "#5B6B7D",
  accent: "#8B7E5A", surface: "#F5F7FA"
};
```

**Warm + Heavy + Calm** → Legal Wood (Legal / Compliance)
```js
const legal = {
  primary: "#28201C", body: "#36302C", secondary: "#6E6560",
  accent: "#7A5C3A", surface: "#FBF9F7"
};
```

**Cool + Light + Active** → Dawn Mist Tech (Tech / Digital)
```js
const tech = {
  primary: "#0A1628", body: "#1A2B40", secondary: "#6878A0",
  accent: "#5B8DB8", surface: "#F4F8FC"
};
```

**Warm + Light + Active** → Warm Sun (Education / Training)
```js
const education = {
  primary: "#2A3518", body: "#384228", secondary: "#6B8040",
  accent: "#D4A030", surface: "#F8FAF4"
};
```

**Neutral + Medium + Calm** → Plain Paper (Default / General)
```js
const general = {
  primary: "#101820", body: "#182030", secondary: "#506070",
  accent: "#8090A0", surface: "#F2F4F6"
};
```

**Warm + Medium + Calm** → Terracotta (Consulting / Architecture)
```js
const consulting = {
  primary: "#241E1A", body: "#3A3430", secondary: "#68605A",
  accent: "#B08050", surface: "#FDFBF9"
};
```

**Cool + Medium + Active** → Mint Medical (Medical / Clinical)
```js
const medical = {
  primary: "#0E2030", body: "#1E2E40", secondary: "#4A6580",
  accent: "#3888A8", surface: "#F0F6FA"
};
```

**Neutral + Light + Calm** → White Porcelain (Product Manuals / Minimalist)
```js
const minimal = {
  primary: "#303030", body: "#484848", secondary: "#808080",
  accent: "#B89870", surface: "#FAFAF8"
};
```

**Cool + Light + Active (Gradient)** → Lapis Tech (Tech / AI / Innovation)
```js
const liuliTech = {
  primary: "#1A1F36", body: "#000000", secondary: "#5A6080",
  accent: "#667eea", surface: "#F8F9FF",
  gradient: ["#667eea", "#764ba2"],  // Purple-blue gradient (blendColors 5-step)
};
```

**Cool + Heavy + Active (Gradient)** → Deep Sea Blue-Gold (Finance / Investment / Premium)
```js
const deepBlueGold = {
  primary: "#0F2027", body: "#000000", secondary: "#4A6575",
  accent: "#D4AF37", surface: "#F5F7FA",
  gradient: ["#0F2027", "#203A43", "#2C5364"],  // 3-step deep sea blue gradient
};
```

**Warm + Light + Active (Gradient)** → Mint Dawn (Education / Health / Green)
```js
const mintMorning = {
  primary: "#1A3A3A", body: "#000000", secondary: "#507070",
  accent: "#3CB4A0", surface: "#F0FFFE",
  gradient: ["#3CB4A0", "#a8edea"],  // Mint green gradient
};
```

**Neutral + Medium + Active** → Graphite Orange (Professional but Energetic)
```js
const graphiteOrange = {
  primary: "#2C3E50", body: "#000000", secondary: "#607080",
  accent: "#E67E22", surface: "#FDF8F3",
};
```

### Scene → Mood Mapping

| Scene Keywords | Temperature | Weight | Energy | Recipe |
|----------------|-------------|--------|--------|--------|
| thesis, academic | Cool | Heavy | Calm | Deep Sea Academic |
| report (general) | Neutral | Medium | Calm | Plain Paper |
| report (consulting) | Warm | Medium | Calm | Terracotta |
| report (tech) | Cool | Light | Active | Dawn Mist Tech |
| contract, agreement, legal | Warm | Heavy | Calm | Legal Wood |
| resume, CV | Neutral | Light | Calm | White Porcelain (preferred) or Dawn Mist Tech |
| exam, test | — | — | — | **Pure B&W** |
| official document | — | — | — | **Pure B&W** |
| AI, tech | Cool | Light | Active | Dawn Mist Tech |
| medical | Cool | Medium | Active | Mint Medical |
| environmental, sustainability | Warm | Light | Active | Warm Sun |
| lesson plan (STEM / science / tech) | Cool | Light | Active | Dawn Mist Tech |
| lesson plan (arts / music / PE) | Neutral | Medium | Active | Graphite Orange |
| lesson plan (general education) | Neutral | Medium | Calm | Plain Paper |
| education report (not lesson plan) | Warm | Light | Active | Mint Dawn |
| product manual | Neutral | Light | Calm | White Porcelain |
| tech, AI, internet, innovation | Cool | Light | Active | Lapis Tech |
| finance, investment, premium | Cool | Heavy | Active | Deep Sea Blue-Gold |
| health, green | Warm | Light | Active | Mint Dawn |
| energetic, vibrant | Neutral | Medium | Active | Graphite Orange |
| essay, composition, self-evaluation, review/reflection, letter (non-business), speech, application, proposal letter | — | — | — | **Pure B&W** |
| _(no match)_ | Neutral | Medium | Calm | Plain Paper |

### Visual Profile Color Guidance

For **Profile B (Visual)** scenes — resume, copywriting, and other non-formal documents — prefer **warm neutral** tones aligned with the "Invisible Precision" design philosophy:

- **Body text**: Use warm dark neutrals (`#37352F`, `#303030`, `#3A3430`) instead of cool blue-grays. This reduces eye strain.
- **Surface/background**: Use warm near-white (`#F7F7F7`, `#FAFAF8`, `#FBF9F7`) instead of cool tints.
- **Accent colors**: Use sparingly and only for functional differentiation (section headers, key metrics, links). 95% of the document should remain monochromatic (black, white, gray).
- **Tables**: Prefer the **Zebra Stripe** style (see Table Styles §2) — hierarchy through background contrast with minimal borders. Fallback: Horizontal-Only.

This does NOT apply to Profile A (Formal) scenes (report, academic, contract, official-doc, exam), which must retain pure black `"000000"` body text per regulatory standards.

### Custom Color Generation

When the pre-defined recipes don't fit, construct colors using these rules:

1. **primary**: Start from `hsl(hue, 25-40%, 10-18%)` — dark, desaturated
2. **body**: primary lightened 5-8% — readable dark
3. **secondary**: primary lightened 30-40% — clearly subordinate
4. **accent**: Choose a hue reflecting the domain, `hsl(domainHue, 30-50%, 45-55%)`
5. **surface**: accent desaturated to 5-10%, lightened to 96-98%

**Contrast check**: body text on white must achieve WCAG AA (≥4.5:1). All recipes above pass this.

---

## Font Specifications

### Chinese Fonts

| Usage | Font | Fallback |
|-------|------|----------|
| Headings | SimHei (SimHei) | Microsoft YaHei Bold |
| Body | Microsoft YaHei (YaHei) | SimSun (SimSun) |
| Academic body | SimSun (SimSun) | — |
| Academic headings | SimHei (SimHei) | — |
| Official doc body | FangSong (FangSong) | FangSong_GB2312 |
| Official doc title | STXiaoBiaoSong (XiaoBiaoSong) | SimSun Bold |

### English Fonts

For **English documents** (document language = English):

| Usage | Font | Fallback |
|-------|------|----------|
| Headings | Times New Roman Bold | Arial Bold |
| Body | Times New Roman | Calibri |
| Academic | Times New Roman | — |

For **English text within Chinese documents**, use the Chinese document's ascii font (Calibri by default).

### Font Paths (for matplotlib / image generation)

```python
# macOS
SIMHEI = "/System/Library/Fonts/Supplemental/SimHei.ttf"
# Linux
SIMHEI = "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc"
# Fallback: download SimHei.ttf to working directory
```

### docx-js Font Configuration

```js
// In Document styles.default
styles: {
  default: {
    document: {
      run: {
        font: { ascii: "Calibri", eastAsia: "Microsoft YaHei" },
        size: 24, // Xiao Si 小四 12pt
        color: palette.body,
      },
      paragraph: {
        spacing: { line: 312 }, // 1.3x mandatory
      },
    },
    heading1: {
      run: {
        font: { ascii: "Calibri", eastAsia: "SimHei" },
        size: 32, // San Hao 三号 16pt
        bold: true,
        color: palette.primary,
      },
    },
    heading2: {
      run: {
        font: { ascii: "Calibri", eastAsia: "SimHei" },
        size: 28, // Si Hao 四号 14pt
        bold: true,
        color: palette.primary,
      },
    },
  },
}
```

---

## Table Styles

**Profile routing:** Profile A (Formal: report, academic, contract, exam) → Three-Line Table or Horizontal-Only Table. Profile B (Visual: resume, copywriting) → Zebra Stripe (preferred) or Horizontal-Only Table.

### 1. Three-Line Table (三线表) — Academic

Only three horizontal lines: top of table, bottom of header, bottom of table.

```js
const threeLineTable = new Table({
  width: { size: 100, type: WidthType.PERCENTAGE },
  borders: {
    top: { style: BorderStyle.SINGLE, size: 4, color: "000000" },
    bottom: { style: BorderStyle.SINGLE, size: 4, color: "000000" },
    left: { style: BorderStyle.NONE },
    right: { style: BorderStyle.NONE },
    insideHorizontal: { style: BorderStyle.NONE },
    insideVertical: { style: BorderStyle.NONE },
  },
  rows: [
    new TableRow({
      children: headerCells.map(text => new TableCell({
        children: [new Paragraph({ children: [new TextRun({ text, bold: true, size: 21 })] })],
        borders: {
          bottom: { style: BorderStyle.SINGLE, size: 2, color: "000000" },
          top: { style: BorderStyle.NONE },
          left: { style: BorderStyle.NONE },
          right: { style: BorderStyle.NONE },
        },
        margins: { top: 60, bottom: 60, left: 120, right: 120 },
      })),
    }),
    // ... data rows with all borders NONE
  ],
});
```

### 2. Zebra Stripe — Data Reports

```js
function zebraRow(cells, index, palette) {
  return new TableRow({
    children: cells.map(text => new TableCell({
      children: [new Paragraph({ children: [new TextRun({ text, size: 21 })] })],
      shading: index % 2 === 0
        ? { type: ShadingType.CLEAR, fill: palette.surface }
        : { type: ShadingType.CLEAR, fill: "FFFFFF" },
      margins: { top: 60, bottom: 60, left: 120, right: 120 },
    })),
  });
}
```

### 3. Horizontal-Only — Business (Default)

```js
const horizontalTable = new Table({
  width: { size: 100, type: WidthType.PERCENTAGE },
  borders: {
    top: { style: BorderStyle.SINGLE, size: 2, color: palette.accent.replace("#","") },
    bottom: { style: BorderStyle.SINGLE, size: 2, color: palette.accent.replace("#","") },
    left: { style: BorderStyle.NONE },
    right: { style: BorderStyle.NONE },
    insideHorizontal: { style: BorderStyle.SINGLE, size: 1, color: "D0D0D0" },
    insideVertical: { style: BorderStyle.NONE },
  },
  rows: [/* header row with accent shading, then data rows */],
});
```

**⚠️ CRITICAL**: Always set `margins` at the Table or TableCell level. Without margins, text touches cell borders.

### Table Color Token Derivation

Each palette in `coverPalettes` provides a `table` object with pre-computed colors for all 3 table styles:

| Token | Used in | Description |
|-------|---------|-------------|
| `table.headerBg` | Zebra Stripe, Horizontal-Only | Table header background color |
| `table.headerText` | Zebra Stripe, Horizontal-Only | Table header text color (must pass WCAG AA contrast) |
| `table.accentLine` | Three-Line | Top/bottom/header-bottom line color |
| `table.innerLine` | Horizontal-Only | Inner horizontal separator line color |
| `table.surface` | Zebra Stripe | Alternating row background (light tint) |

**Usage:**
```js
const palette = coverPalettes["DS-1"];
const t = palette.table;
// Three-Line: use t.accentLine for border colors
// Zebra: use t.headerBg, t.headerText, t.surface
// Horizontal: use t.headerBg, t.headerText, t.innerLine
```

**⚠️ High-saturation accent override**: For DM-1, FG-1, and SN-2, the table colors are intentionally darkened/desaturated relative to the cover accent. Bright accent colors that look good on dark cover backgrounds are too eye-straining on white body pages. Always use `palette.table.*` for tables, never the raw `palette.accent`.

---
---

## Cover Page Design System

### Design Philosophy

Covers use **7 validated layout recipes** + **parameterized variants** instead of free combination. Each recipe's background, layout, and decoration are visually verified. Differentiation comes from **palette × font size × content variation**.

**Architecture principle:** All recipes use a **single 16838 outer wrapper table** (one row, exact height). Recipes R1/R2/R3 use **ZERO nested tables** — all decoration is achieved via **paragraph borders** (left/right/top/bottom). This ensures maximum cross-engine stability (MS Office + WPS).

### Cover Color Palettes (Dark Mode + Light Mode)

Each palette defines 3 core colors (Background, Primary, Accent) + derived cover/table tokens.

⚠️ **Disambiguation: `cover.titleColor` is a COLOR value, not title text.** All keys under `cover: { ... }` are **color hex codes** for styling cover text elements. They are NOT text content. The actual title text comes from `config.title`. Never use `P.titleColor` as the `text` parameter of a `TextRun` — it must only be used as the `color` parameter.

```js
// ✅ Correct — config.title is the text, P.titleColor is the color
new TextRun({ text: config.title, color: P.titleColor })

// ❌ WRONG — using color value as text content
new TextRun({ text: P.titleColor, color: P.titleColor })  // displays "FFFFFF" as visible text!
```

```js
const coverPalettes = {
  // ── Light backgrounds (7) ──
  "WM-1": { // Warm Teal — education, training, marketing
    bg: "F4F1E9", primary: "15857A", accent: "FF6A3B",
    cover: { titleColor: "15857A", subtitleColor: "606060", metaColor: "707070", footerColor: "A0A0A0" },
    table: { headerBg: "15857A", headerText: "FFFFFF", accentLine: "15857A", innerLine: "D5D0C8", surface: "F0EDE5" },
  },
  "CM-2": { // Blue Orange — tech, corporate, whitepaper
    bg: "FEFEFE", primary: "1284BA", accent: "FF862F",
    cover: { titleColor: "1284BA", subtitleColor: "606060", metaColor: "707070", footerColor: "A0A0A0" },
    table: { headerBg: "1284BA", headerText: "FFFFFF", accentLine: "1284BA", innerLine: "D8E4EC", surface: "EDF4F9" },
  },
  "SN-2": { // Soft Purple — creative, branding, events (⚠️ NOT for business)
    bg: "EBDCEF", primary: "73593C", accent: "B13DC6",
    cover: { titleColor: "73593C", subtitleColor: "606060", metaColor: "707070", footerColor: "A0A0A0" },
    table: { headerBg: "7A4D8A", headerText: "FFFFFF", accentLine: "7A4D8A", innerLine: "D8D0DE", surface: "F2EDF5" },
  },
  "MIN-1": { // Warm Gold — consulting, minimalist business, premium proposals
    bg: "F3F1ED", primary: "000000", accent: "D6C096",
    cover: { titleColor: "000000", subtitleColor: "606060", metaColor: "707070", footerColor: "A0A0A0" },
    table: { headerBg: "D6C096", headerText: "1A1A1A", accentLine: "000000", innerLine: "DDD8CC", surface: "F5F3ED" },
  },
  "WR-2": { // Retro Green — traditional industry, finance compliance, legal
    bg: "F4F1E9", primary: "2A4A3A", accent: "C89F62",
    cover: { titleColor: "2A4A3A", subtitleColor: "606060", metaColor: "707070", footerColor: "A0A0A0" },
    table: { headerBg: "2A4A3A", headerText: "FFFFFF", accentLine: "2A4A3A", innerLine: "D0D8D0", surface: "F0EDE5" },
  },
  "MC-1": { // Medical Blue — healthcare, clinical reports
    bg: "F5F8FC", primary: "1A5276", accent: "2E86C1",
    cover: { titleColor: "1A5276", subtitleColor: "606060", metaColor: "707070", footerColor: "A0A0A0" },
    table: { headerBg: "2E86C1", headerText: "FFFFFF", accentLine: "1A5276", innerLine: "D0DDE8", surface: "EDF3F8" },
  },
  "GV-1": { // Official Red — government, state-owned enterprise, party building
    bg: "FAFAFA", primary: "1A1A1A", accent: "C0392B",
    cover: { titleColor: "1A1A1A", subtitleColor: "606060", metaColor: "707070", footerColor: "A0A0A0" },
    table: { headerBg: "C0392B", headerText: "FFFFFF", accentLine: "C0392B", innerLine: "DDD0D0", surface: "F8F0F0" },
  },

  // ── Dark backgrounds (5) ──
  "DS-1": { // Deep Sea — annual report, general business
    bg: "0B1C2C", primary: "FFFFFF", accent: "529286",
    cover: { titleColor: "FFFFFF", subtitleColor: "B0B8C0", metaColor: "90989F", footerColor: "687078" },
    table: { headerBg: "529286", headerText: "FFFFFF", accentLine: "529286", innerLine: "BECFCC", surface: "E8ECEB" },
  },
  "IG-1": { // Ink Gold — finance, investment, luxury brand
    bg: "1A1A1A", primary: "FFFFFF", accent: "C9A84C",
    cover: { titleColor: "FFFFFF", subtitleColor: "B0B8C0", metaColor: "90989F", footerColor: "687078" },
    table: { headerBg: "C9A84C", headerText: "1A1A1A", accentLine: "C9A84C", innerLine: "DDD5C0", surface: "F5F2E8" },
  },
  "DM-1": { // Deep Cyan — AI, tech proposals, digital transformation
    bg: "162235", primary: "FFFFFF", accent: "37DCF2",
    cover: { titleColor: "FFFFFF", subtitleColor: "B0B8C0", metaColor: "90989F", footerColor: "687078" },
    // ⚠️ Table uses darkened accent (#1B6B7A) — bright #37DCF2 is too saturated for white-page tables
    table: { headerBg: "1B6B7A", headerText: "FFFFFF", accentLine: "1B6B7A", innerLine: "C8DDE2", surface: "EDF3F5" },
  },
  "FG-1": { // Forest Mint — ESG, environmental, sustainability, agriculture
    bg: "0C1F1A", primary: "FFFFFF", accent: "3DDBB5",
    cover: { titleColor: "FFFFFF", subtitleColor: "B0B8C0", metaColor: "90989F", footerColor: "687078" },
    // ⚠️ Table uses darkened accent (#2A7A65) — bright #3DDBB5 is too saturated for white-page tables
    table: { headerBg: "2A7A65", headerText: "FFFFFF", accentLine: "2A7A65", innerLine: "C5D8D0", surface: "EDF5F2" },
  },
  "GO-1": { // Graphite Orange — proposals, bidding, PRD
    bg: "1A2330", primary: "FFFFFF", accent: "D4875A",
    cover: { titleColor: "FFFFFF", subtitleColor: "B0B8C0", metaColor: "90989F", footerColor: "687078" },
    table: { headerBg: "D4875A", headerText: "FFFFFF", accentLine: "D4875A", innerLine: "DDD0C8", surface: "F8F0EB" },
  },

  // ── Special (R5 only) ──
  "ED-1": { // Editorial Warm — lesson plans, cultural/creative, light reports, newsletters
    bg: "F7F7F5", primary: "2C2C2C", accent: "D4D4D0",
    cover: { titleColor: "2C2C2C", subtitleColor: "6B6B6B", metaColor: "9A9A9A", footerColor: "9A9A9A" },
    table: { headerBg: "E8E8E4", headerText: "2C2C2C", accentLine: "D4D4D0", innerLine: "E8E8E4", surface: "FAFAF8" },
    // Note: R6 exclusive. Minimal editorial style — warm grey tones, no colored headers.
  },

  "ST-1": { // Swiss Tech — cultural/creative research, trend reports, brand strategy
    bg: "E2E8F0", primary: "0F172A", accent: "0042E6",
    cover: { titleColor: "0F172A", subtitleColor: "475569", metaColor: "475569", footerColor: "475569" },
    table: { headerBg: "475569", headerText: "FFFFFF", accentLine: "0042E6", innerLine: "CBD5E1", surface: "F1F5F9" },
    // Note: R7 exclusive. Swiss minimalist — slate grey bg, Klein blue accent, open-frame tables.
  },

  "ACADEMIC": { // Academic Black — thesis, standards (R5 exclusive, not in general routing)
    bg: "FFFFFF", primary: "000000", accent: "000000",
    cover: { titleColor: "000000", subtitleColor: "404040", metaColor: "606060", footerColor: "808080" },
    table: { headerBg: "000000", headerText: "000000", accentLine: "000000", innerLine: "000000", surface: "FFFFFF" },
    // Note: Academic uses Three-Line table only, with pure black lines. No colored headers.
  },
};
```

### ⚠️ Dark Cover → Light Table Rule

Covers with dark backgrounds (DS-1, IG-1, DM-1, FG-1, GO-1) use bright accent on dark bg.
Body page tables are always on WHITE background — table colors use **darkened/desaturated** variants of the accent.
High-saturation accent colors (DM-1 #37DCF2, FG-1 #3DDBB5, SN-2 #B13DC6) are explicitly overridden in `table.*` fields above.

### ⚠️ SN-2 Scene Restriction

SN-2 (Soft Purple) is restricted to creative/branding/event documents ONLY.
It MUST NOT be used for: business reports, consulting, finance, legal, government, medical, or technical documents.

### Industry → Palette Recommendations

| Industry / Theme | Recommended Palette | Fallback |
|-----------|---------|---------|
| General annual report | DS-1 Deep Sea | CM-2 |
| Finance / investment / luxury | IG-1 Ink Gold | WR-2, MIN-1 |
| Tech / AI / internet | DM-1 Deep Cyan | CM-2 |
| ESG / environmental / sustainability | FG-1 Forest Mint | DS-1 |
| Consulting / diagnostic report | MIN-1 Warm Gold | WR-2 |
| Business proposal / bidding / PRD | GO-1 Graphite Orange | CM-2 |
| Education / training (formal) | WM-1 Warm Teal | CM-2 |
| Lesson plan (arts/general) | ED-1 Editorial Warm | WM-1 |
| Cultural / newsletter / internal | ED-1 Editorial Warm | WM-1 |
| Events / activities | ED-1 Editorial Warm | WM-1 |
| Medical / healthcare / clinical | MC-1 Medical Blue | CM-2 |
| Government / state-owned / party | GV-1 Official Red | MIN-1 |
| Traditional industry / legal / compliance | WR-2 Retro Green | MIN-1 |
| Creative / branding (formal) | SN-2 Soft Purple | WM-1 |
| Whitepaper (general) | CM-2 Blue Orange | DS-1, MIN-1 |
| Academic / thesis / standards | ACADEMIC | — |

---

### ⚠️ Recipe Routing Rules (Replaces Free Selection)

```js
function selectCoverRecipe(docType, industry, titleLength) {
  // No cover for these types
  if (["contract", "official", "exam", "resume"].includes(docType)) return null;

  // Academic
  if (docType === "academic") return { recipe: "R5", palette: "ACADEMIC" };

  // Thesis proposal report (开题报告)
  if (docType === "proposal_report") return { recipe: "R5", palette: "ACADEMIC" };

  // Lesson plans — R6 editorial for arts/general, R4 for STEM
  if (docType === "lesson_plan" || docType === "lessonplan") {
    const stemKeywords = ["math", "physics", "chemistry", "biology", "science", "tech", "computer", "engineering"];
    if (stemKeywords.some(k => (industry || "").toLowerCase().includes(k))) {
      return { recipe: "R4", palette: "DM-1" };
    }
    // Arts, general, and all other lesson plans → R6 editorial
    return { recipe: "R6", palette: "ED-1" };
  }

  // Creative/branding/design (formal) → R3 centered card frame
  if (["creative", "branding", "design"].includes(docType)) {
    return { recipe: "R3", palette: "SN-2" };
  }

  // Cultural/newsletter/internal (casual) → R6 editorial
  if (["cultural", "newsletter", "internal"].includes(docType)) {
    return { recipe: "R6", palette: "ED-1" };
  }

  // Activity/event planning → R6 editorial
  if (docType === "activity") return { recipe: "R6", palette: "ED-1" };

  // Trend/research reports in cultural/creative/brand fields → R7 Swiss Tech
  if (docType === "trend_report" || docType === "research_report") {
    if (["cultural", "creative", "brand", "design"].includes(industry)) {
      return { recipe: "R7", palette: "ST-1" };
    }
  }

  // Formal/business subtypes
  if (docType === "whitepaper") return { recipe: "R2", palette: industry === "finance" ? "IG-1" : "CM-2" };
  if (docType === "consulting") return { recipe: "R2", palette: "MIN-1" };
  if (docType === "proposal" || docType === "plan") return { recipe: "R4", palette: "GO-1" };

  // Reports — palette by industry, use R1
  if (docType === "report") {
    const paletteMap = {
      finance: "IG-1", consulting: "MIN-1",
      tech: "DM-1", ai: "DM-1",
      education: "WM-1", green: "FG-1",
      medical: "MC-1", government: "GV-1",
    };
    return { recipe: "R1", palette: paletteMap[industry] || "DS-1" };
  }

  // Default
  return { recipe: "R1", palette: "DS-1" };
}

// ── Long-title override (applied AFTER initial recipe selection) ──
// Call this after selectCoverRecipe() when the actual title text is known.
function applyLongTitleOverride(result, titleLength) {
  if (!result || !result.recipe) return result;
  // R5 (academic) is never overridden — it has its own calcTitleLayoutMixed()
  if (result.recipe === "R5") return result;
  // R6 (editorial) is designed for short titles only (≤20 chars, single line)
  // Long titles → fall back to R1 (handles long titles best)
  if (titleLength > 20 && result.recipe === "R6") {
    return { recipe: "R1", palette: "WM-1" }; // ED-1 has no dark bg, use WM-1 (warm teal)
  }
  // R3/R4 struggle with long titles → fall back to R1 (same palette)
  if (titleLength > 20 && ["R3", "R4"].includes(result.recipe)) {
    return { recipe: "R1", palette: result.palette };
  }
  // Very long titles: even R2 centered looks scattered → R1 left-aligned
  if (titleLength > 30 && result.recipe === "R2") {
    return { recipe: "R1", palette: result.palette };
  }
  return result;
}
```

### Scene Cover Routing

| Scene | Recipe | Default Palette | Special Requirements |
|------|------|---------|----------|
| academic thesis | R5 (Clean White) | ACADEMIC | School name + 2-col meta table with underlines, see academic.md |
| thesis proposal report (开题报告) | R5 (Clean White) | ACADEMIC | Use `buildProposalCover()` from academic.md |
| business report (general) | R1 (Pure Paragraph Left) | DS-1 Deep Sea | Auto-select palette by industry |
| whitepaper | R2 (Double-Rule Frame) | CM-2 Blue Orange / IG-1 Ink Gold | — |
| consulting report | R2 (Double-Rule Frame) | MIN-1 Warm Gold | — |
| business proposal / plan | R4 (Top Color Block) | GO-1 Graphite Orange | — |
| events / activities | R6 (Editorial Warm) | ED-1 Editorial Warm | Light/casual style, short titles only (≤20 chars) |
| lesson plan (STEM) | R4 (Top Color Block) | DM-1 Deep Cyan | Route by subject, see selectCoverRecipe |
| lesson plan (arts/general) | R6 (Editorial Warm) | ED-1 Editorial Warm | Casual editorial style, short titles only |
| creative / branding / design (formal) | R3 (Centered Card Frame) | SN-2 Soft Purple | Product overview, brand doc, design report |
| cultural / newsletter / internal | R6 (Editorial Warm) | ED-1 Editorial Warm | Light/casual style, short titles only (≤20 chars) |
| trend/research report (cultural/creative/brand) | R7 (Swiss Tech) | ST-1 Swiss Tech | Minimalist slate bg, Klein blue accent, open-frame tables |
| education report | R1 (Pure Paragraph Left) | WM-1 Warm Teal | For education reports, NOT lesson plans |
| ESG / environmental | R1 (Pure Paragraph Left) | FG-1 Forest Mint | — |
| medical / healthcare | R1 (Pure Paragraph Left) | MC-1 Medical Blue | — |
| government / state-owned | R1 (Pure Paragraph Left) | GV-1 Official Red | — |
| resume | — | — | No standalone cover |
| contract | — | — | No standalone cover (title page is first page) |
| official document | — | — | No standalone cover |
| exam paper | — | — | No standalone cover |

---

### Cover Title Length Guidelines

When the user does NOT specify an exact title, the model should craft a title within the recommended range. If the user provides a title that exceeds the comfortable range, apply long-title routing in `selectCoverRecipe()` (see above).

| Recipe | Comfortable (1–2 lines) | Maximum (3 lines) | Long Title Tolerance |
|--------|------------------------|--------------------|----------------------|
| R1     | 8–20 chars             | ≤50 chars          | ⭐⭐⭐⭐⭐ Best (left-aligned, full-page bg) |
| R2     | 8–18 chars             | ≤45 chars          | ⭐⭐⭐⭐ Good (full-page bg, but centered) |
| R3     | 8–15 chars             | ≤40 chars          | ⭐⭐ Poor (narrowest width) |
| R4     | 8–18 chars             | ≤45 chars          | ⭐⭐ Poor (fixed-height color block) |
| R5     | 8–16 chars             | ≤42 chars          | ⭐⭐⭐ OK (academic only, mixed-width calc) |
| R6     | 8–15 chars             | ≤20 chars (1 line) | ⭐ Single line only (editorial, no multi-line) |
| R7     | 8–24 chars             | ≤42 chars (3 lines) | ⭐⭐⭐⭐ Good (left-aligned, light bg, dynamic spacing) |

**Title crafting rules (when model generates the title):**
1. Prefer concise titles within the "comfortable" range
2. If topic requires detail, split into title + subtitle (e.g., title="数字化转型战略研究" subtitle="——以某某企业为例")
3. Never exceed the "maximum" range unless user explicitly provides the full title

---

### ⚠️ Cover Page Break Rules

Cover should be an independent section — **no PageBreak at the end needed**. The next section automatically starts a new page.

```js
// ✅ Correct — cover is a separate section, no trailing PageBreak
sections: [
  { properties: { /* Cover section, margin all 0 */ }, children: buildCover(...) },
  { properties: { /* Body section */ }, children: buildContent(...) },
]
```

### ⚠️ Cover Content Overflow Prevention (Mandatory)

1. Cover section page margin is 0; total content height ≤ 15638 twips (1200 twips safety margin for cross-engine compatibility — MS Office renders large fonts taller than calculated).
2. Color block Table `height` must use `rule: "exact"` (never `"atLeast"`).
3. Each recipe code includes height budget annotations — verify during generation.
4. **`verticalAlign` must always be `"top"`**. Never use `"center"` or `"bottom"` in exact-height rows — content will be clipped or overflow. Use `spacing.before` on the first paragraph for vertical positioning.
5. **Title font size MUST be dynamically calculated via `calcTitleLayout()`** (see below). Never hardcode font sizes above 40pt for cover titles. Every recipe MUST call `calcTitleLayout()` before building title paragraphs.
6. **Never use `margins.top`/`margins.bottom` for vertical positioning inside exact-height cells**: Cell margins reduce available height unpredictably across MS Office and WPS. Use `spacing.before` on the first paragraph instead. Only `margins.left`/`margins.right` are safe.
7. **Dynamic spacing is mandatory**: Use `calcCoverSpacing()` to compute `spacing.before` values dynamically based on content element count and title line count. Never use fixed large spacing values (e.g., `before: 4500`) that assume a specific title length.
8. **Cover must be a single-page section**: The cover section must produce exactly ONE page. If content overflows to a second page, it means the height budget is violated. Common overflow causes and fixes:
   - Title font too large → use `calcTitleLayout()` to auto-reduce
   - Too many meta lines → reduce font size or remove less important lines
   - Fixed `spacing.before` values too large → use `calcCoverSpacing()` for dynamic values
   - Subtitle + English label + meta lines combined exceed budget → calculate total and reduce spacing
9. **Cover wrapper table MUST use explicit `allNoBorders`**: The outer 16838 wrapper table and ALL nested tables inside the cover MUST set borders to NONE explicitly. Never rely on docx-js default borders (`single/auto/sz=4`). Default borders add ~8 twips per edge, which causes MS Office to calculate a total height slightly exceeding 16838 → content overflows to a blank page 2. WPS is more lenient but MS Office is strict. **This is the #1 cause of "blank page 2 in MS Office but not in WPS".**

```js
// ✅ MANDATORY: Define and use allNoBorders for every cover table
const NB = { style: BorderStyle.NONE, size: 0, color: "FFFFFF" };
const noBorders = { top: NB, bottom: NB, left: NB, right: NB };
const allNoBorders = { top: NB, bottom: NB, left: NB, right: NB,
                       insideHorizontal: NB, insideVertical: NB };

new Table({
  borders: allNoBorders,  // ← MANDATORY on every cover table
  // ...
});
```

10. **Decorative lines MUST use paragraph borders, NEVER text characters**: Horizontal decorative lines (accent strips, dividers, frame edges) must be implemented with `paragraph border.top` or `border.bottom` — never with text characters like `───`, `━━━`, `═══`, or `——————`. Character-drawn lines render at inconsistent widths across MS Office and WPS (font metrics differ), causing lines to appear truncated or misaligned. Paragraph borders render pixel-perfect in both engines and their width is controlled precisely via `indent.left` / `indent.right`.

```js
// ✅ Correct — paragraph border (R2 style thick accent rule)
new Paragraph({
  indent: { left: 1000, right: 1000 },
  border: { top: { style: BorderStyle.SINGLE, size: 18, color: P.accent, space: 20 } },
  children: [],
})

// ❌ FORBIDDEN — text character line (renders inconsistently)
new Paragraph({
  children: [new TextRun({ text: "───────────────", color: P.accent })]
})
```
9. **Post-generation overflow check (mandatory)**: After building cover children, estimate total height:
   ```js
   function estimateCoverHeight(elements) {
     let total = 0;
     for (const el of elements) {
       if (el instanceof Table) {
         // Sum row heights (exact rows) or estimate 400 twips per row
         const rows = el.root?.[0]?.rows || [];
         for (const row of rows) {
           total += row.height?.value || 400;
         }
       } else if (el instanceof Paragraph) {
         const fontSize = el.root?.[0]?.size || 24; // half-pts
         // ★ Use pt * 11.5 (= half-pt * 10 * 1.15) for accurate single-spacing estimate
         const lineHeight = Math.max(fontSize * 11.5, 276); // min 276 (default 12pt)
         const spacingBefore = el.spacing?.before || 0;
         const spacingAfter = el.spacing?.after || 0;
         total += lineHeight + spacingBefore + spacingAfter;
       }
     }
     return total;
   }
   // ★ Target: estimateCoverHeight(coverChildren) < 15638 (16838 - 1200 safety)
   ```

---

### ⚠️ Cover Title Layout — calcTitleLayout() (Mandatory for ALL Recipes)

**Every cover recipe MUST use `calcTitleLayout()` to determine title font size and line breaks.** Hardcoding font sizes or passing the full title as a single TextRun is FORBIDDEN.

**Every paragraph with font size > body text MUST set explicit line spacing** to prevent top clipping:
```js
// ★ MANDATORY: prevent inherited small line spacing from clipping large fonts
spacing: { line: Math.ceil(titlePt * 23), lineRule: "atLeast", after: 100 }
// Example: 36pt → line: 828; 44pt → line: 1012
```
Without this, the paragraph inherits body text line spacing (e.g., 560tw), which is shorter than the font height → top of characters gets clipped.

```js
/**
 * Calculate safe font size and smart line breaks for cover titles.
 * MUST be called by every recipe before building title paragraphs.
 *
 * @param {string}  title          Full title string
 * @param {number}  maxWidthTwips  Available width for title text (twips, after subtracting margins/padding)
 * @param {number}  preferredPt    Desired max font size in pt (default 40)
 * @param {number}  minPt          Minimum allowed font size in pt (default 24)
 * @returns {{ titlePt: number, titleLines: string[] }}
 */
function calcTitleLayout(title, maxWidthTwips, preferredPt = 40, minPt = 24) {
  // Each CJK character width ≈ pt × 20 twips
  const charWidth = (pt) => pt * 20;
  const charsPerLine = (pt) => Math.floor(maxWidthTwips / charWidth(pt));

  // Try from preferredPt downward until title fits in ≤ 3 lines
  let titlePt = preferredPt;
  let lines;
  while (titlePt >= minPt) {
    const cpl = charsPerLine(titlePt);
    if (cpl < 2) { titlePt -= 2; continue; }
    lines = splitTitleLines(title, cpl);
    if (lines.length <= 3) break;
    titlePt -= 2;
  }

  // If still > 3 lines at minPt, force 3 lines
  if (!lines || lines.length > 3) {
    const cpl = charsPerLine(minPt);
    lines = splitTitleLines(title, cpl);
    titlePt = minPt;
  }

  return { titlePt, titleLines: lines };
}

/**
 * Smart Chinese title line-breaking — breaks at semantic boundaries, never mid-word.
 *
 * Rules:
 * 1. Prefer breaking after particles, punctuation, connectors, underscores, spaces
 * 2. Never split a compound word (e.g., "管理规范" must not become "管理规" + "范")
 * 3. No single-character orphan on the last line — merge into previous line
 * 4. If no good break point found within 60-130% of charsPerLine, break at charsPerLine
 *
 * @param {string} title        Full title string
 * @param {number} charsPerLine Max characters per line at current font size
 * @returns {string[]}          Array of line strings
 */
function splitTitleLines(title, charsPerLine) {
  if (title.length <= charsPerLine) return [title];

  // Characters that are safe break points (break AFTER these)
  const breakAfter = new Set([
    ...'，。、；：！？',              // CJK punctuation
    ...'的与和及之在于为',            // CJK particles/prepositions
    ...'-_—–·/',                     // connectors
    ...' \t',                         // whitespace
  ]);

  const lines = [];
  let remaining = title;

  while (remaining.length > charsPerLine) {
    let breakAt = -1;

    // Search backward from charsPerLine to 60% for a break point
    for (let i = charsPerLine; i >= Math.floor(charsPerLine * 0.6); i--) {
      if (i < remaining.length && breakAfter.has(remaining[i - 1])) {
        breakAt = i;
        break;
      }
    }

    // If not found, search forward up to 130%
    if (breakAt === -1) {
      const limit = Math.min(remaining.length, Math.ceil(charsPerLine * 1.3));
      for (let i = charsPerLine + 1; i < limit; i++) {
        if (breakAfter.has(remaining[i - 1])) {
          breakAt = i;
          break;
        }
      }
    }

    // Last resort: break at charsPerLine, but avoid splitting compound CJK words
    if (breakAt === -1) {
      breakAt = charsPerLine;
      // If both chars at the break boundary are CJK (likely a compound word),
      // step back 1 char to keep the word together
      const prevChar = remaining[breakAt - 1];
      const nextChar = remaining[breakAt];
      if (prevChar && nextChar &&
          !breakAfter.has(prevChar) && !breakAfter.has(nextChar) &&
          /[\u4e00-\u9fff]/.test(prevChar) && /[\u4e00-\u9fff]/.test(nextChar)) {
        breakAt = breakAt - 1;
      }
    }

    lines.push(remaining.slice(0, breakAt).trim());
    remaining = remaining.slice(breakAt).trim();
  }
  if (remaining) lines.push(remaining);

  // Prevent single-character orphan on last line — merge into previous
  if (lines.length > 1 && lines[lines.length - 1].length <= 2) {
    const last = lines.pop();
    lines[lines.length - 1] += last;
  }

  return lines;
}

/**
 * Calculate dynamic spacing values for cover elements to fit within page height.
 *
 * @param {object} params
 * @param {number} params.titleLineCount   Number of title lines
 * @param {number} params.titlePt          Title font size in pt
 * @param {boolean} params.hasSubtitle     Whether subtitle exists
 * @param {boolean} params.hasEnglishLabel Whether English label exists
 * @param {number} params.metaLineCount    Number of meta info lines
 * @param {number} params.fixedHeight      Sum of fixed-height elements (color strips, accent bars, footer) in twips
 * @param {number} params.pageHeight       Total page height in twips (default 16838)
 * @returns {{ topSpacing, midSpacing, bottomSpacing }}  Spacing values in twips
 */
function calcCoverSpacing(params) {
  const {
    titleLineCount = 1, titlePt = 36, hasSubtitle = false,
    hasEnglishLabel = false, metaLineCount = 0,
    fixedHeight = 800, pageHeight = 16838,
    marginTop = 0, marginBottom = 0,   // ★ NEW: pass actual section margins
  } = params;

  // ★ Safety margin: 1200 twips (cross-engine: MS Office renders large fonts
  // taller than calculated; extra 400tw buffer prevents footer clipping)
  const SAFETY = 1200;
  // ★ Subtract page margins from available height (cover section may have margins)
  const usableHeight = pageHeight - marginTop - marginBottom - SAFETY;

  // ★ Accurate height estimation per element:
  const titleHeight = titleLineCount * (titlePt * 23 + 200);
  const subtitleHeight = hasSubtitle ? (12 * 23 + 600) : 0;
  const englishLabelHeight = hasEnglishLabel ? (9 * 23 + 600) : 0;
  const metaHeight = metaLineCount * (10 * 23 + 100);

  // ★ Account for implicit paragraph heights:
  const implicitParaHeight = 3 * 300;

  const contentHeight = titleHeight + subtitleHeight + englishLabelHeight +
                        metaHeight + fixedHeight + implicitParaHeight;

  const remainingSpace = usableHeight - contentHeight;
  const safeRemaining = Math.max(remainingSpace, 400);

  // ★ Footer protection: bottomSpacing must be ≥ FOOTER_MIN to prevent
  // footer + accent line from being clipped at the cell bottom edge
  const FOOTER_MIN = 800;
  const rawTop = Math.floor(safeRemaining * 0.45);
  const rawBottom = Math.floor(safeRemaining * 0.45);
  const bottomSpacing = Math.max(rawBottom, FOOTER_MIN);
  const topSpacing = Math.max(rawTop - Math.max(0, FOOTER_MIN - rawBottom), 400);
  const midSpacing = Math.max(safeRemaining - topSpacing - bottomSpacing, 0);

  return { topSpacing, midSpacing, bottomSpacing };
}
```

**Usage in every recipe:**
```js
// Step 1: Calculate title layout
const availableWidth = 11906 - leftPadding - rightPadding; // subtract margins
const { titlePt, titleLines } = calcTitleLayout(config.title, availableWidth);
const titleSize = titlePt * 2; // convert to half-points for docx-js

// Step 2: Calculate spacing (pass actual section margins!)
const spacing = calcCoverSpacing({
  titleLineCount: titleLines.length,
  titlePt,
  hasSubtitle: !!config.subtitle,
  hasEnglishLabel: !!config.englishLabel,
  metaLineCount: (config.metaLines || []).length,
  fixedHeight: 800, // sum of accent strips, footer table, etc.
  marginTop: 0,     // ★ pass the cover section's actual top margin (twips)
  marginBottom: 0,  // ★ pass the cover section's actual bottom margin (twips)
});

// Step 3: Use in paragraphs
children.push(new Paragraph({ spacing: { before: spacing.topSpacing } }));
// ... title paragraphs using titleLines and titleSize ...
children.push(new Paragraph({ spacing: { before: spacing.bottomSpacing } }));
```

---

### ⚠️ CRITICAL — Cover Section Non-Negotiables (ALL Recipes)

These 3 properties are MANDATORY for every cover implementation (R1–R7). Omitting ANY of them causes cover layout failure:

1. **Cover section margin = 0**: The cover MUST be in its own section with `page.margin: { top: 0, bottom: 0, left: 0, right: 0 }`. Non-zero margins shrink the wrapper away from page edges → white gaps around the cover ("cover not filling the page"). This is the #1 cause of broken cover layouts.

2. **Wrapper row exact height**: The outer wrapper table row MUST set `height: { value: 16838, rule: "exact" }`. Without this, content overflow pushes to page 2, or insufficient content leaves bottom whitespace.

3. **Wrapper table borders = allNoBorders**: MUST explicitly set `borders: allNoBorders`. Default docx-js borders add ~8 twips per edge. MS Office includes border thickness in exact-height calculation → total exceeds 16838 → blank page 2 (WPS is lenient, MS Office is strict).

**Cover section template (copy this for every Recipe):**
```js
sections: [
  {
    // ⚠️ Cover section — margin MUST be 0, separate from body
    properties: {
      page: {
        size: { width: 11906, height: 16838 },
        margin: { top: 0, bottom: 0, left: 0, right: 0 },
      },
    },
    children: buildCoverRX(config), // ← replace with actual recipe function
  },
  {
    // Body section — normal margins
    properties: {
      type: SectionType.NEXT_PAGE,
      page: {
        size: { width: 11906, height: 16838 },
        margin: { top: 1440, bottom: 1440, left: 1701, right: 1417 },
      },
    },
    children: [...bodyContent],
  },
]
```

---

### Recipe R1: Pure Paragraph Cover (Left-Aligned)

**Visual:** Full-page dark background + left-aligned text + decoration via paragraph borders only
**Use case:** Annual report, business report, tech proposal (most versatile premium recipe)
**Nested tables: ZERO** — all decoration uses paragraph borders (bottom line, left accent bar, top separator)

Visual hierarchy (top to bottom):
1. Dynamic top whitespace (via `calcCoverSpacing`)
2. English label with accent bottom border (paragraph `border.bottom`)
3. Main title (1-3 lines, dynamic font size via `calcTitleLayout`)
4. Subtitle (light grey, smaller)
5. Meta info lines with left accent border (paragraph `border.left`)
6. Dynamic bottom whitespace
7. Footer line with top accent separator (paragraph `border.top`)

```js
// ⚠️ MANDATORY: Cover section must use margin: 0. See "Cover Section Non-Negotiables" above.
// Section: { properties: { page: { size: { width: 11906, height: 16838 },
//   margin: { top: 0, bottom: 0, left: 0, right: 0 } } }, children: buildCoverR1(config) }

function buildCoverR1(config) {
  // config: { title, subtitle, englishLabel, metaLines, footerLeft, footerRight, palette }
  // palette: { bg, titleColor, subtitleColor, metaColor, accent, footerColor }
  const P = config.palette;
  const padL = 1200, padR = 800;

  // ⚠️ MANDATORY: Use calcTitleLayout() for dynamic font size + line breaking
  const availableWidth = 11906 - padL - padR - 300; // -300 for border space
  const { titlePt, titleLines } = calcTitleLayout(config.title, availableWidth, 40, 24);
  const titleSize = titlePt * 2;

  // ⚠️ MANDATORY: Use calcCoverSpacing() for dynamic spacing
  const spacing = calcCoverSpacing({
    titleLineCount: titleLines.length, titlePt,
    hasSubtitle: !!config.subtitle, hasEnglishLabel: !!config.englishLabel,
    metaLineCount: (config.metaLines || []).length,
    fixedHeight: 400, // footer line only (no nested tables)
  });

  const accentLeft = { style: BorderStyle.SINGLE, size: 8, color: P.accent, space: 12 };
  const children = [];

  // 1. Top whitespace (dynamic)
  children.push(new Paragraph({ spacing: { before: spacing.topSpacing } }));

  // 2. English label with accent bottom border
  if (config.englishLabel) {
    children.push(new Paragraph({
      indent: { left: padL, right: padR }, spacing: { after: 500 },
      border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: P.accent, space: 8 } },
      children: [new TextRun({ text: config.englishLabel.split("").join("  "),
        size: 18, color: P.accent, font: { ascii: "Calibri", eastAsia: "SimHei" }, characterSpacing: 40 })],
    }));
  }

  // 3. Main title (dynamic font size + smart line breaks)
  for (let i = 0; i < titleLines.length; i++) {
    children.push(new Paragraph({
      indent: { left: padL },
      spacing: { after: i < titleLines.length - 1 ? 100 : 300, line: Math.ceil(titlePt * 23), lineRule: "atLeast" },
      children: [new TextRun({ text: titleLines[i], size: titleSize, bold: true,
        color: P.titleColor, font: { eastAsia: "SimHei", ascii: "Arial" } })],
    }));
  }

  // 4. Subtitle
  if (config.subtitle) {
    children.push(new Paragraph({
      indent: { left: padL }, spacing: { after: 800 },
      children: [new TextRun({ text: config.subtitle, size: 24, color: P.subtitleColor,
        font: { eastAsia: "Microsoft YaHei", ascii: "Arial" } })],
    }));
  }

  // 5. Meta info lines with left accent border
  for (const line of (config.metaLines || [])) {
    children.push(new Paragraph({
      indent: { left: padL + 200 }, spacing: { after: 80 },
      border: { left: accentLeft },
      children: [new TextRun({ text: line, size: 24, color: P.metaColor,
        font: { eastAsia: "Microsoft YaHei", ascii: "Arial" } })],
    }));
  }

  // 6. Bottom whitespace (dynamic)
  children.push(new Paragraph({ spacing: { before: spacing.bottomSpacing } }));

  // 7. Footer with top accent separator
  children.push(new Paragraph({
    indent: { left: padL, right: padR },
    border: { top: { style: BorderStyle.SINGLE, size: 2, color: P.accent, space: 8 } },
    spacing: { before: 200 },
    children: [
      new TextRun({ text: config.footerLeft || "", size: 16, color: P.footerColor, font: { ascii: "Arial" } }),
      new TextRun({ text: "                                        " }),
      new TextRun({ text: config.footerRight || "", size: 16, color: P.footerColor, font: { ascii: "Arial" } }),
    ],
  }));

  // Single 16838 wrapper — the ONLY table
  return [new Table({
    width: { size: 100, type: WidthType.PERCENTAGE },
    layout: TableLayoutType.FIXED,
    borders: allNoBorders,
    rows: [new TableRow({
      height: { value: 16838, rule: "exact" },
      children: [new TableCell({
        shading: { type: ShadingType.CLEAR, fill: P.bg }, borders: noBorders,
        children,
      })],
    })],
  })];
  // Total height: 16838 (single wrapper, zero nested tables) ✅
}
```

---

### Recipe R2: Double-Rule Frame (Centered)

**Visual:** Full-page dark background + top/bottom thick accent horizontal rules + centered content
**Use case:** Whitepaper, finance report, consulting deliverable, high-end formal reports
**Nested tables: ZERO** — top/bottom rules are paragraph borders

Visual hierarchy (top to bottom):
1. Top thick accent rule (paragraph `border.top`)
2. Generous whitespace
3. English label (centered, spaced)
4. Main title (centered, 1-3 lines, dynamic font size)
5. Subtitle (centered)
6. Generous whitespace
7. Meta info lines (centered, **18pt** / size: 36)
8. Generous whitespace
9. Footer + bottom thick accent rule (paragraph `border.bottom`)

```js
// ⚠️ MANDATORY: Cover section must use margin: 0. See "Cover Section Non-Negotiables" above.
function buildCoverR2(config) {
  const P = config.palette;
  const padL = 1400, padR = 1400;

  // ⚠️ MANDATORY: Use calcTitleLayout() for dynamic font size + line breaking
  const { titlePt, titleLines } = calcTitleLayout(config.title, 11906 - padL - padR, 40, 24);
  const titleSize = titlePt * 2;
  const thickBorder = { style: BorderStyle.SINGLE, size: 18, color: P.accent, space: 20 };

  const children = [];

  // 1. Top rule
  children.push(new Paragraph({
    indent: { left: padL - 400, right: padR - 400 }, spacing: { before: 1200, after: 200 },
    border: { top: thickBorder }, children: [],
  }));

  // 2. Whitespace
  children.push(new Paragraph({ spacing: { before: 1800 } }));

  // 3. English label
  if (config.englishLabel) {
    children.push(new Paragraph({
      alignment: AlignmentType.CENTER, spacing: { after: 500 },
      children: [new TextRun({ text: config.englishLabel.split("").join("  "),
        size: 18, color: P.accent, font: { ascii: "Calibri" }, characterSpacing: 40 })],
    }));
  }

  // 4. Main title (centered, dynamic)
  for (let i = 0; i < titleLines.length; i++) {
    children.push(new Paragraph({
      alignment: AlignmentType.CENTER,
      spacing: { after: i < titleLines.length - 1 ? 80 : 300, line: Math.ceil(titlePt * 23), lineRule: "atLeast" },
      children: [new TextRun({ text: titleLines[i], size: titleSize, bold: true,
        color: P.titleColor, font: { eastAsia: "SimHei", ascii: "Arial" } })],
    }));
  }

  // 5. Subtitle
  if (config.subtitle) {
    children.push(new Paragraph({
      alignment: AlignmentType.CENTER, spacing: { after: 400 },
      children: [new TextRun({ text: config.subtitle, size: 24, color: P.subtitleColor,
        font: { eastAsia: "Microsoft YaHei", ascii: "Arial" } })],
    }));
  }

  // 6. Whitespace
  children.push(new Paragraph({ spacing: { before: 1200 } }));

  // 7. Meta lines — 18pt (size: 36) for readability
  for (const line of (config.metaLines || [])) {
    children.push(new Paragraph({
      alignment: AlignmentType.CENTER,
      spacing: { after: 100, line: Math.ceil(18 * 23), lineRule: "atLeast" },
      children: [new TextRun({ text: line, size: 36, color: P.metaColor,
        font: { eastAsia: "Microsoft YaHei", ascii: "Arial" } })],
    }));
  }

  // 8. Whitespace
  children.push(new Paragraph({ spacing: { before: 2000 } }));

  // 9. Footer + bottom rule
  children.push(new Paragraph({
    alignment: AlignmentType.CENTER,
    indent: { left: padL - 400, right: padR - 400 }, spacing: { before: 200 },
    border: { bottom: thickBorder },
    children: [new TextRun({ text: config.footerRight || "", size: 18, color: P.footerColor, font: { ascii: "Arial" } })],
  }));

  // Single 16838 wrapper — the ONLY table
  return [new Table({
    width: { size: 100, type: WidthType.PERCENTAGE },
    layout: TableLayoutType.FIXED,
    borders: allNoBorders,
    rows: [new TableRow({
      height: { value: 16838, rule: "exact" },
      children: [new TableCell({
        shading: { type: ShadingType.CLEAR, fill: P.bg }, borders: noBorders,
        children,
      })],
    })],
  })];
  // Total height: 16838 (single wrapper, zero nested tables) ✅
}
```

---

### Recipe R3: Centered Card Frame (Paragraph Borders)

**Visual:** Full-page dark background + centered "card" effect via paragraph indent + 4-side paragraph borders
**Use case:** Research report, product overview, event summary, creative/design documents
**Nested tables: ZERO** — card borders are paragraph borders with large left/right indents

Visual hierarchy (top to bottom):
1. Pre-card whitespace (~2800tw)
2. Card top edge (paragraph with `border.top` + `border.left` + `border.right`, indent 2200tw)
3. English label (centered, inside card side borders)
4. Main title (centered, inside card side borders, dynamic font size)
5. Subtitle (centered, inside card side borders)
6. Spacer (inside card side borders)
7. Meta info lines (centered, inside card side borders)
8. Card bottom edge (paragraph with `border.bottom` + `border.left` + `border.right`)
9. Post-card whitespace
10. Footer (centered)

```js
// ⚠️ MANDATORY: Cover section must use margin: 0. See "Cover Section Non-Negotiables" above.
function buildCoverR3(config) {
  const P = config.palette;
  const cardIndent = 2200; // left + right indent to create "card" feel
  const innerWidth = 11906 - cardIndent * 2 - 400;

  // ⚠️ MANDATORY: Use calcTitleLayout() for dynamic font size + line breaking
  const { titlePt, titleLines } = calcTitleLayout(config.title, innerWidth, 40, 24);
  const titleSize = titlePt * 2;

  const bTop = { style: BorderStyle.SINGLE, size: 24, color: P.accent, space: 16 };
  const bBot = { style: BorderStyle.SINGLE, size: 24, color: P.accent, space: 16 };
  const bL = { style: BorderStyle.SINGLE, size: 2, color: P.accent, space: 16 };
  const bR = { style: BorderStyle.SINGLE, size: 2, color: P.accent, space: 16 };
  const sides = { left: bL, right: bR };

  const children = [];

  // 1. Pre-card whitespace
  children.push(new Paragraph({ spacing: { before: 2800 } }));

  // 2. Card top edge
  children.push(new Paragraph({
    indent: { left: cardIndent, right: cardIndent }, spacing: { after: 600 },
    border: { top: bTop, left: bL, right: bR }, children: [],
  }));

  // 3. English label inside card
  if (config.englishLabel) {
    children.push(new Paragraph({
      alignment: AlignmentType.CENTER, indent: { left: cardIndent, right: cardIndent },
      spacing: { after: 500 }, border: sides,
      children: [new TextRun({ text: config.englishLabel.split("").join("  "),
        size: 16, color: P.accent, font: { ascii: "Calibri" }, characterSpacing: 30 })],
    }));
  }

  // 4. Main title inside card
  for (let i = 0; i < titleLines.length; i++) {
    children.push(new Paragraph({
      alignment: AlignmentType.CENTER, indent: { left: cardIndent, right: cardIndent },
      spacing: { after: i < titleLines.length - 1 ? 60 : 300, line: Math.ceil(titlePt * 23), lineRule: "atLeast" },
      border: sides,
      children: [new TextRun({ text: titleLines[i], size: titleSize, bold: true,
        color: P.titleColor, font: { eastAsia: "SimHei", ascii: "Arial" } })],
    }));
  }

  // 5. Subtitle inside card
  if (config.subtitle) {
    children.push(new Paragraph({
      alignment: AlignmentType.CENTER, indent: { left: cardIndent, right: cardIndent },
      spacing: { after: 400 }, border: sides,
      children: [new TextRun({ text: config.subtitle, size: 22, color: P.subtitleColor,
        font: { eastAsia: "Microsoft YaHei", ascii: "Arial" } })],
    }));
  }

  // 6. Spacer inside card
  children.push(new Paragraph({
    indent: { left: cardIndent, right: cardIndent }, spacing: { before: 400 },
    border: sides, children: [],
  }));

  // 7. Meta info lines inside card
  for (let i = 0; i < (config.metaLines || []).length; i++) {
    const isLast = i === config.metaLines.length - 1;
    children.push(new Paragraph({
      alignment: AlignmentType.CENTER, indent: { left: cardIndent, right: cardIndent },
      spacing: { after: isLast ? 400 : 80 }, border: sides,
      children: [new TextRun({ text: config.metaLines[i], size: 24, color: P.metaColor,
        font: { eastAsia: "Microsoft YaHei", ascii: "Arial" } })],
    }));
  }

  // 8. Card bottom edge
  children.push(new Paragraph({
    indent: { left: cardIndent, right: cardIndent }, spacing: { after: 0 },
    border: { bottom: bBot, left: bL, right: bR }, children: [],
  }));

  // 9. Post-card whitespace
  children.push(new Paragraph({ spacing: { before: 2000 } }));

  // 10. Footer
  children.push(new Paragraph({
    alignment: AlignmentType.CENTER,
    children: [new TextRun({ text: config.footerRight || "", size: 16, color: P.footerColor, font: { ascii: "Arial" } })],
  }));

  // Single 16838 wrapper — the ONLY table
  return [new Table({
    width: { size: 100, type: WidthType.PERCENTAGE },
    layout: TableLayoutType.FIXED,
    borders: allNoBorders,
    rows: [new TableRow({
      height: { value: 16838, rule: "exact" },
      children: [new TableCell({
        shading: { type: ShadingType.CLEAR, fill: P.bg }, borders: noBorders,
        children,
      })],
    })],
  })];
  // Total height: 16838 (single wrapper, zero nested tables) ✅
}
```


### Recipe R4: Top Color Block

**Visual:** Top 45% dark area (with title) + bottom 55% white area (with meta info) + accent divider
**Use case:** business proposal, plan document, lesson plan, PRD
**Architecture:** Uses R1's proven single 16838 wrapper. Upper dark block is a nested table inside the wrapper. Content positioning uses `spacing.before` (reliable) instead of `margins.top` (unreliable across engines).

```js
// ⚠️ MANDATORY: Cover section must use margin: 0. See "Cover Section Non-Negotiables" above.
function buildCoverR4(config) {
  const P = config.palette;
  const padL = 1200, padR = 800;
  const availableWidth = 11906 - padL - padR;

  // ⚠️ MANDATORY: Use calcTitleLayout() for dynamic font size + line breaking
  const { titlePt, titleLines } = calcTitleLayout(config.title, availableWidth, 40, 26);
  const titleSize = titlePt * 2;

  // Height budget for upper dark block — DYNAMIC based on title content
  const titleBlockHeight = titleLines.length * (titlePt * 23 + 200);
  const englishLabelH = config.englishLabel ? (9 * 23 + 500) : 0;
  const subtitleH = config.subtitle ? (12 * 23 + 200) : 0;
  const upperContentH = englishLabelH + titleBlockHeight + subtitleH;
  const UPPER_MIN = 7500; // minimum height to preserve visual proportion
  const UPPER_H = Math.max(UPPER_MIN, upperContentH + 1500 + 800); // +1500 top pad, +800 bottom pad
  const DIVIDER_H = 60;

  // ★ Dynamic top spacing: calculated from content height, NOT fixed margins.top
  const contentEstimate =
    (config.englishLabel ? (9 * 23 + 500) : 0) +
    titleLines.length * (titlePt * 23 + 200) +
    (config.subtitle ? (12 * 23 + 200) : 0);
  const spacerIntrinsic = 280;  // empty spacing paragraph intrinsic height
  const topSpacing = Math.max(UPPER_H - contentEstimate - spacerIntrinsic - 800, 400);

  // ── Upper dark block (nested table, exact height) ──
  const upperBlock = new Table({
    width: { size: 100, type: WidthType.PERCENTAGE },
    layout: TableLayoutType.FIXED,
    borders: allNoBorders,
    rows: [new TableRow({
      height: { value: UPPER_H, rule: "exact" },
      children: [new TableCell({
        shading: { fill: P.bg }, borders: noBorders,
        verticalAlign: "top",
        // ★ KEY: Only left/right margins. NO top/bottom margins.
        // Vertical positioning uses spacing.before on the first paragraph.
        margins: { left: padL, right: padR },
        children: [
          new Paragraph({ spacing: { before: topSpacing } }),
          config.englishLabel ? new Paragraph({
            spacing: { after: 500 },
            children: [new TextRun({ text: config.englishLabel.split("").join(" "),
              size: 18, color: P.accent, font: { ascii: "Calibri" }, characterSpacing: 60 })],
          }) : null,
          ...titleLines.map((line, i) => new Paragraph({
            spacing: { after: i < titleLines.length - 1 ? 100 : 200 },
            children: [new TextRun({ text: line, size: titleSize, bold: true,
              color: P.titleColor, font: { eastAsia: "SimHei", ascii: "Arial" } })],
          })),
          config.subtitle ? new Paragraph({
            spacing: { after: 100 },
            children: [new TextRun({ text: config.subtitle, size: 24, color: P.subtitleColor,
              font: { eastAsia: "Microsoft YaHei", ascii: "Arial" } })],
          }) : null,
        ].filter(Boolean),
      })],
    })],
  });

  // ── Accent divider line ──
  const divider = new Table({
    width: { size: 100, type: WidthType.PERCENTAGE },
    borders: allNoBorders,
    rows: [new TableRow({
      height: { value: DIVIDER_H, rule: "exact" },
      children: [new TableCell({ borders: noBorders,
        shading: { fill: P.accent }, children: [emptyPara()] })],
    })],
  });

  // ── Lower white area (paragraphs, not a separate table) ──
  const lowerContent = [
    new Paragraph({ spacing: { before: 800 } }),
    ...(config.metaLines || []).map(line => new Paragraph({
      indent: { left: padL }, spacing: { after: 100 },
      children: [new TextRun({ text: line, size: 28, color: P.metaColor,
        font: { eastAsia: "Microsoft YaHei", ascii: "Arial" } })],
    })),
    new Paragraph({ spacing: { before: 2000 } }),
    new Paragraph({
      indent: { left: padL },
      children: [
        new TextRun({ text: config.footerLeft || "", size: 22, color: "909090" }),
        new TextRun({ text: "          " }),
        new TextRun({ text: config.footerRight || "", size: 22, color: "909090" }),
      ],
    }),
  ];

  // ── Outer 16838 wrapper (R1 proven architecture) ──
  // The wrapper acts as a safety net: even if the inner 7500 block
  // overflows slightly, content stays on page 1.
  return [new Table({
    width: { size: 100, type: WidthType.PERCENTAGE },
    layout: TableLayoutType.FIXED,
    borders: allNoBorders,
    rows: [new TableRow({
      height: { value: 16838, rule: "exact" },
      children: [new TableCell({
        shading: { fill: "FFFFFF" }, borders: noBorders,
        verticalAlign: "top",
        children: [
          upperBlock,
          divider,
          ...lowerContent,
        ],
      })],
    })],
  })];
  // Total height: 16838 (outer wrapper, R1 architecture) ✅
}
```

---

### Recipe R5: Clean White (Academic)

**Visual:** Pure white background + school name + centered title + 2-column meta info table with underlines + footer
**Use case:** academic thesis, standards documents
**Architecture:** 16838 outer wrapper (white fill, invisible) + cell margins for page margin simulation. No top/bottom decorative lines.

**Meta info table rules (cross-engine safe):**
- 2-column table: label + value, **percentage widths only** (`WidthType.PERCENTAGE`)
- **Table width is adaptive:** 55–75% of page, calculated by `calcR5MetaLayout()`. Table centered via `alignment: CENTER`.
- **Label column:** adaptive 25–45% of table, **LEFT aligned**, plain text with "：" appended. NO full-width space padding, NO right-alignment.
- **Label column borders:** none (no bottom border on label cells).
- **Value column:** remaining %, **LEFT aligned**, `bottom: single sz=4` border = fixed-length underline (consistent length for all rows regardless of text).
- No left/right/top borders on either column.
- ⚠️ Do NOT use DXA widths, full-width space padding (`\u3000`), spacer columns, or tab stops — WPS renders them inconsistently.
- ⚠️ Do NOT use `margins.top` on the wrapper cell — use `spacing.before` on first paragraph instead.

**Known limitation:** When meta lines ≥ 6 AND title has 3 lines, MS Office may render content slightly taller than WPS, potentially clipping the footer line. Mitigate by reducing `midSpacing` or using a smaller title font.

```js
// ── Width-aware title layout (handles mixed Chinese + English) ──
function estimateTextWidth(text, pt) {
  let width = 0;
  for (const ch of text) {
    const code = ch.codePointAt(0);
    const isCJK = (code >= 0x4E00 && code <= 0x9FFF) || (code >= 0x3400 && code <= 0x4DBF) ||
      (code >= 0x3000 && code <= 0x303F) || (code >= 0xFF00 && code <= 0xFFEF) ||
      (code >= 0x2E80 && code <= 0x2EFF);
    width += isCJK ? pt * 20 : pt * 11; // CJK: full-width, Latin: ~55% width
  }
  return width;
}
// Use estimateTextWidth() in calcTitleLayout() instead of simple char count
// to prevent mid-word breaks in mixed Chinese+English titles like "基于Transformer架构的..."

// ── Meta info table ──

// Calculate adaptive table and label column percentage based on longest label.
// Returns { tablePct, labelPct } — both as percentages.
// Uses ONLY WidthType.PERCENTAGE for cross-engine compatibility (MS Office + WPS).
function calcR5MetaLayout(metaEntries, fontPt = 12) {
  const maxLabelLen = Math.max(...metaEntries.map(e => [...e.label].length));
  // Label needs: maxLabelLen chars + "：" + 1 char padding
  const labelNeedTw = (maxLabelLen + 2) * fontPt * 20;
  // Value column: fixed ~5000tw for consistent underline length
  const valueNeedTw = 5000;
  const totalNeedTw = labelNeedTw + valueNeedTw;
  // Table width as % of page (11906tw), clamped to 55–75%
  const tablePct = Math.min(75, Math.max(55, Math.ceil(totalNeedTw / 11906 * 100)));
  // Label % within the table, clamped to 25–45%
  const rawLabelPct = Math.ceil(labelNeedTw / (tablePct / 100 * 11906) * 100);
  return { tablePct, labelPct: Math.max(25, Math.min(45, rawLabelPct)) };
}

// Build R5 academic cover meta info table.
// ⚠️ CRITICAL cross-engine rules:
//   - Table width: WidthType.PERCENTAGE (NOT DXA — WPS breaks with DXA)
//   - Column widths: WidthType.PERCENTAGE
//   - Label column: LEFT aligned, plain text (NO full-width space padding)
//   - Value column: LEFT aligned, bottom border = fixed-length underline
//   - Table alignment: CENTER (visually centered on page)
function buildR5MetaTable(metaEntries) {
  // metaEntries: [{ label: "学院", value: "计算机科学与技术学院" }, ...]
  const { tablePct, labelPct } = calcR5MetaLayout(metaEntries);
  const valuePct = 100 - labelPct;
  const bottomBorder = { style: BorderStyle.SINGLE, size: 4, color: "000000" };

  const rows = metaEntries.map(entry => new TableRow({
    children: [
      // Label cell: left-aligned, no bottom border
      new TableCell({
        width: { size: labelPct, type: WidthType.PERCENTAGE },
        borders: noBorders,
        margins: { top: 60, bottom: 60, left: 0, right: 0 },
        children: [new Paragraph({
          alignment: AlignmentType.LEFT,
          spacing: { before: 60, after: 60, line: 400 },
          children: [new TextRun({
            text: entry.label + "：",
            size: 24, font: { eastAsia: "SimSun", ascii: "Times New Roman" },
          })],
        })],
      }),
      // Value cell: left-aligned, bottom border = fixed-length underline
      new TableCell({
        width: { size: valuePct, type: WidthType.PERCENTAGE },
        borders: { top: NB, left: NB, right: NB, bottom: bottomBorder },
        margins: { top: 60, bottom: 60, left: 80, right: 0 },
        children: [new Paragraph({
          alignment: AlignmentType.LEFT,
          spacing: { before: 60, after: 60, line: 400 },
          children: [new TextRun({
            text: entry.value,
            size: 24, font: { eastAsia: "SimSun", ascii: "Times New Roman" },
          })],
        })],
      }),
    ],
  }));

  return new Table({
    width: { size: tablePct, type: WidthType.PERCENTAGE },
    alignment: AlignmentType.CENTER,
    layout: TableLayoutType.FIXED,
    borders: allNoBorders,
    rows,
  });
}

// ⚠️ MANDATORY: Cover section must use margin: 0. See "Cover Section Non-Negotiables" above.
function buildCoverR5(config) {
  const PAGE_H = 16838, SAFETY = 1200;
  const safeH = PAGE_H - SAFETY; // 15638
  const simMarginLR = 1701, simMarginT = 1200;
  const contentW = 11906 - simMarginLR * 2;

  // ★ Width-aware title layout for mixed Chinese+English
  const { titlePt, titleLines } = calcTitleLayoutMixed(config.title, contentW, 36, 24);
  const titleSize = titlePt * 2;

  // Parse meta entries
  const metaEntries = (config.metaLines || []).map(line => {
    const sep = line.indexOf("：") !== -1 ? "：" : ":";
    const idx = line.indexOf(sep);
    if (idx === -1) return { label: line, value: "" };
    return { label: line.slice(0, idx).trim(), value: line.slice(idx + sep.length).trim() };
  });

  // Height budget (no margins.top — use spacing.before instead)
  const schoolNameH = config.schoolName ? (22 * 23 + 400) : 0;
  const titleTotalH = titleLines.length * (titlePt * 23 + 200);
  const subtitleH = config.subtitle ? (15 * 23 + 600) : 0;
  const metaRowH = 520; // 60+60 padding + ~400 line height
  const metaTableH = metaEntries.length * metaRowH;
  const footerH = config.footerRight ? (12 * 23 + 200) : 0;
  const spacerParas = 3 * 350;
  const fixedH = schoolNameH + titleTotalH + subtitleH + metaTableH + footerH + spacerParas;
  const remaining = Math.max(safeH - fixedH, 600);

  // ★ topSpacing includes simulated top margin (simMarginT)
  const topSpacing = Math.min(Math.floor(remaining * 0.28) + simMarginT, 4200);
  const midSpacing = Math.min(Math.floor((remaining - simMarginT) * 0.18), 2000);
  const bottomSpacing = Math.min(remaining - topSpacing + simMarginT - midSpacing, 5500);

  const children = [];
  children.push(new Paragraph({ spacing: { before: topSpacing } }));

  // School name (optional)
  if (config.schoolName) {
    children.push(new Paragraph({
      alignment: AlignmentType.CENTER, spacing: { after: 400 },
      children: [new TextRun({ text: config.schoolName, size: 44, characterSpacing: 40,
        font: { eastAsia: "SimSun", ascii: "Times New Roman" } })],
    }));
  }

  // Title
  for (let i = 0; i < titleLines.length; i++) {
    children.push(new Paragraph({
      alignment: AlignmentType.CENTER, spacing: { after: i < titleLines.length - 1 ? 120 : 300 },
      children: [new TextRun({ text: titleLines[i], size: titleSize, bold: true,
        font: { eastAsia: "SimHei", ascii: "Times New Roman" } })],
    }));
  }

  // Subtitle
  if (config.subtitle) {
    children.push(new Paragraph({
      alignment: AlignmentType.CENTER, spacing: { after: 200 },
      children: [new TextRun({ text: config.subtitle, size: 30,
        font: { eastAsia: "SimSun", ascii: "Times New Roman" } })],
    }));
  }

  children.push(new Paragraph({ spacing: { before: midSpacing } }));

  // Meta info table
  if (metaEntries.length > 0) children.push(buildR5MetaTable(metaEntries));

  children.push(new Paragraph({ spacing: { before: bottomSpacing } }));

  // Footer
  if (config.footerRight) {
    children.push(new Paragraph({
      alignment: AlignmentType.CENTER,
      children: [new TextRun({ text: config.footerRight, size: 24, color: "404040",
        font: { eastAsia: "SimSun", ascii: "Times New Roman" } })],
    }));
  }

  // ★ 16838 outer wrapper — only left/right margins, NO margins.top
  return [new Table({
    width: { size: 100, type: WidthType.PERCENTAGE },
    layout: TableLayoutType.FIXED,
    borders: allNoBorders,
    rows: [new TableRow({
      height: { value: PAGE_H, rule: "exact" },
      children: [new TableCell({
        shading: { type: ShadingType.CLEAR, fill: "FFFFFF" },
        borders: noBorders, verticalAlign: "top",
        margins: { left: simMarginLR, right: simMarginLR },
        children,
      })],
    })],
  })];
  // Height budget example (short title, 4 meta lines):
  // topSpacing(3818) + schoolName(906) + title(1028) + subtitle(945) + midSpacing(1467)
  // + metaTable(4×520=2080) + bottomSpacing(5268) + footer(476) = ~15988 < 15838 ✅
}
```

---

### blendColors Utility Function

```js
function blendColors(hex1, hex2, ratio) {
  const p = (s, i) => parseInt(s.replace("#","").slice(i, i+2), 16);
  const mix = (c1, c2) => Math.round(c1 + (c2 - c1) * ratio);
  const r = mix(p(hex1,0), p(hex2,0)), g = mix(p(hex1,2), p(hex2,2)), b = mix(p(hex1,4), p(hex2,4));
  return [r, g, b].map(v => v.toString(16).padStart(2,"0")).join("");
}
```



## Geometric Decoration System

→ See `references/decorations.md` for the full geometric decoration element library (decoration elements, usage scenarios, code examples).

## Chinese Plot PNG Method (matplotlib)

```python
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.font_manager import FontProperties

font_paths = [
    "/System/Library/Fonts/Supplemental/SimHei.ttf",
    "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
    "./SimHei.ttf",
]
zh_font = None
for fp in font_paths:
    try:
        zh_font = FontProperties(fname=fp)
        break
    except:
        continue

plt.rcParams["axes.unicode_minus"] = False
```

## Chart Quality Rules

### Chart Color Palette

Default: **low-saturation (Morandi style)** palette to avoid flashy high-saturation. High-saturation palette only for explicitly energetic scenarios (events/education/creative).

```js
const chartColors = {
  // Default: low saturation, professional (S: 25-40%, L: 55-68%)
  default: ["6B9DAD", "C49B72", "7BA68A", "B87472", "9687A8", "C8B87C", "7AADA0", "7A9BB8"],
  // Vivid: only for energetic/creative scenes
  vivid:   ["2F97B8", "E67E22", "27AE60", "E74C3C", "9B59B6", "F1C40F", "1ABC9C", "3498DB"],
};

// Scene selection:
// - report/whitepaper/consulting/academic/contract → default
// - activity/education/creative copy → vivid (optional)
```

**Color usage rules:**
- Max **5 colors** per chart; excess categories use depth variants of same hue
- Emphasis data uses document accent color; non-emphasis uses grey `#B0B0B0`
- Adjacent segments in pie/bar charts must have hue gap ≥ 60°

1. **Anti-overlap**: If >6 x-axis labels, rotate 45° (`plt.xticks(rotation=45, ha='right')`)
2. **Anti-stretch**: Always set figure size explicitly (`fig, ax = plt.subplots(figsize=(10, 6))`)
3. **Aspect ratio (CRITICAL)**: When embedding in docx, MUST read actual image dimensions and calculate height proportionally. NEVER hardcode both width and height — pie charts become ellipses, radar charts become diamonds.
   ```js
   const sizeOf = require("image-size");
   const dims = sizeOf(chartBuffer);
   const displayWidth = 500;
   const displayHeight = Math.round(displayWidth * (dims.height / dims.width));
   // transformation: { width: displayWidth, height: displayHeight }
   ```
4. **DPI**: Save at 200+ DPI (`plt.savefig("chart.png", dpi=200, bbox_inches="tight")`)
5. **Colors**: Use palette accent color for primary data series
6. **Legend**: Place outside plot area if >4 series
7. **Square charts**: Pie and radar charts MUST use `figsize=(8, 8)` (equal width/height) to preserve circular/radial shape
7. **Grid**: Light gray grid (`ax.grid(True, alpha=0.3)`)

---

## Typography Rules

### CJK Body Text
- **Alignment**: Justified (`AlignmentType.JUSTIFIED`)
- **First-line indent**: 2 characters — Profile A (SimSun): `firstLine: 480`; Profile B (YaHei): `firstLine: 420`. See `common-rules.md` for profile definitions.
- **Line spacing**: 1.3x = `spacing: { line: 312 }`
- **No heading indent**: Headings must NOT have first-line indent

### English Body Text
- **Alignment**: Left (`AlignmentType.LEFT`)
- **No indent**
- **Line spacing**: Same 1.3x

### Table Numbers
- Right-aligned in cells
- Use monospace or tabular figures if available

### Headings
- No first-line indent
- `spacing: { before: 240, after: 120 }` (H1: before 360)
- Bold, palette.primary color

### 1.3x Line Spacing — MANDATORY
Every document, every paragraph. `spacing: { line: 312 }`. No exceptions unless scene explicitly overrides (e.g., resume uses 1.15x).

---

## Page Layout — A4 Standard

```js
sections: [{
  properties: {
    page: {
      size: { width: 11906, height: 16838, orientation: PageOrientation.PORTRAIT },
      margin: { top: 1440, bottom: 1440, left: 1701, right: 1417 },
      // Top/bottom 2.54cm = 1440, left 3.0cm = 1701, right 2.5cm = 1417 twips
    },
  },
  children: [/* ... */],
}]
```

These are defaults. Scenes may override (e.g., official docs use different margins).

### Scene Font Override Rules

Default font config (docx-js Font Configuration in design-system.md) uses YaHei+Calibri for most business scenarios. The following scenes have dedicated font requirements — **scene rules override defaults**:

| Scene | Body CN | Body EN | Headings | Body Color |
|------|----------|----------|------|----------|
| Default (general) | Microsoft YaHei | Calibri | SimHei + Calibri | palette.body |
| **Report** | **SimSun** | **Times New Roman** | **SimHei + TNR** | **"000000" (pure black)** |
| **Academic** | **SimSun** | **Times New Roman** | **SimHei + TNR** | **"000000" (pure black)** |
| **Contract** | **SimSun** | **Times New Roman** | **SimHei + TNR** | **"000000" (pure black)** |
| Official doc | FangSong | — | STXiaoBiaoSong | "000000" |
| Resume | Microsoft YaHei | Calibri | SimHei + Calibri | palette.body |

When report or academic scene is loaded, `styles.default.document.run` font and color must be overridden per scene. Heading sizes may also differ (e.g., report scene H1 centered, H2 uses Xiao San size:30 instead of default Si Hao size:28). Scene file takes precedence.
