Files
mantle-ai-trader/skills/docx/references/design-system.md
2026-06-06 05:21:10 +00:00

77 KiB
Executable File
Raw Permalink Blame History

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)

const academic = {
  primary: "#162032", body: "#1C2A3D", secondary: "#5B6B7D",
  accent: "#8B7E5A", surface: "#F5F7FA"
};

Warm + Heavy + Calm → Legal Wood (Legal / Compliance)

const legal = {
  primary: "#28201C", body: "#36302C", secondary: "#6E6560",
  accent: "#7A5C3A", surface: "#FBF9F7"
};

Cool + Light + Active → Dawn Mist Tech (Tech / Digital)

const tech = {
  primary: "#0A1628", body: "#1A2B40", secondary: "#6878A0",
  accent: "#5B8DB8", surface: "#F4F8FC"
};

Warm + Light + Active → Warm Sun (Education / Training)

const education = {
  primary: "#2A3518", body: "#384228", secondary: "#6B8040",
  accent: "#D4A030", surface: "#F8FAF4"
};

Neutral + Medium + Calm → Plain Paper (Default / General)

const general = {
  primary: "#101820", body: "#182030", secondary: "#506070",
  accent: "#8090A0", surface: "#F2F4F6"
};

Warm + Medium + Calm → Terracotta (Consulting / Architecture)

const consulting = {
  primary: "#241E1A", body: "#3A3430", secondary: "#68605A",
  accent: "#B08050", surface: "#FDFBF9"
};

Cool + Medium + Active → Mint Medical (Medical / Clinical)

const medical = {
  primary: "#0E2030", body: "#1E2E40", secondary: "#4A6580",
  accent: "#3888A8", surface: "#F0F6FA"
};

Neutral + Light + Calm → White Porcelain (Product Manuals / Minimalist)

const minimal = {
  primary: "#303030", body: "#484848", secondary: "#808080",
  accent: "#B89870", surface: "#FAFAF8"
};

Cool + Light + Active (Gradient) → Lapis Tech (Tech / AI / Innovation)

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)

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)

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)

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)

# 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

// 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.

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

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)

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:

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.

// ✅ 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!
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)

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 (12 lines) Maximum (3 lines) Long Title Tolerance
R1 820 chars ≤50 chars Best (left-aligned, full-page bg)
R2 818 chars ≤45 chars Good (full-page bg, but centered)
R3 815 chars ≤40 chars Poor (narrowest width)
R4 818 chars ≤45 chars Poor (fixed-height color block)
R5 816 chars ≤42 chars OK (academic only, mixed-width calc)
R6 815 chars ≤20 chars (1 line) Single line only (editorial, no multi-line)
R7 824 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.

// ✅ 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".
// ✅ 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
  // ...
});
  1. 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.
// ✅ 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 })]
})
  1. Post-generation overflow check (mandatory): After building cover children, estimate total height:
    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:

// ★ 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.

/**
 * 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:

// 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 (R1R7). 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):

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)
// ⚠️ 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)
// ⚠️ 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)
// ⚠️ 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).

// ⚠️ 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: 5575% of page, calculated by calcR5MetaLayout(). Table centered via alignment: CENTER.
  • Label column: adaptive 2545% 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.

// ── 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 5575%
  const tablePct = Math.min(75, Math.max(55, Math.ceil(totalNeedTw / 11906 * 100)));
  // Label % within the table, clamped to 2545%
  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

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)

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).

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.
    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
  8. 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

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.