77 KiB
Executable File
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:
- primary: Start from
hsl(hue, 25-40%, 10-18%)— dark, desaturated - body: primary lightened 5-8% — readable dark
- secondary: primary lightened 30-40% — clearly subordinate
- accent: Choose a hue reflecting the domain,
hsl(domainHue, 30-50%, 45-55%) - 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 (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):
- Prefer concise titles within the "comfortable" range
- If topic requires detail, split into title + subtitle (e.g., title="数字化转型战略研究" subtitle="——以某某企业为例")
- 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)
- 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).
- Color block Table
heightmust userule: "exact"(never"atLeast"). - Each recipe code includes height budget annotations — verify during generation.
verticalAlignmust always be"top". Never use"center"or"bottom"in exact-height rows — content will be clipped or overflow. Usespacing.beforeon the first paragraph for vertical positioning.- Title font size MUST be dynamically calculated via
calcTitleLayout()(see below). Never hardcode font sizes above 40pt for cover titles. Every recipe MUST callcalcTitleLayout()before building title paragraphs. - Never use
margins.top/margins.bottomfor vertical positioning inside exact-height cells: Cell margins reduce available height unpredictably across MS Office and WPS. Usespacing.beforeon the first paragraph instead. Onlymargins.left/margins.rightare safe. - Dynamic spacing is mandatory: Use
calcCoverSpacing()to computespacing.beforevalues 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. - 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.beforevalues too large → usecalcCoverSpacing()for dynamic values - Subtitle + English label + meta lines combined exceed budget → calculate total and reduce spacing
- Title font too large → use
- 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
// ...
});
- Decorative lines MUST use paragraph borders, NEVER text characters: Horizontal decorative lines (accent strips, dividers, frame edges) must be implemented with
paragraph border.toporborder.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 viaindent.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 })]
})
- 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 (R1–R7). Omitting ANY of them causes cover layout failure:
-
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. -
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. -
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):
- Dynamic top whitespace (via
calcCoverSpacing) - English label with accent bottom border (paragraph
border.bottom) - Main title (1-3 lines, dynamic font size via
calcTitleLayout) - Subtitle (light grey, smaller)
- Meta info lines with left accent border (paragraph
border.left) - Dynamic bottom whitespace
- 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):
- Top thick accent rule (paragraph
border.top) - Generous whitespace
- English label (centered, spaced)
- Main title (centered, 1-3 lines, dynamic font size)
- Subtitle (centered)
- Generous whitespace
- Meta info lines (centered, 18pt / size: 36)
- Generous whitespace
- 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):
- Pre-card whitespace (~2800tw)
- Card top edge (paragraph with
border.top+border.left+border.right, indent 2200tw) - English label (centered, inside card side borders)
- Main title (centered, inside card side borders, dynamic font size)
- Subtitle (centered, inside card side borders)
- Spacer (inside card side borders)
- Meta info lines (centered, inside card side borders)
- Card bottom edge (paragraph with
border.bottom+border.left+border.right) - Post-card whitespace
- 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: 55–75% of page, calculated by
calcR5MetaLayout(). Table centered viaalignment: 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=4border = 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.topon the wrapper cell — usespacing.beforeon 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 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
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°
- Anti-overlap: If >6 x-axis labels, rotate 45° (
plt.xticks(rotation=45, ha='right')) - Anti-stretch: Always set figure size explicitly (
fig, ax = plt.subplots(figsize=(10, 6))) - 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 } - DPI: Save at 200+ DPI (
plt.savefig("chart.png", dpi=200, bbox_inches="tight")) - Colors: Use palette accent color for primary data series
- Legend: Place outside plot area if >4 series
- Square charts: Pie and radar charts MUST use
figsize=(8, 8)(equal width/height) to preserve circular/radial shape - 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. Seecommon-rules.mdfor 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.