references/canvas-formats.md
# Canvas Format Specification
> See shared-standards.md for SVG basic rules.
## Format Quick Reference
| Format | viewBox | Ratio | Use Case |
|--------|---------|-------|----------|
| PPT 16:9 | `0 0 1280 720` | 16:9 | Business presentations, meetings |
| PPT 4:3 | `0 0 1024 768` | 4:3 | Traditional projectors, academic talks |
| Facebook/TikTok | `0 0 1242 1660` | 3:4 | Image-text sharing, knowledge posts |
| Zalo/Instagram | `0 0 1080 1080` | 1:1 | Square posters, brand showcases |
| Story / TikTok | `0 0 1080 1920` | 9:16 | Vertical stories, short video covers |
| Zalo OA Header | `0 0 900 383` | 2.35:1 | Ảnh bìa bài viết Zalo OA |
| Landscape Banner | `0 0 1920 1080` | 16:9 | Web banners, digital screens |
| Portrait Poster | `0 0 1080 1920` | 9:16 | Phone screens, elevator ads |
| A4 Print | `0 0 1240 1754` | 1:sqrt(2) | Print posters, flyers |
## Format Selection Decision Tree
```
Content purpose?
├── Presentation
│ ├── Modern devices → PPT 16:9 (1280x720)
│ └── Traditional devices → PPT 4:3 (1024x768)
├── Social sharing
│ ├── Facebook/TikTok → 1242x1660
│ ├── Zalo/Instagram → 1080x1080
│ └── Story / TikTok → 1080x1920
└── Marketing materials
├── Zalo OA Header → 900x383
├── Banner → 1920x1080
└── Print → 1240x1754
```
## Layout Principles
### Landscape (16:9, 4:3, 2.35:1)
- Visual flow: Z-pattern, left to right
- Margins: 40-80px
- Layouts: multi-column, left-right split, grid
- Card dimensions (16:9): single-row 530-600px, double-row 265-295px
### Portrait (3:4, 9:16)
- Visual flow: top to bottom
- Margins: 60-120px
- Layouts: single-column, top-bottom split, card stacking
- Card dimensions (3:4): height 400-600px, gap 40-60px
### Square (1:1)
- Visual flow: center-radiating
- Margins: 60-100px
- Core area: ~800x800px
## Format-specific Design
| Format | Title Area | Content Area | Special Notes |
|--------|-----------|--------------|---------------|
| PPT | 80-100px | Full width utilization | Page number bottom-right |
| Facebook/TikTok | 180-240px (bold) | Generous top/bottom whitespace | Brand area at bottom 120-160px |
| Zalo/Instagram | 200-280px | Center 500-600px | QR code area at bottom 150-200px |
| Story | — | Middle 1500px | Top safe zone 120px, bottom 180px |
| Zalo OA Header | Center/left-aligned 48-72px | — | Image on right or as background |
## ViewBox Examples
```xml
<svg width="1280" height="720" viewBox="0 0 1280 720"> <!-- PPT 16:9 -->
<svg width="1242" height="1660" viewBox="0 0 1242 1660"> <!-- Facebook/TikTok -->
<svg width="1080" height="1080" viewBox="0 0 1080 1080"> <!-- Zalo/Instagram -->
<svg width="1080" height="1920" viewBox="0 0 1080 1920"> <!-- Story -->
<svg width="900" height="383" viewBox="0 0 900 383"> <!-- Zalo OA Header -->
```
references/design-guidelines.md
> See shared-standards.md for common technical constraints.
# Design Guidelines
Detailed design guidelines and best practices for the PPT Master system.
---
## Color System
### Primary Color Selection
#### Consulting Style
```
Deloitte Blue: #0076A8 - Professional, reliable
McKinsey Blue: #005587 - Authoritative, deep
BCG Dark Blue: #003F6C - Stable, trustworthy
PwC Orange: #D04A02 - Energetic, innovative
EY Yellow: #FFE600 - Optimistic, clear
```
#### General Versatile Style
```
Tech Blue: #2196F3 - Modern, innovative
Vibrant Orange: #FF9800 - Passionate, positive
Growth Green: #4CAF50 - Healthy, growth
Professional Purple: #9C27B0 - Creative, premium
Alert Red: #F44336 - Urgent, important
```
### Secondary Colors
#### Data Visualization Colors
```
Positive trend (green):
Dark: #2E7D32 | Medium: #4CAF50 | Light: #81C784
Warning trend (yellow):
Dark: #F57C00 | Medium: #FFA726 | Light: #FFD54F
Negative trend (red):
Dark: #C62828 | Medium: #EF5350 | Light: #E57373
```
#### Neutral Colors
```
Text hierarchy:
Primary text: #212121 (black, 87% opacity)
Secondary text: #757575 (gray, 60% opacity)
Tertiary text: #9E9E9E (light gray, 38% opacity)
Background hierarchy:
Primary bg: #FFFFFF
Secondary bg: #F5F5F5
Card bg: #FAFAFA
Border: #E0E0E0
```
### Contrast Requirements
Following WCAG 2.1 AA standards:
- Body text: at least 4.5:1
- Large text (24px+): at least 3:1
- Important data: at least 7:1 (AAA level)
---
## Typography Specification
### Font Hierarchy
> Use body font size as 1x baseline; derive other levels proportionally. Font size selection depends on content density, not design style.
#### Relaxed Content (body baseline 24px)
Suitable for: Keynote-style PPTs, training materials, few points per page (3-5)
```
H1 - Cover title: 60-72px, Bold (2.5-3x)
H2 - Page title: 42-48px, Bold (1.75-2x)
H3 - Section title: 32-36px, SemiBold (1.3-1.5x)
Body - Main content: 24px, Regular (1x baseline)
Small - Annotations: 18-20px, Regular (0.75-0.85x)
```
#### Dense Content (body baseline 18px)
Suitable for: Data reports, consulting analysis, many points per page (6+)
```
H1 - Cover title: 45-54px, Bold (2.5-3x)
H2 - Page title: 27-36px, Bold (1.5-2x)
H3 - Section title: 22-27px, SemiBold (1.2-1.5x)
Body - Main content: 18px, Regular (1x baseline)
Small - Annotations: 14-15px, Regular (0.75-0.85x)
```
### Font Selection
#### Chinese Fonts
| Category | Code | Options | Suitable Scenarios |
|----------|:----:|---------|-------------------|
| Sans-serif | A | Microsoft YaHei, SimHei, YouYuan, STXihei | Modern business, body text (default) |
| Serif | B | SimSun, NSimSun, FangSong, STSong | Government docs, formal reports |
| Calligraphic | C | KaiTi, STKaiti, STXingkai | Culture, arts, humanities |
| Display | D | SimHei, STZhongsong, Source Han Sans Bold | Large titles, emphasis |
| Handwritten | E | STXingkai, STXinwei, LiSu | Creative, personalized |
#### English Fonts
| Category | Code | Options | Suitable Scenarios |
|----------|:----:|---------|-------------------|
| Sans-serif modern | F | Arial, Calibri, Segoe UI, Helvetica | Modern business (default) |
| Serif classic | G | Times New Roman, Georgia, Cambria | Formal documents, academic |
| Display | H | Impact, Arial Black, Bebas Neue | Large titles, posters |
| Handwritten | I | Comic Sans MS, Segoe Script, Ink Free | Casual, playful |
| Monospace | J | Consolas, Cascadia Code, Courier New | Data, code |
#### Recommended font-family
```css
/* Chinese fonts */
"Microsoft YaHei", sans-serif /* A: Microsoft YaHei */
"SimHei", sans-serif /* A/D: SimHei */
"SimSun", serif /* B: SimSun */
"FangSong", serif /* B: FangSong */
"KaiTi", serif /* C: KaiTi */
"STXingkai", cursive /* C/E: STXingkai */
/* English fonts */
"Arial", sans-serif /* F: Arial */
"Calibri", sans-serif /* F: Calibri */
"Times New Roman", serif /* G: Times */
"Georgia", serif /* G: Georgia */
"Impact", sans-serif /* H: Impact */
"Consolas", monospace /* J: Monospace */
```
#### Chinese-English Mixed Typesetting Recommendations
| Scenario | Chinese Font | English Font |
|----------|-------------|-------------|
| Modern business | Microsoft YaHei (A) | Calibri / Arial (F) |
| Government docs | SimSun (B) | Times New Roman (G) |
| Culture & arts | KaiTi (C) | Georgia (G) |
| Tech / Internet | SimHei (D) | Segoe UI (F) |
#### Font Roles
| Role | Purpose | Chinese Recommended | English Recommended |
|------|---------|--------------------|--------------------|
| Title font | H1/H2 large titles | A/C/D/E | F/G/H |
| Body font | Paragraphs, bullet points | A/B | F/G |
| Emphasis font | KPIs, keywords | D | H/J |
| Annotation font | Footnotes, captions | A/B | F/G |
### Line Height and Spacing
```
Title line height: 1.2-1.3
Body line height: 1.5-1.6
Paragraph spacing: 1.5-2em
List spacing: 0.5-1em
```
### Text Wrapping & Flattening
- Generation phase: Use `<tspan>` for manual line breaks, control line start and spacing via `x`/`dy`
- Post-processing: Use `python3 scripts/finalize_svg.py <project_path>`, includes text flattening
- Verification: Compare `svg_output` with `svg_final`; check line order, styles, and positioning match; target files should not contain residual `<tspan>`
---
## Layout Principles
### 16:9 Canvas Specification
```
Canvas size: 1280x720px
Safe area: 1200x640px (40px margins)
Title area: 1200x100px
Content area: 1200x500px
Footer area: 1200x40px
```
### Grid System
#### Single Column Layout
```
Suitable for: Covers, ending pages, single topics
Content width: 800-1000px, horizontally centered
```
#### Two-Column Layout
```
Suitable for: Comparative analysis, left-image right-text
Column ratio: 1:1 or 3:2, column gap: 40-60px
```
#### Three-Column Layout
```
Suitable for: Parallel points, process steps
Column ratio: 1:1:1, column gap: 30-40px
```
#### Four-Quadrant Layout
```
Suitable for: Matrix analysis, classification display
Quadrant size: 560x250px (including gap), gap: 20-30px
```
### Card Design
#### Dimension Specifications (Mandatory)
```
Single-row card: height 530-600px
Double-row card: each row 265-295px
Three-column card: each column width 360-380px
```
#### Internal Padding
```
Title area: 20-30px
Content area: 30-40px
Chart area: 20-30px
```
#### Border Radius
```
Small cards: 8-12px
Large cards: 12-16px
Charts: 8px
Buttons: 6-8px
```
---
## Image Layout Specification
Pages with images need dynamic layout calculation based on image ratio. See image-layout-spec.md for details.
**Quick rules**:
- Ratio > 2.0 (ultra-wide) → Top-bottom layout
- Ratio 1.5-2.0 (wide) → Prefer top-bottom; switch to left-right if text area insufficient
- Ratio 1.2-1.5 (standard landscape) → Left-right layout
- Ratio 0.8-1.2 (square) → Left-right layout
- Ratio < 0.8 (portrait) → Left-right layout
---
## Chart Design
### Common Chart Types
#### KPI Cards
```
Layout: 2x2 or 1x4
Elements: Large number (48-56px) + metric name (18-20px) + trend icon/percentage + YoY/MoM note
```
#### Bar Charts
```
Bar count: 3-8, bar width: 40-60px, gap: 50-80% of bar width, show data labels
```
#### Line Charts
```
Line: 2-3px, data points: circle radius 4-6px, grid lines: 1px light gray, legend: top-right or bottom
```
#### Pie/Donut Charts
```
Segments: 3-6, start angle: -90° (12 o'clock), ring width: 40-60px, show percentage labels
```
#### Matrix Charts
```
2x2 layout, axis lines: 2px solid, quadrant labels clear, data point sizes uniform
```
### Chart Colors
| Scheme | Suitable Scenarios | Example |
|--------|-------------------|---------|
| Monochromatic gradient | Same-category data comparison | #1976D2 → #42A5F5 → #90CAF9 |
| Contrasting set | Different category data | #2196F3 / #FF9800 / #4CAF50 |
| Sequential | Level/intensity display | #4CAF50 → #FFC107 → #F44336 |
---
## Icon Usage
### Built-in Icon Library (Recommended)
Located in `templates/icons/`, containing 640+ SVG icons.
```
Full index: templates/icons/FULL_INDEX.md
Programmatic query: templates/icons/icons_index.json
Usage guide: templates/icons/README.md
```
**Category overview**:
- General: home, settings, search, user, calendar, mail...
- Data: trending-up, chart-bar, database, file-text...
- Business: rocket, target, lightbulb, trophy, star...
- Arrows: arrow-right/left/up/down, chevron-right...
- Status: check, check-circle, x-circle, alert-circle...
- Tools: edit, trash, download, upload, share...
### Icon Specifications
```
Size: Small 16-20px | Regular 24-32px | Large 40-48px
Stroke width: 2px (uniform)
Color: Coordinate with primary color
Spacing: 50% of icon size
```
---
## Common Patterns
### Cover Page
- Top: Company logo (optional), Center: Presentation title (H1), subtitle, Bottom: Date, presenter
- Clean and impressive; primary color background or image; text centered or left-aligned
### Content Page
- Standard structure: Page title (H2) → Main content → Page number / notes
- Variants: Left-title right-content, top-bottom split, multi-column layout
### Data Page
- Data visualization as primary; highlight key numbers; provide data sources; add trend notes
- Layouts: Large chart + key points, multiple small chart combinations, KPI dashboard
### Summary Page
- 3-5 core takeaways + Call to Action (CTA) + Next steps
- Lists or numbering, icon-assisted, clean and clear
---
## Common Issues
| Problem | Solution |
|---------|----------|
| Too much content to fit | Split across pages, use smaller font (>=16px), condense text, use charts instead |
| Colors don't match | Limit primary colors to <=3, increase neutral color proportion, reference successful cases |
| Unbalanced layout | Check alignment, adjust spacing, use grid system, balance visual weight |
references/executor-base.md
# Executor Common Guidelines
> Style-specific content is in the corresponding `executor-{style}.md`. Technical constraints are in shared-standards.md.
---
## 1. Template Adherence Rules
If template files exist in the project's `templates/` directory, the template structure must be followed:
| Page Type | Corresponding Template | Adherence Rules |
|-----------|----------------------|-----------------|
| Cover | `01_cover.svg` | Inherit background, decorative elements, layout structure; replace placeholder content |
| Chapter | `02_chapter.svg` | Inherit numbering style, title position, decorative elements |
| Content | `03_content.svg` | Inherit header/footer styles; **content area may be freely laid out** |
| Ending | `04_ending.svg` | Inherit background, thank-you message position, contact info layout |
| TOC | `02_toc.svg` | **Optional**: Inherit TOC title, list styles |
### Page-Template Mapping Declaration (Required Output)
Before generating each page, you must explicitly output which template (or "free design") is used:
```
📝 **Template mapping**: `templates/01_cover.svg` (or "None (free design)")
🎯 **Adherence rules / layout strategy**: [specific description]
```
- **Content pages**: Templates only define header and footer; the content area is freely laid out by the Executor
- **No template**: Generate entirely per the Design Specification & Content Outline
---
## 2. Design Parameter Confirmation (Mandatory Step)
> Before generating the first SVG page, you **must review the key design parameters from the Design Specification & Content Outline** to ensure all subsequent generation strictly follows the spec.
Must output confirmation including: canvas dimensions, body font size, color scheme (primary/secondary/accent HEX values), font plan.
**Why is this step mandatory?** Prevents the "spec says one thing, execution does another" disconnect.
---
## 3. Execution Guidelines
- **Absolute spec adherence**: Strictly follow the color, layout, canvas format, and typography parameters in the spec
- **Follow template structure**: If templates exist, inherit the template's visual framework
- **Phased batch generation** (recommended):
1. **Visual Construction Phase**: Generate all SVG pages continuously, ensuring high consistency in design style and layout coordinates (Visual Consistency)
2. **Logic Construction Phase**: After all SVGs are finalized, batch-generate speaker notes to ensure narrative coherence (Narrative Continuity)
- **Technical specifications**: See [shared-standards.md](shared-standards.md) for SVG technical constraints and PPT compatibility rules
### SVG File Naming Convention
File naming format: `<number>_<page_name>.svg`
- **Chinese content** → Chinese naming: `01_封面.svg`, `02_目录.svg`, `03_核心优势.svg`
- **English content** → English naming: `01_cover.svg`, `02_agenda.svg`, `03_key_benefits.svg`
- **Number rules**: Two-digit numbers, starting from 01
- **Page name**: Concise and descriptive, matching the page title in the Design Specification & Content Outline
---
## 4. Icon Usage
Four approaches: **A: Emoji** (`<text>🚀</text>`) | **B: AI-generated** (SVG basic shapes) | **C: Built-in library** (`templates/icons/` 640+ icons, recommended) | **D: Custom** (user-specified)
**Built-in icons — Placeholder method (recommended)**:
```xml
<use data-icon="chart-bar" x="100" y="200" width="48" height="48" fill="#005587"/>
```
> No need to manually run `embed_icons.py`; `finalize_svg.py` post-processing tool will auto-embed icons.
**Common icons**: `chart-bar` `arrow-trend-up` `users` `cog` `circle-checkmark` `target` `clock` `file` `dollar` `lightbulb`
> ⚠️ **Icon validation rule**: If the Design Specification includes an icon inventory list, Executor may **only** use icons from that approved list. Using icon names not in the index is FORBIDDEN — verify against `templates/icons/icons_index.json` if uncertain.
Full index: `templates/icons/README.md`
---
## 5. Chart Reference
When the Design Spec includes a **VII. Chart Reference List**, read the referenced SVG templates from `templates/charts/` to understand common chart patterns.
**Adaptation rules**:
- **Must preserve**: Chart type (bar/line/pie etc.) as specified in the Design Spec
- **Must adapt**: Data values, labels, colors (match the project's color scheme), and dimensions to fit the page layout
- **May adjust**: Axis ranges, grid lines, legend position, spacing — as long as the chart remains accurate and readable
- **Must NOT**: Change chart type without Design Spec justification, or remove data points specified in the outline
> Chart templates: `templates/charts/` (33 types). Index: `templates/charts/charts_index.json`
---
## 6. Image Handling
Handle images based on their status in the Design Specification's "Image Resource List":
| Status | Source | Handling |
|--------|--------|----------|
| **Existing** | User-provided | Reference images directly from `../images/` directory |
| **AI-generated** | Generated by Image_Generator | Images already in `../images/`, reference directly |
| **Placeholder** | Not yet prepared | Use dashed border placeholder |
**Reference**: `<image href="../images/xxx.png" ... preserveAspectRatio="xMidYMid slice"/>`
**Placeholder**: Dashed border `<rect stroke-dasharray="8,4" .../>` + description text
---
## 7. Font Usage
Apply corresponding fonts for different text roles based on the font plan in the Design Specification & Content Outline:
| Role | Chinese Recommended | English Recommended |
|------|--------------------|--------------------|
| Title font | Microsoft YaHei / KaiTi / SimHei | Arial / Georgia |
| Body font | Microsoft YaHei / SimSun | Calibri / Times |
| Emphasis font | SimHei | Arial Black / Consolas |
| Annotation font | Microsoft YaHei / SimSun | Arial / Times |
See `references/design-guidelines.md` for details.
---
## 8. Speaker Notes Generation Framework
### Task 1. Generate Complete Speaker Notes Document
After **all SVG pages are generated and finalized**, enter the "Logic Construction Phase" and generate the complete speaker notes document in `notes/total.md`.
**Why not generate page-by-page?** Batch-writing notes allows planning transitions like a script, ensuring coherent presentation logic.
**Format**: Each page starts with `# <number>_<page_title>`, separated by `---` between pages. Each page includes: script text (2-5 sentences), `Key points: ① ② ③`, `Duration: X minutes`. Except for the first page, each page's text starts with a `[Transition]` phrase.
**Basic stage direction markers** (common to all styles):
| Marker | Purpose |
|--------|---------|
| `[Pause]` | Whitespace after key content, letting the audience absorb |
| `[Transition]` | Standalone paragraph at the start of each page's text, bridging from the previous page |
> Each style may extend with additional markers (`[Interactive]`/`[Data]`/`[Scan Room]`/`[Benchmark]` etc.), see `executor-{style}.md`.
**Requirements**:
- Notes should be conversational and flow naturally
- Highlight each page's core information and presentation key points
- Users can manually edit and override in the `notes/` directory
### Task 2. Split Into Per-Page Note Files
Automatically split `notes/total.md` into individual speaker note files in the `notes/` directory.
**File naming convention**:
- **Recommended**: Match SVG names (e.g., `01_cover.svg` → `notes/01_cover.md`)
- **Compatible**: Also supports `slide01.md` format (backward compatibility)
---
## 9. Next Steps After Completion
> **Auto-continuation**: After Visual Construction Phase (all SVG pages) and Logic Construction Phase (all notes) are complete, the Executor proceeds directly to the post-processing pipeline.
**Post-processing & Export** (see [shared-standards.md](shared-standards.md)):
```bash
# 1. Split speaker notes
python3 scripts/total_md_split.py <project_path>
# 2. SVG post-processing (auto-embed icons, images, etc.)
python3 scripts/finalize_svg.py <project_path>
# 3. Export PPTX
python3 scripts/svg_to_pptx.py <project_path> -s final
# Fallback: add --native if no PowerPoint available for manual "Convert to Shape"
```
references/executor-consultant-top.md
# Executor Consultant Top — Top-tier Consulting Style (MBB Level)
> Common guidelines: executor-base.md. Technical constraints: shared-standards.md.
---
## Role Definition
An MBB-level (McKinsey / Bain / BCG) top-tier consulting style SVG design executor. Suitable for strategic planning, board presentations, investment roadshows, C-suite decision support, and other **premium consulting** scenarios. Core characteristics: data-driven insights, pyramid-structured narrative, extreme simplicity. Output targets C-level decision-makers.
---
## SCQA Framework (Narrative Structure)
Every MBB-level presentation follows the SCQA narrative structure:
| Element | Purpose | Typical Pages |
|---------|---------|---------------|
| **S - Situation** | Establish shared context | Cover / Pages 1-2 |
| **C - Complication** | Introduce the problem / tension | Pages 2-3 |
| **Q - Question** | The implicit or explicit question to solve | Transition page |
| **A - Answer** | Core solution | All body pages |
**Page sequencing**: Cover embodies S+C → Executive Summary answers Q → Chapters develop A → Closing revisits S
**Title writing**:
| SCQA Element | Weak Title | MBB-level Title |
|-------------|------------|-----------------|
| S | "Industry Background" | "Digital penetration surpasses 60%, industry enters deep waters" |
| C | "Challenges Faced" | "Yet three structural contradictions constrain scaled deployment" |
| Q | "Strategic Question" | "How to move from pilot to full deployment within 18 months?" |
| A | "Solution" | "Three-phase path: Focus, Expand, Scale" |
---
## Data Contextualization (Never Present Data in Isolation)
> **Golden Rule**: Never display a single data point in isolation. Every number needs context.
| Method | Pattern | Visual Implementation |
|--------|---------|----------------------|
| **Time comparison** | "From X to Y" | Line chart + arrow annotating magnitude of change |
| **Benchmark** | "X vs industry average Y" | Bar chart with gray dashed baseline |
| **Competitive comparison** | "Us X vs Competitor Y" | Side-by-side bar chart, highlight own data |
| **Target gap** | "Actual X / Target Y" | Progress bar + gap annotation |
| **Ranking** | "Ranked #N of M" | Horizontal bar chart + highlight marker |
**Data annotation three essentials**: Every data visualization must include (1) The value itself (large bold font) (2) Comparison reference (baseline / prior period / competitor) (3) Meaning interpretation ("So what?")
```xml
<text x="160" y="280" text-anchor="middle" font-size="42" font-weight="bold" fill="#1E293B">97.3%</text>
<text x="160" y="310" text-anchor="middle" font-size="13" fill="#64748B">Industry avg 82% | Competitor A 89%</text>
<text x="160" y="335" text-anchor="middle" font-size="12" fill="#059669">Leading industry by 15.3 percentage points</text>
```
---
## Pyramid Principle (Conclusion First)
> Executives don't care about your *process* — they care about *results*.
```
┌─────────────────┐
│ Core Conclusion │ ← Page title / Takeaway
└────────┬────────┘
┌───────────┼───────────┐
┌───┴───┐ ┌───┴───┐ ┌───┴───┐
│ Arg 1 │ │ Arg 2 │ │ Arg 3 │ ← Key supporting points
└───────┘ └───────┘ └───────┘
```
| Level | Position | Font Size |
|-------|----------|-----------|
| Core conclusion | Takeaway Box | 16-18px bold |
| Arguments | Content area body | 14-16px |
| Supporting data | Charts / cards | 12-14px |
**Contrast**: Title "Market Research Results" (wrong) → "Metaverse needs 5-10 years to reach scale" (right); Body "We researched... and found..." (wrong) → "Three arguments: (1)... (2)... (3)..." (right)
---
## MBB-level Layout Patterns
### Content Page Standard Structure (1280x720)
```
┌──────────────────────────────────┐
│ Gradient top bar (0,0 → 1280,6) │
├──────────────────────────────────┤
│ Assertion title (x=40, y=50, 24px)│
├──────────────────────────────────┤
│ Takeaway Box (x=40, y=75, │ ← Dark background + white text, one-sentence conclusion
│ w=1200, h=45) │
├──────────────────────────────────┤
│ Content area (x=40, y=140, │ ← Charts / data / analysis
│ w=1200, h=520) │
├──────────────────────────────────┤
│ Source | CONFIDENTIAL | Page # │ ← y=700, 10px
└──────────────────────────────────┘
```
### Strategic Roadmap
Three phases laid out horizontally, `<rect rx="8">` + title + action list, `<polygon>` arrows connecting:
```
Focus Core (0-6mo) ──> Expand Capability (6-12mo) ──> Scale Up (12-18mo)
x=40,w=380 x=450,w=380 x=860,w=380
```
### Benchmarking Matrix
Horizontal table; own row highlighted in theme color, others in gray. Leading indicators marked green, lagging marked red. Use `<circle>` or `<rect>` to build score points.
### Waterfall Chart (Change Attribution)
Start → increase/decrease factors → End. Positive factors green bars, negative factors red bars, start/end points dark bars, cumulative line as dashed connector.
---
## Strategic Use of Color
MBB-level color usage is extremely restrained — color serves information:
| Purpose | Method |
|---------|--------|
| **Focus** | Target data in theme color, everything else gray |
| **Reduce cognitive load** | Same series differentiated by opacity (`fill-opacity` 1.0/0.6/0.3) |
| **Semantics** | Green = positive, Red = negative, Gray = baseline |
| **Branding** | Gradient top bar / decorative lines in brand color |
**Commandments**: No more than 3 primary colors; accent color used at most 2-3 places globally; data series use same-hue depth variations, not different colors; background white or very light gray.
---
## Chart vs Table Selection Matrix
| Scenario | Recommended Form | Reason |
|----------|-----------------|--------|
| Comparing 2-7 categories | **Bar chart** | Visual comparison is intuitive |
| Time trends | **Line chart** | Time series clarity |
| Precise values / large rankings | **Table** | 50 data points in a bar chart would be chaotic |
| Proportional composition | **Donut chart** | More modern than pie charts |
| Two-dimensional positioning | **2x2 matrix** | Strategic quadrant analysis |
| Change attribution | **Waterfall chart** | Factor decomposition |
---
## Speaker Notes Style
### Narrative Tone
Targeting **executives / decision-makers**. Pyramid structure — every sentence carries information. Composed, authoritative, and insightful tone.
### Stage Direction Markers
| Marker | Purpose | Example |
|--------|---------|---------|
| `[Pause]` | Silence after key insight, let executives absorb | "This means we are missing a billion-dollar market. [Pause]" |
| `[Scan Room]` | Observe decision-makers' reactions, decide whether to elaborate | "[Scan Room] If there are questions, I can elaborate." |
| `[Data]` | Conversational number delivery | "[Data] 63% → nearly two-thirds" |
| `[Benchmark]` | Emphasize comparison baseline | "[Benchmark] Compared to the industry average of 12%, our 23% is nearly double" |
| `[Transition]` | Bridge from previous page, at start of each page's text | "[Transition] Building on the strategic framework, let's drill into the implementation path." |
### Notes Writing Guidelines
- **Pyramid structure**: Conclusion → Arguments → Details
- **Data contextualization**: Every number has a comparison reference
- **Executive language**: "The strategic implication is...", "The core insight is..."
- **Flex mechanism**: Annotate "content that can be skipped if time is tight" for flexible duration
- **Key points structure**: `Key points: (1) Core insight (2) Data support (3) Strategic implication`
### Notes Example
```markdown
# 03_strategic_path
[Transition] Having clarified the problem and the opportunity, now comes the most critical part — our response path.
We recommend a three-phase strategy. [Pause]
Phase one: Focus. Over the next six months, concentrate resources on conquering the Eastern China market.
[Data] Eastern China contributes nearly two-thirds of industry growth, [Benchmark] yet our penetration rate is only half of Competitor A's. [Pause]
Phase two: Expand. Use Eastern China as a template, replicate to Southern and Northern China.
Phase three: Scale. Leverage a digital platform for nationwide coverage.
[Scan Room] The resource allocation for phase one is in the appendix — would you like me to walk through it now?
Key points: (1) Focus-Expand-Scale three-phase approach (2) Data-backed Eastern China priority (3) 18-month full deployment
Duration: 3 minutes
Flex: [If time is tight, skip phase two and three details and go straight to financial impact]
```
---
## MBB-level Quality Checklist Supplement
### Content Level
- [ ] **SCQA complete**: Overall follows Situation-Complication-Question-Answer structure
- [ ] **Data contextualized**: Every data point has a comparison reference
- [ ] **Conclusion first**: Every page's Takeaway Box has a one-sentence conclusion
- [ ] **MECE principle**: Categorized analysis is mutually exclusive and collectively exhaustive
### Visual Level
- [ ] **Color with intent**: Colors serve information delivery
- [ ] **Ample whitespace**: Not crowded; information can "breathe"
- [ ] **Visual hierarchy**: Conclusion > Arguments > Details, differentiated by font size/weight
- [ ] **Brand consistency**: Gradient top bar and footer unified throughout
### Notes Level
- [ ] **Pyramid structure**: Every page is conclusion-first
- [ ] **Stage markers complete**: [Pause] and [Scan Room] at key positions
- [ ] **Flex content annotated**: Supports flexible duration
references/executor-consultant.md
# Executor Consultant — Consulting Style
> Common guidelines: executor-base.md. Technical constraints: shared-standards.md.
---
## Role Definition
A data-driven, consulting-style SVG design executor. Suitable for business analysis reports, market research, operational reviews, strategic recommendations, and other **general consulting** scenarios. Emphasizes structured information presentation and data visualization; clean, clear, and professional style.
---
## Consultant-specific Data Visualization Techniques
### 1. KPI Dashboard Design
KPI cards are the most common element in consulting reports. Standard layout (1280x720):
```
4-card layout: each card 280x180, gap 30
Card 1: x=45, y=160
Card 2: x=355, y=160
Card 3: x=665, y=160
Card 4: x=975, y=160
```
**Card internal structure** (top to bottom):
| Area | Content | Font Size | Style |
|------|---------|-----------|-------|
| Icon row | data-icon icon | 32x32 | Theme color |
| Metric name | "Monthly Active Users" | 14px | Gray #64748B |
| Core number | "1.2M" | 36-42px | Bold, dark color |
| Trend annotation | "+12.3% vs last month" | 12px | Green=up / Red=down |
**Trend arrow conventions**:
- Up: `arrow-trend-up` icon + green text
- Down: `arrow-trend-down` icon + red text
- Flat: horizontal line icon + gray text
### 2. Chart Color Conventions
Consulting-style charts use **monochromatic depth gradients** rather than rainbow colors:
```
Primary series: Theme color 100% opacity
Comparison series: Theme color 60% opacity
Baseline: #94A3B8 (gray dashed line)
Highlight: Accent color (only for key data points)
```
### 3. Data Annotation Principles
- **Direct data labels**: Place values at the top of bar charts — no legend needed
- **Annotated trend lines**: Add text notes at key inflection points ("Policy change", "Product launch")
- **Comparison baselines**: Mark industry average / target values with gray dashed lines
- **Consistent units**: Maintain the same numeric units and precision within a chart
### 4. Table Design
Consulting reports frequently use tables for precise data:
| Design Element | Specification |
|----------------|---------------|
| Header | Dark background + white text, font-weight="bold" |
| Zebra striping | Alternate rows with `fill-opacity="0.05"` light background |
| Number alignment | Numbers right-aligned, text left-aligned |
| Highlighted row | Mark key rows with accent color `fill-opacity="0.1"` |
| Borders | Use horizontal lines only to separate rows; avoid full grid lines |
---
## Consulting-specific Layout Patterns
### MECE Decomposition Tree
Main trunk on the left, branches expanding rightward — mutually exclusive, collectively exhaustive:
```
Main metric ──┬── Branch A (45%)
├── Branch B (30%)
├── Branch C (20%)
└── Branch D (5%)
```
- Use `<line>` to connect trunk to branches
- Each branch has a `<rect>` container + percentage annotation
- Sum must = 100% (or explicitly label "Other")
### Driver Factor Tree
Top-level metric → decomposed factors → actions. Ideal for performance attribution analysis:
```
Revenue growth 15%
├── Average order value +8% → Premium product line expansion
├── Customer count +5% → New channel acquisition
└── Repurchase rate +2% → Loyalty program optimization
```
### Left-chart Right-text (Chart Interpretation)
Core consulting report layout: chart on the left, key insights on the right:
```
Chart area: x=40, y=120, w=700, h=480
Insight area: x=780, y=120, w=460, h=480
- Core conclusion (bold, 16px)
- 3-5 bullet points (14px)
- Data source (12px, gray)
```
---
## Professional Expression Standards
### Page Titles
Consulting-style page titles should be **assertion headlines**, not descriptive titles:
| Type | Descriptive (avoid) | Assertion (recommended) |
|------|---------------------|------------------------|
| Market | "Market Overview" | "Domestic market grows 23% YoY, significantly outpacing global average" |
| Competition | "Competitive Analysis" | "Three major competitors show clear weaknesses in channel coverage" |
| Finance | "Financial Data" | "Gross margin improved for four consecutive quarters, breaking industry ceiling" |
### Data Source Attribution
Every data-containing page must include a source note at the bottom:
```xml
<text x="40" y="700" font-size="10" fill="#94A3B8">
Source: National Bureau of Statistics 2025 Annual Report; Internal team analysis
</text>
```
### Key Takeaway Box
Consulting-style content pages should include a **Takeaway Box** below the title:
```
Position: x=40, y=80, w=1200, h=50
Background: Light theme color (fill-opacity="0.08")
Text: 14-16px, one-sentence summary of the page's core takeaway
```
---
## Speaker Notes Style
### Narrative Tone
Consultant-style speaker notes use a **conclusion-driven** approach — state the conclusion first, then present supporting evidence. Professional, concise, and persuasive tone.
### Stage Direction Markers
| Marker | Purpose | Example |
|--------|---------|---------|
| `[Pause]` | Silence after key conclusions, let the audience absorb | "This means our market share is being eroded. [Pause]" |
| `[Data]` | Cue to verbalize numbers conversationally | "[Data] 85% → more than eight out of ten customers reported this issue" |
| `[Transition]` | Bridge from previous page, must be at start of each page's text | "[Transition] Based on that analysis, let's look at specific recommendations." |
### Notes Writing Guidelines
- **Conclusion first**: The first sentence of each page's notes is the core takeaway
- **Conversational data**: 30% → "roughly one-third", 85% → "more than eight out of ten", 2.5x → "two and a half times"
- **Evidence follows immediately**: After the conclusion, provide 2-3 supporting data points/facts
- **Professional terminology**: Use terms like "insight", "driver", "key lever" etc.
- **Key points structure**: `Key points: (1) Core conclusion (2) Supporting data (3) Next steps`
### Notes Example
```markdown
# 03_key_findings
[Transition] Based on our market scan, we have distilled three key findings.
First, the primary growth driver is shifting from acquisition to retention.
[Data] New customer acquisition cost rose nearly 40% year-over-year, yet improved repurchase rates contributed over 60% of revenue growth. [Pause]
Second, lower-tier market growth significantly outpaces tier-1 and tier-2 cities.
Third, the 25-35 female user segment has an ARPU 1.8x the overall average.
Key points: (1) Growth engine shifting from acquisition to retention (2) Lower-tier market opportunity window (3) High-value user persona
Duration: 2 minutes
```
---
## Self-check Supplement (Consultant-specific)
- [ ] Data matches source data — no fabrication
- [ ] Every data page has a source attribution at the bottom
- [ ] Page titles are assertion-based, not descriptive
- [ ] KPI cards have trend arrows and comparison annotations
- [ ] Chart colors are unified, using monochromatic scheme rather than rainbow
- [ ] Notes are conclusion-first with conversational data
references/executor-general.md
# Executor General — Creative Versatile Style
> Common guidelines: executor-base.md. Technical constraints: shared-standards.md.
---
## Role Definition
A creative, versatile-style SVG design executor. Suitable for product introductions, training materials, proposal presentations, marketing campaigns, and other **non-consulting** scenarios. Emphasizes visual impact and information engagement, striking a balance between professionalism and approachability.
---
## General-specific Layout Techniques
### 1. Flexible and Varied Layouts
The General style is not confined to fixed templates; layouts can be freely chosen based on content:
| Layout | Use Case | Layout Details (1280x720) |
|--------|----------|--------------------------|
| Full-image background + text overlay | Covers, emotional pages | `<image>` fills canvas + semi-transparent overlay + centered title |
| Left-right split (image-text mix) | Feature introductions, comparisons | Left x=40,w=580 / Right x=660,w=580 |
| Three-column cards | Feature lists, team introductions | x=40,450,860 each w=380, equal-height cards |
| Top-bottom split | Timelines, process flows | Top area: title+description h=250 / Bottom area: charts+content h=420 |
| Center-radiating | Core concepts, ecosystem diagrams | Center element + 4-6 surrounding nodes, lines pointing to center |
| Waterfall / Z-pattern | Storytelling, case studies | Content blocks alternate left-right, guiding the eye in a Z-pattern |
### 2. Visual Rhythm Control
- **Information density alternation**: Follow a data-heavy page with a "breathing page" (large image / quote / transition) to prevent audience fatigue
- **Visual weight balance**: Dark/large-area elements are "heavy", light/small elements are "light" — balance left-right/top-bottom
- **Repetition and variation**: Maintain layout consistency within a chapter; vary between chapters to maintain freshness
### 3. Decorative Element Usage
| Element | Usage | Notes |
|---------|-------|-------|
| Gradient blocks | Background zones, title backing | Use `<linearGradient>` / `<radialGradient>`, limit to 2-3 colors |
| Rounded rectangle cards | Content containers, feature modules | `rx="12"` with light shadow (simulate with lighter rect) |
| Icon accents | List item prefixes, feature markers | Use `data-icon` placeholders, size 32-48px |
| Numbered circles | Step flows, ranked lists | `<circle>` + centered `<text>`, theme color fill |
| Divider lines | Content separation | `<line>` or `<rect height="2">`, opacity 0.2-0.3 |
---
## Visual Strategy
### Color Usage
- **Bold use of theme color**: Covers and chapter pages can use large areas of theme color background
- **Gradients enhance depth**: Title bars and card backgrounds can use same-hue gradients
- **Contrast creates focus**: Key numbers/words use accent color, creating contrast with surroundings
- **Color-mood matching**: Cool tones for tech feel, warm tones for energy, dark tones for gravitas
### Image Handling Strategy
| Scenario | Strategy | SVG Implementation |
|----------|----------|-------------------|
| Full-screen background | Image fills + dark gradient overlay | `preserveAspectRatio="xMidYMid slice"` + gradient rect |
| Portrait image display | Place left/right, maintain original ratio | Control width, height adapts |
| Multi-image grid | Grid arrangement, uniform sizing | Equal-width equal-height `<image>` matrix |
| Person photo | Circular crop effect | `<circle>` background + square image overlay (post-processing crops) |
### Typography Hierarchy
```
Title layer → 28-36px, bold, theme color or white
Subtitle layer → 20-24px, medium weight, secondary color
Body layer → 16-18px, regular, dark gray
Annotation layer → 12-14px, light gray, bottom-aligned
```
---
## Speaker Notes Style
### Narrative Tone
General style speaker notes use **conversational narration** — like talking with the audience, not reading a report. Natural tone with rhythm, using rhetorical devices where appropriate.
### Stage Direction Markers
| Marker | Purpose | Example |
|--------|---------|---------|
| `[Pause]` | Silence after key reveal, letting the audience absorb | "What does this number mean? [Pause] It means 1 in every 3 users..." |
| `[Interactive]` | Ask questions or guide audience participation | "[Interactive] How many of you have used this feature?" |
| `[Transition]` | Bridge from previous page, must be at start of each page's text | "[Transition] Now that we understand the context, let's see how it works." |
### Notes Writing Guidelines
- **Tell stories**: Use "scenario-conflict-resolution" structure for each page's narrative
- **Use metaphors**: Make abstract concepts tangible ("It's like adding a turbocharger to the system")
- **Create suspense**: Pose questions at the right time, answer on the next page
- **Conversational data**: 30% → "nearly one-third", 2.5x → "more than doubled"
- **Key points structure**: `Key points: (1) Core message (2) Supporting evidence (3) Call to action`
### Notes Example
```markdown
# 03_key_advantages
[Transition] Having covered the market landscape, you might be wondering: where is our opportunity?
Our core advantages can be summed up in three words: Fast, Accurate, Efficient.
Fast — deployment time cut from 3 months to 2 weeks; [Pause]
Accurate — recognition accuracy at 97.3%, far exceeding the industry average of 82%;
Efficient — overall costs reduced by nearly one-third.
[Interactive] If you were the decision-maker, which of these three numbers would impress you most?
Key points: (1) Three differentiating advantages (2) Quantitative data support (3) Prompt for reflection
Duration: 2 minutes
```
---
## Self-check Supplement (General-specific)
- [ ] Visual rhythm is reasonable: data-dense pages alternate with breathing pages
- [ ] Decorative elements are moderate: serving content, not overshadowing it
- [ ] Image-text ratio is appropriate: not just text walls, visual highlights present
- [ ] Notes are conversational: reads like speaking, not reading a script
references/image-generator.md
# Image_Generator Reference Manual
> This file is the streamlined reference for the Image_Generator role. Common standards (SVG technical constraints, canvas formats, post-processing pipeline, etc.) are in [shared-standards.md](./shared-standards.md).
## Core Mission
Receive the "Image Resource List" from the Design Specification & Content Outline output by the Strategist, create optimized prompts for each image pending generation, generate images via AI tools, and save them to the project's `images/` directory.
**Trigger condition**: When AI image generation is needed (standalone use or invoked within pipeline)
| Mode | Trigger | Description |
|------|---------|-------------|
| **Standalone** | Directly describe image needs | Generate single or multiple AI images |
| **In-pipeline** | `generate-ppt` with AI image generation selected | Batch-generate image assets for a project |
> Next step in pipeline: Executor generates SVGs
---
## 1. Input & Output
### Input
- **Design Specification & Content Outline** (from Strategist): project theme, target audience, design style, color scheme, canvas format
- **Image Resource List** (key input):
| Filename | Dimensions | Purpose | Type | Status | Generation Description |
|----------|-----------|---------|------|--------|----------------------|
| cover_bg.png | 1920x1080 | Cover background | Background | Pending | Modern tech abstract background, deep blue gradient |
### Output
| Deliverable | Path / Description | Requirements |
|------------|-------------------|--------------|
| Prompt document | `project/images/image_prompts.md` | **Must** be saved using file write tool — cannot just be output in conversation |
| Optimized prompts | Individual prompt per image | Directly usable with AI image generation tools; doubles as alt text |
| Image files | `project/images/` directory | Named per the resource list filenames |
| Updated list | Status changes | "Pending" → "Generated" |
---
## 2. Unified Prompt Structure
### 2.1 Standard Output Format
Every image must be output in the following format:
```markdown
### Image N: {filename}
| Attribute | Value |
| --------- | ----- |
| Purpose | {which page / what function} |
| Type | {Background / Illustration / Photography / Diagram / Decorative} |
| Dimensions | {width}x{height} ({aspect ratio}) |
| Original description | {description provided by user in the list} |
**Prompt**:
{subject description}, {style directive}, {color directive}, {composition directive}, {quality directive}
**Negative Prompt**:
{elements to exclude}
**Alt Text**:
> {Description for accessibility and image captions}
```
### 2.2 Prompt Components
| Component | Description | Example |
|-----------|-------------|---------|
| Subject description | Core content | `Abstract geometric shapes`, `Team collaboration scene` |
| Style directive | Visual style | `flat design`, `3D isometric`, `watercolor style` |
| Color directive | Color scheme | `color palette: navy blue (#1E3A5F), gold (#D4AF37)` |
| Composition directive | Layout ratio | `16:9 aspect ratio`, `centered composition` |
| Quality directive | Resolution quality | `high quality`, `4K resolution`, `sharp details` |
| Negative prompt | Exclude elements | `text, watermark, blurry, low quality` |
### 2.3 Style Keywords Quick Reference
| Design Style | Recommended Image Style | Core Keywords |
|-------------|------------------------|---------------|
| General Versatile | Modern illustration, flat design | `modern`, `flat design`, `gradient`, `vibrant colors` |
| General Consulting | Clean professional, corporate | `professional`, `clean`, `corporate`, `minimalist` |
| Top Consulting | Premium minimal, abstract geometric | `premium`, `sophisticated`, `geometric`, `abstract`, `elegant` |
### 2.4 Color Integration Method
Extract colors from design spec, convert to prompt directives:
```
Primary: #1E3A5F (Deep Navy) → "deep navy blue (#1E3A5F)"
Secondary: #F8F9FA (Light Gray) → "light gray (#F8F9FA)"
Accent: #D4AF37 (Gold) → "gold accent (#D4AF37)"
Full directive: "color palette: deep navy blue (#1E3A5F), light gray (#F8F9FA), gold accent (#D4AF37)"
```
### 2.5 Canvas Format & Aspect Ratio
| Canvas Format | Background Aspect Ratio | Recommended Resolution |
|--------------|------------------------|----------------------|
| PPT 16:9 | 16:9 | 1920x1080 or 2560x1440 |
| PPT 4:3 | 4:3 | 1600x1200 |
| Facebook/TikTok | 3:4 | 1242x1660 |
| Zalo/Instagram | 1:1 | 1080x1080 |
| Story | 9:16 | 1080x1920 |
> Nano Banana supported aspect ratios: `1:1`, `2:3`, `3:2`, `3:4`, `4:3`, `4:5`, `5:4`, `9:16`, `16:9`, `21:9`
---
## 3. Image Type Classification & Handling
### Type Determination Flow
1. Full-page / large-area backdrop → **Background** (3.1)
2. Real scenes / people / products → **Photography** (3.2)
3. Flat / illustration / cartoon style → **Illustration** (3.3)
4. Process / architecture / relationships → **Diagram** (3.4)
5. Partial decoration / texture → **Decorative Pattern** (3.5)
### 3.1 Background
**Identifying characteristics**: Full-page background for covers or chapter pages; must support text overlay
| Key Point | Description |
|-----------|-------------|
| Emphasize background nature | Add `background`, `backdrop` |
| Reserve text area | `negative space in center for text overlay` |
| Avoid strong subjects | Use abstract, gradient, geometric elements |
| Low-contrast details | `subtle`, `soft`, `muted` |
**Template**: `Abstract {theme element} background, {style} style, {primary color} to {secondary color} gradient, subtle {decorative elements}, clean negative space in center for text overlay, {aspect ratio} aspect ratio, high resolution, professional presentation background`
**Negative prompt**: `text, letters, watermark, faces, busy patterns, high contrast details`
### 3.2 Photography
**Identifying characteristics**: Real scenes, people, products, architecture — photographic quality
| Key Point | Description |
|-----------|-------------|
| Emphasize realism | `photography`, `photorealistic`, `real photo` |
| Lighting effects | `natural lighting`, `soft shadows`, `studio lighting` |
| Background handling | `white background` / `blurred background` / `contextual setting` |
| People diversity | `diverse`, `professional attire` |
**Template**: `{subject description}, professional photography, {lighting type} lighting, {background type} background, color grading matching {color scheme}, high quality, sharp focus, 8K resolution`
**Negative prompt**: `watermark, text overlay, artificial, CGI, illustration, cartoon, distorted faces`
### 3.3 Illustration
**Identifying characteristics**: Flat design, vector style, cartoon, concept diagrams
| Key Point | Description |
|-----------|-------------|
| Specify style | `flat design`, `isometric`, `vector style`, `hand-drawn` |
| Simplify details | `simplified`, `clean lines`, `minimal details` |
| Unified palette | Strictly use design spec colors |
| Background choice | `white background` or `transparent background` |
**Template**: `{subject description}, {illustration style} illustration style, {detail level} with clean lines, color palette: {color list}, {background type} background, professional {purpose} illustration`
**Negative prompt**: `realistic, photography, 3D render, complex textures, watermark`
### 3.4 Diagram
**Identifying characteristics**: Flowcharts, architecture diagrams, concept relationship maps, data visualizations
| Key Point | Description |
|-----------|-------------|
| Clear structure | `clear structure`, `organized layout`, `logical flow` |
| Connection representation | `arrows indicating flow`, `connecting lines` |
| Academic / professional feel | `suitable for academic publication`, `professional diagram` |
| Light background | `white background` or `light gray background` |
**Template**: `{diagram type} diagram showing {content description}, {component description} connected by {connection method}, {style} style with {color scheme}, white background, clear labels, professional technical diagram`
**Negative prompt**: `cluttered, messy, overlapping elements, dark background, realistic, photography`
### 3.5 Decorative Pattern
**Identifying characteristics**: Partial decoration, textures, borders, divider elements
| Key Point | Description |
|-----------|-------------|
| Repeatability | `seamless`, `tileable`, `repeatable` (if needed) |
| Understated support | `subtle`, `understated`, `supporting element` |
| Transparency-friendly | `transparent background` or `isolated element` |
| Small-size readability | Consider legibility at small dimensions |
**Template**: `{pattern type} decorative pattern, {style} style, {color scheme}, {background type} background, subtle and elegant, suitable for {purpose}`
**Negative prompt**: `busy, cluttered, high contrast, distracting, photorealistic`
---
## 4. Image Generation Workflow
### 4.1 Analysis Phase
1. Read the design spec; understand overall project style
2. Extract color scheme, canvas format, target audience
3. Analyze each image in the resource list individually
4. Determine each image's type (refer to Section 3)
### 4.2 Prompt Generation Phase
For each image with "Pending" status:
1. **Determine type** → Background / Photography / Illustration / Diagram / Decorative
2. **Understand purpose** → Which page? What function?
3. **Analyze original description** → Information from the user's "Generation description"
4. **Apply type-specific key points** → Reference the corresponding type's table
5. **Generate optimized prompt** → Use the 2.1 standard output format
6. **Save prompt document** → **Must** write to `project/images/image_prompts.md`
### 4.3 Image Generation Phase
> Prerequisite: Section 4.2 must be complete; `images/image_prompts.md` must exist
#### Method 1: Nano Banana CLI Tool (Recommended)
```bash
python3 scripts/nano_banana_gen.py "your prompt" \
--aspect_ratio 16:9 --image_size 1K \
--output project/images --filename cover_bg
```
**Parameters**:
| Parameter | Short | Description | Default |
|-----------|-------|-------------|---------|
| `prompt` | - | Positive prompt (positional arg) | `Nano Banana` |
| `--negative_prompt` | `-n` | Negative prompt | None |
| `--aspect_ratio` | - | Image aspect ratio | `1:1` |
| `--image_size` | - | Size (`1K`/`2K`/`4K`) | `1K` |
| `--output` | `-o` | Output directory | Current directory |
| `--filename` | `-f` | Output filename (no extension) | Auto-named |
**Environment variables**: Required: `GEMINI_API_KEY`. Optional: `GEMINI_BASE_URL`
**Generation pacing (mandatory)**:
- Execute only one generation command at a time; wait for file confirmation before the next
- Recommend 2-5 second intervals between images to avoid concurrency failures
- If failure/no output occurs, halt the queue, check environment variables and output directory, then resume
#### Method 2: Auto-generation
Directly call image generation API, download and save to `project/images/` directory.
#### Method 3: Gemini Web Interface
1. Generate images in [Gemini](https://gemini.google.com/)
2. Select **Download full size** for high-resolution version
3. Remove watermark: `python3 scripts/gemini_watermark_remover.py <image_path>`
4. Place processed images in `project/images/` directory
#### Method 4: Manual Generation (Other AI Platforms)
Prompts are saved in `images/image_prompts.md`; inform the user of the file location. User generates on Midjourney, DALL-E, Stable Diffusion, etc. and places images in `project/images/` directory.
### 4.4 Verification Phase
- Confirm all images are saved to `images/` directory
- Check filenames match the resource list
- Update image resource list status to "Generated"
---
## 5. Prompt Document Template
Use the following structure when creating `project/images/image_prompts.md`:
```markdown
# Image Generation Prompts
> Project: {project_name}
> Generated: {date}
> Color scheme: Primary {#HEX} | Secondary {#HEX} | Accent {#HEX}
---
## Image List Overview
| # | Filename | Type | Dimensions | Status |
|---|----------|------|-----------|--------|
| 1 | cover_bg.png | Background | 1920x1080 | Pending |
---
## Detailed Prompts
### Image 1: cover_bg.png
| Attribute | Value |
|-----------|-------|
| Purpose | Cover background |
| Type | Background |
| Dimensions | 1920x1080 (16:9) |
| Original description | Modern tech abstract background, deep blue gradient |
**Prompt**:
Abstract futuristic background with flowing digital waves...
**Alt Text**:
> Modern tech abstract background with deep blue gradient, digital waves, and particle effects
---
## Usage Instructions
1. Copy the "Prompt" above into an AI image generation tool
2. Recommended platforms: Midjourney / DALL-E 3 / Gemini / Stable Diffusion
3. Rename generated images to the corresponding filenames
4. Place in the `images/` directory
```
---
## 6. Negative Prompt Quick Reference
### By Image Type
| Type | Recommended Negative Prompt |
|------|---------------------------|
| Background | `text, letters, watermark, faces, busy patterns, high contrast details` |
| Photography | `watermark, text overlay, artificial, CGI, illustration, cartoon, distorted faces` |
| Illustration | `realistic, photography, 3D render, complex textures, watermark` |
| Diagram | `cluttered, messy, overlapping elements, dark background, realistic` |
| Decorative pattern | `busy, cluttered, high contrast, distracting, photorealistic` |
### Universal Negative Prompts
- **Standard**: `text, watermark, signature, blurry, distorted, low quality`
- **Extended** (people scenarios): `text, watermark, signature, blurry, low quality, distorted, extra fingers, mutated hands, poorly drawn face, bad anatomy, extra limbs, disfigured, deformed`
---
## 7. Common Issues
### Default Inference When No "Generation Description" Provided
| Purpose | Default Inference |
|---------|------------------|
| Cover background | Abstract gradient background, reserve central text area |
| Chapter page background | Clean geometric pattern, monochrome focus |
| Team introduction page | Team collaboration scene illustration (flat style) |
| Data display page | Clean geometric pattern or solid color background |
| Product showcase | Product photography style, white or gradient background |
### When Images Are Unsatisfactory
Provide prompt variants for user selection: Variant A (more abstract), Variant B (more concrete), Variant C (different color tone).
---
## 8. Role Collaboration
### Handoff with Strategist
| Direction | Content |
|-----------|---------|
| Receives | Design Specification & Content Outline (with image resource list) |
| Trigger condition | User selected "C) AI generation" in "Image usage" |
| Key information | Color scheme, design style, canvas format |
### Handoff with Executor
| Direction | Content |
|-----------|---------|
| Delivers | All images placed in `project/images/` directory |
| Executor reference | `<image href="../images/xxx.png" .../>` |
| Path note | SVGs in `svg_output/`, images in `images/`; use relative path `../images/` |
---
## 9. Task Completion Checkpoint
### Must-complete Items
- [ ] Created prompt document `project/images/image_prompts.md`
- [ ] Each image has: type determination + optimized prompt + negative prompt + Alt Text
- [ ] Uses unified output format (2.1 standard format)
- [ ] Phase completion confirmation output
### Image Readiness (at least one must be satisfied)
- [ ] All images saved to `project/images/` directory
- [ ] Or: User clearly informed to self-generate using `image_prompts.md`
### Pipeline Flow
- [ ] User prompted to proceed to next step (switch to Executor role)
> **Critical check**: If `images/image_prompts.md` was not created, or the output format does not comply with 2.1 standard, the task is NOT complete.
### Completion Confirmation Output Format
```markdown
## Image_Generator Phase Complete
- [x] Created prompt document `project/images/image_prompts.md`
- [x] Generated optimized prompts for X images
- [x] All images saved to `images/` directory
- [x] Updated image resource list status
**Image Status Summary**:
| Filename | Type | Dimensions | Status |
|----------|------|-----------|--------|
| cover_bg.png | Background | 1920x1080 | Generated |
**Next step**: Switch to Executor role to begin SVG generation
```
references/image-layout-spec.md
> See shared-standards.md for common technical constraints.
# Image Layout Specification (Mandatory)
Layout rules for pages containing images. Both the Strategist planning phase and Executor generation phase must follow these rules.
**Core principle: Calculate layout based on the image's original aspect ratio, ensuring the image is displayed completely without excess whitespace or cropping.**
---
## Layout Decision Flow
```
1. Get image original dimensions → Calculate ratio (width/height)
2. Select layout type based on ratio
3. Calculate maximum display size for the image
4. Allocate remaining space for text area
5. Fill results into the Design Specification's image resource list
```
**When to execute**: If the image approach includes "B) User-provided", after the Strategist completes the Eight Confirmations and before content analysis and outlining, the scan must be run and the image resource list populated.
---
## Layout Type Selection (Mandatory)
| Image Ratio | Layout Type | Image Position | Description |
|-------------|-------------|----------------|-------------|
| > 2.0 (ultra-wide) | Top-bottom split | Top full-width | Image spans canvas width, height proportional |
| 1.5-2.0 (wide) | Top-bottom split | Top | Image width = content area width, height proportional |
| 1.2-1.5 (standard) | Left-right split | Left | Image height-first fit, width proportional |
| 0.8-1.2 (square) | Left-right split | Left | Image takes content area height, width proportional |
| < 0.8 (portrait) | Left-right split | Left | Image height = content area height, width proportional |
> Edge cases: When ratio is at a boundary (e.g., 1.5), decide based on text volume. More text → left-right; less text → top-bottom.
---
## Dimension Calculation Formulas
### PPT 16:9 (1280x720) Canvas Parameters
```
Canvas: 1280 x 720 px
Content area: 1160 x 640 px (left/right margin 60px, top/bottom margin 40px)
Title area height: 60 px
Content start: y = 80 px (title + spacing)
```
### Top-Bottom Layout Calculation
```
Image width = W = 1160 px
Image height = W / R = 1160 / R px
Text area height = H - image height - gap(20px)
Validation: Text area height >= 150px (at least 3-4 lines of text)
If not satisfied → Switch to left-right layout
```
### Left-Right Layout Calculation
**Method 1 (height-first, suitable for portrait images)**:
```
Image height = H = 600 px
Image width = H x R = 600 x R px
Text area width = W - image width - gap(20px)
```
**Method 2 (width-constrained, for wide images converted to left-right)**:
```
Image width = W x 0.7 = 812 px
Image height = image width / R
Text area width = W - image width - gap(20px)
```
**Validation**: Text area width >= 280px; otherwise reduce image area width.
---
## Layout Examples
### Ultra-wide Image (ratio 2.45)
```
Original: 1960x800, R=2.45 → Top-bottom split
Image: 1160x473, Text area: 1160x147 → 7:3 top-bottom
```
### Standard Landscape (ratio 1.38)
```
Original: 1614x1171, R=1.38 → Left-right split
Image: 773x560 (left), Text area: 367x560 (right) → 7:3 left-right
```
### Wide Image Edge Case (ratio 1.75)
```
Original: 1820x1040, R=1.75
Try top-bottom: image height=663, text area=-43 ❌
Switch to left-right: image 780x446 (left), text area 360x600 (right) → 7:3 left-right
```
---
## Prohibited Practices
| Prohibited | Correct Approach |
|-----------|-----------------|
| Fixed 50:50 or arbitrary ratios | Dynamic calculation based on image ratio |
| Forcing wide image into square container | Use top-bottom layout or increase image area width |
| Placing portrait image in narrow horizontal strip | Use left-right layout, image on left |
| Image whitespace exceeding 10% | Recalculate layout or choose alternative approach |
| Cropping key image content | Use `preserveAspectRatio="xMidYMid meet"` |
| Text area too small to read | Ensure text area >= 150px (top-bottom) or >= 280px (left-right) |
---
## SVG Image Embedding Code
### Complete Display (recommended for data charts)
```xml
<image href="../images/xxx.png"
x="60" y="80" width="780" height="446"
preserveAspectRatio="xMidYMid meet"/>
```
### Crop-to-Fill (backgrounds only)
```xml
<image href="../images/bg.png"
x="0" y="0" width="1280" height="720"
preserveAspectRatio="xMidYMid slice"/>
```
---
## Image Resource List Template
In the Design Specification & Content Outline, the image resource list must include:
| Field | Description | Example |
|-------|-------------|---------|
| Filename | Image filename | `p12_0.png` |
| Original dimensions | Width x Height | 1524x968 |
| Ratio | Width / Height | 1.57 |
| Page | Usage page number | Page 5 |
| Type | Visual type | Background / Photography / Illustration / Diagram / Decorative |
| Layout plan | Top-bottom/Left-right + split ratio | Top-bottom 6:4 or Left-right 7:3 |
| Image area | Image display dimensions | 1160x420 or 780x446 |
| Text area | Remaining space dimensions | 1160x200 or 360x600 |
**The Type field is used by Image_Generator to select the appropriate prompt strategy.**
---
## Automation Tool
```bash
python3 scripts/analyze_images.py <project_path>/images
```
Output includes dimensions, ratios, and layout recommendations (Markdown table), which can be directly used to populate the image resource list.
---
## Role Responsibilities
| Role | Responsibility |
|------|---------------|
| **Strategist** | Run analyze_images.py, calculate layout per this spec, populate image resource list |
| **Executor** | Strictly follow the layout plan and dimensions in the image resource list when generating SVGs |
references/optimizer-crap.md
> See shared-standards.md for common technical constraints.
# Optimizer CRAP — Visual Optimization Role
## Core Mission
Strictly follow CRAP design principles to analyze and restructure single-page SVG code, outputting a visually more professional and structurally clearer version. Supports all canvas formats; optimizes based on the original SVG canvas dimensions.
## Usage Modes
| Mode | Trigger | Description |
|------|---------|-------------|
| Standalone | Provide SVG file/code | Visually optimize any SVG |
| In-pipeline | Optional after `generate-ppt` | Optimize Executor-generated SVGs |
> Next step in pipeline: Post-processing + Export PPTX
## Workflow
1. Receive original SVG code
2. Identify canvas format (`width`, `height`, `viewBox`)
3. (Optional) Analyze reference SVG for overall style
4. Diagnose and restructure according to the four core principles
5. Keep canvas dimensions unchanged
6. Output optimized SVG with `yh_` prefix in filename
7. Output optimization report
---
## Four Core Design Principles
### 1. Alignment
- **Diagnose**: Check for randomly placed elements
- **Optimize**: Strictly align all elements, creating strong visual connection lines (left-aligned, centered, or right-aligned); every element's position must have a clear alignment relationship with other elements
- **Reference**: Element coordinate deviation within 5px
**Common techniques**:
- Unify scattered elements along the same vertical or horizontal line
- Use consistent left/right margins
- Keep title, body, and annotation starting positions aligned
### 2. Contrast
- **Diagnose**: Check whether elements have sufficient visual hierarchy; whether key information stands out
- **Optimize**: Make different elements **distinctly different** by significantly increasing size, weight, or color differences to highlight the most important information
- **Reference**: Title font size 1.3-2x larger than body text
**Common techniques**:
- Enlarge key numbers or title font sizes
- Use bold to emphasize key terms
- Use accent colors to mark critical information
- Use light/dark contrast to distinguish foreground from background
### 3. Repetition
- **Diagnose**: Check whether similar elements have consistent visual styling
- **Optimize**: Intentionally repeat visual elements (colors, font styles, border radius, line thickness) to create organization and unity
- **Reference**: Same-type elements maintain consistent styling
**Common techniques**:
- Unify border radius across all cards
- Unify font size and color for same-level headings
- Repeat the same icon style
- Maintain a consistent spacing system
### 4. Proximity
- **Diagnose**: Check whether logically related content is spatially close enough
- **Optimize**: Place related items close together spatially, forming visual units; increase distance between different groups
- **Reference**: Related elements close together, unrelated elements separated
**Common techniques**:
- Reduce spacing between a title and its content below
- Increase spacing between different sections
- Group charts and labels into visual units
- Use divider lines or background colors to reinforce relationships
---
## Optional Supplementary Principles
Apply only when the user explicitly requests:
### 5. White Space
Trigger phrases: "add whitespace", "improve premium feel". Increase element spacing to give the design breathing room.
### 6. Noise Reduction
Trigger phrases: "simplify design", "reduce distractions". Remove redundant elements, simplify complex effects, focus on core information.
---
## Optimization Report Format
```
## Optimization Report
### Key Improvements
1. [Alignment] Specific modification description
2. [Contrast] Specific modification description
3. [Repetition] Specific modification description
4. [Proximity] Specific modification description
### Optimization Results
Brief description of overall optimization effect and visual improvements.
```
## Technical Constraints
- **Preserve canvas dimensions**: Optimized SVG's `width`, `height`, `viewBox` must match the original
- **Text handling**: Preserve `<tspan>` line breaks
- **File naming**: `yh_original_filename.svg`
- SVG banned features and PPT compatibility rules: see shared-standards.md
references/shared-standards.md
# Shared Technical Standards
Common technical constraints for PPT Master, eliminating cross-role file duplication.
---
## 1. SVG Banned Features Blacklist
The following features are **absolutely forbidden** when generating SVGs — PPT export will break if any are used:
| Banned Feature | Description |
|----------------|-------------|
| `clipPath` | Clipping paths |
| `mask` | Masks |
| `<style>` | Embedded stylesheets |
| `class` | CSS selector attributes (`id` inside `<defs>` is a legitimate reference and is NOT banned) |
| External CSS | External stylesheet links |
| `<foreignObject>` | Embedded external content |
| `<symbol>` + `<use>` | Symbol reference reuse |
| `textPath` | Text along a path |
| `@font-face` | Custom font declarations |
| `<animate*>` / `<set>` | SVG animations |
| `<script>` / event attributes | Scripts and interactivity |
| `marker` / `marker-end` | Line endpoint markers |
| `<iframe>` | Embedded frames |
---
## 2. PPT Compatibility Alternatives
| Banned Syntax | Correct Alternative |
|---------------|---------------------|
| `fill="rgba(255,255,255,0.1)"` | `fill="#FFFFFF" fill-opacity="0.1"` |
| `<g opacity="0.2">...</g>` | Set `fill-opacity` / `stroke-opacity` on each child element individually |
| `<image opacity="0.3"/>` | Overlay a `<rect fill="background-color" opacity="0.7"/>` mask layer after the image |
| `marker-end` arrows | Draw triangle arrows with `<polygon>` |
**Mnemonic**: PPT does not recognize rgba, group opacity, image opacity, or markers.
---
## 3. Canvas Format Quick Reference
### Presentations
| Format | viewBox | Dimensions | Ratio |
|--------|---------|------------|-------|
| PPT 16:9 | `0 0 1280 720` | 1280x720 | 16:9 |
| PPT 4:3 | `0 0 1024 768` | 1024x768 | 4:3 |
### Social Media
| Format | viewBox | Dimensions | Ratio |
|--------|---------|------------|-------|
| Facebook/TikTok | `0 0 1242 1660` | 1242x1660 | 3:4 |
| Zalo/Instagram | `0 0 1080 1080` | 1080x1080 | 1:1 |
| Story / TikTok Vertical | `0 0 1080 1920` | 1080x1920 | 9:16 |
### Marketing Materials
| Format | viewBox | Dimensions | Ratio |
|--------|---------|------------|-------|
| Zalo OA Header | `0 0 900 383` | 900x383 | 2.35:1 |
| Landscape Banner | `0 0 1920 1080` | 1920x1080 | 16:9 |
| Portrait Poster | `0 0 1080 1920` | 1080x1920 | 9:16 |
| A4 Print (150dpi) | `0 0 1240 1754` | 1240x1754 | 1:1.414 |
---
## 4. Basic SVG Rules
- **viewBox** must match the canvas dimensions (`width`/`height` must match `viewBox`)
- **Background**: Use `<rect>` to define the page background color
- **Line breaks**: Use `<tspan>` for manual line breaks; `<foreignObject>` is FORBIDDEN
- **Fonts**: Use system fonts only (Microsoft YaHei, Arial, Calibri, etc.); `@font-face` is FORBIDDEN
- **Styles**: Use inline styles only (`fill="..."` `font-size="..."`); `<style>` / `class` are FORBIDDEN (`id` inside `<defs>` is legitimate)
- **Colors**: Use HEX values; for transparency use `fill-opacity` / `stroke-opacity`
- **Image references**: `<image href="../images/xxx.png" preserveAspectRatio="xMidYMid slice"/>`
- **Icon placeholders**: `<use data-icon="icon-name" x="" y="" width="48" height="48" fill="#HEX"/>` (auto-embedded during post-processing)
---
## 5. Post-processing Pipeline (3 Steps)
Must be executed in order — skipping or adding extra flags is FORBIDDEN:
```bash
# 1. Split speaker notes into per-page note files
python3 scripts/total_md_split.py <project_path>
# 2. SVG post-processing (icon embedding, image crop/embed, text flattening, rounded rect to path)
python3 scripts/finalize_svg.py <project_path>
# 3. Export PPTX (from svg_final/, embeds speaker notes by default)
python3 scripts/svg_to_pptx.py <project_path> -s final
# Fallback: add --native if no PowerPoint available for manual "Convert to Shape"
```
**Prohibited**:
- NEVER use `cp` as a substitute for `finalize_svg.py`
- NEVER export directly from `svg_output/` — MUST export from `svg_final/` (use `-s final`)
- NEVER add extra flags like `--only`
- After Optimizer optimization, re-run all three steps
---
## 6. Project Directory Structure
```
project/
├── svg_output/ # Raw SVGs (Executor output, contains placeholders)
├── svg_final/ # Post-processed final SVGs (finalize_svg.py output)
├── images/ # Image assets (user-provided + AI-generated)
├── notes/ # Speaker notes (.md files matching SVG names)
│ └── total.md # Complete speaker notes document (before splitting)
├── templates/ # Project templates (if any)
└── *.pptx # Exported PPT file
```
references/strategist.md
# Role: Strategist
## Core Mission
As a top-tier AI presentation strategist, receive source documents, perform content analysis and design planning, and output the **Design Specification & Content Outline** (hereafter `design_spec`).
## Pipeline Context
| Previous Step | Current | Next Step |
|--------------|---------|-----------|
| Project creation + Template option confirmed | **Strategist**: Eight Confirmations + Design Spec | Image_Generator or Executor |
> See shared-standards.md for SVG technical constraints, PPT compatibility rules, canvas format table, and post-processing commands.
---
## 1. Eight Confirmations Process
⛔ **BLOCKING**: Before starting analysis, reference `templates/design_spec_reference.md` and provide professional recommendations for the following eight items, then **present them as a bundled package to the user and wait for explicit confirmation or modifications**.
> **Execution discipline**: This is the last BLOCKING checkpoint in the pipeline (besides template selection). Once the user confirms, the AI must automatically complete the Design Specification & Content Outline and seamlessly proceed to subsequent image generation (if applicable), SVG generation, and post-processing — no additional questions or pauses in between.
### a. Canvas Format Confirmation
Recommend format based on scenario (see canvas format quick reference in shared-standards.md).
### b. Page Count Confirmation
Provide specific page count recommendation based on source document content volume.
### c. Key Information Confirmation
Confirm target audience, usage occasion, and core message; provide initial assessment based on document nature.
### d. Style Objective Confirmation
| Style | Core Focus | Target Audience | One-line Description |
|-------|-----------|----------------|---------------------|
| **A) General Versatile** | Visual impact first | Public / clients / trainees | "Catch the eye at a glance" |
| **B) General Consulting** | Data clarity first | Teams / management | "Let data speak" |
| **C) Top Consulting** | Logical persuasion first | Executives / board | "Lead with conclusions" |
**Style selection decision tree**:
```
Content characteristics?
├── Heavy imagery / promotional ──→ A) General Versatile
├── Data analysis / progress report ──→ B) General Consulting
└── Strategic decisions / persuading executives ──→ C) Top Consulting
Audience?
├── Public / clients / trainees ────→ A) General Versatile
├── Teams / management ────────────→ B) General Consulting
└── Executives / board / investors → C) Top Consulting
```
### e. Color Scheme Recommendation
Proactively provide a color scheme (HEX values) based on content characteristics and industry.
**Industry color quick reference** (full 14-industry list in `scripts/config.py` under `INDUSTRY_COLORS`):
| Industry | Primary Color | Characteristics |
|----------|--------------|-----------------|
| Finance / Business | `#003366` Navy Blue | Stable, trustworthy |
| Technology / Internet | `#1565C0` Bright Blue | Innovative, energetic |
| Healthcare / Health | `#00796B` Teal Green | Professional, reassuring |
| Government / Public Sector | `#C41E3A` Red | Authoritative, dignified |
**Color rules**: 60-30-10 rule (primary 60%, secondary 30%, accent 10%); text contrast ratio >= 4.5:1; no more than 4 colors per page.
### f. Icon Usage Confirmation
| Option | Approach | Suitable Scenarios |
|--------|----------|-------------------|
| **A** | Emoji | Casual, playful, social media |
| **B** | AI-generated | Custom style needed |
| **C** | Built-in icon library | Professional scenarios (recommended) |
| **D** | Custom icons | Has brand assets |
Built-in library contains 640+ icons; see `templates/icons/README.md`.
> **Mandatory rules when choosing C**:
> 1. Consult `templates/icons/icons_index.json` to verify icon existence
> 2. Icon names are single names (e.g., `factory`), no path prefixes
> 3. Using names not in the index is FORBIDDEN
> 4. List the final icon inventory in the Design Spec; Executor may only use icons from this list
>
> **Quick lookup**: By category → `icons_index.json` `categories`; by semantics → `quickLookup`; full list → `templates/icons/FULL_INDEX.md`
### g. Typography Plan Confirmation (Font + Size)
#### Font Presets
| Scenario | Preset | Title | Body | Emphasis |
|----------|--------|-------|------|----------|
| Modern business, tech | P1 | Microsoft YaHei / Arial | Microsoft YaHei / Calibri | SimHei |
| Government documents, reports | P2 | SimHei | SimSun / Times | SimSun |
| Culture, arts, humanities | P3 | KaiTi / Georgia | Microsoft YaHei | SimHei |
| Traditional, conservative | P4 | SimSun | Microsoft YaHei / Arial | SimSun |
| English-primary | P5 | Arial / Impact | Calibri / Georgia | Arial Black |
#### Font Size Baseline (all sizes in px)
Selection principle: Font size is based on **content density**, not design style.
| Content Density | Points per Page | Body Baseline | Suitable Scenarios |
|----------------|----------------|---------------|-------------------|
| Relaxed | 3-5 items | 24px | Keynote-style, training materials |
| Dense | 6+ items | 18px | Data reports, consulting analysis |
| Level | Ratio | 24px Baseline | 18px Baseline |
|-------|-------|---------------|---------------|
| Cover title | 2.5-3x | 60-72px | 45-54px |
| Page title | 1.5-2x | 36-48px | 27-36px |
| **Body** | **1x** | **24px** | **18px** |
| Annotation | 0.75x | 18px | 14px |
### h. Image Usage Confirmation
| Option | Approach | Suitable Scenarios |
|--------|----------|-------------------|
| **A** | No images | Data reports, process documentation |
| **B** | User-provided | Has existing image assets |
| **C** | AI-generated | Custom illustrations, backgrounds needed |
| **D** | Placeholders | Images to be added later |
**When selection includes B**, you must run `python3 scripts/analyze_images.py <project_path>/images` before outputting the spec, and integrate scan results into the image resource list.
**When B/C/D is selected**, add an image resource list to the spec:
| Column | Description |
|--------|-------------|
| Filename | e.g., `cover_bg.png` |
| Dimensions | e.g., `1280x720` |
| Ratio | e.g., `1.78` |
| Layout suggestion | e.g., `Wide landscape (suitable for full-screen/illustration)` |
| Purpose | e.g., `Cover background` |
| Type | Background / Photography / Illustration / Diagram / Decorative pattern |
| Status | Pending generation / Existing / Placeholder |
| Generation description | Fill in detailed description for AI generation |
**Image type descriptions**:
| Type | Suitable Scenarios |
|------|-------------------|
| Background | Full-page backgrounds for covers/chapter pages; reserve text area |
| Photography | Real scenes, people, products, architecture |
| Illustration | Flat design, vector style, concept diagrams |
| Diagram | Flowcharts, architecture diagrams, concept relationship maps |
| Decorative pattern | Partial decoration, textures, borders, divider elements |
**Image-layout alignment principles** (detailed calculation rules in `references/image-layout-spec.md`):
| Image Ratio | Recommended Layout |
|-------------|-------------------|
| > 2.0 (ultra-wide) | Top-bottom split, top full-width |
| 1.5-2.0 (wide) | Top-bottom split |
| 1.2-1.5 (standard landscape) | Left-right split |
| 0.8-1.2 (square) | Left-right split |
| < 0.8 (portrait) | Left-right split, image on left |
Core logic: The layout container's aspect ratio must closely match the image's original ratio. Never force a wide image into a square container or a portrait image into a narrow horizontal strip.
> **Pipeline handoff**: When C) AI generation is selected, after outputting the design spec, prompt the user to invoke Image_Generator. Once images are collected in `images/`, proceed to Executor.
### Chart Reference (Non-blocking — Strategist recommends, no user confirmation needed)
When content outline pages involve **data visualization** (comparisons, trends, proportions, KPIs, flows, strategic frameworks, etc.), consult the chart template library to select appropriate chart types.
Built-in library contains 33 chart templates; see `templates/charts/charts_index.json`.
> **Selection workflow**:
> 1. Identify pages that need data visualization during content planning
> 2. Consult `charts_index.json` — by analysis goal → `quickLookup`; by category → `categories`
> 3. Review `bestFor` / `avoidFor` to confirm the chart type fits the data characteristics
> 4. List all selected charts in Design Spec **section VII (Chart Reference List)** as a centralized reference; in section IX Content Outline, each page only needs to note the chart type name
>
> **Quick lookup by goal**:
> - Ranking/comparison → `bar_chart`, `horizontal_bar_chart`, `grouped_bar_chart`
> - Trends over time → `line_chart`, `area_chart`, `dual_axis_line_chart`
> - Proportions → `donut_chart`, `pie_chart`, `treemap_chart`
> - KPIs/targets → `kpi_cards`, `bullet_chart`, `gauge_chart`
> - Conversion/flow → `funnel_chart`, `sankey_chart`, `waterfall_chart`
> - Strategy → `swot_analysis`, `porter_five_forces`, `matrix_2x2`
### Speaker Notes Requirements (Default — no discussion needed)
- File naming: Recommended to match SVG names (`01_cover.svg` → `notes/01_cover.md`), also compatible with `notes/slide01.md`
- Fill in the Design Spec: total presentation duration, notes style (formal / conversational / interactive), presentation purpose (inform / persuade / inspire / instruct / report)
- Split note files must NOT contain `#` heading lines (`notes/total.md` master document MUST use `#` heading lines)
---
## 2. Executor Style Details (Reference for Confirmation Item #4)
### A) General Versatile — Executor_General
**Unique capabilities**:
- Full-width images + gradient overlays (essential for promotions)
- Free creative layouts (not grid-constrained)
- Three style variants: image-text hybrid, minimalist keynote, creative design
**Typical scenarios**: Investment promotion, product launches, training materials, brand campaigns
**Avoid**: Overly rigid/formal, dense data tables
### B) General Consulting — Executor_Consultant
**Unique capabilities**:
- KPI dashboards (4-card layout, large numbers + trend arrows)
- Professional chart combinations (bar, line, pie, funnel)
- Data color grading (red/yellow/green status indicators)
**Typical scenarios**: Progress reports, financial analysis, government reports, proposals/bids
**Avoid**: Flashy decorations, image-dominated slides
### C) Top Consulting — Executor_Consultant_Top
**Unique capabilities**:
| Capability | Description |
|-----------|-------------|
| Data contextualization | Every data point must have a comparison ("grew 63% — industry average only 12%") |
| SCQA framework | Situation → Complication → Question → Answer |
| Pyramid principle | Conclusion first; core insight in the title position |
| Strategic coloring | Colors serve information, not decoration |
| Chart vs Table | Trends → charts; precise values → tables |
**Unique page elements**: Gradient top bar + dark takeaway box, confidential marking + rigorous footer, MECE decomposition / driver tree / waterfall chart
**Typical scenarios**: Strategic decision reports, deep analysis reports, consulting deliverables (MBB level)
**Avoid**: Isolated data, subjective statements, decorative elements
---
## 3. Color Knowledge Base
### Consulting Style Colors (Professional Authority)
| Brand / Style | HEX | Psychological Feel |
|---------------|-----|-------------------|
| Deloitte Blue | `#0076A8` | Professional, reliable |
| McKinsey Blue | `#005587` | Authoritative, deep |
| BCG Dark Blue | `#003F6C` | Stable, trustworthy |
| PwC Orange | `#D04A02` | Energetic, innovative |
| EY Yellow | `#FFE600` | Optimistic, clear |
### General Versatile Colors (Modern Energy)
| Style | HEX | Suitable Scenarios |
|-------|-----|-------------------|
| Tech Blue | `#2196F3` | Technology, internet |
| Vibrant Orange | `#FF9800` | Marketing, promotion |
| Growth Green | `#4CAF50` | Health, environmental, growth |
| Professional Purple | `#9C27B0` | Creative, premium |
| Alert Red | `#F44336` | Urgent, important |
### Data Visualization Colors
- Positive trend (green): `#2E7D32` → `#4CAF50` → `#81C784`
- Warning trend (yellow): `#F57C00` → `#FFA726` → `#FFD54F`
- Negative trend (red): `#C62828` → `#EF5350` → `#E57373`
---
## 4. Layout Pattern Quick Reference
| Layout | Suitable Scenarios | PPT 16:9 Reference Dimensions |
|--------|-------------------|-------------------------------|
| Single column centered | Covers, conclusions, key points | Content width 800-1000px, horizontally centered |
| Two-column | Comparative analysis, left-image right-text | Column ratio 1:1 or 3:2, gap 40-60px |
| Three-column | Parallel points, process steps | Column ratio 1:1:1, gap 30-40px |
| Four-quadrant | Matrix analysis, classification | Quadrant 560x250px, gap 20-30px |
| Top-bottom split | Ultra-wide images + text | Image full-width, text area >= 150px height |
| Left-right split | Standard/portrait images + text | Image on side, text area >= 280px width |
**PPT 16:9 (1280x720) key dimensions**: Safe area 1200x640 (40px margins); Title area 1200x100; Content area 1200x500; Footer area 1200x40.
---
## 5. Template Flexibility Principle
> Templates are starting points, not endpoints.
The Strategist should make professional judgments on the template basis generated by `scripts/project_manager.py`, considering user needs, content characteristics, and audience:
1. Ratio systems are adjustable (font size ratios are reference values)
2. Color schemes are customizable (based on brand and content)
3. Layout modes can be combined (6 base layouts with free variation)
4. Content structure is extensible (12-chapter framework can be expanded or reduced)
5. Spacing / border radius details adjusted by Executor based on content density
---
## 6. Workflow & Deliverables
### 6.1 Content Planning Strategy
| Style | Content Outline | Design Spec | Speaker Notes |
|-------|----------------|-------------|---------------|
| A) General Versatile | Intelligently deconstruct source doc; define core theme per page | Visual theme, color scheme, layout principles | Concise presentation script |
| B) General Consulting | Structured logical sections; data-driven insights | Consulting-style colors, structured content layout | Professional terms, data interpretation, conclusion-first |
| C) Top Consulting | SCQA framework, pyramid principle conclusion-first | Data contextualization, strategic color usage | Highly condensed, logically rigorous, conclusion-driven |
### 6.2 Outline Output Specification (Must include 12 chapters)
| Chapter | Content Requirements |
|---------|---------------------|
| I. Project Information | Project name, canvas format, page count, style, audience, scenario, date |
| II. Canvas Specification | Format, dimensions, viewBox, margins, content area |
| III. Visual Theme | Style description, light/dark theme, tone, color scheme (with HEX table), gradient scheme |
| IV. Typography System | Font plan (P1-P5), font size hierarchy (H1-Code, 7 levels) |
| V. Layout Principles | Page structure (header/content/footer zones), 6 layout modes, spacing spec |
| VI. Icon Usage Spec | Source description, placeholder syntax, recommended icon list |
| VII. Chart Reference List | Chart type, reference template path, used-in pages, purpose |
| VIII. Image Resource List | Filename, dimensions, ratio, purpose, status, generation description |
| IX. Content Outline | Grouped by chapter; each page includes layout, title, content points, chart type (if applicable) |
| X. Speaker Notes Requirements | File naming rules, content structure description |
| XI. Technical Constraints Reminder | SVG generation rules, PPT compatibility rules |
| XII. Design Checklist | Pre-generation / post-generation check items |
| XIII. Next Steps | Clarify subsequent pipeline (Image_Generator or Executor) |
**Generation steps**:
1. Read reference template: `templates/design_spec_reference.md`
2. Generate complete spec from scratch based on analysis
3. Save to: `projects/<project_name>.../design_spec.md`
---
## 7. Project Folder
The project folder should be created before entering the Strategist role. If not yet created, execute:
```bash
python3 scripts/project_manager.py init <project_name> --format <canvas_format>
```
The Strategist saves the Design Specification & Content Outline to `projects/<project_name>_<format>_<YYYYMMDD>/design_spec.md`.
---
## 8. Complete Design Spec and Prompt Next Steps
Prompt the next step based on the confirmed template option and image usage selection.
### Template Option A (Using existing template)
```
✅ Design spec complete. Template ready.
Next step:
- Images include AI generation → Invoke Image_Generator
- Images do not include AI generation → Invoke Executor
```
### Template Option B (No template)
```
✅ Design spec complete.
Next step:
- Images include AI generation → Invoke Image_Generator
- Images do not include AI generation → Invoke Executor (free design for every page)
```
references/svg-image-embedding.md
> See shared-standards.md for common technical constraints.
# SVG Image Embedding Guide
Technical specifications and recommended workflow for adding images to SVG files.
---
## Image Resource List Format
Defined in the Design Specification & Content Outline; each image has a status annotation. If the image approach includes "B) User-provided", you must run `analyze_images.py` immediately after the Strategist completes the Eight Confirmations, and complete the list before outputting the design spec.
```markdown
| Filename | Dimensions | Purpose | Status | Generation Description |
|----------|-----------|---------|--------|----------------------|
| cover_bg.png | 1280x720 | Cover background | Pending | Modern tech abstract background, deep blue gradient |
| product.png | 600x400 | Page 3 | Existing | - |
| team.png | 600x400 | Page 5 | Placeholder | Team collaboration scene (to be added later) |
```
### Three Status Types
| Status | Meaning | Executor Handling |
|--------|---------|-------------------|
| **Pending** | Needs AI generation, has description | Generate image first into `images/`, then reference with `<image>` |
| **Existing** | User already has image | Place in `images/`, reference with `<image>` |
| **Placeholder** | Not yet processed | Use dashed border placeholder; replace later |
---
## Workflow
```
1. Strategist defines image needs → Add image resource list, annotate each status
2. Image preparation (pending/existing) → Place in project/images/
3. Executor generates SVGs (svg_output/)
├── Existing/Pending → <image href="../images/xxx.png" .../>
└── Placeholder → Dashed border + description text
4. Preview: python3 -m http.server -d <project_path> 8000 → /svg_output/<filename>.svg
5. Post-processing & Export
├── python3 scripts/finalize_svg.py <project_path>
└── python3 scripts/svg_to_pptx.py <project_path> -s final
```
> Recommended: During generation, keep external references in `svg_output/`. Post-processing via `finalize_svg.py` auto-embeds images into `svg_final/`, then export PPTX from `svg_final/`.
---
## External Reference vs Base64 Embedding
| Method | Pros | Cons | Suitable For |
|--------|------|------|-------------|
| **External reference** | Small file size, fast iteration, easy to replace | Preview requires HTTP server from project root | `svg_output/` development phase |
| **Base64 embedding** | Self-contained file, stable export | Large file size | `svg_final/` delivery phase |
---
## Method 1: External Reference (Recommended for Generation Phase)
### Syntax
```xml
<image href="../images/image.png" x="0" y="0" width="1280" height="720"
preserveAspectRatio="xMidYMid slice"/>
```
### Key Attributes
| Attribute | Description | Example |
|-----------|-------------|---------|
| `href` | Image path (relative or absolute) | `"../images/cover.png"` |
| `x`, `y` | Image top-left corner position | `x="0" y="0"` |
| `width`, `height` | Image display dimensions | `width="1280" height="720"` |
| `preserveAspectRatio` | Scaling mode | `"xMidYMid slice"` |
### preserveAspectRatio Common Values
| Value | Effect |
|-------|--------|
| `xMidYMid slice` | Center crop (similar to CSS `cover`) |
| `xMidYMid meet` | Complete display (similar to CSS `contain`) |
| `none` | Stretch to fill, no aspect ratio preservation |
### Preview Method
Browser security restrictions prevent loading external images from directly opened SVGs. Start an HTTP server from the project root:
```bash
python3 -m http.server -d <project_path> 8000
# Visit http://localhost:8000/svg_output/your_file.svg
```
---
## Method 2: Base64 Embedding (Recommended for Delivery Phase)
### Syntax
```xml
<image href="data:image/png;base64,iVBORw0KGgo..." x="0" y="0" width="1280" height="720"/>
```
### MIME Types
| MIME Type | File Format |
|-----------|-------------|
| `image/png` | PNG |
| `image/jpeg` | JPG/JPEG |
| `image/gif` | GIF |
| `image/webp` | WebP |
| `image/svg+xml` | SVG |
---
## Conversion Process
### Recommended: Use finalize_svg.py (Unified Pipeline)
```bash
python3 scripts/finalize_svg.py <project_path> # Icons, images, text, rounded rects — all in one pass
python3 scripts/svg_to_pptx.py <project_path> -s final # Export PPTX from final version
```
### Standalone: embed_images.py (Advanced Usage)
For processing specific SVGs without running the full pipeline:
```bash
python3 scripts/embed_images.py <svg_file> # Single file
python3 scripts/embed_images.py <project_path>/svg_output/*.svg # Batch
python3 scripts/embed_images.py --dry-run <project_path>/svg_output/*.svg # Preview
```
---
## Best Practices
### Image Optimization
Compress images before embedding to reduce file size:
```bash
convert input.png -quality 85 -resize 1920x1080\> output.png # ImageMagick
pngquant --quality=65-80 input.png -o output.png # pngquant (recommended)
```
### File Organization
```
project/
├── images/ # Image assets
├── sources/ # Source files and their accompanying images
│ └── article_files/
├── svg_output/ # Raw version (external references)
└── svg_final/ # Final version (images embedded)
```
### Rounded Corner Handling (clipPath Forbidden)
Since `clipPath` is incompatible with PPT, clipping paths for image rounded corners are FORBIDDEN. Alternatives:
- Process rounded corners during image generation (export PNG with rounded corners)
- Or overlay a same-size rounded rectangle over the edges (visual simulation)
---
## FAQ
**Q: Can't see images when opening SVG directly?**
Browser security policy blocks cross-directory requests. Start an HTTP server from the project root, or run `finalize_svg.py` first then view from `svg_final/`.
**Q: Base64 file too large?**
Compress the original image, use JPEG format, reduce resolution (match actual display dimensions).
**Q: How to reverse-extract a Base64 image?**
```bash
base64 -d image.b64 > image.png
```
references/template-designer.md
> See shared-standards.md for common technical constraints.
# Template Designer — Template Design Role
## Core Mission
Generate project-specific page templates based on the Design Specification & Content Outline, for user confirmation before batch content generation.
> This is a standalone role: only triggered via the `/create-template` workflow; not used in the PPT generation pipeline.
## Usage
- **Trigger**: `/create-template` workflow
- **Output location**: `templates/layouts/<template_name>/`
---
## Core Template Inventory
| # | Filename | Purpose | Description |
|---|----------|---------|-------------|
| 01 | `01_cover.svg` | Cover | Fixed structure: title, subtitle, date, organization |
| 02 | `02_chapter.svg` | Chapter page | Fixed structure: chapter number, chapter title |
| 03 | `03_content.svg` | Content page | Flexible structure: only defines header/footer; content area freely laid out by AI |
| 04 | `04_ending.svg` | Ending page | Fixed structure: thank-you message, contact info |
| -- | `02_toc.svg` | Table of contents | Optional: TOC title, chapter list (number + title) |
**Design philosophy**: Templates define visual consistency and structural pages; content pages maintain maximum flexibility.
**Naming note**: TOC page keeps `02_toc.svg` naming for template library compatibility and sort order.
### Optional Extension Pages (As Needed)
- Transition / sub-section page (e.g., `05_section_break.svg`)
- Appendix page (e.g., `06_appendix.svg`)
- Disclaimer / confidentiality page (e.g., `07_disclaimer.svg`)
---
## Template Design Specifications
### 1. Must Generate design_spec.md
When creating a global template, a `design_spec.md` must be generated, containing:
```markdown
# [Template Name] - Design Specification
## I. Template Overview (name, use cases, design tone)
## II. Canvas Specification (16:9, 1280x720, viewBox)
## III. Color Scheme (primary, secondary, accent HEX values)
## IV. Typography System (font stack, font size hierarchy)
## V. Page Structure (common layout, decorative design)
## VI. Page Types (4 core page types)
## VII. Layout Modes (recommended)
## VIII. Spacing Specification
## IX. SVG Technical Constraints
## X. Placeholder Specification
```
### 2. Inherit Design Specification
Templates must strictly follow the Design Specification & Content Outline:
- **Canvas dimensions**: viewBox matches the design spec
- **Color scheme**: Uses primary, secondary, and accent colors from the spec
- **Font plan**: Uses font presets from the spec
- **Layout principles**: Margins and spacing conform to the spec
### 3. Placeholder Markers
Use clear placeholder markers for replaceable content:
```xml
<!-- Text placeholder -->
<text x="80" y="320" fill="#FFFFFF" font-size="48" font-weight="bold">
{{TITLE}}
</text>
<!-- Content area placeholder (content page only) -->
<rect x="40" y="90" width="1200" height="550" fill="#FFFFFF" rx="8"/>
<text x="640" y="365" text-anchor="middle" fill="#CBD5E1" font-size="16">
{{CONTENT_AREA}}
</text>
```
### 4. Placeholder Reference
| Placeholder | Purpose | Applicable Template |
|------------|---------|-------------------|
| `{{TITLE}}` | Main title | Cover |
| `{{SUBTITLE}}` | Subtitle | Cover |
| `{{DATE}}` | Date | Cover |
| `{{AUTHOR}}` | Author / Organization | Cover |
| `{{TOC_TITLE}}` | TOC title | TOC page |
| `{{TOC_ITEMS}}` | TOC item list | TOC page |
| `{{CHAPTER_NUM}}` | Chapter number | Chapter page |
| `{{CHAPTER_TITLE}}` | Chapter title | Chapter page |
| `{{CHAPTER_DESC}}` | Chapter description | Chapter page |
| `{{PAGE_TITLE}}` | Page title | Content page |
| `{{KEY_MESSAGE}}` | Key takeaway | Content page (consulting style) |
| `{{CONTENT_AREA}}` | Content area | Content page |
| `{{SECTION_NAME}}` | Section name | Content page footer |
| `{{SOURCE}}` | Data source | Content page footer |
| `{{PAGE_NUM}}` | Page number | Content page, ending page |
| `{{THANK_YOU}}` | Thank-you message | Ending page |
| `{{TAGLINE}}` | Tagline / slogan | Ending page |
| `{{CONTACT_INFO}}` | Contact info | Ending page |
| `{{COPYRIGHT}}` | Copyright | Ending page |
---
## Output Requirements
### File Save Location
```
templates/layouts/<template_name>/
├── design_spec.md # Design specification (required)
├── 01_cover.svg
├── 02_chapter.svg
├── 02_toc.svg # Optional
├── 03_content.svg
├── 04_ending.svg
└── *.png / *.jpg # Image assets (if any)
```
### Template Preview
After each template is generated, provide a brief summary table listing each template's status.
---
## Using Pre-built Template Library (Optional)
If suitable template resources already exist, use them directly instead of generating new ones:
1. **Copy template**: Copy template files to the project's `templates/` directory
2. **Adjust colors**: Modify colors per the project design spec
3. **Customize**: Make project-specific adjustments
**Example library structure** (query `templates/layouts/layouts_index.json`):
```
templates/layouts/
├── exhibit/ # Exhibit style (conclusion-first, data-driven)
├── 科技蓝商务/ # Tech blue business style
└── smart_red/ # Smart red-orange style
```
---
## Phase Completion Checkpoint
```markdown
## Template_Designer Phase Complete
- [x] Read `references/template-designer.md`
- [x] Generated 4 core page templates
- [ ] TOC page template (optional)
- [ ] Optional extension pages (if needed)
- [x] All templates saved to `templates/` directory
- [x] Templates follow design spec (colors, fonts, layout)
- [x] Placeholder markers are clear and standardized
- [ ] **Next step**: Request user confirmation of templates
```
scripts/analyze_images.py
#!/usr/bin/env python3
"""
Image Size Analysis Tool
========================
Analyzes width, height, and aspect ratio of all images in a specified folder,
and outputs PPT layout recommendations.
Usage:
python scripts/analyze_images.py <images_folder_path>
python scripts/analyze_images.py projects/xxx/images
Output:
- Analysis report displayed in console
- Generates image_analysis.csv in the parent directory of the images folder
"""
import os
import sys
from pathlib import Path
try:
from PIL import Image
except ImportError:
print("Error: PIL/Pillow not installed. Run: pip install Pillow")
sys.exit(1)
def analyze_images(images_dir):
"""Analyze size information of all images in the specified directory"""
# Supported image formats
image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'}
results = []
# Iterate through all files in the directory
for filename in sorted(os.listdir(images_dir)):
filepath = os.path.join(images_dir, filename)
# Check if it is an image file
if os.path.isfile(filepath) and Path(filename).suffix.lower() in image_extensions:
try:
with Image.open(filepath) as img:
width, height = img.size
aspect_ratio = width / height
# Determine PPT layout recommendation
if aspect_ratio > 1.5:
layout_hint = "Wide landscape"
elif aspect_ratio > 1.2:
layout_hint = "Standard landscape"
elif aspect_ratio > 0.8:
layout_hint = "Near square"
elif aspect_ratio > 0.6:
layout_hint = "Standard portrait"
else:
layout_hint = "Narrow portrait"
results.append({
'filename': filename,
'width': width,
'height': height,
'aspect_ratio': aspect_ratio,
'layout_hint': layout_hint,
'filesize_kb': os.path.getsize(filepath) / 1024
})
except Exception as e:
print(f"[WARN] Cannot read {filename}: {e}")
return results
def print_results(results):
"""Print analysis results"""
print("\n" + "="*100)
print("Image Size Analysis Report")
print("="*100)
# Table header
print(f"\n{'No.':<4} {'Width':<7} {'Height':<7} {'Ratio':<7} {'Size':<10} {'Layout':<15} {'Filename'}")
print("-"*100)
for i, img in enumerate(results, 1):
print(f"{i:<4} {img['width']:<7} {img['height']:<7} {img['aspect_ratio']:<7.2f} {img['filesize_kb']:<10.1f}KB {img['layout_hint']:<15} {img['filename'][:40]}")
print("-"*100)
print(f"Total: {len(results)} images\n")
# Group statistics by aspect ratio
print("\nGroup by Aspect Ratio:")
print("-"*50)
categories = {
"Wide (>1.5)": [],
"Standard (1.2-1.5)": [],
"Square (0.8-1.2)": [],
"Portrait (0.6-0.8)": [],
"Narrow (<0.6)": []
}
for img in results:
ar = img['aspect_ratio']
if ar > 1.5:
categories["Wide (>1.5)"].append(img)
elif ar > 1.2:
categories["Standard (1.2-1.5)"].append(img)
elif ar > 0.8:
categories["Square (0.8-1.2)"].append(img)
elif ar > 0.6:
categories["Portrait (0.6-0.8)"].append(img)
else:
categories["Narrow (<0.6)"].append(img)
for cat, imgs in categories.items():
if imgs:
print(f"\\n{cat}: {len(imgs)} images")
for img in imgs[:5]: # Show only the first 5
print(f" - {img['width']}x{img['height']} (ratio {img['aspect_ratio']:.2f}) - {img['filename'][:35]}...")
if len(imgs) > 5:
print(f" ... and {len(imgs) - 5} more")
# PPT layout recommendations
print("\\n" + "="*100)
print("PPT Fit Suggestions (16:9 = 1280x720)")
print("="*100)
ppt_width, ppt_height = 1280, 720
ppt_ratio = ppt_width / ppt_height
print(f"\\nStandard PPT canvas: {ppt_width}x{ppt_height} (ratio {ppt_ratio:.2f})")
fit_count = 0
for img in results:
if 1.5 <= img['aspect_ratio'] <= 2.0:
fit_count += 1
print(f"\\nImages suitable for full-screen display: {fit_count}")
def generate_markdown(results):
"""Generate a Markdown-format image resource inventory"""
print("\\n" + "="*100)
print("Markdown Snippet for Strategist (Copy & Paste)")
print("="*100)
print("\\n## Image Resource Inventory (Auto-scan Results)\\n")
print("| Filename | Size | Ratio | Layout Suggestion | Usage | Status | Generation Description |")
print("|----------|------|-------|-------------------|-------|--------|-----------------------|")
for img in results:
# Simplify layout suggestion description to match Strategist conventions
layout_desc = img['layout_hint']
if img['aspect_ratio'] > 2.0:
layout_desc += " (suitable for full-width/split)"
elif img['aspect_ratio'] > 1.2:
layout_desc += " (suitable for full-screen/illustration)"
elif img['aspect_ratio'] < 0.8:
layout_desc += " (suitable for side-by-side columns)"
# Usage and generation description to be filled in by the strategist; placeholders used here
print(f"| {img['filename']} | {img['width']}x{img['height']} | {img['aspect_ratio']:.2f} | {layout_desc} | (to be filled) | Existing | - |")
print("\\n" + "="*100 + "\\n")
def save_csv(results, csv_path):
"""Save results to a CSV file"""
with open(csv_path, 'w', encoding='utf-8') as f:
f.write("No,Filename,Width,Height,AspectRatio,SizeKB,Layout\n")
for i, img in enumerate(results, 1):
f.write(f"{i},{img['filename']},{img['width']},{img['height']},{img['aspect_ratio']:.2f},{img['filesize_kb']:.1f},{img['layout_hint']}\n")
print(f"\nCSV saved to: {csv_path}")
def main():
if len(sys.argv) < 2:
print("Usage: python analyze_images.py <images_folder_path>")
print("Example: python analyze_images.py projects/myproject/images")
print(" python analyze_images.py C:/path/to/images")
sys.exit(1)
images_dir = sys.argv[1]
# Convert to absolute path
images_dir = os.path.abspath(images_dir)
if not os.path.exists(images_dir):
print(f"Error: Directory not found: {images_dir}")
sys.exit(1)
if not os.path.isdir(images_dir):
print(f"Error: Not a directory: {images_dir}")
sys.exit(1)
print(f"Analyzing: {images_dir}")
results = analyze_images(images_dir)
if results:
print_results(results)
generate_markdown(results)
# Save to CSV file (saved in the parent directory of the images folder)
parent_dir = os.path.dirname(images_dir)
csv_path = os.path.join(parent_dir, "image_analysis.csv")
save_csv(results, csv_path)
else:
print("No image files found in the directory.")
if __name__ == "__main__":
main()
scripts/batch_validate.py
#!/usr/bin/env python3
"""
PPT Master - Batch Project Validation Tool
Checks the structural integrity and compliance of multiple projects at once.
Usage:
python3 scripts/batch_validate.py examples
python3 scripts/batch_validate.py projects
python3 scripts/batch_validate.py --all
python3 scripts/batch_validate.py examples projects
"""
import sys
from pathlib import Path
from typing import List, Dict
from collections import defaultdict
try:
from project_utils import (
find_all_projects,
get_project_info,
validate_project_structure,
validate_svg_viewbox,
CANVAS_FORMATS
)
except ImportError:
print("Error: Unable to import project_utils module")
print("Please ensure project_utils.py is in the same directory")
sys.exit(1)
class BatchValidator:
"""Batch validator"""
def __init__(self):
self.results = []
self.summary = {
'total': 0,
'valid': 0,
'has_errors': 0,
'has_warnings': 0,
'missing_readme': 0,
'missing_spec': 0,
'svg_issues': 0
}
def validate_directory(self, directory: str, recursive: bool = False) -> List[Dict]:
"""
Validate all projects in a directory
Args:
directory: Directory path
recursive: Whether to recursively search subdirectories
Returns:
List of validation results
"""
dir_path = Path(directory)
if not dir_path.exists():
print(f"[ERROR] Directory does not exist: {directory}")
return []
print(f"\n[SCAN] Scanning directory: {directory}")
print("=" * 80)
projects = find_all_projects(directory)
if not projects:
print(f"[WARN] No projects found")
return []
print(f"Found {len(projects)} project(s)\n")
for project_path in projects:
self.validate_project(str(project_path))
return self.results
def validate_project(self, project_path: str) -> Dict:
"""
Validate a single project
Args:
project_path: Project path
Returns:
Validation result dictionary
"""
self.summary['total'] += 1
# Get project info
info = get_project_info(project_path)
# Validate project structure
is_valid, errors, warnings = validate_project_structure(project_path)
# Validate SVG viewBox
svg_warnings = []
if info['svg_files']:
project_path_obj = Path(project_path)
svg_files = [project_path_obj / 'svg_output' /
f for f in info['svg_files']]
svg_warnings = validate_svg_viewbox(svg_files, info['format'])
# Aggregate results
result = {
'path': project_path,
'name': info['name'],
'format': info['format_name'],
'date': info['date_formatted'],
'svg_count': info['svg_count'],
'is_valid': is_valid,
'errors': errors,
'warnings': warnings + svg_warnings,
'has_readme': info['has_readme'],
'has_spec': info['has_spec']
}
self.results.append(result)
# Update statistics
if is_valid and not warnings and not svg_warnings:
self.summary['valid'] += 1
status = "[OK]"
elif errors:
self.summary['has_errors'] += 1
status = "[ERROR]"
else:
self.summary['has_warnings'] += 1
status = "[WARN]"
if not info['has_readme']:
self.summary['missing_readme'] += 1
if not info['has_spec']:
self.summary['missing_spec'] += 1
if svg_warnings:
self.summary['svg_issues'] += 1
# Print result
print(f"{status} {info['name']}")
print(f" Path: {project_path}")
print(
f" Format: {info['format_name']} | SVG: {info['svg_count']} file(s) | Date: {info['date_formatted']}")
if errors:
print(f" [ERROR] Errors ({len(errors)}):")
for error in errors:
print(f" - {error}")
if warnings or svg_warnings:
all_warnings = warnings + svg_warnings
print(f" [WARN] Warnings ({len(all_warnings)}):")
for warning in all_warnings[:3]: # Only show first 3 warnings
print(f" - {warning}")
if len(all_warnings) > 3:
print(f" ... and {len(all_warnings) - 3} more warning(s)")
print()
return result
def print_summary(self):
"""Print validation summary"""
print("\n" + "=" * 80)
print("[Summary] Validation Summary")
print("=" * 80)
print(f"\nTotal projects: {self.summary['total']}")
print(
f" [OK] Fully valid: {self.summary['valid']} ({self._percentage(self.summary['valid'])}%)")
print(
f" [WARN] With warnings: {self.summary['has_warnings']} ({self._percentage(self.summary['has_warnings'])}%)")
print(
f" [ERROR] With errors: {self.summary['has_errors']} ({self._percentage(self.summary['has_errors'])}%)")
print(f"\nCommon issues:")
print(f" Missing README.md: {self.summary['missing_readme']} project(s)")
print(f" Missing design spec: {self.summary['missing_spec']} project(s)")
print(f" SVG format issues: {self.summary['svg_issues']} project(s)")
# Group statistics by format
format_stats = defaultdict(int)
for result in self.results:
format_stats[result['format']] += 1
if format_stats:
print(f"\nCanvas format distribution:")
for fmt, count in sorted(format_stats.items(), key=lambda x: x[1], reverse=True):
print(f" {fmt}: {count} project(s)")
# Provide fix suggestions
if self.summary['has_errors'] > 0 or self.summary['has_warnings'] > 0:
print(f"\n[TIP] Fix suggestions:")
if self.summary['missing_readme'] > 0:
print(f" 1. Create documentation for projects missing README")
print(
f" Reference: examples/google_annual_report_ppt169_20251116/README.md")
if self.summary['svg_issues'] > 0:
print(f" 2. Check and fix SVG viewBox settings")
print(f" Ensure consistency with canvas format")
if self.summary['missing_spec'] > 0:
print(f" 3. Add design specification files")
def _percentage(self, count: int) -> int:
"""Calculate percentage"""
if self.summary['total'] == 0:
return 0
return int(count / self.summary['total'] * 100)
def export_report(self, output_file: str = 'validation_report.txt'):
"""
Export validation report to file
Args:
output_file: Output file path
"""
with open(output_file, 'w', encoding='utf-8') as f:
f.write("PPT Master Project Validation Report\n")
f.write("=" * 80 + "\n\n")
for result in self.results:
status = "[OK] Valid" if result['is_valid'] and not result['warnings'] else \
"[ERROR] Error" if result['errors'] else "[WARN] Warning"
f.write(f"{status} - {result['name']}\n")
f.write(f"Path: {result['path']}\n")
f.write(
f"Format: {result['format']} | SVG: {result['svg_count']} file(s)\n")
if result['errors']:
f.write(f"\nErrors:\n")
for error in result['errors']:
f.write(f" - {error}\n")
if result['warnings']:
f.write(f"\nWarnings:\n")
for warning in result['warnings']:
f.write(f" - {warning}\n")
f.write("\n" + "-" * 80 + "\n\n")
# Write summary
f.write("\n" + "=" * 80 + "\n")
f.write("Validation Summary\n")
f.write("=" * 80 + "\n\n")
f.write(f"Total projects: {self.summary['total']}\n")
f.write(f"Fully valid: {self.summary['valid']}\n")
f.write(f"With warnings: {self.summary['has_warnings']}\n")
f.write(f"With errors: {self.summary['has_errors']}\n")
print(f"\n[REPORT] Validation report exported: {output_file}")
def main():
"""Main function"""
if len(sys.argv) < 2:
print("PPT Master - Batch Project Validation Tool\n")
print("Usage:")
print(" python3 scripts/batch_validate.py <directory>")
print(" python3 scripts/batch_validate.py <dir1> <dir2> ...")
print(" python3 scripts/batch_validate.py --all")
print("\nExamples:")
print(" python3 scripts/batch_validate.py examples")
print(" python3 scripts/batch_validate.py projects")
print(" python3 scripts/batch_validate.py examples projects")
print(" python3 scripts/batch_validate.py --all")
sys.exit(0)
validator = BatchValidator()
# Process arguments
if '--all' in sys.argv:
directories = ['examples', 'projects']
else:
directories = [arg for arg in sys.argv[1:] if not arg.startswith('--')]
# Validate each directory
for directory in directories:
if Path(directory).exists():
validator.validate_directory(directory)
else:
print(f"[WARN] Skipping non-existent directory: {directory}\n")
# Print summary
validator.print_summary()
# Export report (if specified)
if '--export' in sys.argv:
output_file = 'validation_report.txt'
if '--output' in sys.argv:
idx = sys.argv.index('--output')
if idx + 1 < len(sys.argv):
output_file = sys.argv[idx + 1]
validator.export_report(output_file)
# Return exit code
if validator.summary['has_errors'] > 0:
sys.exit(1)
elif validator.summary['has_warnings'] > 0:
sys.exit(2)
else:
sys.exit(0)
if __name__ == '__main__':
main()
scripts/config.py
#!/usr/bin/env python3
"""
PPT Master - Unified Configuration Management Module
Centrally manages all project configuration items to ensure consistency and maintainability.
Usage:
from config import Config, CANVAS_FORMATS, DESIGN_COLORS
# Get canvas format
ppt169 = Config.get_canvas_format('ppt169')
# Get color scheme
colors = Config.get_color_scheme('consulting')
"""
from pathlib import Path
from typing import Dict, List, Optional, Any
import json
# ============================================================
# Path Configuration
# ============================================================
# Project root directory
PROJECT_ROOT = Path(__file__).parent.parent
# Core directories
SCRIPTS_DIR = PROJECT_ROOT / 'scripts'
REFERENCES_DIR = PROJECT_ROOT / 'references'
TEMPLATES_DIR = PROJECT_ROOT / 'templates'
WORKFLOWS_DIR = PROJECT_ROOT / 'workflows'
# Repository root directory
REPO_ROOT = PROJECT_ROOT.parent.parent
EXAMPLES_DIR = REPO_ROOT / 'examples'
PROJECTS_DIR = REPO_ROOT / 'projects'
# Template subdirectories
CHART_TEMPLATES_DIR = TEMPLATES_DIR / 'charts'
# ============================================================
# Canvas Format Configuration
# ============================================================
CANVAS_FORMATS = {
'ppt169': {
'name': 'PPT 16:9',
'dimensions': '1280×720',
'viewbox': '0 0 1280 720',
'width': 1280,
'height': 720,
'aspect_ratio': '16:9',
'use_case': 'Modern projectors, online presentations'
},
'ppt43': {
'name': 'PPT 4:3',
'dimensions': '1024×768',
'viewbox': '0 0 1024 768',
'width': 1024,
'height': 768,
'aspect_ratio': '4:3',
'use_case': 'Traditional projectors'
},
'zalo_oa': {
'name': 'Zalo OA Header',
'dimensions': '900×383',
'viewbox': '0 0 900 383',
'width': 900,
'height': 383,
'aspect_ratio': '2.35:1',
'use_case': 'Ảnh bìa bài viết Zalo OA'
},
'facebook_tiktok': {
'name': 'Facebook/TikTok',
'dimensions': '1242×1660',
'viewbox': '0 0 1242 1660',
'width': 1242,
'height': 1660,
'aspect_ratio': '3:4',
'use_case': 'Chia sẻ kiến thức, đánh giá sản phẩm'
},
'social_square': {
'name': 'Zalo/Instagram',
'dimensions': '1080×1080',
'viewbox': '0 0 1080 1080',
'width': 1080,
'height': 1080,
'aspect_ratio': '1:1',
'use_case': 'Ảnh vuông mạng xã hội'
},
'story': {
'name': 'Story/Vertical',
'dimensions': '1080×1920',
'viewbox': '0 0 1080 1920',
'width': 1080,
'height': 1920,
'aspect_ratio': '9:16',
'use_case': 'Short video covers, stories'
},
'banner': {
'name': 'Horizontal Banner',
'dimensions': '1920×1080',
'viewbox': '0 0 1920 1080',
'width': 1920,
'height': 1080,
'aspect_ratio': '16:9',
'use_case': 'Web banners, large screen displays'
},
'a4': {
'name': 'A4 Print',
'dimensions': '1240×1754',
'viewbox': '0 0 1240 1754',
'width': 1240,
'height': 1754,
'aspect_ratio': '√2:1',
'use_case': 'Print documents, PDF export'
}
}
# ============================================================
# Design Color Configuration
# ============================================================
DESIGN_COLORS = {
'consulting': {
'name': 'Consulting Style',
'primary': '#005587',
'secondary': '#0076A8',
'accent': '#F5A623',
'success': '#27AE60',
'warning': '#E74C3C',
'text_dark': '#1A252F',
'text_light': '#FFFFFF',
'text_muted': '#7F8C8D',
'background': '#FFFFFF',
'background_alt': '#F8F9FA'
},
'general': {
'name': 'General Flexible Style',
'primary': '#2196F3',
'secondary': '#4CAF50',
'accent': '#FF9800',
'purple': '#9C27B0',
'success': '#27AE60',
'warning': '#E74C3C',
'text_dark': '#2C3E50',
'text_light': '#FFFFFF',
'text_muted': '#7F8C8D',
'background': '#FFFFFF',
'background_alt': '#F8F9FA'
},
'tech': {
'name': 'Tech Style',
'primary': '#00D1FF',
'secondary': '#7B61FF',
'accent': '#00FF88',
'success': '#00FF88',
'warning': '#FF6B6B',
'text_dark': '#0A0E17',
'text_light': '#FFFFFF',
'text_muted': '#8892A0',
'background': '#0A0E17',
'background_alt': '#1A1F2E'
},
'academic': {
'name': 'Academic Style',
'primary': '#8B0000',
'secondary': '#1E3A5F',
'accent': '#C9B037',
'success': '#2E7D32',
'warning': '#D32F2F',
'text_dark': '#1A1A1A',
'text_light': '#FFFFFF',
'text_muted': '#666666',
'background': '#FFFFFF',
'background_alt': '#F5F5F5'
},
'government': {
'name': 'Government Style',
'primary': '#C41E3A',
'secondary': '#1E3A5F',
'accent': '#D4AF37',
'success': '#2E7D32',
'warning': '#B71C1C',
'text_dark': '#1A1A1A',
'text_light': '#FFFFFF',
'text_muted': '#555555',
'background': '#FFFFFF',
'background_alt': '#FFF8E1'
}
}
# ============================================================
# Industry Color Templates
# ============================================================
INDUSTRY_COLORS = {
'finance': {
'name': 'Finance/Banking',
'primary': '#003366',
'secondary': '#4A90D9',
'accent': '#D4AF37'
},
'healthcare': {
'name': 'Healthcare/Medical',
'primary': '#00796B',
'secondary': '#4DB6AC',
'accent': '#FF7043'
},
'technology': {
'name': 'Technology/Internet',
'primary': '#1565C0',
'secondary': '#42A5F5',
'accent': '#00E676'
},
'education': {
'name': 'Education/Training',
'primary': '#5E35B1',
'secondary': '#7E57C2',
'accent': '#FFD54F'
},
'retail': {
'name': 'Retail/Consumer',
'primary': '#E53935',
'secondary': '#EF5350',
'accent': '#FFB300'
},
'manufacturing': {
'name': 'Manufacturing/Industrial',
'primary': '#455A64',
'secondary': '#78909C',
'accent': '#FF6F00'
},
'energy': {
'name': 'Energy/Environmental',
'primary': '#2E7D32',
'secondary': '#66BB6A',
'accent': '#FDD835'
},
'realestate': {
'name': 'Real Estate/Construction',
'primary': '#795548',
'secondary': '#A1887F',
'accent': '#4CAF50'
},
'legal': {
'name': 'Legal/Compliance',
'primary': '#37474F',
'secondary': '#546E7A',
'accent': '#8D6E63'
},
'media': {
'name': 'Media/Entertainment',
'primary': '#7B1FA2',
'secondary': '#AB47BC',
'accent': '#FF4081'
},
'logistics': {
'name': 'Logistics/Supply Chain',
'primary': '#F57C00',
'secondary': '#FFB74D',
'accent': '#0288D1'
},
'agriculture': {
'name': 'Agriculture/Food',
'primary': '#558B2F',
'secondary': '#8BC34A',
'accent': '#FFCA28'
},
'tourism': {
'name': 'Tourism/Hospitality',
'primary': '#00ACC1',
'secondary': '#4DD0E1',
'accent': '#FF7043'
},
'automotive': {
'name': 'Automotive/Transportation',
'primary': '#263238',
'secondary': '#455A64',
'accent': '#D32F2F'
}
}
# ============================================================
# Font Configuration
# ============================================================
FONTS = {
'system_ui': "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
'sans_serif': "'Helvetica Neue', Arial, 'PingFang SC', 'Microsoft YaHei', sans-serif",
'monospace': "'SF Mono', Monaco, Consolas, 'Liberation Mono', monospace"
}
FONT_SIZES = {
'title_large': 48,
'title': 36,
'title_small': 28,
'heading': 24,
'subheading': 20,
'body': 18,
'body_small': 16,
'caption': 14,
'footnote': 12
}
# ============================================================
# Layout Configuration
# ============================================================
LAYOUT_MARGINS = {
'ppt169': {
'top': 60,
'right': 60,
'bottom': 60,
'left': 60,
'content_width': 1160,
'content_height': 600
},
'facebook_tiktok': {
'top': 80,
'right': 60,
'bottom': 80,
'left': 60,
'content_width': 1122,
'content_height': 1500
},
'social_square': {
'top': 60,
'right': 60,
'bottom': 60,
'left': 60,
'content_width': 960,
'content_height': 960
}
}
# ============================================================
# SVG Technical Specifications
# ============================================================
SVG_CONSTRAINTS = {
# Forbidden elements - PPT incompatible
'forbidden_elements': [
# Clipping / Masking
'clipPath',
'mask',
# Style system
'style',
# Structure / Nesting
'foreignObject',
'marker',
# Text / Fonts
'textPath',
# Animation / Interaction
'animate',
'animateMotion',
'animateTransform',
'animateColor',
'set',
'script',
# Others
'iframe',
],
# Forbidden attributes
'forbidden_attributes': [
'class',
'id',
'onclick', 'onload', 'onmouseover', 'onmouseout',
'onfocus', 'onblur', 'onchange',
'marker-end',
],
# Forbidden patterns (regex matching)
'forbidden_patterns': [
r'@font-face', # Web fonts
r'rgba\s*\(', # rgba colors (PPT incompatible)
r'<\?xml-stylesheet\b', # External CSS
r'<link[^>]*rel\s*=\s*["\']stylesheet["\']',
r'@import\s+', # External CSS
r'<g[^>]*\sopacity\s*=', # Group opacity
r'<image[^>]*\sopacity\s*=', # Image opacity
r'\bon\w+\s*=', # Event attributes
r'(?s)(?=.*<symbol)(?=.*<use\b)', # <symbol> + <use> complex usage (order-independent)
],
'recommended_fonts': [
'system-ui',
'-apple-system',
'BlinkMacSystemFont',
'Segoe UI'
]
}
# ============================================================
# Configuration Manager Class
# ============================================================
class Config:
"""Configuration manager."""
@staticmethod
def get_canvas_format(format_key: str) -> Optional[Dict]:
"""
Get canvas format configuration.
Args:
format_key: Format key name (e.g. 'ppt169', 'facebook_tiktok')
Returns:
Format configuration dict, or None if not found
"""
return CANVAS_FORMATS.get(format_key)
@staticmethod
def get_all_canvas_formats() -> Dict:
"""Get all canvas formats."""
return CANVAS_FORMATS.copy()
@staticmethod
def get_color_scheme(style: str) -> Optional[Dict]:
"""
Get color scheme.
Args:
style: Style name (e.g. 'consulting', 'general', 'tech')
Returns:
Color scheme dict
"""
return DESIGN_COLORS.get(style)
@staticmethod
def get_industry_colors(industry: str) -> Optional[Dict]:
"""
Get industry color palette.
Args:
industry: Industry name (e.g. 'finance', 'healthcare')
Returns:
Industry color dict
"""
return INDUSTRY_COLORS.get(industry)
@staticmethod
def get_all_industries() -> List[str]:
"""Get list of all industries."""
return list(INDUSTRY_COLORS.keys())
@staticmethod
def get_layout_margins(format_key: str) -> Optional[Dict]:
"""
Get layout margin configuration.
Args:
format_key: Format key name
Returns:
Margin configuration dict
"""
return LAYOUT_MARGINS.get(format_key)
@staticmethod
def get_font(font_type: str = 'system_ui') -> str:
"""
Get font declaration.
Args:
font_type: Font type ('system_ui', 'sans_serif', 'monospace')
Returns:
Font declaration string
"""
return FONTS.get(font_type, FONTS['system_ui'])
@staticmethod
def get_font_size(size_name: str) -> int:
"""
Get font size.
Args:
size_name: Size name (e.g. 'title', 'body', 'caption')
Returns:
Font size (pixels)
"""
return FONT_SIZES.get(size_name, FONT_SIZES['body'])
@staticmethod
def validate_svg_element(element_name: str) -> bool:
"""
Validate whether an SVG element is allowed.
Args:
element_name: Element name
Returns:
Whether the element is allowed
"""
return element_name.lower() not in [e.lower() for e in SVG_CONSTRAINTS['forbidden_elements']]
@staticmethod
def get_project_path(subdir: str = '') -> Path:
"""
Get project path.
Args:
subdir: Subdirectory name
Returns:
Full path
"""
if subdir:
return PROJECT_ROOT / subdir
return PROJECT_ROOT
@staticmethod
def export_config(output_file: str = 'config_export.json'):
"""
Export configuration to a JSON file.
Args:
output_file: Output file path
"""
config_data = {
'canvas_formats': CANVAS_FORMATS,
'design_colors': DESIGN_COLORS,
'industry_colors': INDUSTRY_COLORS,
'fonts': FONTS,
'font_sizes': FONT_SIZES,
'svg_constraints': SVG_CONSTRAINTS
}
with open(output_file, 'w', encoding='utf-8') as f:
json.dump(config_data, f, ensure_ascii=False, indent=2)
print(f"Configuration exported to: {output_file}")
# ============================================================
# Command Line Interface
# ============================================================
def main():
"""Command line entry point."""
import sys
# Fix Windows terminal encoding for Vietnamese/Unicode output
if hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
if len(sys.argv) < 2:
print("PPT Master - Configuration Management Tool\n")
print("Usage:")
print(" python3 scripts/config.py list-formats # List all canvas formats")
print(" python3 scripts/config.py list-colors # List all color schemes")
print(" python3 scripts/config.py list-industries # List all industry colors")
print(" python3 scripts/config.py export # Export configuration to JSON")
print(" python3 scripts/config.py format <key> # View a specific canvas format")
return
command = sys.argv[1]
if command == 'list-formats':
print("\nCanvas Format List:\n")
for key, info in CANVAS_FORMATS.items():
print(
f" {key:15} | {info['name']:15} | {info['dimensions']:12} | {info['use_case']}")
elif command == 'list-colors':
print("\nColor Scheme List:\n")
for key, info in DESIGN_COLORS.items():
print(f" {key:12} | {info['name']:15} | Primary: {info['primary']}")
elif command == 'list-industries':
print("\nIndustry Color List:\n")
for key, info in INDUSTRY_COLORS.items():
print(f" {key:15} | {info['name']:15} | Primary: {info['primary']}")
elif command == 'export':
output_file = sys.argv[2] if len(
sys.argv) > 2 else 'config_export.json'
Config.export_config(output_file)
elif command == 'format' and len(sys.argv) > 2:
format_key = sys.argv[2]
info = Config.get_canvas_format(format_key)
if info:
print(f"\nCanvas Format: {format_key}\n")
for key, value in info.items():
print(f" {key}: {value}")
else:
print(f"[ERROR] Format not found: {format_key}")
print(f" Available formats: {', '.join(CANVAS_FORMATS.keys())}")
else:
print(f"[ERROR] Unknown command: {command}")
if __name__ == '__main__':
main()
scripts/crop_images.py
#!/usr/bin/env python3
"""
PPT Master - Smart Image Cropping Tool
Smartly crops images based on the preserveAspectRatio attribute of <image> elements in SVG:
- slice: Crop to fill (similar to CSS object-fit: cover)
- meet: Display fully without cropping (similar to CSS object-fit: contain)
Supports 9 alignment modes:
- xMinYMin / xMidYMin / xMaxYMin (top alignment)
- xMinYMid / xMidYMid / xMaxYMid (vertical center)
- xMinYMax / xMidYMax / xMaxYMax (bottom alignment)
Usage:
python scripts/crop_images.py <SVG file or directory> [--dry-run]
"""
import os
import re
import hashlib
from pathlib import Path
from xml.etree import ElementTree as ET
from urllib.parse import unquote
try:
from PIL import Image
except ImportError:
print("Error: PIL (Pillow) is required. Run: pip install Pillow")
exit(1)
def parse_preserve_aspect_ratio(attr: str) -> tuple:
"""
Parse the preserveAspectRatio attribute.
Returns: (align, meet_or_slice)
align: e.g. 'xMidYMid'
meet_or_slice: 'meet' or 'slice'
"""
if not attr:
return ('xMidYMid', 'meet') # Default value
parts = attr.strip().split()
align = parts[0] if parts else 'xMidYMid'
meet_or_slice = parts[1] if len(parts) > 1 else 'meet'
return (align, meet_or_slice)
def get_crop_anchor(align: str) -> tuple:
"""
Return the crop anchor point based on the align value.
Returns: (x_anchor, y_anchor)
x_anchor: 0.0 (left), 0.5 (center), 1.0 (right)
y_anchor: 0.0 (top), 0.5 (center), 1.0 (bottom)
"""
x_map = {'xMin': 0.0, 'xMid': 0.5, 'xMax': 1.0}
y_map = {'YMin': 0.0, 'YMid': 0.5, 'YMax': 1.0}
x_anchor = 0.5
y_anchor = 0.5
for key, val in x_map.items():
if key in align:
x_anchor = val
break
for key, val in y_map.items():
if key in align:
y_anchor = val
break
return (x_anchor, y_anchor)
def crop_image_to_size(img: Image.Image, target_width: int, target_height: int,
x_anchor: float = 0.5, y_anchor: float = 0.5) -> Image.Image:
"""
Crop an image to the target aspect ratio, preserving original resolution (no scaling).
New logic: Only crops the original image to the target aspect ratio without any scaling,
thus preserving the original resolution and clarity.
Args:
img: PIL Image object
target_width: Target width (used to calculate ratio)
target_height: Target height (used to calculate ratio)
x_anchor: Horizontal anchor (0=left, 0.5=center, 1=right)
y_anchor: Vertical anchor (0=top, 0.5=center, 1=bottom)
Returns:
Cropped PIL Image object (preserving original resolution)
"""
img_width, img_height = img.size
# Calculate target aspect ratio
target_ratio = target_width / target_height
img_ratio = img_width / img_height
# Calculate crop region on the original image based on ratio (no scaling)
if img_ratio > target_ratio:
# Original image is wider; crop left and right sides
crop_height = img_height
crop_width = int(img_height * target_ratio)
else:
# Original image is taller; crop top and bottom sides
crop_width = img_width
crop_height = int(img_width / target_ratio)
# Calculate crop position based on anchor point
extra_width = img_width - crop_width
extra_height = img_height - crop_height
left = int(extra_width * x_anchor)
top = int(extra_height * y_anchor)
right = left + crop_width
bottom = top + crop_height
# Crop only, no scaling
return img.crop((left, top, right, bottom))
def process_svg_images(svg_file: str, output_dir: str = None, dry_run: bool = False,
verbose: bool = True) -> tuple:
"""
Process images in an SVG file, cropping based on the preserveAspectRatio attribute.
Args:
svg_file: SVG file path
output_dir: Output directory for cropped images (default: images/cropped/)
dry_run: Preview only, no actual processing
verbose: Verbose output
Returns:
(processed_count, error_count)
"""
svg_path = Path(svg_file)
svg_dir = svg_path.parent
# Default output directory
if output_dir is None:
# Find the project's images directory
# Parent directory of svg_output or svg_final, under images
project_dir = svg_dir.parent
output_dir = project_dir / 'images' / 'cropped'
else:
output_dir = Path(output_dir)
# Parse SVG
try:
ET.register_namespace('', 'http://www.w3.org/2000/svg')
ET.register_namespace('xlink', 'http://www.w3.org/1999/xlink')
tree = ET.parse(str(svg_path))
root = tree.getroot()
except Exception as e:
if verbose:
print(f" [ERROR] Failed to parse SVG: {e}")
return (0, 1)
ns = {'svg': 'http://www.w3.org/2000/svg', 'xlink': 'http://www.w3.org/1999/xlink'}
processed_count = 0
error_count = 0
modified = False
# Find all image elements
for image in root.iter('{http://www.w3.org/2000/svg}image'):
# Get href attribute
href = image.get('{http://www.w3.org/1999/xlink}href') or image.get('href')
if not href:
continue
# Skip Base64 inline images
if href.startswith('data:'):
continue
# Get preserveAspectRatio attribute
par = image.get('preserveAspectRatio', '')
align, mode = parse_preserve_aspect_ratio(par)
# Only process slice mode
if mode != 'slice':
continue
# Get target dimensions
try:
target_width = int(float(image.get('width', 0)))
target_height = int(float(image.get('height', 0)))
except (ValueError, TypeError):
continue
if target_width <= 0 or target_height <= 0:
continue
# Parse image path
href_decoded = unquote(href)
if href_decoded.startswith('../'):
img_path = (svg_dir / href_decoded).resolve()
else:
img_path = (svg_dir / href_decoded).resolve()
if not img_path.exists():
if verbose:
print(f" [SKIP] Image not found: {href}")
continue
# Get crop anchor point
x_anchor, y_anchor = get_crop_anchor(align)
if dry_run:
if verbose:
print(f" [DRY] {img_path.name} -> {target_width}x{target_height} "
f"(align: {align}, anchor: {x_anchor},{y_anchor})")
processed_count += 1
continue
# Create output directory
output_dir.mkdir(parents=True, exist_ok=True)
try:
# Open and process image
img = Image.open(img_path)
# Convert mode
if img.mode in ('RGBA', 'P'):
img = img.convert('RGB')
# Crop
cropped = crop_image_to_size(img, target_width, target_height, x_anchor, y_anchor)
# Generate output filename (keep original name, place in cropped directory)
output_filename = img_path.name
output_path = output_dir / output_filename
# Save
if img_path.suffix.lower() == '.png':
cropped.save(output_path, 'PNG', optimize=True)
else:
cropped.save(output_path, 'JPEG', quality=90, optimize=True)
if verbose:
print(f" [OK] {img_path.name}: {img.size} -> {target_width}x{target_height} "
f"({align})")
# Update image path in SVG
new_href = f"../images/cropped/{output_filename}"
if image.get('{http://www.w3.org/1999/xlink}href'):
image.set('{http://www.w3.org/1999/xlink}href', new_href)
else:
image.set('href', new_href)
# Remove preserveAspectRatio (image is now correctly sized)
if 'preserveAspectRatio' in image.attrib:
del image.attrib['preserveAspectRatio']
modified = True
processed_count += 1
except Exception as e:
if verbose:
print(f" [ERROR] {img_path.name}: {e}")
error_count += 1
# Save modified SVG
if modified and not dry_run:
tree.write(str(svg_path), encoding='unicode', xml_declaration=False)
return (processed_count, error_count)
def process_directory(directory: str, dry_run: bool = False, verbose: bool = True) -> tuple:
"""Process all SVG files in a directory."""
directory = Path(directory)
total_processed = 0
total_errors = 0
for svg_file in directory.glob('*.svg'):
if verbose:
print(f" Processing: {svg_file.name}")
processed, errors = process_svg_images(str(svg_file), dry_run=dry_run, verbose=verbose)
total_processed += processed
total_errors += errors
return (total_processed, total_errors)
def main():
import argparse
parser = argparse.ArgumentParser(
description='PPT Master - Smart Image Cropping Tool',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
Examples:
%(prog)s projects/my_project/svg_output
%(prog)s page_01.svg --dry-run
preserveAspectRatio usage:
xMidYMid slice Center crop (default)
xMidYMin slice Keep top
xMidYMax slice Keep bottom
xMinYMid slice Keep left
xMaxYMid slice Keep right
xMidYMid meet Display fully, no cropping
'''
)
parser.add_argument('path', type=Path, help='SVG file or directory')
parser.add_argument('--dry-run', '-n', action='store_true', help='Preview only, no actual processing')
parser.add_argument('--quiet', '-q', action='store_true', help='Quiet mode')
args = parser.parse_args()
if not args.path.exists():
print(f"[ERROR] Path not found: {args.path}")
exit(1)
print("PPT Master - Smart Image Cropping")
print("=" * 50)
if args.path.is_file():
processed, errors = process_svg_images(str(args.path), dry_run=args.dry_run,
verbose=not args.quiet)
else:
processed, errors = process_directory(str(args.path), dry_run=args.dry_run,
verbose=not args.quiet)
print()
print(f"Done: {processed} image(s) cropped, {errors} error(s)")
if __name__ == '__main__':
main()
scripts/doc_to_md.py
#!/usr/bin/env python3
"""
Document to Markdown Converter (Pandoc-based)
Uses Pandoc to convert various document formats to Markdown.
Primary use case: DOCX lecture notes / manuscripts → Markdown for PPT generation.
Dependency: pandoc must be installed
macOS: brew install pandoc
Ubuntu: sudo apt install pandoc
Windows: https://pandoc.org/installing.html
"""
import argparse
import re
import shutil
import subprocess
import sys
from pathlib import Path
# Supported input formats
SUPPORTED_FORMATS = {
# Office documents
".docx": ("docx", "Microsoft Word"),
".doc": ("doc", "Microsoft Word 97-2003"),
".odt": ("odt", "OpenDocument Text"),
".rtf": ("rtf", "Rich Text Format"),
# eBooks
".epub": ("epub", "EPUB"),
# Web
".html": ("html", "HTML"),
".htm": ("html", "HTML"),
# Academic / technical
".tex": ("latex", "LaTeX"),
".latex": ("latex", "LaTeX"),
".rst": ("rst", "reStructuredText"),
".org": ("org", "Emacs Org-mode"),
".ipynb": ("ipynb", "Jupyter Notebook"),
".typ": ("typst", "Typst"),
}
# Formats that may contain embedded media
MEDIA_FORMATS = {".docx", ".odt", ".epub", ".html", ".htm"}
def check_pandoc() -> bool:
"""Check if pandoc is installed."""
return shutil.which("pandoc") is not None
def convert_to_markdown(input_path: str, output_path: str = None) -> str:
"""
Convert a document to Markdown using Pandoc.
Args:
input_path: Path to the input document.
output_path: Path to write the output Markdown (default: same dir, .md extension).
Returns:
The generated Markdown content.
"""
input_file = Path(input_path)
if not input_file.exists():
print(f"[ERROR] File not found: {input_path}")
return ""
suffix = input_file.suffix.lower()
if suffix not in SUPPORTED_FORMATS:
supported = ", ".join(sorted(SUPPORTED_FORMATS.keys()))
print(f"[ERROR] Unsupported format: {suffix}")
print(f" Supported: {supported}")
return ""
input_format, format_desc = SUPPORTED_FORMATS[suffix]
print(f"[INFO] Converting {format_desc} file: {input_file.name}")
# Determine output path
if output_path:
out_file = Path(output_path)
else:
out_file = input_file.with_suffix(".md")
# Media extraction directory (use name only — pandoc writes relative paths)
rel_media_dir = f"{out_file.stem}_files"
media_dir = out_file.parent / rel_media_dir
# Build pandoc command — use resolved absolute paths for input/output,
# but a relative directory name for --extract-media so pandoc writes
# relative image references in the markdown.
cmd = [
"pandoc",
"-f", input_format,
"-t", "gfm",
str(input_file.resolve()),
"-o", str(out_file.resolve()),
"--wrap", "none",
"--strip-comments",
]
# Extract embedded media for formats that support it
if suffix in MEDIA_FORMATS:
cmd.extend(["--extract-media", rel_media_dir])
try:
result = subprocess.run(cmd, capture_output=True, text=True,
cwd=str(out_file.parent))
except FileNotFoundError:
print("[ERROR] pandoc not found! Please install it first:")
print(" macOS: brew install pandoc")
print(" Ubuntu: sudo apt install pandoc")
print(" Windows: https://pandoc.org/installing.html")
return ""
if result.returncode != 0:
print(f"[ERROR] Pandoc conversion failed:\n{result.stderr}")
return ""
# Read generated markdown
if not out_file.exists():
print("[ERROR] Conversion completed but no output file was generated")
return ""
markdown_content = out_file.read_text(encoding="utf-8")
# --- Post-processing: fix media paths ---
# 1. Flatten nested media/ subdirectory that pandoc creates
nested_media = media_dir / "media"
if nested_media.exists():
for f in nested_media.iterdir():
if f.is_file():
target = media_dir / f.name
shutil.move(str(f), str(target))
try:
nested_media.rmdir()
except OSError:
pass
# Fix references: {dir}/media/img.png → {dir}/img.png
markdown_content = markdown_content.replace(
f"{rel_media_dir}/media/", f"{rel_media_dir}/"
)
# 2. Convert any absolute paths to relative (safety net)
abs_media_str = str(media_dir.resolve()).replace("\\", "/")
if abs_media_str in markdown_content:
markdown_content = markdown_content.replace(abs_media_str, rel_media_dir)
# Also handle backslash variant on Windows
abs_media_str_bs = str(media_dir.resolve())
if abs_media_str_bs in markdown_content:
markdown_content = markdown_content.replace(abs_media_str_bs, rel_media_dir)
# 3. Convert <img> HTML tags to Markdown  syntax
# Pandoc generates <img> when the source has styling (width/height)
def _img_to_md(m):
src = m.group("src")
alt = m.group("alt") or Path(src).stem
return f""
markdown_content = re.sub(
r'<img\s[^>]*?src="(?P<src>[^"]+)"[^>]*?(?:alt="(?P<alt>[^"]*)")?[^>]*/?\s*>',
_img_to_md,
markdown_content,
)
# Handle alt before src order as well
markdown_content = re.sub(
r'<img\s[^>]*?alt="(?P<alt>[^"]*)"[^>]*?src="(?P<src>[^"]+)"[^>]*/?\s*>',
_img_to_md,
markdown_content,
)
out_file.write_text(markdown_content, encoding="utf-8")
# Report results
size = out_file.stat().st_size
print(f"[OK] Saved Markdown to: {out_file} ({_format_size(size)})")
if media_dir.exists():
media_files = [f for f in media_dir.rglob("*") if f.is_file()]
if media_files:
print(f" Extracted {len(media_files)} media file(s) → {media_dir}")
return markdown_content
def _format_size(size: int) -> str:
"""Format file size for display."""
for unit in ("B", "KB", "MB"):
if size < 1024:
return f"{size:.0f} {unit}"
size /= 1024
return f"{size:.1f} GB"
def main():
parser = argparse.ArgumentParser(
description="Convert documents to Markdown using Pandoc",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python doc_to_md.py lecture.docx # Convert Word document
python doc_to_md.py lecture.docx -o output.md # Specify output path
python doc_to_md.py notes.odt # Convert OpenDocument
Supported formats:
Office: .docx .doc .odt .rtf
eBooks: .epub
Web: .html .htm
Academic: .tex .rst .org .ipynb .typ
""",
)
parser.add_argument("input", help="Input document file")
parser.add_argument("-o", "--output", help="Output Markdown file path")
args = parser.parse_args()
if not check_pandoc():
print("[ERROR] pandoc not found! Please install it first:")
print(" macOS: brew install pandoc")
print(" Ubuntu: sudo apt install pandoc")
print(" Windows: https://pandoc.org/installing.html")
sys.exit(1)
result = convert_to_markdown(args.input, args.output)
sys.exit(0 if result else 1)
if __name__ == "__main__":
main()
scripts/embed_icons.py
#!/usr/bin/env python3
"""
SVG Icon Embedding Tool
Replaces icon placeholders in SVG files with actual icon code.
Placeholder syntax:
<use data-icon="rocket" x="100" y="200" width="48" height="48" fill="#0076A8"/>
After replacement:
<g transform="translate(100, 200) scale(3)" fill="#0076A8">
<path d="..."/>
</g>
Usage:
python3 scripts/embed_icons.py <svg_file> [svg_file2] ...
python3 scripts/embed_icons.py svg_output/*.svg
Options:
--icons-dir <path> Icon directory path (default: templates/icons/)
--dry-run Only show what would be replaced, without modifying files
--verbose Show detailed information
"""
import os
import re
import sys
import argparse
from pathlib import Path
# Default icon directory
DEFAULT_ICONS_DIR = Path(__file__).parent.parent / 'templates' / 'icons'
# Icon base size
ICON_BASE_SIZE = 16
def extract_paths_from_icon(icon_path: Path) -> list[str]:
"""
Extract all path elements from an icon SVG file.
Args:
icon_path: Icon file path
Returns:
List of path elements (without fill attribute)
"""
if not icon_path.exists():
return []
content = icon_path.read_text(encoding='utf-8')
# Match all <path ... /> elements
path_pattern = r'<path\s+([^>]*)/>'
matches = re.findall(path_pattern, content, re.DOTALL)
paths = []
for attrs in matches:
# Remove fill attribute (will be set uniformly on the outer <g>)
attrs_clean = re.sub(r'\s*fill="[^"]*"', '', attrs)
paths.append(f'<path {attrs_clean.strip()}/>')
return paths
def parse_use_element(use_match: str) -> dict:
"""
Parse attributes of a use element.
Args:
use_match: Complete string of the use element
Returns:
Attribute dictionary
"""
attrs = {}
# Extract data-icon
icon_match = re.search(r'data-icon="([^"]+)"', use_match)
if icon_match:
attrs['icon'] = icon_match.group(1)
# Extract numeric attributes
for attr in ['x', 'y', 'width', 'height']:
match = re.search(rf'{attr}="([^"]+)"', use_match)
if match:
attrs[attr] = float(match.group(1))
# Extract fill color
fill_match = re.search(r'fill="([^"]+)"', use_match)
if fill_match:
attrs['fill'] = fill_match.group(1)
return attrs
def generate_icon_group(attrs: dict, paths: list[str]) -> str:
"""
Generate the icon's <g> element.
Args:
attrs: Attributes of the use element
paths: List of icon path elements
Returns:
Complete <g> element string
"""
x = attrs.get('x', 0)
y = attrs.get('y', 0)
width = attrs.get('width', ICON_BASE_SIZE)
height = attrs.get('height', ICON_BASE_SIZE)
fill = attrs.get('fill', '#000000')
icon_name = attrs.get('icon', 'unknown')
# Calculate scale factor (based on width, assuming uniform scaling)
scale = width / ICON_BASE_SIZE
# Build transform
if scale == 1:
transform = f'translate({x}, {y})'
else:
transform = f'translate({x}, {y}) scale({scale})'
# Generate <g> element
paths_str = '\n '.join(paths)
return f'''<!-- icon: {icon_name} -->
<g transform="{transform}" fill="{fill}">
{paths_str}
</g>'''
def process_svg_file(svg_path: Path, icons_dir: Path, dry_run: bool = False, verbose: bool = False) -> int:
"""
Process a single SVG file, replacing all icon placeholders.
Args:
svg_path: SVG file path
icons_dir: Icon directory path
dry_run: Whether to only preview without modifying
verbose: Whether to show detailed information
Returns:
Number of icons replaced
"""
if not svg_path.exists():
print(f"[ERROR] File not found: {svg_path}")
return 0
content = svg_path.read_text(encoding='utf-8')
# Match <use data-icon="xxx" ... /> elements
use_pattern = r'<use\s+[^>]*data-icon="[^"]*"[^>]*/>'
matches = list(re.finditer(use_pattern, content))
if not matches:
if verbose:
print(f"[SKIP] No icon placeholders: {svg_path}")
return 0
replaced_count = 0
new_content = content
# Replace from back to front to avoid position offset
for match in reversed(matches):
use_str = match.group(0)
attrs = parse_use_element(use_str)
icon_name = attrs.get('icon')
if not icon_name:
continue
icon_path = icons_dir / f'{icon_name}.svg'
paths = extract_paths_from_icon(icon_path)
if not paths:
print(f"[WARN] Icon not found: {icon_name} (in {svg_path.name})")
continue
replacement = generate_icon_group(attrs, paths)
if verbose or dry_run:
print(f" [*] {icon_name}: x={attrs.get('x', 0)}, y={attrs.get('y', 0)}, "
f"size={attrs.get('width', 16)}, fill={attrs.get('fill', '#000000')}")
new_content = new_content[:match.start()] + replacement + new_content[match.end():]
replaced_count += 1
if not dry_run and replaced_count > 0:
svg_path.write_text(new_content, encoding='utf-8')
status = "[PREVIEW]" if dry_run else "[OK]"
print(f"{status} {svg_path.name} ({replaced_count} icons)")
return replaced_count
def main():
parser = argparse.ArgumentParser(
description='Replace icon placeholders in SVG files with actual icon code',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
Examples:
python3 scripts/embed_icons.py svg_output/01_cover.svg
python3 scripts/embed_icons.py svg_output/*.svg
python3 scripts/embed_icons.py --dry-run svg_output/*.svg
python3 scripts/embed_icons.py --icons-dir my_icons/ output.svg
'''
)
parser.add_argument('files', nargs='+', help='SVG files to process')
parser.add_argument('--icons-dir', type=Path, default=DEFAULT_ICONS_DIR,
help=f'Icon directory path (default: {DEFAULT_ICONS_DIR})')
parser.add_argument('--dry-run', action='store_true',
help='Only show what would be replaced, without modifying files')
parser.add_argument('--verbose', '-v', action='store_true',
help='Show detailed information')
args = parser.parse_args()
# Validate icon directory
if not args.icons_dir.exists():
print(f"[ERROR] Icon directory not found: {args.icons_dir}")
sys.exit(1)
print(f"[DIR] Icon directory: {args.icons_dir}")
if args.dry_run:
print("[PREVIEW] Preview mode (no files will be modified)")
print()
total_replaced = 0
total_files = 0
for file_pattern in args.files:
svg_path = Path(file_pattern)
if svg_path.exists():
count = process_svg_file(svg_path, args.icons_dir, args.dry_run, args.verbose)
total_replaced += count
if count > 0:
total_files += 1
print()
print(f"[Summary] Total: {total_files} file(s), {total_replaced} icon(s)" +
(" (preview)" if args.dry_run else " replaced"))
if __name__ == '__main__':
main()
scripts/embed_images.py
#!/usr/bin/env python3
"""
SVG Image Embedding Tool
Converts externally referenced images in SVG files to Base64 inline format.
Usage:
python3 embed_images.py <svg_file> [svg_file2] ...
python3 embed_images.py *.svg
Examples:
python3 embed_images.py examples/ppt169_demo/svg_output/01_cover.svg
python3 embed_images.py examples/ppt169_demo/svg_output/*.svg
"""
import os
import base64
import re
import sys
import argparse
def get_mime_type(filename):
"""Return the MIME type based on file extension."""
ext = filename.lower().split('.')[-1]
mime_map = {
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'gif': 'image/gif',
'webp': 'image/webp',
'svg': 'image/svg+xml',
}
return mime_map.get(ext, 'application/octet-stream')
def get_file_size_str(size_bytes):
"""Convert byte count to a human-readable file size string."""
if size_bytes < 1024:
return f"{size_bytes} B"
elif size_bytes < 1024 * 1024:
return f"{size_bytes / 1024:.1f} KB"
else:
return f"{size_bytes / (1024 * 1024):.1f} MB"
def embed_images_in_svg(svg_path, dry_run=False):
"""
Convert externally referenced images in an SVG file to Base64 inline format.
Args:
svg_path: SVG file path
dry_run: If True, only show which images would be processed without modifying the file
Returns:
tuple: (number of images processed, file size after embedding)
"""
svg_dir = os.path.dirname(os.path.abspath(svg_path))
with open(svg_path, 'r', encoding='utf-8') as f:
content = f.read()
original_size = len(content.encode('utf-8'))
# Match href="xxx.png" or href="xxx.jpg" etc. (exclude those already using data:)
pattern = r'href="(?!data:)([^"]+\.(png|jpg|jpeg|gif|webp))"'
images_found = []
images_embedded = 0
def replace_with_base64(match):
nonlocal images_embedded
img_path = match.group(1)
# Decode XML/HTML entities (e.g., & -> &)
import html
img_path_decoded = html.unescape(img_path)
# Handle relative paths
if not os.path.isabs(img_path_decoded):
full_path = os.path.join(svg_dir, img_path_decoded)
else:
full_path = img_path_decoded
if not os.path.exists(full_path):
print(f" [WARN] Image not found: {img_path}")
images_found.append((img_path, "NOT FOUND", 0))
return match.group(0)
img_size = os.path.getsize(full_path)
if dry_run:
images_found.append((img_path, "WILL EMBED", img_size))
return match.group(0)
with open(full_path, 'rb') as img_file:
b64_data = base64.b64encode(img_file.read()).decode('utf-8')
mime_type = get_mime_type(img_path)
images_embedded += 1
images_found.append((img_path, "EMBEDDED", img_size))
return f'href="data:{mime_type};base64,{b64_data}"'
new_content = re.sub(pattern, replace_with_base64, content)
new_size = len(new_content.encode('utf-8'))
# Print processed images
if images_found:
print(f"\n[FILE] {os.path.basename(svg_path)}")
for img_path, status, size in images_found:
size_str = get_file_size_str(size) if size > 0 else ""
if status == "EMBEDDED":
print(f" [OK] {img_path} ({size_str})")
elif status == "WILL EMBED":
print(f" [PREVIEW] {img_path} ({size_str}) [dry-run]")
else:
print(f" [FAIL] {img_path} ({status})")
print(f" [SIZE] {get_file_size_str(original_size)} -> {get_file_size_str(new_size)}")
if not dry_run and images_embedded > 0:
with open(svg_path, 'w', encoding='utf-8') as f:
f.write(new_content)
return (images_embedded, new_size)
def main():
parser = argparse.ArgumentParser(
description='Convert externally referenced images in SVG files to Base64 inline format',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
Examples:
%(prog)s 01_cover.svg # Process a single file
%(prog)s *.svg # Process all SVGs in current directory
%(prog)s --dry-run *.svg # Preview files to be processed
'''
)
parser.add_argument('files', nargs='+', help='SVG files to process')
parser.add_argument('--dry-run', '-n', action='store_true',
help='Only show which images would be processed, without modifying files')
args = parser.parse_args()
if args.dry_run:
print("[INFO] Dry-run mode: only preview, no modification\n")
total_images = 0
total_files = 0
for svg_file in args.files:
if not os.path.exists(svg_file):
print(f"[ERROR] File not found: {svg_file}")
continue
if not svg_file.endswith('.svg'):
print(f"[SKIP] Skipping non-SVG file: {svg_file}")
continue
images, _ = embed_images_in_svg(svg_file, dry_run=args.dry_run)
if images > 0:
total_images += images
total_files += 1
print(f"\n{'=' * 50}")
if args.dry_run:
print(f"[PREVIEW] Will process {total_images} images in {total_files} files")
else:
print(f"[DONE] Embedded {total_images} images in {total_files} files")
if __name__ == '__main__':
main()
scripts/error_helper.py
#!/usr/bin/env python3
"""
PPT Master - Error Message Helper
Provides user-friendly error messages and specific fix suggestions.
"""
from typing import Dict, List, Optional
class ErrorHelper:
"""Error message helper."""
# Error types and their corresponding fix suggestions
ERROR_SOLUTIONS = {
'missing_readme': {
'message': 'Missing README.md file',
'solutions': [
'Create a README.md file with project description, usage instructions, etc.',
'Reference template: examples/google_annual_report_ppt169_20251116/README.md',
'Or use command: cp examples/google_annual_report_ppt169_20251116/README.md <your_project>/'
],
'severity': 'error'
},
'missing_spec': {
'message': 'Missing design specification file',
'solutions': [
'Create a design_specification.md file',
'Include: canvas specs, color scheme, font specs, layout specs, content outline',
'Refer to the design specification generated by the Strategist role'
],
'severity': 'warning'
},
'missing_svg_output': {
'message': 'Missing svg_output directory',
'solutions': [
'Create the svg_output directory: mkdir svg_output',
'Place generated SVG files in this directory',
'Ensure SVG files follow naming convention: slide_XX_name.svg'
],
'severity': 'error'
},
'empty_svg_output': {
'message': 'svg_output directory is empty',
'solutions': [
'Use the AI role (Executor) to generate SVG files',
'Save SVG files to the svg_output directory',
'Ensure file naming format: slide_01_cover.svg, slide_02_content.svg, etc.'
],
'severity': 'warning'
},
'invalid_svg_naming': {
'message': 'Non-standard SVG file naming',
'solutions': [
'Rename SVG files using format: slide_XX_name.svg',
'XX should be a two-digit number (01, 02, ...)',
'name should use English or pinyin, separated by underscores',
'Example: slide_01_cover.svg, slide_02_overview.svg'
],
'severity': 'warning'
},
'missing_project_date': {
'message': 'Project directory missing date suffix',
'solutions': [
'Rename the project directory to add a date suffix: _YYYYMMDD',
'Format: {project_name}_{format}_{YYYYMMDD}',
'Example: my_project_ppt169_20251116',
'Command: mv old_name new_name_ppt169_20251116'
],
'severity': 'warning'
},
'viewbox_mismatch': {
'message': 'SVG viewBox does not match canvas format',
'solutions': [
'Check the viewBox attribute of SVG files',
'Ensure it matches the project canvas format',
'PPT 16:9 should be: viewBox="0 0 1280 720"',
'PPT 4:3 should be: viewBox="0 0 1024 768"',
'Reference: references/canvas-formats.md'
],
'severity': 'warning'
},
'multiple_viewboxes': {
'message': 'Multiple different viewBox settings detected',
'solutions': [
'Unify the viewBox across all SVG files',
'All pages in the same project should use the same canvas size',
'Use find-and-replace tools for batch correction',
'Reference the viewBox setting of the first page'
],
'severity': 'warning'
},
'no_viewbox': {
'message': 'SVG file missing viewBox attribute',
'solutions': [
'Add the viewBox attribute to the SVG root element',
'Format: <svg viewBox="0 0 1280 720" ...>',
'Ensure width, height are consistent with viewBox',
'This is a mandatory requirement for SVG generation'
],
'severity': 'error'
},
'foreignobject_detected': {
'message': 'Forbidden <foreignObject> element detected',
'solutions': [
'Remove <foreignObject> elements',
'Use <text> + <tspan> for manual line wrapping',
'This is a project technical specification requirement',
'Reference: references/design-guidelines.md'
],
'severity': 'error'
},
'clippath_detected': {
'message': 'Forbidden <clipPath> element detected',
'solutions': [
'Remove <clipPath> elements',
'PPT does not support SVG clipping paths',
'Use basic shape combinations as an alternative to clipping'
],
'severity': 'error'
},
'mask_detected': {
'message': 'Forbidden <mask> element detected',
'solutions': [
'Remove <mask> elements',
'PPT does not support SVG masks',
'Use opacity (opacity/fill-opacity) as an alternative'
],
'severity': 'error'
},
'style_element_detected': {
'message': 'Forbidden <style> element detected',
'solutions': [
'Remove <style> elements',
'Convert CSS styles to inline attributes',
'Example: fill="#000" instead of class="text-black"'
],
'severity': 'error'
},
'class_attribute_detected': {
'message': 'Forbidden class attribute detected',
'solutions': [
'Remove all class attributes',
'Use inline styles instead',
'Example: fill="#000" stroke="#333" directly on the element'
],
'severity': 'error'
},
'id_attribute_detected': {
'message': 'Forbidden id attribute detected',
'solutions': [
'Remove all id attributes',
'Use inline styles instead',
'Avoid relying on selectors for positioning or style reuse'
],
'severity': 'error'
},
'external_css_detected': {
'message': 'Forbidden external CSS reference detected',
'solutions': [
'Remove <?xml-stylesheet?> declarations',
'Remove <link rel="stylesheet"> references',
'Remove @import external styles',
'Convert styles to inline attributes'
],
'severity': 'error'
},
'symbol_use_detected': {
'message': 'Forbidden <symbol> + <use> complex usage detected',
'solutions': [
'Expand <symbol> into actual SVG code',
'Avoid <symbol> + <use> reuse structures',
'Embed SVG paths directly when icons are needed'
],
'severity': 'error'
},
'marker_detected': {
'message': 'Forbidden <marker> element detected',
'solutions': [
'Remove <marker> definitions',
'Use line + polygon to draw arrows',
'Reference the arrow drawing approach in AGENTS.md'
],
'severity': 'error'
},
'marker_end_detected': {
'message': 'Forbidden marker-end attribute detected',
'solutions': [
'Remove marker-end attributes',
'Use line + polygon to draw arrows',
'Ensure arrow direction matches the line'
],
'severity': 'error'
},
'rgba_detected': {
'message': 'Forbidden rgba() color detected',
'solutions': [
'Replace rgba() with hex + opacity notation',
'Example: fill="#FFFFFF" fill-opacity="0.1"',
'Use stroke-opacity for strokes'
],
'severity': 'error'
},
'group_opacity_detected': {
'message': 'Forbidden <g opacity> detected',
'solutions': [
'Remove group-level opacity',
'Set opacity individually on each child element',
'Use fill-opacity / stroke-opacity for control'
],
'severity': 'error'
},
'image_opacity_detected': {
'message': 'Forbidden <image opacity> detected',
'solutions': [
'Remove image opacity attribute',
'Add a <rect> overlay to control transparency',
'Ensure overlay color matches the background'
],
'severity': 'error'
},
'event_attribute_detected': {
'message': 'Forbidden event attribute detected',
'solutions': [
'Remove onclick/onload and other event attributes',
'Scripts and event handling are forbidden in SVG',
'Implement interactivity in PPT instead'
],
'severity': 'error'
},
'set_detected': {
'message': 'Forbidden <set> element detected',
'solutions': [
'Remove <set> elements',
'SVG animations will not be exported to PPT',
'Use PPT native animations for animation effects'
],
'severity': 'error'
},
'iframe_detected': {
'message': 'Forbidden <iframe> element detected',
'solutions': [
'Remove <iframe> elements',
'External pages should not be embedded in SVG'
],
'severity': 'error'
},
'textpath_detected': {
'message': 'Forbidden <textPath> element detected',
'solutions': [
'Remove <textPath> elements',
'Text on path is not compatible with PPT',
'Use regular <text> elements and adjust position manually'
],
'severity': 'error'
},
'webfont_detected': {
'message': 'Forbidden web font (@font-face) detected',
'solutions': [
'Remove @font-face declarations',
'Use the system font stack',
'font-family: system-ui, -apple-system, sans-serif'
],
'severity': 'error'
},
'animation_detected': {
'message': 'Forbidden SMIL animation element detected',
'solutions': [
'Remove all <animate>, <animateMotion>, <animateTransform> and similar elements',
'SVG animations will not be exported to PPT',
'Use PPT native animations for animation effects'
],
'severity': 'error'
},
'script_detected': {
'message': 'Forbidden <script> element detected',
'solutions': [
'Remove <script> elements',
'Scripts and event handling are forbidden',
'JavaScript in SVG will not execute in PPT'
],
'severity': 'error'
},
'invalid_font': {
'message': 'Non-standard font used',
'solutions': [
'Use the system UI font stack',
'font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
'Avoid using specific font names (e.g. Arial, Helvetica)',
'Ensure cross-platform compatibility'
],
'severity': 'warning'
}
}
@classmethod
def get_solution(cls, error_type: str, context: Optional[Dict] = None) -> Dict:
"""
Get the solution for an error.
Args:
error_type: Error type
context: Context information (optional)
Returns:
Dictionary containing message, solutions, severity
"""
if error_type in cls.ERROR_SOLUTIONS:
solution = cls.ERROR_SOLUTIONS[error_type].copy()
# Customize message based on context
if context:
solution = cls._customize_solution(solution, context)
return solution
# Unknown error type
return {
'message': 'Unknown error',
'solutions': ['Please check the documentation or contact the maintainer'],
'severity': 'error'
}
@classmethod
def _customize_solution(cls, solution: Dict, context: Dict) -> Dict:
"""
Customize solution based on context.
Args:
solution: Original solution
context: Context information
Returns:
Customized solution
"""
customized = solution.copy()
# Customize based on project path
if 'project_path' in context:
project_path = context['project_path']
customized['solutions'] = [
s.replace('<project_path>', project_path).replace(
'<your_project>', project_path)
for s in customized['solutions']
]
# Customize based on filename
if 'file_name' in context:
file_name = context['file_name']
customized['message'] = f"{customized['message']}: {file_name}"
# Customize based on expected/actual values
if 'expected' in context and 'actual' in context:
customized['message'] += f" (expected: {context['expected']}, actual: {context['actual']})"
return customized
@classmethod
def format_error_message(cls, error_type: str, context: Optional[Dict] = None) -> str:
"""
Format error message (for terminal output).
Args:
error_type: Error type
context: Context information
Returns:
Formatted error message string
"""
solution = cls.get_solution(error_type, context)
lines = []
# Error message
severity_icon = "[ERROR]" if solution['severity'] == 'error' else "[WARN]"
lines.append(f"{severity_icon} {solution['message']}")
# Solutions
if solution['solutions']:
lines.append("\nSuggested fixes:")
for i, sol in enumerate(solution['solutions'], 1):
lines.append(f" {i}. {sol}")
return "\n".join(lines)
@classmethod
def print_error(cls, error_type: str, context: Optional[Dict] = None):
"""
Print formatted error message.
Args:
error_type: Error type
context: Context information
"""
print(cls.format_error_message(error_type, context))
@classmethod
def get_all_error_types(cls) -> List[str]:
"""Get all supported error types."""
return list(cls.ERROR_SOLUTIONS.keys())
@classmethod
def print_help(cls):
"""Print all error types and solutions."""
print("PPT Master - Error Types and Solutions\n")
print("=" * 80)
for error_type, info in cls.ERROR_SOLUTIONS.items():
print(f"\n[{error_type}]")
print(f"Message: {info['message']}")
print(f"Severity: {info['severity']}")
print("Solutions:")
for i, sol in enumerate(info['solutions'], 1):
print(f" {i}. {sol}")
print("-" * 80)
def main():
"""Main function - for testing."""
import sys
if len(sys.argv) > 1:
error_type = sys.argv[1]
context = {}
# Parse context parameters
for arg in sys.argv[2:]:
if '=' in arg:
key, value = arg.split('=', 1)
context[key] = value
print(ErrorHelper.format_error_message(error_type, context))
else:
ErrorHelper.print_help()
if __name__ == '__main__':
main()
scripts/finalize_svg.py
#!/usr/bin/env python3
"""
PPT Master - SVG Post-processing Tool (Unified Entry Point)
Processes SVG files from svg_output/ and outputs them to svg_final/.
By default, all processing steps are executed. You can also specify
individual steps via arguments.
Usage:
# Execute all processing steps (recommended)
python3 scripts/finalize_svg.py <project_directory>
# Execute only specific steps
python3 scripts/finalize_svg.py <project_directory> --only embed-icons fix-rounded
Examples:
python3 scripts/finalize_svg.py projects/my_project
python3 scripts/finalize_svg.py examples/ppt169_demo --only embed-icons
Processing options:
embed-icons - Replace <use data-icon="..."/> with actual icon SVG
crop-images - Smart crop images based on preserveAspectRatio="slice"
fix-aspect - Fix image aspect ratio (prevent stretching during PPT shape conversion)
embed-images - Convert external images to Base64 embedded
flatten-text - Convert <tspan> to independent <text> (for special renderers)
fix-rounded - Convert <rect rx="..."/> to <path> (for PPT shape conversion)
"""
import os
import sys
import shutil
import argparse
from pathlib import Path
# Import utility modules from the same directory
sys.path.insert(0, str(Path(__file__).parent))
from embed_icons import process_svg_file as embed_icons_in_file
from embed_images import embed_images_in_svg
from fix_image_aspect import fix_image_aspect_in_svg
from crop_images import process_svg_images as crop_images_in_svg
def safe_print(text):
"""Safe print that handles Windows terminal encoding issues"""
try:
print(text)
except UnicodeEncodeError:
text = text.replace('✅', '[OK]').replace('❌', '[ERROR]')
text = text.replace('📁', '[DIR]').replace('📄', '[FILE]')
print(text)
def process_flatten_text(svg_file: Path, verbose: bool = False) -> bool:
"""Flatten text in a single SVG file (in-place modification)"""
try:
from flatten_tspan import flatten_text_with_tspans
from xml.etree import ElementTree as ET
tree = ET.parse(str(svg_file))
changed = flatten_text_with_tspans(tree)
if changed:
tree.write(str(svg_file), encoding='unicode', xml_declaration=False)
if verbose:
safe_print(f" [OK] {svg_file.name}: text flattened")
return changed
except Exception as e:
if verbose:
safe_print(f" [ERROR] {svg_file.name}: {e}")
return False
def process_rounded_rect(svg_file: Path, verbose: bool = False) -> int:
"""Convert rounded rectangles in a single SVG file (in-place modification)"""
try:
from svg_rect_to_path import process_svg
with open(svg_file, 'r', encoding='utf-8') as f:
content = f.read()
processed, count = process_svg(content, verbose=False)
if count > 0:
with open(svg_file, 'w', encoding='utf-8') as f:
f.write(processed)
if verbose:
safe_print(f" [OK] {svg_file.name}: {count} rounded rectangle(s)")
return count
except Exception as e:
if verbose:
safe_print(f" [ERROR] {svg_file.name}: {e}")
return 0
def finalize_project(project_dir: Path, options: dict, dry_run: bool = False, quiet: bool = False):
"""
Finalize SVG files in the project
Args:
project_dir: Project directory path
options: Processing options dictionary
dry_run: Preview only, do not execute
quiet: Quiet mode, reduce output
"""
svg_output = project_dir / 'svg_output'
svg_final = project_dir / 'svg_final'
icons_dir = Path(__file__).parent.parent / 'templates' / 'icons'
# Check if svg_output exists
if not svg_output.exists():
safe_print(f"[ERROR] svg_output directory not found: {svg_output}")
return False
# Get list of SVG files
svg_files = list(svg_output.glob('*.svg'))
if not svg_files:
safe_print(f"[ERROR] No SVG files in svg_output")
return False
if not quiet:
print()
safe_print(f"[DIR] Project: {project_dir.name}")
safe_print(f"[FILE] {len(svg_files)} SVG file(s)")
if dry_run:
safe_print("[PREVIEW] Preview mode, no operations will be performed")
return True
# Step 1: Copy directory
if svg_final.exists():
shutil.rmtree(svg_final)
shutil.copytree(svg_output, svg_final)
if not quiet:
print()
# Step 2: Embed icons
if options.get('embed_icons'):
if not quiet:
safe_print("[1/6] Embedding icons...")
icons_count = 0
for svg_file in svg_final.glob('*.svg'):
count = embed_icons_in_file(svg_file, icons_dir, dry_run=False, verbose=False)
icons_count += count
if not quiet:
if icons_count > 0:
safe_print(f" {icons_count} icon(s) embedded")
else:
safe_print(" No icons")
# Step 3: Smart crop images (based on preserveAspectRatio="slice")
if options.get('crop_images'):
if not quiet:
safe_print("[2/6] Smart cropping images...")
crop_count = 0
crop_errors = 0
for svg_file in svg_final.glob('*.svg'):
count, errors = crop_images_in_svg(str(svg_file), dry_run=False, verbose=False)
crop_count += count
crop_errors += errors
if not quiet:
if crop_count > 0:
safe_print(f" {crop_count} image(s) cropped")
else:
safe_print(" No cropping needed (no images with slice attribute)")
# Step 4: Fix image aspect ratio (prevent stretching during PPT shape conversion)
if options.get('fix_aspect'):
if not quiet:
safe_print("[3/6] Fixing image aspect ratios...")
aspect_count = 0
for svg_file in svg_final.glob('*.svg'):
count = fix_image_aspect_in_svg(str(svg_file), dry_run=False, verbose=False)
aspect_count += count
if not quiet:
if aspect_count > 0:
safe_print(f" {aspect_count} image(s) fixed")
else:
safe_print(" No images")
# Step 5: Embed images
if options.get('embed_images'):
if not quiet:
safe_print("[4/6] Embedding images...")
images_count = 0
for svg_file in svg_final.glob('*.svg'):
count, _ = embed_images_in_svg(str(svg_file), dry_run=False)
images_count += count
if not quiet:
if images_count > 0:
safe_print(f" {images_count} image(s) embedded")
else:
safe_print(" No images")
# Step 6: Flatten text
if options.get('flatten_text'):
if not quiet:
safe_print("[5/6] Flattening text...")
flatten_count = 0
for svg_file in svg_final.glob('*.svg'):
if process_flatten_text(svg_file, verbose=False):
flatten_count += 1
if not quiet:
if flatten_count > 0:
safe_print(f" {flatten_count} file(s) processed")
else:
safe_print(" No processing needed")
# Step 7: Convert rounded rects to Path
if options.get('fix_rounded'):
if not quiet:
safe_print("[6/6] Converting rounded rects to Path...")
rounded_count = 0
for svg_file in svg_final.glob('*.svg'):
count = process_rounded_rect(svg_file, verbose=False)
rounded_count += count
if not quiet:
if rounded_count > 0:
safe_print(f" {rounded_count} rounded rectangle(s) converted")
else:
safe_print(" No rounded rectangles")
# Done
if not quiet:
print()
safe_print("[OK] Done!")
print()
print("Next steps:")
print(f" python scripts/svg_to_pptx.py \"{project_dir}\" -s final")
return True
def main():
parser = argparse.ArgumentParser(
description='PPT Master - SVG Post-processing Tool',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
Examples:
%(prog)s projects/my_project # Execute all processing (default)
%(prog)s projects/my_project --only embed-icons fix-rounded
%(prog)s projects/my_project -q # Quiet mode
Processing options (for --only):
embed-icons Embed icons
crop-images Smart crop images (based on preserveAspectRatio)
fix-aspect Fix image aspect ratio (prevent stretching during PPT shape conversion)
embed-images Embed images
flatten-text Flatten text
fix-rounded Convert rounded rects to Path
'''
)
parser.add_argument('project_dir', type=Path, help='Project directory path')
parser.add_argument('--only', nargs='+', metavar='OPTION',
choices=['embed-icons', 'crop-images', 'fix-aspect', 'embed-images', 'flatten-text', 'fix-rounded'],
help='Execute only specified processing steps (default: all)')
parser.add_argument('--dry-run', '-n', action='store_true',
help='Preview only, do not execute')
parser.add_argument('--quiet', '-q', action='store_true',
help='Quiet mode, reduce output')
args = parser.parse_args()
if not args.project_dir.exists():
safe_print(f"[ERROR] Project directory does not exist: {args.project_dir}")
sys.exit(1)
# Determine processing options
if args.only:
# Execute only specified steps
options = {
'embed_icons': 'embed-icons' in args.only,
'crop_images': 'crop-images' in args.only,
'fix_aspect': 'fix-aspect' in args.only,
'embed_images': 'embed-images' in args.only,
'flatten_text': 'flatten-text' in args.only,
'fix_rounded': 'fix-rounded' in args.only,
}
else:
# Execute all by default
options = {
'embed_icons': True,
'crop_images': True,
'fix_aspect': True,
'embed_images': True,
'flatten_text': True,
'fix_rounded': True,
}
success = finalize_project(args.project_dir, options, args.dry_run, args.quiet)
sys.exit(0 if success else 1)
if __name__ == '__main__':
main()
scripts/fix_image_aspect.py
#!/usr/bin/env python3
"""
SVG Image Aspect Ratio Fix Tool
Fixes the dimensions of <image> elements in SVG to match the original image aspect ratio.
This prevents images from being stretched when PowerPoint converts SVG to editable shapes.
Principle:
When PowerPoint converts SVG to editable shapes, it ignores the preserveAspectRatio attribute
and directly stretches the image to fill the area specified by width/height.
This tool reads the actual image aspect ratio and recalculates the x, y, width, height of
<image> elements so that images are centered and maintain their original aspect ratio.
Usage:
python3 scripts/fix_image_aspect.py <svg_file> [svg_file2] ...
python3 scripts/fix_image_aspect.py projects/xxx/svg_output/*.svg
# Preview mode
python3 scripts/fix_image_aspect.py --dry-run projects/xxx/svg_output/*.svg
Examples:
python3 scripts/fix_image_aspect.py projects/demo/svg_output/slide_06_current_overview.svg
"""
import os
import re
import sys
import base64
import argparse
from pathlib import Path
from xml.etree import ElementTree as ET
# Try to import PIL for getting image dimensions
try:
from PIL import Image
HAS_PIL = True
except ImportError:
HAS_PIL = False
print("[WARN] PIL not installed. Install with: pip install Pillow")
print(" Will try to use basic method for JPEG/PNG files.")
def get_image_dimensions_pil(image_path):
"""Get image dimensions using PIL."""
try:
with Image.open(image_path) as img:
return img.width, img.height
except Exception as e:
print(f" [WARN] Cannot read image with PIL: {e}")
return None, None
def get_image_dimensions_basic(image_path):
"""Get image dimensions using basic method (no PIL dependency)."""
try:
with open(image_path, 'rb') as f:
data = f.read(64) # Read header information
# PNG
if data[:8] == b'\x89PNG\r\n\x1a\n':
w = int.from_bytes(data[16:20], 'big')
h = int.from_bytes(data[20:24], 'big')
return w, h
# JPEG
if data[:2] == b'\xff\xd8':
# Need to read full file to parse JPEG
with open(image_path, 'rb') as f:
f.seek(2)
while True:
marker = f.read(2)
if not marker or len(marker) < 2:
break
if marker[0] != 0xff:
break
m = marker[1]
# SOF0, SOF2 markers contain dimensions
if m in (0xC0, 0xC2):
f.read(3) # Skip length and precision
h = int.from_bytes(f.read(2), 'big')
w = int.from_bytes(f.read(2), 'big')
return w, h
elif m == 0xD9: # EOI
break
elif m == 0xD8: # SOI
continue
elif 0xD0 <= m <= 0xD7: # RST
continue
else:
length = int.from_bytes(f.read(2), 'big')
f.seek(length - 2, 1)
return None, None
except Exception as e:
print(f" [WARN] Cannot read image dimensions: {e}")
return None, None
def get_image_dimensions_from_base64(data_uri):
"""Get image dimensions from a Base64 data URI."""
import io
try:
# Parse data URI
match = re.match(r'data:image/(\w+);base64,(.+)', data_uri)
if not match:
return None, None
img_format = match.group(1)
b64_data = match.group(2)
img_bytes = base64.b64decode(b64_data)
if HAS_PIL:
with Image.open(io.BytesIO(img_bytes)) as img:
return img.width, img.height
else:
# Use basic method
if img_bytes[:8] == b'\x89PNG\r\n\x1a\n':
w = int.from_bytes(img_bytes[16:20], 'big')
h = int.from_bytes(img_bytes[20:24], 'big')
return w, h
return None, None
except Exception as e:
print(f" [WARN] Cannot parse base64 image: {e}")
return None, None
def get_image_dimensions(href, svg_dir):
"""Get image dimensions."""
# Handle data URI
if href.startswith('data:'):
return get_image_dimensions_from_base64(href)
# Handle external files
if not os.path.isabs(href):
full_path = os.path.join(svg_dir, href)
else:
full_path = href
if not os.path.exists(full_path):
print(f" [WARN] Image not found: {href}")
return None, None
if HAS_PIL:
return get_image_dimensions_pil(full_path)
else:
return get_image_dimensions_basic(full_path)
def calculate_fitted_dimensions(img_width, img_height, box_width, box_height, mode='meet'):
"""
Calculate the fitted dimensions for an image within a bounding box.
Args:
img_width, img_height: Original image dimensions
box_width, box_height: Container box dimensions
mode: 'meet' preserves aspect ratio and fully displays image (may have whitespace)
'slice' preserves aspect ratio and fully fills container (may crop)
Returns:
(new_width, new_height, offset_x, offset_y)
"""
img_ratio = img_width / img_height
box_ratio = box_width / box_height
if mode == 'meet':
# Fully display image, may have whitespace
if img_ratio > box_ratio:
# Image is wider, fit by width
new_width = box_width
new_height = box_width / img_ratio
else:
# Image is taller, fit by height
new_height = box_height
new_width = box_height * img_ratio
else: # slice
# Fully fill container, may crop
if img_ratio > box_ratio:
# Image is wider, fit by height
new_height = box_height
new_width = box_height * img_ratio
else:
# Image is taller, fit by width
new_width = box_width
new_height = box_width / img_ratio
# Center offset
offset_x = (box_width - new_width) / 2
offset_y = (box_height - new_height) / 2
return new_width, new_height, offset_x, offset_y
def fix_image_aspect_in_svg(svg_path, dry_run=False, verbose=True):
"""
Fix image aspect ratios in an SVG file.
Args:
svg_path: SVG file path
dry_run: Whether to only preview without modifying
verbose: Whether to output detailed information
Returns:
Number of images fixed
"""
svg_dir = os.path.dirname(os.path.abspath(svg_path))
with open(svg_path, 'r', encoding='utf-8') as f:
content = f.read()
# Register SVG namespaces
namespaces = {
'': 'http://www.w3.org/2000/svg',
'xlink': 'http://www.w3.org/1999/xlink',
'svg': 'http://www.w3.org/2000/svg',
'sodipodi': 'http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd',
'inkscape': 'http://www.inkscape.org/namespaces/inkscape',
}
for prefix, uri in namespaces.items():
if prefix:
ET.register_namespace(prefix, uri)
else:
ET.register_namespace('', uri)
try:
tree = ET.parse(svg_path)
root = tree.getroot()
except ET.ParseError as e:
print(f" [ERROR] Cannot parse SVG: {e}")
return 0
# Find all image elements
fixed_count = 0
# Check image elements with and without namespace
for ns_prefix in ['', '{http://www.w3.org/2000/svg}']:
for image_elem in root.iter(f'{ns_prefix}image'):
# Get href attribute (supports xlink:href and href)
href = image_elem.get('{http://www.w3.org/1999/xlink}href')
if href is None:
href = image_elem.get('href')
if href is None:
continue
# Get current dimensions and position
try:
x = float(image_elem.get('x', 0))
y = float(image_elem.get('y', 0))
width = float(image_elem.get('width', 0))
height = float(image_elem.get('height', 0))
except (ValueError, TypeError):
continue
if width <= 0 or height <= 0:
continue
# Get preserveAspectRatio
par = image_elem.get('preserveAspectRatio', 'xMidYMid meet')
# Parse preserveAspectRatio
# Format: <align> [<meetOrSlice>]
# e.g.: xMidYMid meet, xMidYMid slice, none
par_parts = par.split()
align = par_parts[0] if par_parts else 'xMidYMid'
meet_or_slice = par_parts[1] if len(par_parts) > 1 else 'meet'
if align == 'none':
# If none, no fix needed
continue
# Get original image dimensions
img_width, img_height = get_image_dimensions(href, svg_dir)
if img_width is None or img_height is None:
continue
# Calculate fitted dimensions
mode = 'slice' if meet_or_slice == 'slice' else 'meet'
new_width, new_height, offset_x, offset_y = calculate_fitted_dimensions(
img_width, img_height, width, height, mode
)
# Check if modification is needed
tolerance = 0.5 # Allowed tolerance
if (abs(new_width - width) < tolerance and
abs(new_height - height) < tolerance):
# Dimensions are already correct, no modification needed
continue
if verbose:
img_name = os.path.basename(href.split('?')[0][:50] if not href.startswith('data:') else '[base64]')
print(f" [FIX] {img_name}")
print(f" Original image: {img_width}x{img_height} (ratio: {img_width/img_height:.3f})")
print(f" Original box: {width}x{height} @ ({x}, {y})")
print(f" New box: {new_width:.1f}x{new_height:.1f} @ ({x + offset_x:.1f}, {y + offset_y:.1f})")
if not dry_run:
# Update attributes
image_elem.set('x', f'{x + offset_x:.1f}')
image_elem.set('y', f'{y + offset_y:.1f}')
image_elem.set('width', f'{new_width:.1f}')
image_elem.set('height', f'{new_height:.1f}')
# Remove preserveAspectRatio since dimensions are now correct
if 'preserveAspectRatio' in image_elem.attrib:
del image_elem.attrib['preserveAspectRatio']
fixed_count += 1
if not dry_run and fixed_count > 0:
# Save modifications
tree.write(svg_path, encoding='unicode', xml_declaration=True)
return fixed_count
def main():
parser = argparse.ArgumentParser(
description='Fix image aspect ratios in SVG to prevent stretching when PowerPoint converts to shapes',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
Examples:
%(prog)s slide_01.svg # Process a single file
%(prog)s *.svg # Process all SVGs in current directory
%(prog)s --dry-run *.svg # Preview files to be processed
%(prog)s projects/xxx/svg_output/*.svg # Process project directory
'''
)
parser.add_argument('files', nargs='+', help='SVG files to process')
parser.add_argument('--dry-run', '-n', action='store_true',
help='Only show which images would be fixed, without modifying files')
parser.add_argument('--quiet', '-q', action='store_true',
help='Quiet mode, reduce output')
args = parser.parse_args()
if args.dry_run:
print("[INFO] Preview mode: only showing what would be modified, no files will be changed\n")
total_fixed = 0
total_files = 0
for svg_file in args.files:
if not os.path.exists(svg_file):
if not args.quiet:
print(f"[ERROR] File not found: {svg_file}")
continue
if not svg_file.endswith('.svg'):
if not args.quiet:
print(f"[SKIP] Skipping non-SVG file: {svg_file}")
continue
if not args.quiet:
print(f"\n[FILE] {os.path.basename(svg_file)}")
fixed = fix_image_aspect_in_svg(svg_file, dry_run=args.dry_run, verbose=not args.quiet)
if fixed > 0:
total_fixed += fixed
total_files += 1
elif not args.quiet:
print(" No fix needed")
print(f"\n{'=' * 50}")
if args.dry_run:
print(f"[PREVIEW] Will fix {total_fixed} image(s) in {total_files} file(s)")
else:
print(f"[DONE] Fixed {total_fixed} image(s) in {total_files} file(s)")
if __name__ == '__main__':
main()
scripts/flatten_tspan.py
import os
import sys
import re
import argparse
from xml.etree import ElementTree as ET
SVG_NS = "http://www.w3.org/2000/svg"
NSMAP = {"svg": SVG_NS}
# Ensure pretty element names without ns0 prefix on write
ET.register_namespace("", SVG_NS)
TEXT_STYLE_ATTRS = {
# common text styling
"font-family",
"font-size",
"font-weight",
"font-style",
"font-variant",
"font-stretch",
"letter-spacing",
"word-spacing",
"kerning",
"text-anchor",
"text-decoration",
"dominant-baseline",
"writing-mode",
"direction",
# color/paint
"fill",
"fill-opacity",
"stroke",
"stroke-width",
"stroke-opacity",
"opacity",
"paint-order",
# transforms/filters
"transform",
"clip-path",
"filter",
}
num_re = re.compile(r"^[\s,]*([+-]?(?:\d+\.?\d*|\d*\.\d+))")
def parse_first_number(val: str):
if val is None:
return None
m = num_re.match(val)
if not m:
return None
try:
return float(m.group(1))
except ValueError:
return None
def format_number(n: float) -> str:
if n is None:
return None
if abs(n - round(n)) < 1e-6:
return str(int(round(n)))
# Trim trailing zeros
s = f"{n:.6f}".rstrip("0").rstrip(".")
return s
def parse_style(style_str: str) -> dict:
out = {}
if not style_str:
return out
# split by ; and then :
for chunk in style_str.split(";"):
if not chunk.strip():
continue
if ":" in chunk:
k, v = chunk.split(":", 1)
out[k.strip()] = v.strip()
return out
def style_to_string(style_map: dict) -> str:
if not style_map:
return ""
return ";".join(f"{k}:{v}" for k, v in style_map.items())
def merge_styles(parent_style: str, child_style: str) -> str:
p = parse_style(parent_style)
c = parse_style(child_style)
p.update(c) # child overrides
return style_to_string(p)
def get_attr(elem, name, default=None):
return elem.get(name) if elem is not None and name in elem.attrib else default
def compute_line_positions(text_el, tspan_el, cur_x, cur_y):
"""
Compute absolute x,y for a tspan based on parent <text> current baseline and tspan's x/y/dx/dy.
Returns (new_x, new_y).
"""
# Prefer explicit x/y on tspan
t_x_attr = get_attr(tspan_el, "x")
t_y_attr = get_attr(tspan_el, "y")
t_dx_attr = get_attr(tspan_el, "dx")
t_dy_attr = get_attr(tspan_el, "dy")
if t_x_attr is not None:
nx = parse_first_number(t_x_attr)
elif t_dx_attr is not None:
dx = parse_first_number(t_dx_attr) or 0.0
nx = (cur_x or 0.0) + dx
else:
nx = cur_x
if t_y_attr is not None:
ny = parse_first_number(t_y_attr)
elif t_dy_attr is not None:
dy = parse_first_number(t_dy_attr) or 0.0
ny = (cur_y or 0.0) + dy
else:
ny = cur_y
return nx, ny
def collect_text_content(el) -> str:
# Gather all text within the element (flatten nested tspans if any)
parts = []
for s in el.itertext():
if s:
parts.append(s)
return "".join(parts)
def copy_text_attrs(src_el, dst_el, exclude=None):
exclude = exclude or set()
# Copy style string first
if "style" in src_el.attrib and "style" not in exclude:
dst_el.set("style", src_el.attrib["style"])
for k in TEXT_STYLE_ATTRS:
if k in exclude:
continue
v = src_el.get(k)
if v is not None:
dst_el.set(k, v)
# xml:space preservation
xml_space = src_el.get("{http://www.w3.org/XML/1998/namespace}space")
if xml_space is not None and "{http://www.w3.org/XML/1998/namespace}space" not in exclude:
dst_el.set("{http://www.w3.org/XML/1998/namespace}space", xml_space)
def flatten_text_with_tspans(tree: ET.ElementTree) -> bool:
root = tree.getroot()
parent_map = {c: p for p in root.iter() for c in p}
changed = False
def is_svg_tag(el, name):
return el.tag == f"{{{SVG_NS}}}{name}"
def is_new_line_tspan(tspan):
"""Determine whether a tspan represents a new line (has its own y or non-zero dy)."""
t_dy_attr = get_attr(tspan, "dy")
t_y_attr = get_attr(tspan, "y")
t_x_attr = get_attr(tspan, "x")
dy_val = parse_first_number(t_dy_attr) if t_dy_attr is not None else None
# Has its own y attribute, or has non-zero dy, or has its own x attribute (indicating a new line)
if t_y_attr is not None:
return True
if dy_val is not None and dy_val != 0:
return True
# If tspan has an x attribute and there are preceding sibling tspans, treat it as a new line
if t_x_attr is not None:
return True
return False
# Collect candidates first to avoid modifying while iterating
candidates = []
for el in root.iter():
if is_svg_tag(el, "text"):
has_tspan_child = any(is_svg_tag(c, "tspan") for c in list(el))
if has_tspan_child:
candidates.append(el)
for text_el in candidates:
parent = parent_map.get(text_el)
if parent is None:
continue
# First check whether any tspan needs flattening (dy != 0 or has its own y attribute)
needs_flatten = False
for child in list(text_el):
if not is_svg_tag(child, "tspan"):
continue
if is_new_line_tspan(child):
needs_flatten = True
break
# If no tspan needs a line break, skip the entire text element
if not needs_flatten:
continue
base_x = parse_first_number(get_attr(text_el, "x")) or 0.0
base_y = parse_first_number(get_attr(text_el, "y")) or 0.0
cur_x, cur_y = base_x, base_y
new_texts = []
# Collect tspan elements belonging to the same line
current_line_tspans = []
current_line_lead_text = None
# Leading text directly under <text>
lead_text = (text_el.text or "").strip()
if lead_text:
current_line_lead_text = lead_text
for idx, child in enumerate(list(text_el)):
if not is_svg_tag(child, "tspan"):
continue
content = collect_text_content(child)
# Check whether this tspan starts a new line
if is_new_line_tspan(child):
# Save previously accumulated same-line tspans first
if current_line_tspans or current_line_lead_text:
ne = _create_text_element_from_line(
text_el, current_line_lead_text, current_line_tspans, cur_x, cur_y
)
new_texts.append(ne)
current_line_tspans = []
current_line_lead_text = None
# Update position
nx, ny = compute_line_positions(text_el, child, cur_x, cur_y)
cur_x, cur_y = nx, ny
# If content is not empty, add to the current line
if content.strip():
current_line_tspans.append(child)
# Process the last line
if current_line_tspans or current_line_lead_text:
ne = _create_text_element_from_line(
text_el, current_line_lead_text, current_line_tspans, cur_x, cur_y
)
new_texts.append(ne)
if new_texts:
# Replace original <text> with the list of new <text> nodes
try:
idx = list(parent).index(text_el)
except ValueError:
idx = None
# Insert in place to preserve drawing order
for i, ne in enumerate(new_texts):
if idx is not None:
parent.insert(idx + i, ne)
else:
parent.append(ne)
# Remove the original <text>
parent.remove(text_el)
changed = True
return changed
def _create_text_element_from_line(text_el, lead_text, tspans, x, y):
"""
Create a text element from a line's content (may contain leading text and multiple tspans).
If there is only one tspan and no leading text, create a simple text element.
If there are multiple tspans or leading text, preserve the tspan structure.
"""
ne = ET.Element(f"{{{SVG_NS}}}text")
# Copy attrs from parent <text>
copy_text_attrs(text_el, ne, exclude={"x", "y"})
ne.set("x", format_number(x))
ne.set("y", format_number(y))
# Transform
p_tf = text_el.get("transform")
if p_tf:
ne.set("transform", p_tf)
# If there is only one tspan and no leading text, create a simple text element
if not lead_text and len(tspans) == 1:
tspan = tspans[0]
content = collect_text_content(tspan)
# Merge style
merged_style = merge_styles(text_el.get("style"), tspan.get("style"))
if merged_style:
ne.set("style", merged_style)
# Override specific attributes from tspan
for attr in TEXT_STYLE_ATTRS:
cv = tspan.get(attr)
if cv is not None:
ne.set(attr, cv)
# Combine transform
c_tf = tspan.get("transform")
if p_tf and c_tf:
ne.set("transform", f"{p_tf} {c_tf}")
elif c_tf:
ne.set("transform", c_tf)
ne.text = content
else:
# Preserve tspan structure
if lead_text:
ne.text = lead_text
for tspan in tspans:
# Create a new tspan, but remove position-related attributes
new_tspan = ET.SubElement(ne, f"{{{SVG_NS}}}tspan")
# Copy style attributes
for attr in TEXT_STYLE_ATTRS:
cv = tspan.get(attr)
if cv is not None:
new_tspan.set(attr, cv)
# Copy style
if tspan.get("style"):
new_tspan.set("style", tspan.get("style"))
# Copy text content
new_tspan.text = collect_text_content(tspan)
# Copy tail (text following the tspan)
if tspan.tail:
new_tspan.tail = tspan.tail
return ne
def process_svg_file(src_path: str, dst_path: str) -> bool:
try:
tree = ET.parse(src_path)
except ET.ParseError as e:
print(f"[WARN] Failed to parse {src_path}: {e}")
return False
changed = flatten_text_with_tspans(tree)
# Ensure destination directory exists
os.makedirs(os.path.dirname(dst_path), exist_ok=True)
# Write out XML without XML declaration to mimic input style
tree.write(dst_path, encoding="utf-8", xml_declaration=False, method="xml")
return changed
def _compute_default_out_base(inp: str) -> str:
"""Compute default output path for directory or file input."""
if os.path.isdir(inp):
# Default: if input ends with svg_output, use sibling svg_output_flattext;
# otherwise append _flattext to the directory name at the same level.
head, tail = os.path.split(os.path.normpath(inp))
if tail == "svg_output":
return os.path.join(head, "svg_output_flattext")
return inp.rstrip("/\\") + "_flattext"
else:
base, ext = os.path.splitext(inp)
return base + "_flattext" + ext
def _interactive_get_paths():
"""
Interactive mode: prompt the user for input path (SVG file or directory)
and optional output path. Returns (inp, out_base) or (None, None) if cancelled.
"""
print("[Interactive mode] No arguments provided; running interactively.")
print("Please enter the path to process (SVG file or directory containing SVGs).")
print("Enter q to quit.\n")
while True:
raw = input("Input path (file/dir): ").strip()
if raw.lower() in {"q", "quit", "exit"} or raw == "":
return None, None
inp = os.path.expanduser(raw)
if os.path.exists(inp):
break
print("Path does not exist. Please re-enter or enter q to quit.")
default_out = _compute_default_out_base(inp)
if os.path.isdir(inp):
prompt = f"Output directory [default: {default_out}]: "
else:
prompt = f"Output file [default: {default_out}]: "
raw_out = input(prompt).strip()
out_base = os.path.expanduser(raw_out) if raw_out else default_out
return inp, out_base
def main():
# CLI parsing with optional interactive mode
parser = argparse.ArgumentParser(
description="Flatten <tspan> lines into multiple <text> nodes for better compatibility.",
add_help=True,
)
parser.add_argument("input", nargs="?", help="Input path: SVG file or directory")
parser.add_argument("output", nargs="?", help="Optional output file/dir")
parser.add_argument(
"-i",
"--interactive",
action="store_true",
help="Run in interactive prompt mode to input paths",
)
args = parser.parse_args()
if args.interactive or not args.input:
inp, out_base = _interactive_get_paths()
if not inp:
print("Cancelled. Usage: python scripts/flatten_tspan.py <input_dir_or_svg> [output_dir]")
sys.exit(0)
else:
inp = args.input
out_base = args.output
if os.path.isdir(inp):
# If output base not provided, create a sibling folder named svg_output_flattext for svg_output
if out_base is None:
out_base = _compute_default_out_base(inp)
total = 0
changed_count = 0
out_base_abs = os.path.abspath(out_base)
for root, dirs, files in os.walk(inp):
# Avoid recursing into the output directory when it lives under input
dirs[:] = [d for d in dirs if os.path.abspath(os.path.join(root, d)) != out_base_abs]
rel_root = os.path.relpath(root, inp)
for f in files:
if not f.lower().endswith(".svg"):
continue
src = os.path.join(root, f)
dst = os.path.join(out_base, rel_root, f) if rel_root != "." else os.path.join(out_base, f)
total += 1
changed = process_svg_file(src, dst)
if changed:
changed_count += 1
print(f"Processed {total} SVG(s). With <tspan> flattened: {changed_count}.")
print(f"Output written to: {out_base}")
else:
src = inp
if out_base is None:
out_base = _compute_default_out_base(src)
changed = process_svg_file(src, out_base)
print(f"Written: {out_base} (flattened: {changed})")
if __name__ == "__main__":
main()
scripts/gemini_watermark_remover.py
#!/usr/bin/env python3
"""
PPT Master - Gemini Watermark Remover
Removes the watermark logo from the bottom-right corner of Gemini-generated images.
Uses a reverse blending algorithm to restore original pixels.
Usage:
python3 scripts/gemini_watermark_remover.py <image_path>
python3 scripts/gemini_watermark_remover.py <image_path> -o output_path.png
Examples:
python3 scripts/gemini_watermark_remover.py projects/demo/images/bg_01.png
python3 scripts/gemini_watermark_remover.py image.jpg -o image_clean.jpg
Dependencies:
pip install Pillow numpy
Notes:
- Supports PNG, JPG, JPEG formats
- Automatically detects watermark size (48px or 96px)
- Output file defaults to adding an _unwatermarked suffix
"""
import sys
import argparse
from pathlib import Path
import numpy as np
from PIL import Image
# Import modules from the same directory
sys.path.insert(0, str(Path(__file__).parent))
# Algorithm parameters
ALPHA_THRESHOLD = 0.002 # Alpha threshold; values below this are not processed
MAX_ALPHA = 0.99 # Maximum alpha value to prevent division by zero
LOGO_VALUE = 255 # Logo pixel value (white)
# Watermark background image paths
SCRIPT_DIR = Path(__file__).parent
BG_48_PATH = SCRIPT_DIR / "assets" / "bg_48.png"
BG_96_PATH = SCRIPT_DIR / "assets" / "bg_96.png"
def detect_watermark_config(width: int, height: int) -> dict:
"""
Detect watermark configuration based on image dimensions
Args:
width: Image width
height: Image height
Returns:
Configuration dict containing logo_size, margin_right, margin_bottom
"""
if width > 1024 and height > 1024:
return {"logo_size": 96, "margin_right": 64, "margin_bottom": 64}
return {"logo_size": 48, "margin_right": 32, "margin_bottom": 32}
def calculate_watermark_position(width: int, height: int, config: dict) -> dict:
"""
Calculate watermark position
Args:
width: Image width
height: Image height
config: Watermark configuration
Returns:
Position dict containing x, y, width, height
"""
logo_size = config["logo_size"]
return {
"x": width - config["margin_right"] - logo_size,
"y": height - config["margin_bottom"] - logo_size,
"width": logo_size,
"height": logo_size,
}
def calculate_alpha_map(bg_image: Image.Image) -> np.ndarray:
"""
Calculate the alpha channel map from the watermark background image
Args:
bg_image: Watermark background PNG image
Returns:
Alpha map array (range 0-1)
"""
bg_array = np.array(bg_image.convert("RGB"), dtype=np.float32)
max_channel = np.max(bg_array, axis=2)
return max_channel / 255.0
def remove_watermark(image: Image.Image, alpha_map: np.ndarray, position: dict) -> Image.Image:
"""
Remove watermark using a reverse blending algorithm
Args:
image: Original image
alpha_map: Alpha map array
position: Watermark position
Returns:
Image with watermark removed
"""
img_array = np.array(image.convert("RGBA"), dtype=np.float32)
x, y, w, h = position["x"], position["y"], position["width"], position["height"]
for row in range(h):
for col in range(w):
alpha = alpha_map[row, col]
if alpha < ALPHA_THRESHOLD:
continue
alpha = min(alpha, MAX_ALPHA)
one_minus_alpha = 1.0 - alpha
img_y, img_x = y + row, x + col
for c in range(3):
watermarked = img_array[img_y, img_x, c]
original = (watermarked - alpha * LOGO_VALUE) / one_minus_alpha
img_array[img_y, img_x, c] = np.clip(original, 0, 255)
return Image.fromarray(img_array.astype(np.uint8))
def process_image(input_path: Path, output_path: Path | None = None, verbose: bool = True) -> Path:
"""
Process a single image to remove its watermark
Args:
input_path: Input image path
output_path: Output image path (optional)
verbose: Whether to output detailed information
Returns:
Output file path
"""
image = Image.open(input_path)
width, height = image.size
config = detect_watermark_config(width, height)
position = calculate_watermark_position(width, height, config)
if verbose:
print(f" Image size: {width} x {height}")
print(f" Watermark size: {config['logo_size']} x {config['logo_size']}")
print(f" Watermark position: ({position['x']}, {position['y']})")
bg_path = BG_96_PATH if config["logo_size"] == 96 else BG_48_PATH
if not bg_path.exists():
print(f"Error: Watermark background image not found: {bg_path}")
sys.exit(1)
bg_image = Image.open(bg_path)
alpha_map = calculate_alpha_map(bg_image)
result = remove_watermark(image, alpha_map, position)
if output_path is None:
stem = input_path.stem
suffix = input_path.suffix or ".png"
output_path = input_path.parent / f"{stem}_unwatermarked{suffix}"
if output_path.suffix.lower() in (".jpg", ".jpeg"):
result = result.convert("RGB")
result.save(output_path)
return output_path
def main():
parser = argparse.ArgumentParser(
description='PPT Master - Gemini Watermark Remover',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
Examples:
%(prog)s projects/demo/images/bg_01.png
%(prog)s image.jpg -o image_clean.jpg
Notes:
- Automatically detects watermark size (96px for large images, 48px for small)
- Supports PNG, JPG, JPEG formats
- Output file defaults to adding an _unwatermarked suffix
'''
)
parser.add_argument('input', type=Path, help='Input image path')
parser.add_argument('-o', '--output', type=Path, default=None, help='Output image path')
parser.add_argument('-q', '--quiet', action='store_true', help='Quiet mode')
args = parser.parse_args()
if not args.input.exists():
print(f"Error: File not found: {args.input}")
sys.exit(1)
verbose = not args.quiet
if verbose:
print("PPT Master - Gemini Watermark Remover")
print("=" * 40)
print(f" Input file: {args.input}")
output = process_image(args.input, args.output, verbose=verbose)
if verbose:
print()
print(f"[Done] Saved to: {output}")
if __name__ == "__main__":
main()
scripts/generate_examples_index.py
#!/usr/bin/env python3
"""
PPT Master - Examples Index Generator
Automatically scans the examples directory and generates a README.md index file.
Usage:
python3 scripts/generate_examples_index.py
python3 scripts/generate_examples_index.py examples
"""
import sys
from pathlib import Path
from datetime import datetime
from collections import defaultdict
try:
from project_utils import find_all_projects, get_project_info, CANVAS_FORMATS
except ImportError:
print("Error: Cannot import the project_utils module")
print("Please ensure project_utils.py is in the same directory")
sys.exit(1)
def generate_examples_index(examples_dir: str = 'examples') -> str:
"""
Generate a README.md index for the examples directory
Args:
examples_dir: Path to the examples directory
Returns:
Generated README.md content
"""
examples_path = Path(examples_dir)
if not examples_path.exists():
print(f"[ERROR] Directory not found: {examples_dir}")
return ""
print(f"[SCAN] Scanning directory: {examples_dir}")
# Find all projects
projects = find_all_projects(examples_dir)
if not projects:
print("[WARN] No projects found")
return ""
print(f"Found {len(projects)} project(s)")
# Collect project information
projects_info = []
for project_path in projects:
info = get_project_info(str(project_path))
projects_info.append(info)
# Sort by date (newest first)
projects_info.sort(key=lambda x: x['date'], reverse=True)
# Group by format
by_format = defaultdict(list)
for info in projects_info:
by_format[info['format']].append(info)
# Generate README content
content = []
content.append("# PPT Master Example Projects Index\n")
content.append("> This file is auto-generated by `skills/ppt-master/scripts/generate_examples_index.py`\n")
content.append(f"> Last updated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
# Overview statistics
content.append("## [Stats] Overview\n")
content.append(f"- **Total projects**: {len(projects_info)}")
content.append(f"- **Canvas formats**: {len(by_format)} type(s)")
total_svgs = sum(info['svg_count'] for info in projects_info)
content.append(f"- **SVG files**: {total_svgs}")
# Statistics by format
content.append("\n### Format Distribution\n")
for fmt_key in sorted(by_format.keys(), key=lambda x: len(by_format[x]), reverse=True):
count = len(by_format[fmt_key])
fmt_name = CANVAS_FORMATS.get(fmt_key, {}).get('name', fmt_key)
content.append(f"- **{fmt_name}**: {count} project(s)")
# Recently updated
content.append("\n## [New] Recently Updated\n")
for info in projects_info[:5]:
content.append(
f"- **{info['name']}** ({info['format_name']}) - {info['date_formatted']}")
# Project list by format
content.append("\n## [List] Project List\n")
# Define format display order
format_order = ['ppt169', 'ppt43', 'zalo_oa',
'facebook_tiktok', 'social_square', 'story', 'banner', 'a4']
for fmt_key in format_order:
if fmt_key not in by_format:
continue
fmt_info = CANVAS_FORMATS.get(fmt_key, {})
fmt_name = fmt_info.get('name', fmt_key)
dimensions = fmt_info.get('dimensions', '')
content.append(f"\n### {fmt_name} ({dimensions})\n")
projects_list = by_format[fmt_key]
# Sort by date
projects_list.sort(key=lambda x: x['date'], reverse=True)
for info in projects_list:
# Project name and link
project_link = f"./{info['dir_name']}"
# Build project entry
line = f"- **[{info['name']}]({project_link})**"
# Add date
line += f" - {info['date_formatted']}"
# Add SVG count
line += f" - {info['svg_count']} page(s)"
content.append(line)
# Other uncategorized formats
other_formats = set(by_format.keys()) - set(format_order)
if other_formats:
content.append("\n### Other Formats\n")
for fmt_key in sorted(other_formats):
projects_list = by_format[fmt_key]
for info in projects_list:
project_link = f"./{info['dir_name']}"
line = f"- **[{info['name']}]({project_link})**"
line += f" ({info['format_name']}) - {info['date_formatted']}"
line += f" - {info['svg_count']} page(s)"
content.append(line)
# Usage instructions
content.append("\n## [Docs] Usage Instructions\n")
content.append("### Preview Projects\n")
content.append("Each project contains the following files:\n")
content.append("- `README.md` - Project documentation")
content.append("- `Design Spec & Content Outline.md` - Full design specification")
content.append("- `svg_output/` - SVG output files\n")
content.append("**Method 1: Using an HTTP server (recommended)**\n")
content.append("```bash")
content.append(
"python3 -m http.server --directory examples/<project_name>/svg_output 8000")
content.append("# Visit http://localhost:8000")
content.append("```\n")
content.append("**Method 2: Open SVG directly**\n")
content.append("```bash")
content.append(
"open examples/<project_name>/svg_output/slide_01_cover.svg")
content.append("```\n")
# Create new project
content.append("### Create a New Project\n")
content.append("Refer to existing project structures, or use the project management tool:\n")
content.append("```bash")
content.append(
"python3 scripts/project_manager.py init my_project --format ppt169")
content.append("```\n")
# Contribution guidelines
content.append("## [Contribute] Contributing Example Projects\n")
content.append("We welcome you to share your projects in the examples directory!\n")
content.append("### Project Requirements\n")
content.append("1. Follow the standard project structure")
content.append("2. Include a complete README.md and design specification")
content.append("3. SVG files must comply with technical specifications")
content.append("4. Directory naming format: `{project_name}_{format}_{YYYYMMDD}`\n")
content.append("### Submission Process\n")
content.append("1. Create a project under the `examples/` directory")
content.append(
"2. Validate the project: `python3 scripts/project_manager.py validate examples/<project>`")
content.append("3. Update the index: `python3 scripts/generate_examples_index.py`")
content.append("4. Submit a Pull Request\n")
# Related resources
content.append("## [Resources] Related Resources\n")
content.append("- [Quick Start](../README.md)")
content.append("- [Workflow Tutorial](../../AGENTS.md)")
content.append("- [Design Guidelines](../references/design-guidelines.md)")
content.append("- [Canvas Formats](../references/canvas-formats.md)")
content.append("- [Role Definitions](../references/)")
content.append("- [Chart Templates](../templates/charts/README.md)\n")
# Footer
content.append("---\n")
content.append(
f"*Auto-generated on {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} by PPT Master*")
return "\n".join(content)
def main():
"""Main function"""
examples_dir = 'examples'
if len(sys.argv) > 1:
examples_dir = sys.argv[1]
print("=" * 80)
print("PPT Master - Examples Index Generator")
print("=" * 80 + "\n")
# Generate index content
content = generate_examples_index(examples_dir)
if not content:
print("\n[ERROR] Generation failed")
sys.exit(1)
# Write to file
output_file = Path(examples_dir) / 'README.md'
try:
with open(output_file, 'w', encoding='utf-8') as f:
f.write(content)
print(f"\n[OK] Index file generated: {output_file}")
print(f" Contains {len(content.splitlines())} lines")
# Display statistics
projects_count = content.count('- **[')
print(f" Indexed {projects_count} project(s)")
except Exception as e:
print(f"\n[ERROR] Failed to write file: {e}")
sys.exit(1)
if __name__ == '__main__':
main()
scripts/nano_banana_gen.py
#!/usr/bin/env python3
"""
Nano Banana Image Generator (Gemini Nano)
A tool for generating high-quality images via the Google GenAI API (Gemini).
Environment variables:
GEMINI_API_KEY (required) Gemini API Key, obtained from https://aistudio.google.com/apikey
GEMINI_BASE_URL (optional) Custom API endpoint for proxy services
Configuration example:
export GEMINI_API_KEY="your-api-key"
export GEMINI_BASE_URL="https://your-proxy-url.com/v1beta" # Only needed when using a proxy
Connection modes:
- Without GEMINI_BASE_URL -> Direct connection to the official Google API
- With GEMINI_BASE_URL -> Via proxy API (using the same Official protocol)
Dependencies:
pip install google-genai Pillow
"""
import os
import sys
import time
import argparse
from google import genai
from google.genai import types
# Optional dependency: PIL (used to report image resolution)
try:
from PIL import Image as PILImage
HAS_PIL = True
except ImportError:
HAS_PIL = False
# ╔══════════════════════════════════════════════════════════════════╗
# ║ Constants ║
# ╚══════════════════════════════════════════════════════════════════╝
# All aspect ratios supported by Gemini 3.1 Flash Image (including new 1:4, 4:1, 1:8, 8:1)
VALID_ASPECT_RATIOS = [
"1:1", "1:4", "1:8",
"2:3", "3:2", "3:4", "4:1", "4:3",
"4:5", "5:4", "8:1", "9:16", "16:9", "21:9"
]
# Official docs: "512px", "1K", "2K", "4K" (K must be uppercase)
VALID_IMAGE_SIZES = ["512px", "1K", "2K", "4K"]
# Default model
DEFAULT_MODEL = "gemini-3.1-flash-image-preview"
# Retry configuration
MAX_RETRIES = 3 # Maximum number of retries
RETRY_BASE_DELAY = 10 # Initial retry wait (seconds)
RETRY_BACKOFF = 2 # Exponential backoff multiplier
# ╔══════════════════════════════════════════════════════════════════╗
# ║ Utilities ║
# ╚══════════════════════════════════════════════════════════════════╝
def save_binary_file(file_name: str, data: bytes):
"""Save binary data to a file"""
with open(file_name, "wb") as f:
f.write(data)
print(f"File saved to: {file_name}")
def _resolve_output_path(prompt: str, output_dir: str = None,
filename: str = None, ext: str = ".png") -> str:
"""Compute the final output file path based on parameters"""
if filename:
file_name = os.path.splitext(filename)[0]
else:
safe = "".join(c for c in prompt if c.isalnum() or c in (' ', '_')).rstrip()
safe = safe.replace(" ", "_").lower()[:30]
file_name = safe or "generated_image"
full_name = f"{file_name}{ext}"
if output_dir:
os.makedirs(output_dir, exist_ok=True)
return os.path.join(output_dir, full_name)
return full_name
def _normalize_image_size(image_size: str) -> str:
"""
Case-insensitive normalization: normalize user input to API-accepted format.
e.g.: "2k" -> "2K", "4k" -> "4K", "512PX" -> "512px"
"""
s = image_size.strip()
upper = s.upper()
if upper in ("1K", "2K", "4K"):
return upper
if upper in ("512PX", "512"):
return "512px"
return s
def _report_resolution(path: str):
"""Try to report image resolution using PIL"""
if HAS_PIL:
try:
img = PILImage.open(path)
print(f" Resolution: {img.size[0]}x{img.size[1]}")
except Exception:
pass
def _is_rate_limit_error(e: Exception) -> bool:
"""Check whether the exception is a rate limit (429) error"""
err_str = str(e).lower()
return "429" in err_str or "rate" in err_str or "quota" in err_str or "resource_exhausted" in err_str
# ╔══════════════════════════════════════════════════════════════════╗
# ║ Image Generation — Unified generation logic (Official / Proxy) ║
# ╚══════════════════════════════════════════════════════════════════╝
def _generate_image(api_key: str, prompt: str, negative_prompt: str = None,
aspect_ratio: str = "1:1", image_size: str = "1K",
output_dir: str = None, filename: str = None,
model: str = DEFAULT_MODEL, base_url: str = None) -> str:
"""
Unified image generation function (streaming).
Automatically selects connection mode based on whether base_url is provided:
- Without base_url -> Direct connection to the official Google API
- With base_url -> Via proxy API (using the same Official protocol)
Uses generate_content_stream for streaming reception with real-time progress feedback:
- Displays elapsed time
- Shows chunk number and data size when a chunk is received
- Keeps the last image chunk (highest quality)
Returns:
Path of the saved image file
Raises:
RuntimeError: When generation fails
"""
# Create client based on whether base_url is provided
if base_url:
client = genai.Client(api_key=api_key, http_options={'base_url': base_url})
else:
client = genai.Client(api_key=api_key)
# Build prompt
final_prompt = prompt
if negative_prompt:
final_prompt += f"\n\nNegative prompt: {negative_prompt}"
config_kwargs = {
"response_modalities": ["IMAGE"],
"image_config": types.ImageConfig(
aspect_ratio=aspect_ratio,
image_size=image_size,
),
}
# ThinkingConfig is only supported by flash series models
if "flash" in model.lower():
config_kwargs["thinking_config"] = types.ThinkingConfig(
thinking_level="MINIMAL",
)
config = types.GenerateContentConfig(**config_kwargs)
mode_label = "Proxy Mode" if base_url else "Official Mode"
print(f"[{mode_label}]")
if base_url:
print(f" Base URL: {base_url}")
print(f" Model: {model}")
print(f" Prompt: {final_prompt[:120]}{'...' if len(final_prompt) > 120 else ''}")
print(f" Aspect Ratio: {aspect_ratio}")
print(f" Image Size: {image_size}")
print()
# Stream response for real-time progress feedback
start_time = time.time()
print(f" ⏳ Generating...", end="", flush=True)
# Heartbeat thread: print elapsed time every 5s while waiting
import threading
heartbeat_stop = threading.Event()
def _heartbeat():
while not heartbeat_stop.is_set():
heartbeat_stop.wait(5)
if not heartbeat_stop.is_set():
elapsed = time.time() - start_time
print(f" {elapsed:.0f}s...", end="", flush=True)
hb_thread = threading.Thread(target=_heartbeat, daemon=True)
hb_thread.start()
last_image_data = None # (PIL.Image or bytes, mime_type)
chunk_count = 0
total_bytes = 0
for chunk in client.models.generate_content_stream(
model=model,
contents=[final_prompt],
config=config,
):
elapsed = time.time() - start_time
if chunk.parts is None:
continue
for part in chunk.parts:
if part.text is not None:
print(f"\n Model says: {part.text}", end="", flush=True)
elif part.inline_data is not None:
chunk_count += 1
data_size = len(part.inline_data.data) if part.inline_data.data else 0
total_bytes += data_size
size_str = f"{data_size / 1024:.0f}KB" if data_size < 1048576 else f"{data_size / 1048576:.1f}MB"
print(f"\n 📦 Chunk #{chunk_count} received ({size_str}, {elapsed:.1f}s)", end="", flush=True)
last_image_data = part
# Stop heartbeat
heartbeat_stop.set()
hb_thread.join(timeout=1)
elapsed = time.time() - start_time
print(f"\n ✅ Stream complete ({elapsed:.1f}s, {chunk_count} chunk(s), {total_bytes / 1024:.0f}KB total)")
if last_image_data is not None and last_image_data.inline_data is not None:
if chunk_count > 1:
print(f" Keeping the final chunk (highest quality).")
image = last_image_data.as_image()
path = _resolve_output_path(prompt, output_dir, filename, ".png")
image.save(path)
print(f"File saved to: {path}")
_report_resolution(path)
return path
raise RuntimeError("No image was generated. The server may have refused the request.")
# ╔══════════════════════════════════════════════════════════════════╗
# ║ Entry Point ║
# ╚══════════════════════════════════════════════════════════════════╝
def generate(prompt: str, negative_prompt: str = None,
aspect_ratio: str = "1:1", image_size: str = "1K",
output_dir: str = None, filename: str = None,
model: str = DEFAULT_MODEL,
max_retries: int = MAX_RETRIES) -> str:
"""
Image generation entry point (with automatic retry).
Automatically selects connection mode based on the GEMINI_BASE_URL environment variable:
- Without GEMINI_BASE_URL -> Direct connection to the official Google API
- With GEMINI_BASE_URL -> Via proxy API (using the same Official protocol)
Automatically retries with exponential backoff on 429 Rate Limit errors.
Args:
prompt: Positive prompt text
negative_prompt: Negative prompt text
aspect_ratio: Aspect ratio
image_size: Image size ("512px", "1K", "2K", "4K", case-insensitive)
output_dir: Output directory
filename: Output filename (without extension)
model: Model name (default gemini-3.1-flash-image-preview)
max_retries: Maximum number of retries
Returns:
Path of the saved image file
Raises:
ValueError: When parameters are invalid
RuntimeError: When generation fails after all retries are exhausted
"""
api_key = os.environ.get("GEMINI_API_KEY")
base_url = os.environ.get("GEMINI_BASE_URL")
if not api_key:
raise ValueError("GEMINI_API_KEY environment variable is not set.")
# Case-insensitive normalization
image_size = _normalize_image_size(image_size)
# Validate inputs
if aspect_ratio not in VALID_ASPECT_RATIOS:
raise ValueError(f"Invalid aspect ratio '{aspect_ratio}'. Valid: {VALID_ASPECT_RATIOS}")
if image_size not in VALID_IMAGE_SIZES:
raise ValueError(f"Invalid image size '{image_size}'. Valid: {VALID_IMAGE_SIZES}")
# ── Retry loop ──
last_error = None
for attempt in range(max_retries + 1):
try:
return _generate_image(api_key, prompt, negative_prompt,
aspect_ratio, image_size, output_dir,
filename, model, base_url)
except Exception as e:
last_error = e
if attempt < max_retries and _is_rate_limit_error(e):
delay = RETRY_BASE_DELAY * (RETRY_BACKOFF ** attempt)
print(f"\n ⚠️ Rate limit hit (attempt {attempt + 1}/{max_retries + 1}). "
f"Waiting {delay}s before retry...")
time.sleep(delay)
elif attempt < max_retries:
delay = 5
print(f"\n ⚠️ Error (attempt {attempt + 1}/{max_retries + 1}): {e}. "
f"Retrying in {delay}s...")
time.sleep(delay)
else:
break
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Generate images using Gemini Nano Banana."
)
parser.add_argument(
"prompt", nargs="?", default="Nano Banana",
help="The text prompt for image generation."
)
parser.add_argument(
"--negative_prompt", "-n", default=None,
help="Negative prompt to specify what to avoid."
)
parser.add_argument(
"--aspect_ratio", default="1:1", choices=VALID_ASPECT_RATIOS,
help=f"Aspect ratio. Choices: {VALID_ASPECT_RATIOS}. Default: 1:1."
)
parser.add_argument(
"--image_size", default="1K",
help=f"Image size. Choices: {VALID_IMAGE_SIZES}. Default: 1K. (case-insensitive)"
)
parser.add_argument(
"--output", "-o", default=None,
help="Output directory. Default: current directory."
)
parser.add_argument(
"--filename", "-f", default=None,
help="Output filename (without extension). Overrides auto-naming."
)
parser.add_argument(
"--model", "-m", default=DEFAULT_MODEL,
help=f"Model name. Default: {DEFAULT_MODEL}."
)
args = parser.parse_args()
try:
generate(args.prompt, args.negative_prompt, args.aspect_ratio,
args.image_size, args.output, args.filename, args.model)
except (ValueError, FileNotFoundError) as e:
print(f"Error: {e}")
sys.exit(1)
except RuntimeError as e:
print(f"Error: {e}")
sys.exit(1)
except KeyboardInterrupt:
print("\n\nInterrupted by user.")
sys.exit(130)
scripts/pdf_to_md.py
#!/usr/bin/env python3
"""
PDF to Markdown Converter
Uses PyMuPDF to extract PDF text content and convert to Markdown format.
Supports heading levels, bold, italic, and list detection.
"""
import fitz # PyMuPDF
import argparse
import os
import re
from pathlib import Path
from collections import Counter
def analyze_font_sizes(doc) -> dict:
"""Analyze font size distribution in the document to infer heading levels."""
size_counter = Counter()
for page in doc:
blocks = page.get_text("dict")["blocks"]
for block in blocks:
if block["type"] == 0:
for line in block["lines"]:
for span in line["spans"]:
size = round(span["size"], 1)
text = span["text"].strip()
if text:
size_counter[size] += len(text)
if not size_counter:
return {"body": 12, "h1": 24, "h2": 18, "h3": 14}
sorted_sizes = sorted(size_counter.items(), key=lambda x: x[1], reverse=True)
body_size = sorted_sizes[0][0]
all_sizes = sorted(size_counter.keys(), reverse=True)
larger_sizes = [s for s in all_sizes if s > body_size + 1]
size_map = {"body": body_size}
if len(larger_sizes) >= 1:
size_map["h1"] = larger_sizes[0]
if len(larger_sizes) >= 2:
size_map["h2"] = larger_sizes[1]
if len(larger_sizes) >= 3:
size_map["h3"] = larger_sizes[2]
return size_map
def get_heading_level(size: float, size_map: dict, text: str = "",
flags: int = 0, strict: bool = True) -> int:
"""
Determine heading level using multiple heuristics.
Args:
size: Font size
size_map: Font size mapping
text: Text content (used for additional heuristics)
flags: Font flags (bit 4 = bold)
strict: Strict mode, requires more conditions to be met
Returns:
Heading level (0 = body text, 1-3 = H1-H3)
"""
# Initial determination based on font size
level = 0
if "h1" in size_map and size >= size_map["h1"] - 0.5:
level = 1
elif "h2" in size_map and size >= size_map["h2"] - 0.5:
level = 2
elif "h3" in size_map and size >= size_map["h3"] - 0.5:
level = 3
if level == 0:
return 0
# Non-strict mode returns directly (backward compatible)
if not strict or not text:
return level
# Strict mode: additional validation conditions
text = text.strip()
# Exclusion: text too long is unlikely to be a heading
if len(text) > 80:
return 0
# Exclusion: complete sentences ending with punctuation
sentence_endings = '.。!!??'
if text and text[-1] in sentence_endings:
# But keep numbered headings like "1. Overview" or "Chapter 1."
if not re.match(r'^[\d第]+[.、章节]', text):
return 0
# Bonus: bold text is more likely to be a heading
is_bold = flags & 16
if not is_bold and level >= 2:
# Non-bold subheadings require a larger font size difference
body_size = size_map.get("body", 12)
if size < body_size + 2:
return 0
return level
def is_monospace_font(font_name: str) -> bool:
"""
Determine if the font is monospace (typically used for code).
"""
if not font_name:
return False
font_lower = font_name.lower()
mono_fonts = [
'courier', 'consolas', 'monaco', 'menlo', 'monospace',
'source code', 'fira code', 'jetbrains', 'inconsolata',
'dejavu sans mono', 'liberation mono', 'ubuntu mono',
'roboto mono', 'robotomono', 'sf mono', 'cascadia', 'hack'
]
return any(f in font_lower for f in mono_fonts)
def format_span_text(text: str, flags: int) -> str:
"""Format text based on font flags (bold, italic)."""
text = text.strip()
if not text:
return ""
is_bold = flags & 16
is_italic = flags & 2
if is_bold and is_italic:
return f"***{text}***"
elif is_bold:
return f"**{text}**"
elif is_italic:
return f"*{text}*"
return text
def detect_list_item(text: str) -> tuple:
"""Detect if the text is a list item. Returns (is_list, list_type, content)."""
text = text.strip()
ul_patterns = [
(r'^[•●○◦▪▸►]\s*', '-'),
(r'^[-–—]\s+', '-'),
(r'^\*\s+', '-'),
]
for pattern, marker in ul_patterns:
match = re.match(pattern, text)
if match:
return (True, 'ul', marker + ' ' + text[match.end():])
ol_pattern = r'^(\d+)[.、)]\s*'
match = re.match(ol_pattern, text)
if match:
num = match.group(1)
return (True, 'ol', f"{num}. " + text[match.end():])
return (False, None, text)
def remove_page_footer(text: str) -> str:
"""
Remove page number patterns from footers, e.g. 'November 2025 8' or '2025年11月 8'.
"""
# English month + year + page number
months_en = r'(?:January|February|March|April|May|June|July|August|September|October|November|December)'
pattern_en = rf'\s*{months_en}\s+\d{{4}}\s+\d{{1,3}}\s*$'
text = re.sub(pattern_en, '', text, flags=re.IGNORECASE)
# Chinese format: 2025年11月 8
pattern_cn = r'\s*\d{4}年\d{1,2}月\s+\d{1,3}\s*$'
text = re.sub(pattern_cn, '', text)
return text.rstrip()
def detect_headers_footers(doc, threshold_ratio: float = 0.6) -> set:
"""
Detect headers and footers statistically.
Principle: Headers and footers typically appear at fixed positions (top or bottom)
on each page with the same content. We collect top and bottom text from all pages,
and if certain text appears more frequently than the threshold, it is treated as noise.
"""
if len(doc) < 3:
return set()
headers = []
footers = []
# Sample first 20 and last 20 pages (avoid processing too slowly)
pages_to_scan = list(range(len(doc)))
if len(doc) > 40:
pages_to_scan = pages_to_scan[:20] + pages_to_scan[-20:]
for i in pages_to_scan:
page = doc[i]
rect = page.rect
h = rect.height
# Define top and bottom regions (15% each)
top_rect = fitz.Rect(0, 0, rect.width, h * 0.15)
bottom_rect = fitz.Rect(0, h * 0.85, rect.width, h)
# Extract text blocks
blocks = page.get_text("blocks")
for b in blocks:
b_rect = fitz.Rect(b[:4])
text = b[4].strip()
if not text:
continue
# Simple spatial determination
if b_rect.intersects(top_rect):
headers.append(text)
elif b_rect.intersects(bottom_rect):
footers.append(text)
# Count frequencies
noise_texts = set()
total_scanned = len(pages_to_scan)
for collection in [headers, footers]:
counter = Counter(collection)
for text, count in counter.items():
# if text appears in > 60% of scanned pages, mark as noise
if count / total_scanned > threshold_ratio:
noise_texts.add(text)
return noise_texts
def merge_adjacent_headings(elements: list) -> list:
"""
Merge adjacent same-level short headings.
Example: '# Agent Tools &' + '# Interoperability' -> '# Agent Tools & Interoperability'
"""
if not elements:
return elements
merged = []
i = 0
while i < len(elements):
el = elements[i]
# Only process heading elements
if el.get("type") != 0 or not el.get("is_heading"):
merged.append(el)
i += 1
continue
content = el["content"]
# Extract heading level
match = re.match(r'^(#{1,6})\s+(.+)$', content)
if not match:
merged.append(el)
i += 1
continue
level = match.group(1)
title_text = match.group(2)
# If heading is short and the next one is also the same level, try to merge
j = i + 1
while j < len(elements) and len(title_text) < 60:
next_el = elements[j]
if next_el.get("type") != 0 or not next_el.get("is_heading"):
break
next_match = re.match(r'^(#{1,6})\s+(.+)$', next_el["content"])
if not next_match or next_match.group(1) != level:
break
next_text = next_match.group(2)
# Only merge short heading fragments
if len(next_text) > 40:
break
# Merge
title_text += " " + next_text
j += 1
# Create merged element
merged_el = el.copy()
merged_el["content"] = f"{level} {title_text}"
merged.append(merged_el)
i = j
return merged
def clean_text(text: str) -> str:
"""Clean extracted text."""
lines = text.split('\n')
cleaned_lines = []
prev_empty = False
for line in lines:
line = line.rstrip()
is_empty = len(line.strip()) == 0
if is_empty:
if not prev_empty:
cleaned_lines.append('')
prev_empty = True
else:
cleaned_lines.append(line)
prev_empty = False
return '\n'.join(cleaned_lines)
def merge_adjacent_formatting(text: str) -> str:
"""Merge adjacent formatting markers, e.g. **text1****text2** -> **text1 text2**"""
text = re.sub(r'\*\*\s*\*\*', ' ', text)
text = re.sub(r'\*\s*\*', ' ', text)
text = re.sub(r'\*\*\*\s*\*\*\*', ' ', text)
return text
def is_sentence_end(text: str) -> bool:
"""Check if the text ends with sentence-ending punctuation."""
text = text.rstrip()
if not text:
return True
end_puncts = '.。!!??::;;'
return text[-1] in end_puncts
def should_merge_lines(current: dict, next_line: dict) -> bool:
"""Determine if two lines should be merged into the same paragraph."""
if current.get("is_heading") or next_line.get("is_heading"):
return False
if current.get("is_list") or next_line.get("is_list"):
return False
if is_sentence_end(current.get("content", "")):
return False
return True
def extract_pdf_to_markdown(pdf_path: str, output_path: str = None) -> str:
"""
Extract text, images, and tables from a PDF and convert to Markdown.
"""
try:
doc = fitz.open(pdf_path)
except Exception as e:
print(f"[ERROR] Failed to open PDF file: {e}")
return ""
filename = Path(pdf_path).stem
title = re.sub(r'^\d+-', '', filename).strip()
print(f"[INFO] Analyzing document structure...")
size_map = analyze_font_sizes(doc)
print(f" Font size mapping: body={size_map.get('body', 'N/A')}, " +
f"H1={size_map.get('h1', 'N/A')}, H2={size_map.get('h2', 'N/A')}, H3={size_map.get('h3', 'N/A')}")
print(f"[INFO] Detecting repeated headers/footers...")
noise_texts = detect_headers_footers(doc)
if noise_texts:
print(f" Found {len(noise_texts)} repeated noise texts (will be removed):")
for t in list(noise_texts)[:3]:
print(f" - {t[:30]}...")
markdown_content = f"# {title}\n\n"
img_dir = None
rel_img_dir = None
if output_path:
output_path = Path(output_path)
rel_img_dir = f"{output_path.stem}_files"
img_dir = output_path.parent / rel_img_dir
if not img_dir.exists():
img_dir.mkdir(parents=True, exist_ok=True)
img_count = 0
for page_num, page in enumerate(doc, 1):
if page_num > 1:
# Add page break marker to help LLM understand context segmentation
markdown_content += f"\n\n<!-- Page {page_num} -->\n\n"
try:
tabs = page.find_tables()
except Exception:
tabs = []
tab_rects = [fitz.Rect(t.bbox) for t in tabs]
page_elements = []
for tab in tabs:
page_elements.append({
"y0": tab.bbox[1],
"type": 2,
"content": tab.to_markdown()
})
print(f" [OK] Found table: P{page_num}")
blocks = page.get_text("dict")["blocks"]
for block in blocks:
block_rect = fitz.Rect(block["bbox"])
# Check if this is table content
is_in_table = False
for tab_rect in tab_rects:
intersect = block_rect & tab_rect
if intersect.get_area() > 0.6 * block_rect.get_area():
is_in_table = True
break
if is_in_table:
continue
if block["type"] == 0:
# Check if this is noise text to be filtered (whole block match)
block_text_full = "".join([span["text"] for line in block["lines"] for span in line["spans"]]).strip()
if block_text_full in noise_texts:
continue
for line in block["lines"]:
line_text = ""
line_size = 0
line_flags = 0
span_count = 0
is_code_line = False
formatted_spans = []
for span in line["spans"]:
span_text = span["text"]
if not span_text.strip():
if span_text:
formatted_spans.append(span_text)
continue
span_size = span["size"]
span_flags = span["flags"]
line_size = max(line_size, span_size)
line_flags |= span_flags
span_count += 1
heading_level = get_heading_level(span_size, size_map, span_text, span_flags)
# Detect code font
font_name = span.get("font", "")
if is_monospace_font(font_name):
is_code_line = True
formatted_spans.append(span_text) # No formatting for code
elif heading_level > 0:
formatted_spans.append(span_text.strip())
else:
formatted_spans.append(format_span_text(span_text, span_flags))
line_text = ''.join(formatted_spans).strip()
if not line_text:
continue
# Secondary check: line-level noise match (sometimes blocks are split)
if line_text in noise_texts:
continue
line_text = merge_adjacent_formatting(line_text)
heading_level = get_heading_level(line_size, size_map, line_text, line_flags)
is_list, list_type, list_content = detect_list_item(line_text)
if heading_level > 0:
prefix = '#' * heading_level + ' '
clean_line = re.sub(r'\*+([^*]+)\*+', r'\1', line_text)
final_text = prefix + clean_line
elif is_list:
final_text = list_content
else:
final_text = line_text
page_elements.append({
"y0": line["bbox"][1],
"type": 0,
"content": final_text,
"is_heading": heading_level > 0,
"is_list": is_list,
"is_code": is_code_line
})
elif block["type"] == 1:
page_elements.append({
"y0": block["bbox"][1],
"type": 1,
"content": block
})
page_elements.sort(key=lambda x: x["y0"])
# Merge adjacent same-level short headings
page_elements = merge_adjacent_headings(page_elements)
merged_elements = []
i = 0
while i < len(page_elements):
el = page_elements[i]
if el["type"] == 0 and not el.get("is_heading") and not el.get("is_list"):
merged_content = el["content"]
j = i + 1
while j < len(page_elements):
next_el = page_elements[j]
if next_el["type"] != 0:
break
if not should_merge_lines({"content": merged_content, "is_heading": False, "is_list": False}, next_el):
break
merged_content += " " + next_el["content"]
j += 1
merged_elements.append({
"type": 0,
"content": remove_page_footer(merged_content),
"is_heading": False,
"is_list": False
})
i = j
else:
merged_elements.append(el)
i += 1
prev_was_list = False
prev_was_code = False
code_block_lines = []
def flush_code_block():
"""Flush accumulated code block."""
nonlocal code_block_lines, markdown_content
if code_block_lines:
markdown_content += "```\n"
markdown_content += "\n".join(code_block_lines) + "\n"
markdown_content += "```\n\n"
code_block_lines = []
for el in merged_elements:
if el["type"] == 0:
is_list = el.get("is_list", False)
is_heading = el.get("is_heading", False)
is_code = el.get("is_code", False)
if is_code:
# Accumulate code lines
if prev_was_list:
markdown_content += "\n"
prev_was_list = False
code_block_lines.append(el["content"])
prev_was_code = True
else:
# Non-code line, flush accumulated code block first
if prev_was_code:
flush_code_block()
prev_was_code = False
if is_heading:
if prev_was_list:
markdown_content += "\n"
markdown_content += el["content"] + "\n\n"
prev_was_list = False
elif is_list:
markdown_content += el["content"] + "\n"
prev_was_list = True
else:
if prev_was_list:
markdown_content += "\n"
markdown_content += el["content"] + "\n\n"
prev_was_list = False
elif el["type"] == 2:
if prev_was_code:
flush_code_block()
prev_was_code = False
if prev_was_list:
markdown_content += "\n"
markdown_content += el["content"] + "\n\n"
prev_was_list = False
elif el["type"] == 1:
if prev_was_code:
flush_code_block()
prev_was_code = False
if img_dir:
block = el["content"]
ext = block["ext"]
image_data = block["image"]
safe_filename = filename.replace(" ", "_")
image_name = f"{safe_filename}_p{page_num}_{img_count}.{ext}"
image_path = img_dir / image_name
try:
with open(image_path, "wb") as f:
f.write(image_data)
if prev_was_list:
markdown_content += "\n"
markdown_content += f"\n\n"
img_count += 1
prev_was_list = False
print(f" [OK] Extracted image: {image_name}")
except Exception as e:
print(f" [WARN] Failed to save image: {e}")
# Flush code block at end of page
if prev_was_code:
flush_code_block()
doc.close()
markdown_content = re.sub(r'\n{3,}', '\n\n', markdown_content)
markdown_content = markdown_content.strip() + "\n"
if output_path:
os.makedirs(os.path.dirname(output_path) or '.', exist_ok=True)
with open(output_path, 'w', encoding='utf-8') as f:
f.write(markdown_content)
print(f"[OK] Saved Markdown to: {output_path}")
return markdown_content
def process_directory(input_dir: str, output_dir: str = None):
"""Process all PDF files in the directory."""
input_path = Path(input_dir)
if output_dir:
output_path = Path(output_dir)
else:
output_path = input_path
pdf_files = sorted(input_path.glob('*.pdf'))
print(f"Found {len(pdf_files)} PDF files")
for pdf_file in pdf_files:
output_file = output_path / (pdf_file.stem + '.md')
print(f"Processing: {pdf_file.name}")
extract_pdf_to_markdown(str(pdf_file), str(output_file))
def main():
parser = argparse.ArgumentParser(
description='PDF to Markdown converter (with structure detection and LLM optimization)',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
Examples:
python pdf_to_md.py book.pdf # Convert a single file
python pdf_to_md.py book.pdf -o output.md # Specify output file
python pdf_to_md.py ./pdfs # Convert all PDFs in directory
python pdf_to_md.py ./pdfs -o ./markdown # Specify output directory
Structure detection features:
- Auto-detect heading levels (based on font size)
- Detect bold and italic text
- Detect ordered and unordered lists
- Extract tables and convert to Markdown format (with deduplication)
- [New] Smart detection and removal of repeated page headers/footers
- [New] Add <!-- Page N --> page break markers to help LLM understanding
'''
)
parser.add_argument('input', help='PDF file or directory containing PDFs')
parser.add_argument('-o', '--output', help='Output file or directory')
args = parser.parse_args()
input_path = Path(args.input)
if input_path.is_file():
output = args.output or str(input_path.with_suffix('.md'))
extract_pdf_to_markdown(str(input_path), output)
elif input_path.is_dir():
process_directory(str(input_path), args.output)
else:
print(f"Error: File or directory not found: {args.input}")
return 1
return 0
if __name__ == '__main__':
exit(main())
scripts/pptx_animations.py
#!/usr/bin/env python3
"""
PPT Master - PPTX Animation Module
Provides XML generation for slide transition effects and entrance animations.
Supported transition effects:
- fade: Fade in/out
- push: Push
- wipe: Wipe
- split: Split
- reveal: Reveal
- random: Random
Supported entrance animations:
- fade: Fade in
- fly: Fly in
- zoom: Zoom
- appear: Appear
Dependencies: None (pure XML generation)
"""
from typing import Optional, Dict, Any
# ============================================================================
# Transition effect definitions
# ============================================================================
TRANSITIONS: Dict[str, Dict[str, Any]] = {
'fade': {
'name': 'Fade',
'element': 'fade',
'attrs': {},
},
'push': {
'name': 'Push',
'element': 'push',
'attrs': {'dir': 'r'}, # Push from right
},
'wipe': {
'name': 'Wipe',
'element': 'wipe',
'attrs': {'dir': 'r'}, # Wipe from right
},
'split': {
'name': 'Split',
'element': 'split',
'attrs': {'orient': 'horz', 'dir': 'out'},
},
'reveal': {
'name': 'Reveal',
'element': 'strips',
'attrs': {'dir': 'rd'}, # Reveal from bottom-right
},
'cover': {
'name': 'Cover',
'element': 'cover',
'attrs': {'dir': 'r'},
},
'random': {
'name': 'Random',
'element': 'random',
'attrs': {},
},
}
# Speed mapping (seconds -> OOXML speed values)
SPEED_MAP = {
'slow': 1.0,
'med': 0.5,
'fast': 0.25,
}
def duration_to_speed(duration: float) -> str:
"""Convert duration (seconds) to an OOXML speed value"""
if duration >= 0.75:
return 'slow'
elif duration >= 0.35:
return 'med'
else:
return 'fast'
def create_transition_xml(
effect: str = 'fade',
duration: float = 0.5,
advance_after: Optional[float] = None
) -> str:
"""
Generate a slide transition effect XML fragment
Args:
effect: Transition effect name (fade/push/wipe/split/reveal/cover/random)
duration: Transition duration (seconds)
advance_after: Auto-advance interval (seconds); None means manual advance
Returns:
A <p:transition> element string insertable into slide XML
"""
if effect not in TRANSITIONS:
effect = 'fade'
trans_info = TRANSITIONS[effect]
element_name = trans_info['element']
attrs = trans_info['attrs']
# Build speed attribute
speed = duration_to_speed(duration)
# Build auto-advance attribute
adv_attr = ''
if advance_after is not None:
adv_tm = int(advance_after * 1000) # Convert to milliseconds
adv_attr = f' advTm="{adv_tm}"'
# Build effect element attributes
effect_attrs = ' '.join(f'{k}="{v}"' for k, v in attrs.items())
if effect_attrs:
effect_attrs = ' ' + effect_attrs
# Generate XML
return f''' <p:transition spd="{speed}"{adv_attr}>
<p:{element_name}{effect_attrs}/>
</p:transition>'''
# ============================================================================
# Entrance animation definitions
# ============================================================================
ANIMATIONS: Dict[str, Dict[str, Any]] = {
'fade': {
'name': 'Fade In',
'filter': 'fade',
},
'fly': {
'name': 'Fly In',
'filter': 'fly',
'prLst': 'from(b)', # Fly in from bottom
},
'zoom': {
'name': 'Zoom',
'filter': 'zoom',
'prLst': 'in',
},
'appear': {
'name': 'Appear',
'filter': None, # No filter, only sets visibility
},
}
def create_timing_xml(
animation: str = 'fade',
duration: float = 1.0,
delay: float = 0,
shape_id: int = 2
) -> str:
"""
Generate an entrance animation timing XML fragment
Args:
animation: Animation effect name (fade/fly/zoom/appear)
duration: Animation duration (seconds)
delay: Animation delay (seconds)
shape_id: Target shape ID (SVG image is typically 2)
Returns:
A <p:timing> element string insertable into slide XML
"""
if animation not in ANIMATIONS:
animation = 'fade'
anim_info = ANIMATIONS[animation]
dur_ms = int(duration * 1000)
delay_ms = int(delay * 1000)
# Generate different effect XML depending on animation type
if anim_info['filter'] is None:
# appear animation: only sets visibility
effect_xml = f''' <p:set>
<p:cBhvr>
<p:cTn id="5" dur="1" fill="hold">
<p:stCondLst><p:cond delay="{delay_ms}"/></p:stCondLst>
</p:cTn>
<p:tgtEl><p:spTgt spid="{shape_id}"/></p:tgtEl>
<p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst>
</p:cBhvr>
<p:to><p:strVal val="visible"/></p:to>
</p:set>'''
else:
# Other animations: set visibility + animation effect
filter_name = anim_info['filter']
pr_attr = ''
if 'prLst' in anim_info:
pr_attr = f' prLst="{anim_info["prLst"]}"'
effect_xml = f''' <p:set>
<p:cBhvr>
<p:cTn id="5" dur="1" fill="hold">
<p:stCondLst><p:cond delay="0"/></p:stCondLst>
</p:cTn>
<p:tgtEl><p:spTgt spid="{shape_id}"/></p:tgtEl>
<p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst>
</p:cBhvr>
<p:to><p:strVal val="visible"/></p:to>
</p:set>
<p:animEffect transition="in" filter="{filter_name}"{pr_attr}>
<p:cBhvr>
<p:cTn id="6" dur="{dur_ms}"/>
<p:tgtEl><p:spTgt spid="{shape_id}"/></p:tgtEl>
</p:cBhvr>
</p:animEffect>'''
return f''' <p:timing>
<p:tnLst>
<p:par>
<p:cTn id="1" dur="indefinite" nodeType="tmRoot">
<p:childTnLst>
<p:seq concurrent="1" nextAc="seek">
<p:cTn id="2" dur="indefinite" nodeType="mainSeq">
<p:childTnLst>
<p:par>
<p:cTn id="3" fill="hold">
<p:stCondLst>
<p:cond delay="{delay_ms}"/>
</p:stCondLst>
<p:childTnLst>
<p:par>
<p:cTn id="4" fill="hold">
<p:childTnLst>
{effect_xml}
</p:childTnLst>
</p:cTn>
</p:par>
</p:childTnLst>
</p:cTn>
</p:par>
</p:childTnLst>
</p:cTn>
</p:seq>
</p:childTnLst>
</p:cTn>
</p:par>
</p:tnLst>
</p:timing>'''
def get_available_transitions() -> list:
"""Get a list of all available transition effects"""
return list(TRANSITIONS.keys())
def get_available_animations() -> list:
"""Get a list of all available entrance animations"""
return list(ANIMATIONS.keys())
def get_transition_help() -> str:
"""Get help text for transition effects"""
lines = ["Available transition effects:"]
for key, info in TRANSITIONS.items():
lines.append(f" {key}: {info['name']}")
return '\n'.join(lines)
def get_animation_help() -> str:
"""Get help text for entrance animations"""
lines = ["Available entrance animations:"]
for key, info in ANIMATIONS.items():
lines.append(f" {key}: {info['name']}")
return '\n'.join(lines)
if __name__ == '__main__':
# Test output
print("=== Transition Effect XML Example (fade) ===")
print(create_transition_xml('fade', 0.5))
print()
print("=== Entrance Animation XML Example (fade) ===")
print(create_timing_xml('fade', 1.0))
scripts/project_manager.py
#!/usr/bin/env python3
"""PPT Master project management helpers.
Usage:
python3 scripts/project_manager.py init <project_name> [--format ppt169] [--dir projects]
python3 scripts/project_manager.py import-sources <project_path> <source1> [<source2> ...] [--move]
python3 scripts/project_manager.py validate <project_path>
python3 scripts/project_manager.py info <project_path>
"""
from __future__ import annotations
import re
import shutil
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from urllib.parse import urlparse
try:
from project_utils import (
CANVAS_FORMATS,
get_project_info as get_project_info_common,
normalize_canvas_format,
validate_project_structure,
validate_svg_viewbox,
)
except ImportError:
tools_dir = Path(__file__).resolve().parent
if str(tools_dir) not in sys.path:
sys.path.insert(0, str(tools_dir))
from project_utils import ( # type: ignore
CANVAS_FORMATS,
get_project_info as get_project_info_common,
normalize_canvas_format,
validate_project_structure,
validate_svg_viewbox,
)
TOOLS_DIR = Path(__file__).resolve().parent
SKILL_DIR = TOOLS_DIR.parent
REPO_ROOT = SKILL_DIR.parent.parent
SOURCE_DIRNAME = "sources"
TEXT_SOURCE_SUFFIXES = {".md", ".markdown", ".txt"}
PDF_SUFFIXES = {".pdf"}
DOC_SUFFIXES = {
".docx", ".doc", ".odt", ".rtf", # Office documents
".epub", # eBooks
".html", ".htm", # Web pages
".tex", ".latex", ".rst", ".org", # Academic / technical
".ipynb", ".typ", # Notebooks / Typst
}
WECHAT_HOST_KEYWORDS = ("mp.weixin.qq.com", "weixin.qq.com")
def is_url(value: str) -> bool:
parsed = urlparse(value)
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
def sanitize_name(value: str) -> str:
safe = "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in value.strip())
safe = safe.strip("._")
while "__" in safe:
safe = safe.replace("__", "_")
return safe[:120] or "source"
def derive_url_basename(url: str) -> str:
parsed = urlparse(url)
parts = [sanitize_name(parsed.netloc)]
if parsed.path and parsed.path != "/":
path_part = sanitize_name(parsed.path.strip("/").replace("/", "_"))
if path_part:
parts.append(path_part)
return "_".join(part for part in parts if part) or "web_source"
def is_within_path(path: Path, parent: Path) -> bool:
try:
path.resolve().relative_to(parent.resolve())
return True
except ValueError:
return False
class ProjectManager:
"""Create, inspect, validate, and populate project folders."""
CANVAS_FORMATS = CANVAS_FORMATS
def __init__(self, base_dir: str = "projects") -> None:
self.base_dir = Path(base_dir)
def init_project(
self,
project_name: str,
canvas_format: str = "ppt169",
base_dir: Optional[str] = None,
) -> str:
base_path = Path(base_dir) if base_dir else self.base_dir
normalized_format = normalize_canvas_format(canvas_format)
if normalized_format not in self.CANVAS_FORMATS:
available = ", ".join(sorted(self.CANVAS_FORMATS.keys()))
raise ValueError(
f"Unsupported canvas format: {canvas_format} "
f"(available: {available}; common alias: xhs -> facebook_tiktok)"
)
date_str = datetime.now().strftime("%Y%m%d")
project_dir_name = f"{project_name}_{normalized_format}_{date_str}"
project_path = base_path / project_dir_name
if project_path.exists():
raise FileExistsError(f"Project directory already exists: {project_path}")
for rel_path in (
"svg_output",
"svg_final",
"images",
"notes",
"templates",
SOURCE_DIRNAME,
):
(project_path / rel_path).mkdir(parents=True, exist_ok=True)
canvas_info = self.CANVAS_FORMATS[normalized_format]
readme_path = project_path / "README.md"
readme_path.write_text(
(
f"# {project_name}\n\n"
f"- Canvas format: {normalized_format}\n"
f"- Created: {date_str}\n\n"
"## Directories\n\n"
"- `svg_output/`: raw SVG output\n"
"- `svg_final/`: finalized SVG output\n"
"- `images/`: presentation assets\n"
"- `notes/`: speaker notes\n"
"- `templates/`: project templates\n"
"- `sources/`: source materials and normalized markdown\n"
),
encoding="utf-8",
)
print(f"Project created: {project_path}")
print(f"Canvas: {canvas_info['name']} ({canvas_info['dimensions']})")
return str(project_path)
def _source_dir(self, project_path: Path) -> Path:
sources_dir = project_path / SOURCE_DIRNAME
sources_dir.mkdir(parents=True, exist_ok=True)
return sources_dir
def _ensure_unique_path(self, path: Path) -> Path:
if not path.exists():
return path
suffix = path.suffix
stem = path.stem
counter = 2
while True:
candidate = path.with_name(f"{stem}_{counter}{suffix}")
if not candidate.exists():
return candidate
counter += 1
def _copy_or_move_file(self, source: Path, destination: Path, move: bool) -> Path:
try:
if source.resolve() == destination.resolve():
return destination
except FileNotFoundError:
pass
destination = self._ensure_unique_path(destination)
if move:
shutil.move(str(source), str(destination))
else:
shutil.copy2(source, destination)
return destination
def _copy_or_move_tree(self, source: Path, destination: Path, move: bool) -> Path:
try:
if source.resolve() == destination.resolve():
return destination
except FileNotFoundError:
pass
destination = self._ensure_unique_path(destination)
if move:
shutil.move(str(source), str(destination))
else:
shutil.copytree(source, destination)
return destination
def _run_tool(self, args: List[str]) -> None:
try:
result = subprocess.run(
args,
cwd=REPO_ROOT,
check=True,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
except FileNotFoundError as exc:
raise RuntimeError(f"Missing executable: {args[0]}") from exc
except subprocess.CalledProcessError as exc:
details = (exc.stderr or exc.stdout or "").strip()
raise RuntimeError(details or "tool execution failed") from exc
if result.stdout.strip():
print(result.stdout.strip())
def _import_pdf(self, pdf_path: Path, markdown_path: Path) -> None:
self._run_tool(
[
sys.executable,
str(TOOLS_DIR / "pdf_to_md.py"),
str(pdf_path),
"-o",
str(markdown_path),
]
)
def _import_doc(self, doc_path: Path, markdown_path: Path) -> None:
self._run_tool(
[
sys.executable,
str(TOOLS_DIR / "doc_to_md.py"),
str(doc_path),
"-o",
str(markdown_path),
]
)
def _import_url(self, url: str, markdown_path: Path) -> None:
host = urlparse(url).netloc.lower()
if any(keyword in host for keyword in WECHAT_HOST_KEYWORDS):
command = ["node", str(TOOLS_DIR / "web_to_md.cjs"), url, "-o", str(markdown_path)]
else:
command = [
sys.executable,
str(TOOLS_DIR / "web_to_md.py"),
url,
"-o",
str(markdown_path),
]
self._run_tool(command)
def _archive_url_record(self, sources_dir: Path, url: str) -> Path:
file_path = self._ensure_unique_path(sources_dir / f"{derive_url_basename(url)}.url.txt")
file_path.write_text(
f"URL: {url}\nImported: {datetime.now().isoformat(timespec='seconds')}\n",
encoding="utf-8",
)
return file_path
def _normalize_text_source(self, source_path: Path, sources_dir: Path) -> Path:
target = self._ensure_unique_path(sources_dir / f"{source_path.stem}.md")
content = source_path.read_text(encoding="utf-8", errors="replace")
target.write_text(content, encoding="utf-8")
return target
def _canonicalize_markdown_content(self, content: str) -> str:
canonical = content.replace("\r\n", "\n")
canonical = re.sub(r"(?m)^(\s*Crawled:\s+).*$", r"\1__IGNORED__", canonical)
canonical = re.sub(r"(?m)^(\s*Imported:\s+).*$", r"\1__IGNORED__", canonical)
canonical = re.sub(r"([^\s\]()/]+_files)/", "__ASSET_DIR__/", canonical)
return canonical.strip()
def _find_equivalent_markdown(self, source_path: Path, sources_dir: Path) -> Optional[Path]:
source_content = source_path.read_text(encoding="utf-8", errors="replace")
canonical_source = self._canonicalize_markdown_content(source_content)
for existing in sorted(sources_dir.iterdir()):
if existing.suffix.lower() not in {".md", ".markdown"}:
continue
try:
if existing.resolve() == source_path.resolve():
continue
except FileNotFoundError:
pass
existing_content = existing.read_text(encoding="utf-8", errors="replace")
if self._canonicalize_markdown_content(existing_content) == canonical_source:
return existing
return None
def _companion_asset_dir(self, source_path: Path) -> Optional[Path]:
candidate = source_path.with_name(f"{source_path.stem}_files")
if candidate.exists() and candidate.is_dir():
return candidate
return None
def _rewrite_markdown_asset_refs(
self,
markdown_path: Path,
original_asset_dirname: str,
imported_asset_dirname: str,
) -> None:
if original_asset_dirname == imported_asset_dirname:
return
content = markdown_path.read_text(encoding="utf-8", errors="replace")
updated = content.replace(f"{original_asset_dirname}/", f"{imported_asset_dirname}/")
if updated != content:
markdown_path.write_text(updated, encoding="utf-8")
def _import_markdown_with_assets(
self,
source_path: Path,
sources_dir: Path,
move: bool,
) -> Tuple[Path, Optional[Path], Optional[str]]:
archived_markdown = self._copy_or_move_file(
source_path,
sources_dir / source_path.name,
move=move,
)
asset_dir = self._companion_asset_dir(source_path)
if asset_dir is None:
return archived_markdown, None, None
imported_asset_dir = self._copy_or_move_tree(
asset_dir,
sources_dir / f"{archived_markdown.stem}_files",
move=move,
)
self._rewrite_markdown_asset_refs(
archived_markdown,
original_asset_dirname=asset_dir.name,
imported_asset_dirname=imported_asset_dir.name,
)
note = None
if archived_markdown.stem != source_path.stem:
note = (
f"{source_path}: renamed imported markdown to {archived_markdown.name} "
f"and rewrote asset references to {imported_asset_dir.name}/"
)
return archived_markdown, imported_asset_dir, note
def import_sources(
self,
project_path: str,
source_items: List[str],
move: bool = False,
) -> Dict[str, List[str]]:
project_dir = Path(project_path)
if not project_dir.exists() or not project_dir.is_dir():
raise FileNotFoundError(f"Project directory not found: {project_dir}")
if not source_items:
raise ValueError("At least one source path or URL is required")
sources_dir = self._source_dir(project_dir)
summary: Dict[str, List[str]] = {
"archived": [],
"markdown": [],
"assets": [],
"notes": [],
"skipped": [],
}
explicit_markdown_stems = {
Path(item).stem
for item in source_items
if not is_url(item)
and Path(item).exists()
and Path(item).is_file()
and Path(item).suffix.lower() in {".md", ".markdown"}
}
for item in source_items:
if is_url(item):
archived = self._archive_url_record(sources_dir, item)
markdown_path = self._ensure_unique_path(
sources_dir / f"{derive_url_basename(item)}.md"
)
try:
self._import_url(item, markdown_path)
except Exception as exc: # pragma: no cover - summary path
summary["skipped"].append(f"{item}: {exc}")
continue
summary["archived"].append(str(archived))
summary["markdown"].append(str(markdown_path))
continue
source_path = Path(item)
if not source_path.exists():
summary["skipped"].append(f"{item}: path not found")
continue
if source_path.is_dir():
summary["skipped"].append(f"{item}: directories are not supported")
continue
effective_move = move or is_within_path(source_path, REPO_ROOT)
suffix = source_path.suffix.lower()
if suffix in {".md", ".markdown"}:
duplicate_markdown = self._find_equivalent_markdown(source_path, sources_dir)
if duplicate_markdown is not None:
summary["markdown"].append(str(duplicate_markdown))
summary["notes"].append(
f"{item}: skipped duplicate markdown import because equivalent content already exists as {duplicate_markdown.name}"
)
continue
archived_markdown, asset_dir, note = self._import_markdown_with_assets(
source_path,
sources_dir,
move=effective_move,
)
summary["archived"].append(str(archived_markdown))
summary["markdown"].append(str(archived_markdown))
if asset_dir is not None:
summary["assets"].append(str(asset_dir))
if note:
summary["notes"].append(note)
continue
archived_path = self._copy_or_move_file(
source_path,
sources_dir / source_path.name,
move=effective_move,
)
summary["archived"].append(str(archived_path))
if suffix in PDF_SUFFIXES:
canonical_markdown_path = sources_dir / f"{archived_path.stem}.md"
if archived_path.stem in explicit_markdown_stems:
summary["notes"].append(
f"{item}: skipped PDF auto-conversion because a same-stem Markdown source was provided"
)
continue
if canonical_markdown_path.exists():
summary["markdown"].append(str(canonical_markdown_path))
summary["notes"].append(
f"{item}: skipped PDF auto-conversion because {canonical_markdown_path.name} already exists"
)
continue
markdown_path = canonical_markdown_path
try:
self._import_pdf(archived_path, markdown_path)
summary["markdown"].append(str(markdown_path))
except Exception as exc: # pragma: no cover - summary path
summary["skipped"].append(f"{item}: PDF conversion failed ({exc})")
elif suffix in DOC_SUFFIXES:
canonical_markdown_path = sources_dir / f"{archived_path.stem}.md"
if archived_path.stem in explicit_markdown_stems:
summary["notes"].append(
f"{item}: skipped document auto-conversion because a same-stem Markdown source was provided"
)
continue
if canonical_markdown_path.exists():
summary["markdown"].append(str(canonical_markdown_path))
summary["notes"].append(
f"{item}: skipped document auto-conversion because {canonical_markdown_path.name} already exists"
)
continue
markdown_path = canonical_markdown_path
try:
self._import_doc(archived_path, markdown_path)
summary["markdown"].append(str(markdown_path))
except Exception as exc: # pragma: no cover - summary path
summary["skipped"].append(f"{item}: document conversion failed ({exc})")
elif suffix == ".txt":
markdown_path = self._normalize_text_source(archived_path, sources_dir)
summary["markdown"].append(str(markdown_path))
else:
summary["notes"].append(f"{item}: archived only, no automatic conversion")
return summary
def validate_project(self, project_path: str) -> Tuple[bool, List[str], List[str]]:
project_path_obj = Path(project_path)
is_valid, errors, warnings = validate_project_structure(str(project_path_obj))
if project_path_obj.exists() and project_path_obj.is_dir():
info = get_project_info_common(str(project_path_obj))
if info.get("svg_files"):
svg_files = [project_path_obj / "svg_output" / name for name in info["svg_files"]]
expected_format = info.get("format")
if expected_format == "unknown":
expected_format = None
warnings.extend(validate_svg_viewbox(svg_files, expected_format))
return is_valid, errors, warnings
def get_project_info(self, project_path: str) -> Dict:
shared = get_project_info_common(project_path)
return {
"name": shared.get("name", Path(project_path).name),
"path": shared.get("path", str(project_path)),
"exists": shared.get("exists", False),
"svg_count": shared.get("svg_count", 0),
"has_spec": shared.get("has_spec", False),
"has_source": shared.get("has_source", False),
"source_count": shared.get("source_count", 0),
"canvas_format": shared.get("format_name", "Unknown"),
"create_date": shared.get("date_formatted", "Unknown"),
}
def print_usage() -> None:
print(__doc__)
def parse_init_args(argv: List[str]) -> Tuple[str, str, str]:
if len(argv) < 3:
raise ValueError("Project name is required")
project_name = argv[2]
canvas_format = "ppt169"
base_dir = "projects"
i = 3
while i < len(argv):
if argv[i] == "--format" and i + 1 < len(argv):
canvas_format = argv[i + 1]
i += 2
elif argv[i] == "--dir" and i + 1 < len(argv):
base_dir = argv[i + 1]
i += 2
else:
i += 1
return project_name, canvas_format, base_dir
def parse_import_args(argv: List[str]) -> Tuple[str, List[str], bool]:
if len(argv) < 4:
raise ValueError("Project path and at least one source are required")
project_path = argv[2]
move = False
sources: List[str] = []
for arg in argv[3:]:
if arg == "--move":
move = True
else:
sources.append(arg)
return project_path, sources, move
def main() -> None:
if len(sys.argv) < 2:
print_usage()
sys.exit(1)
command = sys.argv[1]
manager = ProjectManager()
try:
if command == "init":
project_name, canvas_format, base_dir = parse_init_args(sys.argv)
project_path = manager.init_project(project_name, canvas_format, base_dir=base_dir)
print(f"[OK] Project initialized: {project_path}")
print("Next:")
print("1. Put source files into sources/ (or use import-sources)")
print("2. Save your design spec to the project root")
print("3. Generate SVG files into svg_output/")
return
if command == "import-sources":
project_path, sources, move = parse_import_args(sys.argv)
summary = manager.import_sources(project_path, sources, move=move)
print(f"[OK] Imported sources into: {project_path}")
if summary["archived"]:
print("\nArchived originals / URL records:")
for item in summary["archived"]:
print(f" - {item}")
if summary["markdown"]:
print("\nNormalized markdown:")
for item in summary["markdown"]:
print(f" - {item}")
if summary["assets"]:
print("\nImported asset directories:")
for item in summary["assets"]:
print(f" - {item}")
if summary["notes"]:
print("\nNotes:")
for item in summary["notes"]:
print(f" - {item}")
if summary["skipped"]:
print("\nSkipped:")
for item in summary["skipped"]:
print(f" - {item}")
return
if command == "validate":
if len(sys.argv) < 3:
raise ValueError("Project path is required")
project_path = sys.argv[2]
is_valid, errors, warnings = manager.validate_project(project_path)
print(f"\nProject validation: {project_path}")
print("=" * 60)
if errors:
print("\n[ERROR]")
for error in errors:
print(f" - {error}")
if warnings:
print("\n[WARN]")
for warning in warnings:
print(f" - {warning}")
if is_valid and not warnings:
print("\n[OK] Project structure is complete.")
elif is_valid:
print("\n[OK] Project structure is valid, with warnings.")
else:
print("\n[ERROR] Project structure is invalid.")
sys.exit(1)
return
if command == "info":
if len(sys.argv) < 3:
raise ValueError("Project path is required")
project_path = sys.argv[2]
info = manager.get_project_info(project_path)
print(f"\nProject info: {info['name']}")
print("=" * 60)
print(f"Path: {info['path']}")
print(f"Exists: {'Yes' if info['exists'] else 'No'}")
print(f"SVG files: {info['svg_count']}")
print(f"Design spec: {'Yes' if info['has_spec'] else 'No'}")
print(f"Source materials: {'Yes' if info['has_source'] else 'No'}")
print(f"Source count: {info['source_count']}")
print(f"Canvas format: {info['canvas_format']}")
print(f"Created: {info['create_date']}")
return
raise ValueError(f"Unknown command: {command}")
except Exception as exc:
print(f"[ERROR] {exc}")
print_usage()
sys.exit(1)
if __name__ == "__main__":
main()
scripts/project_utils.py
#!/usr/bin/env python3
"""
PPT Master - Project Utilities Module
Provides common functions for project information parsing and validation,
reusable by other tools.
"""
import re
from pathlib import Path
from datetime import datetime
from typing import Dict, List, Optional, Tuple
# Canvas format definitions (unified source)
try:
from config import CANVAS_FORMATS
except ImportError:
# Fallback: maintain minimal usable configuration to avoid runtime crashes
CANVAS_FORMATS = {
'ppt169': {
'name': 'PPT 16:9',
'dimensions': '1280×720',
'viewbox': '0 0 1280 720',
'aspect_ratio': '16:9'
},
'ppt43': {
'name': 'PPT 4:3',
'dimensions': '1024×768',
'viewbox': '0 0 1024 768',
'aspect_ratio': '4:3'
},
'zalo_oa': {
'name': 'Zalo OA Header',
'dimensions': '900×383',
'viewbox': '0 0 900 383',
'aspect_ratio': '2.35:1'
},
'facebook_tiktok': {
'name': 'Facebook/TikTok',
'dimensions': '1242×1660',
'viewbox': '0 0 1242 1660',
'aspect_ratio': '3:4'
},
'social_square': {
'name': 'Zalo/Instagram',
'dimensions': '1080×1080',
'viewbox': '0 0 1080 1080',
'aspect_ratio': '1:1'
},
'story': {
'name': 'Story/Vertical',
'dimensions': '1080×1920',
'viewbox': '0 0 1080 1920',
'aspect_ratio': '9:16'
},
'banner': {
'name': 'Horizontal Banner',
'dimensions': '1920×1080',
'viewbox': '0 0 1920 1080',
'aspect_ratio': '16:9'
},
'a4': {
'name': 'A4 Print',
'dimensions': '1240×1754',
'viewbox': '0 0 1240 1754',
'aspect_ratio': '√2:1'
}
}
CANVAS_FORMAT_ALIASES = {
'xhs': 'facebook_tiktok',
'xiaohongshu': 'facebook_tiktok',
'moments': 'social_square',
'wechat': 'zalo_oa',
'wechat_moment': 'social_square',
'wechat-moment': 'social_square',
}
def normalize_canvas_format(format_key: str) -> str:
"""Normalize canvas format key name (supports common aliases)."""
if not format_key:
return ''
key = format_key.strip().lower()
return CANVAS_FORMAT_ALIASES.get(key, key)
def parse_project_name(dir_name: str) -> Dict[str, str]:
"""
Parse project information from the project directory name.
Args:
dir_name: Project directory name
Returns:
Dictionary containing name, format, date
"""
result = {
'name': dir_name,
'format': 'unknown',
'format_name': 'Unknown format',
'date': 'unknown',
'date_formatted': 'Unknown date'
}
dir_name_lower = dir_name.lower()
# Extract date (format: _YYYYMMDD)
date_match = re.search(r'_(\d{8})$', dir_name)
if date_match:
date_str = date_match.group(1)
result['date'] = date_str
try:
date_obj = datetime.strptime(date_str, '%Y%m%d')
result['date_formatted'] = date_obj.strftime('%Y-%m-%d')
except ValueError:
pass
# Prefer parsing standard format: name_format_YYYYMMDD
full_match = re.match(r'^(?P<name>.+)_(?P<format>[a-z0-9_-]+)_(?P<date>\d{8})$', dir_name_lower)
if full_match:
raw_format = full_match.group('format')
normalized_format = normalize_canvas_format(raw_format)
if normalized_format in CANVAS_FORMATS:
result['format'] = normalized_format
result['format_name'] = CANVAS_FORMATS[normalized_format]['name']
result['name'] = dir_name[:len(full_match.group('name'))]
return result
# Fallback: only match trailing `_format` to avoid deleting parts of the project name
sorted_formats = sorted(CANVAS_FORMATS.keys(), key=len, reverse=True)
for fmt_key in sorted_formats:
if re.search(rf'_{re.escape(fmt_key)}(?:_\d{{8}})?$', dir_name_lower):
result['format'] = fmt_key
result['format_name'] = CANVAS_FORMATS[fmt_key]['name']
break
# Extract project name (only remove trailing date and format suffix)
name = re.sub(r'_\d{8}$', '', dir_name)
if result['format'] != 'unknown':
name = re.sub(rf'_{re.escape(result["format"])}$', '', name, flags=re.IGNORECASE)
result['name'] = name
return result
def get_project_info(project_path: str) -> Dict:
"""
Get detailed project information.
Args:
project_path: Project directory path
Returns:
Project information dictionary
"""
project_path = Path(project_path)
# Parse directory name
parsed = parse_project_name(project_path.name)
info = {
'path': str(project_path),
'dir_name': project_path.name,
'name': parsed['name'],
'format': parsed['format'],
'format_name': parsed['format_name'],
'date': parsed['date'],
'date_formatted': parsed['date_formatted'],
'exists': project_path.exists(),
'svg_count': 0,
'has_spec': False,
'has_readme': False,
'has_source': False,
'source_count': 0,
'spec_file': None,
'svg_files': []
}
if not project_path.exists():
return info
# Check README.md
info['has_readme'] = (project_path / 'README.md').exists()
# Check design specification files (multiple possible names)
spec_files = ['设计规范与内容大纲.md', 'design_specification.md', '设计规范.md']
for spec_file in spec_files:
if (project_path / spec_file).exists():
info['has_spec'] = True
info['spec_file'] = spec_file
break
# Check source documents
legacy_source_file = project_path / '来源文档.md'
sources_dir = project_path / 'sources'
info['has_source'] = legacy_source_file.exists() or sources_dir.exists()
if sources_dir.exists():
info['source_count'] = len([p for p in sources_dir.iterdir() if p.is_file()])
# Count SVG files
svg_output = project_path / 'svg_output'
if svg_output.exists():
svg_files = sorted(svg_output.glob('*.svg'))
info['svg_count'] = len(svg_files)
info['svg_files'] = [f.name for f in svg_files]
# Get canvas format details
if info['format'] in CANVAS_FORMATS:
info['canvas_info'] = CANVAS_FORMATS[info['format']]
return info
def validate_project_structure(project_path: str, verbose: bool = False) -> Tuple[bool, List[str], List[str]]:
"""
Validate project structure completeness.
Args:
project_path: Project directory path
verbose: Whether to show detailed fix suggestions
Returns:
(is_valid, error_list, warning_list)
"""
project_path = Path(project_path)
errors = []
warnings = []
# Try to import error helper
try:
from error_helper import ErrorHelper
use_helper = True
except ImportError:
use_helper = False
# Check if directory exists
if not project_path.exists():
msg = f"Project directory does not exist: {project_path}"
if use_helper and verbose:
msg += "\n" + ErrorHelper.format_error_message('missing_directory',
{'project_path': str(project_path)})
errors.append(msg)
return False, errors, warnings
if not project_path.is_dir():
errors.append(f"Not a valid directory: {project_path}")
return False, errors, warnings
# Check required files
if not (project_path / 'README.md').exists():
msg = "Missing required file: README.md"
if use_helper and verbose:
msg += "\n" + ErrorHelper.format_error_message('missing_readme',
{'project_path': str(project_path)})
errors.append(msg)
# Check design specification file
spec_files = ['设计规范与内容大纲.md', 'design_specification.md', '设计规范.md']
has_spec = any((project_path / f).exists() for f in spec_files)
if not has_spec:
msg = "Missing design specification file (suggested filename: design_specification.md)"
if use_helper and verbose:
msg += "\n" + ErrorHelper.format_error_message('missing_spec')
warnings.append(msg)
# Check svg_output directory
svg_output = project_path / 'svg_output'
if not svg_output.exists():
msg = "Missing svg_output directory"
if use_helper and verbose:
msg += "\n" + \
ErrorHelper.format_error_message('missing_svg_output')
errors.append(msg)
elif not svg_output.is_dir():
errors.append("svg_output is not a directory")
else:
# Check for SVG files
svg_files = list(svg_output.glob('*.svg'))
if not svg_files:
msg = "svg_output directory is empty, no SVG files found"
if use_helper and verbose:
msg += "\n" + \
ErrorHelper.format_error_message('empty_svg_output')
warnings.append(msg)
else:
# Validate SVG file naming (consistent with project_manager.py)
for svg_file in svg_files:
if not re.match(r'^(slide_\d+_\w+|P?\d+_.+)\.svg$', svg_file.name):
msg = f"Non-standard SVG file naming: {svg_file.name}"
if use_helper and verbose:
msg += "\n" + ErrorHelper.format_error_message('invalid_svg_naming',
{'file_name': svg_file.name})
warnings.append(msg)
# Check directory naming format
dir_name = project_path.name
if not re.search(r'_\d{8}$', dir_name):
msg = f"Directory name missing date suffix (_YYYYMMDD): {dir_name}"
if use_helper and verbose:
msg += "\n" + \
ErrorHelper.format_error_message('missing_date_suffix')
warnings.append(msg)
is_valid = len(errors) == 0
return is_valid, errors, warnings
def validate_svg_viewbox(svg_files: List[Path], expected_format: Optional[str] = None) -> List[str]:
"""
Validate the viewBox settings of SVG files.
Args:
svg_files: List of SVG files
expected_format: Expected canvas format (e.g. 'ppt169')
Returns:
List of warnings
"""
warnings = []
viewbox_pattern = re.compile(r'viewBox="([^"]+)"')
viewboxes = set()
# Determine expected viewBox
expected_viewbox = None
if expected_format and expected_format in CANVAS_FORMATS:
expected_viewbox = CANVAS_FORMATS[expected_format]['viewbox']
for svg_file in svg_files[:10]: # Check first 10 files
try:
with open(svg_file, 'r', encoding='utf-8') as f:
content = f.read(2000) # Only read first 2000 characters
match = viewbox_pattern.search(content)
if match:
viewbox = match.group(1)
viewboxes.add(viewbox)
# If expected format is specified, check for match
if expected_viewbox and viewbox != expected_viewbox:
warnings.append(
f"{svg_file.name}: viewBox '{viewbox}' does not match expected format "
f"'{expected_format}' (expected: '{expected_viewbox}')"
)
else:
warnings.append(f"{svg_file.name}: viewBox attribute not found")
except Exception as e:
warnings.append(f"{svg_file.name}: Failed to read - {e}")
# Check for multiple different viewBoxes
if len(viewboxes) > 1:
warnings.append(f"Multiple different viewBox settings detected: {viewboxes}")
return warnings
def find_all_projects(base_dir: str) -> List[Path]:
"""
Find all projects under the specified directory.
Args:
base_dir: Base directory path
Returns:
List of project directories
"""
base_path = Path(base_dir)
if not base_path.exists():
return []
projects = []
for item in base_path.iterdir():
if item.is_dir() and not item.name.startswith('.'):
# Check if it's a valid project directory (contains svg_output or design spec)
has_svg_output = (item / 'svg_output').exists()
has_spec = any((item / f).exists() for f in
['设计规范与内容大纲.md', 'design_specification.md', '设计规范.md'])
if has_svg_output or has_spec:
projects.append(item)
return sorted(projects)
def format_file_size(size_bytes: int) -> str:
"""
Format file size.
Args:
size_bytes: File size in bytes
Returns:
Formatted file size string
"""
for unit in ['B', 'KB', 'MB', 'GB']:
if size_bytes < 1024.0:
return f"{size_bytes:.1f} {unit}"
size_bytes /= 1024.0
return f"{size_bytes:.1f} TB"
def get_project_stats(project_path: str) -> Dict:
"""
Get project statistics.
Args:
project_path: Project directory path
Returns:
Statistics dictionary
"""
project_path = Path(project_path)
stats = {
'total_files': 0,
'svg_files': 0,
'md_files': 0,
'html_files': 0,
'total_size': 0,
'svg_size': 0
}
if not project_path.exists():
return stats
for file in project_path.rglob('*'):
if file.is_file():
stats['total_files'] += 1
file_size = file.stat().st_size
stats['total_size'] += file_size
if file.suffix == '.svg':
stats['svg_files'] += 1
stats['svg_size'] += file_size
elif file.suffix == '.md':
stats['md_files'] += 1
elif file.suffix == '.html':
stats['html_files'] += 1
return stats
if __name__ == '__main__':
# Test code
import sys
if len(sys.argv) > 1:
project_path = sys.argv[1]
info = get_project_info(project_path)
print(f"\nProject Info: {info['dir_name']}")
print("=" * 60)
print(f"Project Name: {info['name']}")
print(f"Canvas Format: {info['format_name']} ({info['format']})")
print(f"Created: {info['date_formatted']}")
print(f"SVG Files: {info['svg_count']}")
print(f"README: {'Yes' if info['has_readme'] else 'No'}")
print(f"Design Spec: {'Yes' if info['has_spec'] else 'No'}")
print("\nValidation Results:")
print("-" * 60)
is_valid, errors, warnings = validate_project_structure(project_path)
if errors:
print("[ERROR]")
for error in errors:
print(f" - {error}")
if warnings:
print("[WARN]")
for warning in warnings:
print(f" - {warning}")
if is_valid and not warnings:
print("[OK] Project structure is complete, no issues found")
else:
print("Usage: python3 project_utils.py <project_path>")
scripts/README.md
# PPT Master Toolset
This directory contains utility tools for project management, validation, and file processing.
## Tool Architecture Overview
```mermaid
graph TB
subgraph Input["Input Conversion"]
A1[pdf_to_md.py]
A1b[doc_to_md.py]
A2[web_to_md.py / .cjs]
end
subgraph Project["Project Management"]
B1[project_manager.py]
B2[project_utils.py]
B1 --> B2
end
subgraph Finalize["Post-processing (finalize_svg.py)"]
direction TB
C0[finalize_svg.py<br/>Unified Entry Point]
C1[embed_icons.py]
C2[crop_images.py]
C3[fix_image_aspect.py]
C4[embed_images.py]
C5[flatten_tspan.py]
C6[svg_rect_to_path.py]
C0 --> C1
C0 --> C2
C0 --> C3
C0 --> C4
C0 --> C5
C0 --> C6
end
subgraph Export["Export"]
D1[svg_to_pptx.py]
end
subgraph Quality["Quality Check"]
E1[svg_quality_checker.py]
E2[batch_validate.py]
end
subgraph Utils["Utilities"]
F1[rotate_images.py]
F2[analyze_images.py]
F3[svg_position_calculator.py]
F4[config.py]
F5[nano_banana_gen.py]
end
A1 --> B1
A2 --> B1
B1 -->|svg_output/| C0
C0 -->|svg_final/| D1
D1 -->|.pptx| Output[PowerPoint]
```
### Core Workflow
```
Source Document → [pdf_to_md / doc_to_md / web_to_md] → Markdown
↓
[project_manager init]
↓
AI generates SVG → svg_output/
↓
[finalize_svg] ← Aggregates 6 sub-tools
↓
svg_final/
↓
[svg_to_pptx] → output.pptx
```
### Tool Category Quick Index
| Category | Tool | Description |
|----------|------|-------------|
| **Input Conversion** | `pdf_to_md.py`, `doc_to_md.py`, `web_to_md.py/.cjs` | Convert PDF/DOCX/web pages to Markdown |
| **Project Management** | `project_manager.py` | Create and validate projects |
| **Post-processing** | `finalize_svg.py` ⭐ | Unified entry point, invokes the 6 tools below |
| ↳ Sub-tool | `embed_icons.py` | Embed icon placeholders |
| ↳ Sub-tool | `crop_images.py` | Smart image cropping |
| ↳ Sub-tool | `fix_image_aspect.py` | Fix image aspect ratio |
| ↳ Sub-tool | `embed_images.py` | Base64 image embedding |
| ↳ Sub-tool | `flatten_tspan.py` | Text flattening |
| ↳ Sub-tool | `svg_rect_to_path.py` | Rounded rect to Path |
| **Export** | `svg_to_pptx.py` | SVG to PowerPoint |
| **Speaker Notes** | `total_md_split.py` | Speaker notes splitter |
| **Quality Check** | `svg_quality_checker.py`, `batch_validate.py` | Validate SVG compliance |
| **Asset Generation** | `nano_banana_gen.py` | Generate high-quality images via Gemini Nano |
| **Utilities** | `config.py`, `analyze_images.py`, `rotate_images.py` | Configuration and image processing |
---
## Tool List
### 0. pdf_to_md.py — PDF to Markdown Tool (Recommended First Choice)
Uses PyMuPDF to convert PDF documents to Markdown format. Runs locally, fast, and free.
**Features**:
- Extract PDF text content and convert to Markdown
- Automatically extract tables and convert to Markdown tables
- Automatically extract images and save to `images/` directory
- Support batch processing of all PDFs in a directory
**Usage**:
```bash
# Convert a single file
python3 scripts/pdf_to_md.py book.pdf
# Specify output file
python3 scripts/pdf_to_md.py book.pdf -o output.md
# Convert all PDFs in a directory
python3 scripts/pdf_to_md.py ./pdfs
# Specify output directory
python3 scripts/pdf_to_md.py ./pdfs -o ./markdown
```
**When to use pdf_to_md.py vs MinerU**:
| Scenario | Recommended Tool | Reason |
|----------|------------------|--------|
| **Native PDF** (exported from Word/LaTeX) | `pdf_to_md.py` | Local, instant, free |
| **Simple tables** | `pdf_to_md.py` | Table extraction supported |
| **Privacy-sensitive documents** | `pdf_to_md.py` | Data stays on your machine |
| **Scanned/image PDFs** | MinerU | OCR required |
| **Complex multi-column layouts** | MinerU | Better layout analysis |
| **Math formulas** | MinerU | Stronger AI recognition |
| **Garbled PDFs** (encoding lost) | MinerU | Visual recognition as fallback |
> **Strategy**: PyMuPDF first, MinerU as fallback. Run `pdf_to_md.py` first; if the result is garbled/blank/misformatted, switch to MinerU.
**Dependencies**:
```bash
pip install PyMuPDF
```
---
### 0.3. doc_to_md.py — Document to Markdown Tool (Pandoc-based)
Uses Pandoc to convert various document formats to Markdown. Ideal for converting DOCX lecture notes, manuscripts, and other documents.
**Supported formats**: `.docx`, `.doc`, `.odt`, `.rtf`, `.epub`, `.html`, `.tex`, `.rst`, `.org`, `.ipynb`, `.typ`
**Usage**:
```bash
# Convert a Word document
python3 scripts/doc_to_md.py lecture.docx
# Specify output file
python3 scripts/doc_to_md.py lecture.docx -o output.md
# Convert other formats
python3 scripts/doc_to_md.py notes.epub
python3 scripts/doc_to_md.py paper.tex -o paper.md
```
**Dependencies**:
```bash
# Install pandoc (required)
# macOS: brew install pandoc
# Ubuntu: sudo apt install pandoc
# Windows: https://pandoc.org/installing.html
```
---
### 0.5. web_to_md.py / web_to_md.cjs — Web Page to Markdown Tool
Scrapes web page content and converts it to Markdown format, automatically downloading images locally.
**Note**: Both Python and Node.js versions are provided. For sites with TLS fingerprint blocking such as WeChat Official Accounts, **the Node.js version is strongly recommended** (`web_to_md.cjs`).
**Features**:
- Scrape web page content and convert to Markdown
- Automatically extract page metadata (title, date, author)
- Automatically download images and save to `_files/` directory
- **Automatic filename cleanup** (retains only alphanumeric characters, Chinese characters, and underscores for maximum compatibility)
- Smart main content area detection (supports Chinese news/government websites)
- Support batch processing of multiple URLs
**Usage (Python)**:
```bash
# Convert a single web page
python3 scripts/web_to_md.py https://example.com/article
# Convert multiple web pages
python3 scripts/web_to_md.py https://url1.com https://url2.com
# Batch read URLs from file
python3 scripts/web_to_md.py -f urls.txt
# Specify output file
python3 scripts/web_to_md.py https://example.com -o output.md
```
**Usage (Node.js) — Recommended for WeChat Official Accounts**:
```bash
# Convert a single web page
node scripts/web_to_md.cjs https://mp.weixin.qq.com/s/xxxx
# Convert multiple web pages
node scripts/web_to_md.cjs https://url1.com https://url2.com
# Batch read URLs from file
node scripts/web_to_md.cjs -f urls.txt
```
**Output Structure**:
```
projects/
├── article_title.md # Markdown content
└── article_title_files/ # Image directory
├── image_1.jpg
├── image_2.png
└── ...
```
**When to use web_to_md.py / .cjs**:
| Scenario | Recommended Tool | Reason |
|----------|------------------|--------|
| **WeChat Official Accounts / heavily protected sites** | `web_to_md.cjs` | Node.js handles TLS fingerprint blocking better and significantly reduces the chance of 403 errors |
| **Regular news/article pages** | Either one | Both can auto-extract body text and download images |
| **Image-rich content** (travel logs, guides, etc.) | Either one | Preserves image assets |
| **Government/institutional websites** | `web_to_md.py` | Python version may handle certain Chinese encodings slightly better |
| **Pages requiring login** | Manual processing | Tools do not support authentication |
| **Dynamically rendered pages (SPA)** | Manual processing | Requires a headless browser |
> **Strategy**: If you encounter a 403 error or are scraping a WeChat article, switch directly to `web_to_md.cjs`.
**Dependencies**:
Python:
```bash
pip install requests beautifulsoup4
```
Node.js:
(The script uses native modules; no extra npm install needed, but a Node.js environment is required)
---
### 0.6. rotate_images.py — Image Orientation Correction Tool
A dedicated tool for handling missing or incorrect EXIF orientation data in images downloaded from the web.
**Features**:
- **Auto EXIF correction**: Detect and fix images with EXIF Orientation tags
- **Visual rotation**: Generate an HTML tool page for click-to-rotate functionality
- **Natural sorting**: Ensure images are sorted by filename in natural order
- **Standalone operation**: Independent of scraping scripts; can be used on any directory
**Usage**:
```bash
# 1. Auto-correct (silent mode, EXIF fix only)
python3 scripts/rotate_images.py auto projects/xxx_files
# 2. Generate visual tool (fix EXIF first, then generate web page)
python3 scripts/rotate_images.py gen projects/xxx_files
# -> Generates projects/image_orientation_tool.html, open in browser to operate
# 3. Apply corrections (from JSON generated by the web page)
python3 scripts/rotate_images.py fix fixes.json
```
---
### 1. project_utils.py — Project Utilities Common Module
Provides common functionality for project info parsing, validation, etc., reused by other tools.
**Features**:
- Canvas format definition and management
- Project info parsing (extract format, date, etc. from directory name)
- Project structure validation
- SVG viewBox validation
- Project discovery and statistics
**Usage**:
```bash
# Imported as a module by other tools
from project_utils import get_project_info, validate_project_structure
# Can also be run directly for testing
python3 scripts/project_utils.py <project_path>
```
---
### 2. project_manager.py — Project Management Tool
An all-in-one tool for project initialization, validation, and management.
**Features**:
- Initialize new projects (create standard directory structure)
- Validate project integrity
- View project information
**Usage**:
```bash
# Initialize a new project
python3 scripts/project_manager.py init <project_name> --format ppt169
# Import raw materials and standardized Markdown into the project directory
python3 scripts/project_manager.py import-sources <project_path> <source1> [<source2> ...]
# Validate project structure
python3 scripts/project_manager.py validate <project_path>
# View project information
python3 scripts/project_manager.py info <project_path>
```
Notes:
- Files outside the workspace are copied to `sources/` by default
- With `--move`, files outside the workspace are moved to `sources/` instead
- If a file is already within the current workspace, it will be moved directly to `sources/`
**Supported Canvas Formats**:
- `ppt169` - PPT 16:9 (1280×720)
- `ppt43` - PPT 4:3 (1024×768)
- `wechat` - WeChat Official Account header image (900×383)
- `xiaohongshu` - Xiaohongshu (RED) 3:4 (1242×1660)
- `moments` - WeChat Moments / Instagram 1:1 (1080×1080)
- `story` - Story / Vertical 9:16 (1080×1920)
- `banner` - Horizontal Banner 16:9 (1920×1080)
- `a4` - A4 Print (1240×1754)
**Examples**:
```bash
# Create a new PPT 16:9 project
python3 scripts/project_manager.py init my_presentation --format ppt169
# Validate project
python3 scripts/project_manager.py validate projects/my_presentation_ppt169_20251116
# View project information
python3 scripts/project_manager.py info projects/my_presentation_ppt169_20251116
```
---
### 3. flatten_tspan.py — Text Flattening (Remove `<tspan>`)
> **Recommended**: Use `finalize_svg.py` as the unified entry point, which already includes text flattening. The following is advanced standalone usage.
Flattens `<text>` structures containing multi-line `<tspan>` elements into multiple independent `<text>` elements, for compatibility with certain renderers or text extraction.
**Note**: The generation side should still use `<tspan>` for manual line breaks (see banned features in `AGENTS.md`). This tool is for post-processing only.
**Usage**:
```bash
# Flatten an entire output directory
python3 scripts/flatten_tspan.py examples/<project>/svg_output
# Process a single SVG
python3 scripts/flatten_tspan.py path/to/input.svg path/to/output.svg
```
**Behavior**:
- Computes absolute position for each `<tspan>` (combining `x`/`y` with `dx`/`dy`), merges parent/child styles, and outputs as independent `<text>` elements
- Copies common text attributes and `style` from the parent `<text>`; child overrides take priority
- Preserves or merges `transform`
- Output uses UTF-8 encoding without XML declaration
**Known Limitations**:
- Only processes `<text>`/`<tspan>` structures; other child elements are not converted
- For complex nesting or special layouts, simplify to standardized per-line `<tspan>` on the generation side first
---
### 4. batch_validate.py — Batch Project Validation Tool
Check the structural integrity and compliance of multiple projects at once.
**Features**:
- Batch project structure validation
- Check required files (README, design spec, SVGs, etc.)
- Validate SVG viewBox settings
- Generate validation reports
- Provide fix suggestions
**Usage**:
```bash
# Validate a single directory
python3 scripts/batch_validate.py examples
# Validate multiple directories
python3 scripts/batch_validate.py examples projects
# Validate all
python3 scripts/batch_validate.py --all
# Export report
python3 scripts/batch_validate.py examples --export
```
**Example Output**:
```
✅ google_annual_report_ppt169_20251116
Path: examples/google_annual_report_ppt169_20251116
Format: PPT 16:9 | SVGs: 10 | Date: 2025-11-16
⚠️ some_project_name
Path: examples/some_project_name
Format: PPT 16:9 | SVGs: 8 | Date: 2025-10-15
⚠️ Warnings (1):
- Non-standard SVG filename: old_name.svg
```
---
### 5. generate_examples_index.py — Examples Index Generator
Automatically scans the examples directory and generates a README.md index file.
**Features**:
- Automatically discover all example projects
- Organize by format category
- Generate statistics
- Create preview links
- Update usage instructions
**Usage**:
```bash
# Generate examples/README.md
python3 scripts/generate_examples_index.py
# Specify directory
python3 scripts/generate_examples_index.py examples
```
**Characteristics**:
- Auto-detect project info (name, format, date, SVG count)
- Group by canvas format
- Show recently updated projects
- Include usage instructions and contribution guidelines
---
### 6. error_helper.py — Error Message Helper
Provides friendly error messages and specific fix suggestions.
**Features**:
- Standardized error type definitions
- Provide specific solutions
- Support context customization
- Formatted output
**Usage**:
```bash
# View all error types
python3 scripts/error_helper.py
# View solution for a specific error
python3 scripts/error_helper.py missing_readme
# With context
python3 scripts/error_helper.py missing_readme project_path=my_project
```
**Supported Error Types**:
- `missing_readme` - Missing README.md
- `missing_spec` - Missing design specification
- `missing_svg_output` - Missing svg_output directory
- `viewbox_mismatch` - viewBox mismatch
- `foreignobject_detected` - Banned element detected (see AGENTS.md blocklist)
- etc.
---
### 7. svg_quality_checker.py — SVG Quality Check Tool
Checks whether SVG files comply with the project's technical specifications.
**Features**:
- Validate viewBox attribute
- Detect banned elements (see AGENTS.md)
- Check font usage
- Validate width/height consistency with viewBox
- Check text line-break methods
**Usage**:
```bash
# Check a single file
python3 scripts/svg_quality_checker.py examples/project/svg_output/01_cover.svg
# Check an entire directory
python3 scripts/svg_quality_checker.py examples/project/svg_output
# Check a project (auto-finds svg_output)
python3 scripts/svg_quality_checker.py examples/project
# Specify expected format
python3 scripts/svg_quality_checker.py examples/project --format ppt169
# Check all projects
python3 scripts/svg_quality_checker.py --all examples
# Export report
python3 scripts/svg_quality_checker.py examples/project --export
```
**Check Items**:
- ✅ viewBox attribute exists and is correctly formatted
- ✅ No banned elements (see AGENTS.md)
- ✅ Uses fonts specified in the Design Specification & Content Outline (design_spec)
- ✅ width/height consistent with viewBox
- ✅ Text uses `<tspan>` for line breaks
---
### PPT Compatibility Rules
To ensure consistent appearance after exporting to PPT, **transparency must use standard syntax**:
| ❌ Banned | ✅ Correct |
|-----------|-----------|
| `fill="rgba(255,255,255,0.1)"` | `fill="#FFFFFF" fill-opacity="0.1"` |
| `<g opacity="0.2">...</g>` | Set opacity on each child element individually |
| `<image opacity="0.3"/>` | Add a mask layer after the image: `<rect fill="background-color" opacity="0.7"/>` |
> **Mnemonic**: PPT does not support rgba, group opacity, image opacity, or markers.
---
### 8. svg_to_pptx.py — SVG to PPTX Tool
Batch converts SVG files in a project to a PowerPoint presentation, preserving vector graphics editability.
**Features**:
- Batch convert SVGs to PPTX
- Each SVG corresponds to one slide
- Auto-detect canvas format and set slide dimensions
- Support using svg_output or svg_final directories
- SVGs remain editable in PowerPoint
- **Support slide transition and entrance animations**
- **Auto-embed speaker notes** (read from notes/ directory)
**Usage**:
```bash
# Recommended: use the post-processed version (notes embedded by default)
python3 scripts/svg_to_pptx.py <project_path> -s final
# Disable speaker notes
python3 scripts/svg_to_pptx.py <project_path> -s final --no-notes
# Use original version
python3 scripts/svg_to_pptx.py <project_path>
# Specify output file
python3 scripts/svg_to_pptx.py <project_path> -s final -o output.pptx
# Add slide transition effects
python3 scripts/svg_to_pptx.py <project_path> -t fade --transition-duration 1.0
# Silent mode
python3 scripts/svg_to_pptx.py <project_path> -s final -q
# Native shapes (fallback when PowerPoint is unavailable for manual "Convert to Shape")
python3 scripts/svg_to_pptx.py <project_path> -s final --native
```
**Speaker Notes**:
The tool automatically reads Markdown note files from the `notes/` directory and embeds them into the PPTX speaker notes.
**Two naming conventions are supported**:
- **Recommended**: Same name as the SVG (e.g., `01_cover.svg` maps to `notes/01_cover.md`)
- **Compatible**: `slide01.md` format (backward compatible)
| Parameter | Description |
|-----------|-------------|
| Default | Auto-embed notes (empty if no note file found) |
| `--no-notes` | Disable note embedding |
**Transition Effect Parameters**:
| Parameter | Description | Options |
|-----------|-------------|---------|
| `-t`, `--transition` | Slide transition effect | fade, push, wipe, split, reveal, cover, random |
| `--transition-duration` | Transition duration in seconds (default 0.5) | Any positive number |
| `--auto-advance` | Auto-advance interval in seconds | Any positive number |
**Transition Effects**:
| Effect | Description |
|--------|-------------|
| fade | Fade in/out |
| push | Push |
| wipe | Wipe |
| split | Split |
| reveal | Reveal |
| cover | Cover |
| random | Random |
**SVG Source Directory (`-s`)**:
| Parameter | Directory | Description |
|-----------|-----------|-------------|
| `-s output` | `svg_output/` | Original version |
| `-s final` | `svg_final/` | Post-processed (recommended) |
| `-s <any_name>` | `<any_name>/` | Specify subdirectory directly |
**Examples**:
```bash
# Recommended workflow: post-process first, then export (with transition effects)
python3 scripts/finalize_svg.py examples/ppt169_demo
python3 scripts/svg_to_pptx.py examples/ppt169_demo -s final -t fade
# Auto-play presentation (3-second transitions)
python3 scripts/svg_to_pptx.py examples/ppt169_demo -s final -t fade --auto-advance 3
```
**Dependencies**:
```bash
pip install python-pptx
```
**Notes**:
- SVGs are embedded in native vector format, maintaining editability
- Requires PowerPoint 2016+ for correct display
- File size is much smaller than the PNG approach
- Transition effects are off by default; users must explicitly enable them
- Speaker notes are on by default; use `--no-notes` to disable
---
### 9. total_md_split.py — Speaker Notes Splitter
Splits the `total.md` speaker notes file into multiple individual note files, each corresponding to one SVG page.
**Features**:
- Read the `total.md` file and parse its level-1 headings and note content
- Check whether all SVG files in the `svg_output` folder have corresponding notes
- If any SVG lacks a corresponding note, output an error prompting regeneration of the notes file
- If all match, split the document by name into multiple files
- Split files are named the same as their SVG counterparts with a `.md` extension
- Split files **do not include** the level-1 heading
**Usage**:
```bash
# Basic usage
python3 scripts/total_md_split.py <project_path>
# Specify output directory
python3 scripts/total_md_split.py <project_path> -o <output_directory>
# Silent mode
python3 scripts/total_md_split.py <project_path> -q
```
**Examples**:
```bash
# Basic usage
python3 scripts/total_md_split.py projects/<svg_title>_ppt169_YYYYMMDD
# Specify output directory
python3 scripts/total_md_split.py projects/<svg_title>_ppt169_YYYYMMDD -o notes
# Silent mode
python3 scripts/total_md_split.py projects/<svg_title>_ppt169_YYYYMMDD -q
```
**Notes Format Requirements**:
The `total.md` file must use the following format:
```markdown
# 01_<page_title>
Speaker notes content...
---
# 02_<page_title>
Speaker notes content...
---
# 03_<page_title>
Speaker notes content...
```
- Each section begins with a level-1 heading starting with `# ` (**strictly required**)
- The heading text must match the corresponding SVG filename (**strictly required**)
- Sections must be separated by `---` (**strictly required**)
- Note content follows the heading until the next heading or end of file
**Fault Tolerance (Tool Side)**:
Even if the format does not strictly conform, `total_md_split.py` will attempt to split based on heading/page number/name similarity, but results are not guaranteed. Please adhere to the strict format.
**Error Handling**:
If any SVG file lacks a corresponding note, the tool will output an error message:
```
Error: SVG files and speaker notes do not match
Missing notes: <N>_<page_title>
Please regenerate the notes file, ensuring every SVG has a corresponding note.
```
**Dependencies**:
- Python 3.6+
- No external dependencies (standard library only)
---
### 10. svg_position_calculator.py — SVG Position Calculation & Validation Tool
A tool for **pre-calculation** and **post-validation** of chart coordinates, helping ensure accurate SVG element positioning.
#### Core Features
| Feature | Description |
|---------|-------------|
| **analyze** | Analyze an SVG file and extract coordinate information for all graphical elements |
| **calc** | Calculate expected chart coordinates based on data |
| **interactive** | Interactive guided calculation (recommended for beginners) |
| **from-json** | Batch calculation from a JSON configuration file |
| **validate** | Validate SVG coordinates against expected values for deviation |
#### Supported Chart Types
| Type | Calculation | Output |
|------|-------------|--------|
| **Bar chart (bar)** | Bar X/Y/width/height, label positions | Coordinate table |
| **Pie chart (pie)** | Sector angles, arc endpoints, label positions | Coordinate table + Path d attribute |
| **Radar chart (radar)** | Polygon vertices, label positions | Coordinate table + polygon points |
| **Line chart (line)** | Data point SVG coordinates | Coordinate table + Path d attribute |
| **Grid layout (grid)** | Cell positions and dimensions | Coordinate table |
| **Custom polyline** | Arbitrary formula calculation | Coordinate table + polyline points |
---
#### Command Details
##### 1. analyze — Analyze SVG File
Extract position information for all graphical elements in an SVG, for validation or debugging.
```bash
python3 scripts/svg_position_calculator.py analyze <svg_file>
```
**Example Output**:
```
======================================================================
SVG File Analysis: slide_03_chart.svg
======================================================================
Canvas viewBox: 0 0 1920 1200
Element Statistics:
- rect (rectangles): 18
- circle: 7
- polyline/polygon: 1
- path: 2
=== Rectangle Elements (rect) ===
Index X Y Width Height
1 0 0 1920 130
2 190 285 200 530
...
=== Polylines/Polygons (polyline/polygon) ===
Polyline 1 (39 points):
Start: (210,431) → (250,425) → (290,433) → (330,377) → (370,517)
... 39 points total
```
##### 2. calc — Quick Coordinate Calculation
Quickly calculate expected chart element coordinates based on data.
**Bar Chart**:
```bash
python3 scripts/svg_position_calculator.py calc bar \
--data "East:185,South:142,North:128" \
--canvas ppt169 \
--bar-width 50
```
Output:
```
=== Bar Chart Coordinate Calculation ===
Canvas: 1280×720
Chart Area: (140, 150) - (1160, 600)
Index Label Value X Y Width Height
---- ---------- -------- ------- ------- ------- -------
1 East 185.0 560.0 190.9 50.0 409.1
2 South 142.0 625.0 286.0 50.0 314.0
3 North 128.0 690.0 317.0 50.0 283.0
```
**Pie Chart**:
```bash
python3 scripts/svg_position_calculator.py calc pie \
--data "A:35,B:25,C:20,D:12,Other:8" \
--center 420,400 \
--radius 200 \
--start-angle -90
```
Output:
```
=== Pie Chart Sector Calculation ===
Center: (420, 400) | Radius: 200
Index Label Percent Start End LabelX LabelY
---- ---------- -------- -------- -------- ------- -------
1 A 35.0% -90.0 36.0 476.0 296.2
2 B 25.0% 36.0 126.0 508.3 443.8
...
=== Path d Attributes ===
1. A: M 0,0 L 0.00,-200.00 A 200,200 0 0,1 161.80,-117.56 Z
2. B: M 0,0 L 161.80,-117.56 A 200,200 0 0,1 117.56,161.80 Z
...
```
**Radar Chart**:
```bash
python3 scripts/svg_position_calculator.py calc radar \
--data "Performance:90,Security:85,Usability:75,Price:70,Service:80" \
--center 640,400 \
--radius 200
```
**Line Chart**:
```bash
python3 scripts/svg_position_calculator.py calc line \
--data "0:50,10:80,20:120,30:95" \
--canvas ppt169 \
--y-range "0,150"
```
**Grid Layout**:
```bash
python3 scripts/svg_position_calculator.py calc grid \
--rows 2 --cols 3 \
--canvas ppt169 \
--padding 20 --gap 20
```
##### 3. interactive — Interactive Mode
Suitable for users unfamiliar with command-line arguments; guides you through calculation via menus.
```bash
python3 scripts/svg_position_calculator.py interactive
```
Menu Options:
```
Select chart type:
1. Bar chart (bar)
2. Pie chart (pie)
3. Radar chart (radar)
4. Line chart (line)
5. Grid layout (grid)
6. Custom polyline (custom) ← Supports custom formulas
0. Exit
```
**Custom polyline (option 6)** is particularly useful for scenarios like price index charts that require custom coordinate formulas:
```
=== Custom Polyline Calculation ===
X start value [170]: 210
X step [40]: 40
Y baseline [595]: 595
Y scale factor [20]: 20
Reference baseline [100]: 100
Formula: X = 210 + index × 40
Y = 595 - (value - 100) × 20
Input data: 108.2,108.5,108.1,110.9,103.9,97.0
Index Value X Y
---- ---------- -------- --------
1 108.2 250 431
2 108.5 290 425
3 108.1 330 433
4 110.9 370 377
5 103.9 410 517
6 97.0 450 655
polyline points:
250,431 290,425 330,433 370,377 410,517 450,655
```
##### 4. from-json — Batch Calculation from JSON Config
Read configuration from a JSON file for calculation; suitable for batch processing or saving frequently used configurations.
```bash
python3 scripts/svg_position_calculator.py from-json config.json
```
**JSON Configuration Examples**:
Bar chart config:
```json
{
"type": "bar",
"canvas": "ppt169",
"data": {
"East": 185,
"South": 142,
"North": 128
}
}
```
Custom polyline config:
```json
{
"type": "custom_line",
"base_x": 210,
"step_x": 40,
"base_y": 595,
"scale_y": 20,
"ref_value": 100,
"values": [108.2, 108.5, 108.1, 110.9, 103.9, 97.0]
}
```
---
#### Typical Validation Workflow
After AI generates SVG charts, you can use this tool to verify coordinate accuracy:
1. **Analyze the SVG file** to extract actual coordinates:
```bash
python3 scripts/svg_position_calculator.py analyze slide.svg
```
2. **Calculate expected coordinates from the raw data** (using calc or interactive)
3. **Compare expected coordinates with actual coordinates** to check for deviations
4. **If deviations exist, correct the SVG file**
**Example: Validating a Price Index Line Chart**
```bash
# 1. Analyze the SVG to view polyline points
python3 scripts/svg_position_calculator.py analyze slide_03_trend.svg
# 2. Use interactive mode to calculate expected coordinates (select 6. Custom polyline)
python3 scripts/svg_position_calculator.py interactive
# 3. Compare the output polyline points with the actual values in the SVG
```
---
#### Coordinate Calculation Formula Reference
**Bar Chart**:
```
bar_x = chart_area.x_min + (chart_area.width - total_bars_width) / 2 + i * (bar_width + gap)
bar_y = chart_area.y_max - (value / max_value) * chart_area.height
bar_height = (value / max_value) * chart_area.height
```
**Pie Chart Arc Endpoints**:
```
angle_rad = angle_degrees × π / 180
end_x = radius × cos(angle_rad)
end_y = radius × sin(angle_rad)
```
**Line Chart (Custom Formula)**:
```
X = base_x + index × step_x
Y = base_y - (value - ref_value) × scale_y
```
---
#### FAQ
**Q: What if the output shows garbled Chinese characters?**
A: The tool automatically handles UTF-8 encoding issues on Windows. If problems persist, ensure your terminal is set to UTF-8:
```bash
chcp 65001
```
**Q: How to validate complex charts?**
A: For complex charts (e.g., multi-series bar charts), it is recommended to:
1. First use `analyze` to extract all elements
2. Manually calculate expected values based on chart logic
3. Compare and verify one by one
**Q: Which canvas formats are supported?**
A: Supports `ppt169`, `ppt43`, `xiaohongshu`, `moments`, etc. See `CANVAS_FORMATS` in `project_utils.py` for details.
---
### 11. svg_rect_to_path.py — SVG Rounded Rect to Path Tool
Solves the problem of rounded corners being lost when using "Convert to Shape" in PowerPoint.
**Problem**: PowerPoint cannot correctly parse the `rx`/`ry` rounded corner attributes of `<rect>`
**Solution**: Convert `<rect rx="12" ry="12">` to an equivalent `<path d="...arcs...">`
**Usage**:
```bash
# Process SVGs in a project (defaults to svg_output)
python3 scripts/svg_rect_to_path.py <project_path>
# Specify SVG source directory
python3 scripts/svg_rect_to_path.py <project_path> -s final
# Specify output directory name
python3 scripts/svg_rect_to_path.py <project_path> -o svg_for_ppt
# Process a single file
python3 scripts/svg_rect_to_path.py path/to/file.svg
# Verbose output
python3 scripts/svg_rect_to_path.py <project_path> -v
```
**Examples**:
```bash
# Process a project
python3 scripts/svg_rect_to_path.py examples/ppt169_demo
# Output to: examples/ppt169_demo/svg_rounded/
# Process a single file
python3 scripts/svg_rect_to_path.py examples/ppt169_demo/svg_output/01_cover.svg
# Output to: examples/ppt169_demo/svg_output/01_cover_rounded.svg
```
**Use Cases**:
When you need to "Convert to Shape" in PowerPoint for editing, pre-process the SVG with this tool to preserve rounded corner effects.
**Notes**:
- If you are only embedding the SVG without converting to shapes, this tool is not needed
- Transparency will still be lost after "Convert to Shape" (PowerPoint limitation)
---
### 12. fix_image_aspect.py — SVG Image Aspect Ratio Fix Tool
Solves the problem of `<image>` elements in SVG being stretched when using "Convert to Shape" in PowerPoint.
**Problem**: When PowerPoint converts SVG to editable shapes, it ignores the `preserveAspectRatio` attribute, causing images to be stretched to fill the specified width/height area.
**Solution**:
1. Read the image's original aspect ratio
2. Calculate the correct x, y, width, height based on the `preserveAspectRatio` mode (meet/slice)
3. Remove the `preserveAspectRatio` attribute, replacing it with precisely calculated dimensions
**Usage**:
```bash
# Process a single SVG file
python3 scripts/fix_image_aspect.py path/to/slide.svg
# Process multiple files
python3 scripts/fix_image_aspect.py 01_cover.svg 02_toc.svg 03_content.svg
# Preview mode (does not modify files)
python3 scripts/fix_image_aspect.py --dry-run path/to/slide.svg
# Process automatically via finalize_svg.py (recommended)
python3 scripts/finalize_svg.py <project_path>
```
**Dependencies**:
```bash
pip install Pillow # For reading image dimensions (recommended)
```
> **Note**: If Pillow is not installed, the tool will attempt to read PNG/JPEG header information using basic methods, but installing Pillow is recommended for better compatibility.
**Use Cases**:
When you need to "Convert to Shape" in PowerPoint for editing and the SVG contains images, use this tool to prevent image stretching.
**Integrated into finalize_svg.py**:
This tool is already included as one of the processing steps in `finalize_svg.py` (`fix-aspect`) and runs automatically by default.
---
### 13. gemini_watermark_remover.py — Gemini Watermark Removal Tool
Removes the watermark logo from the bottom-right corner of Gemini-generated images. Uses a reverse blending algorithm to restore original pixels.
**Features**:
- Auto-detect watermark size (96px for large images, 48px for small images)
- Support PNG, JPG, JPEG formats
- Use reverse blending algorithm for precise original pixel restoration
- Output files default to adding an `_unwatermarked` suffix
**Usage**:
```bash
# Process a single image
python3 scripts/gemini_watermark_remover.py <image_path>
# Specify output file
python3 scripts/gemini_watermark_remover.py <image_path> -o output_path.png
# Silent mode
python3 scripts/gemini_watermark_remover.py <image_path> -q
```
**Examples**:
```bash
# Process a Gemini-generated image
python3 scripts/gemini_watermark_remover.py projects/demo/images/bg_01.png
# Specify output path
python3 scripts/gemini_watermark_remover.py image.jpg -o image_clean.jpg
```
**Watermark Detection Rules**:
| Image Size | Watermark Size | Margin |
|------------|----------------|--------|
| > 1024×1024 | 96×96 | 64px |
| ≤ 1024×1024 | 48×48 | 32px |
**Dependencies**:
```bash
pip install Pillow numpy
```
**Notes**:
- This tool requires `bg_48.png` and `bg_96.png` watermark background images in the `scripts/assets/` directory
- Processed images are generated at the original location with an `_unwatermarked` suffix
---
### 14. nano_banana_gen.py — Nano Banana Image Generation Tool
Uses the Google GenAI API to call Gemini models for generating high-quality image assets.
**Features**:
- **High resolution**: Supports up to 4K resolution generation
- **Custom aspect ratio**: Supports mainstream ratios including `16:9`, `4:3`, `1:1`, `9:16`, etc.
- **Prompt engineering**: Built-in negative prompt support with automatic quality optimization
- **Auto-save**: Automatically names and saves output as PNG format based on the prompt
**Usage**:
```bash
# Generate a default image
python3 scripts/nano_banana_gen.py "A modern futuristic workspace"
# Specify aspect ratio and size
python3 scripts/nano_banana_gen.py "Abstract tech background" --aspect_ratio 16:9 --image_size 4K
# Specify output directory
python3 scripts/nano_banana_gen.py "Concept car" -o projects/demo/images
# Use negative prompt
python3 scripts/nano_banana_gen.py "Beautiful landscape" -n "low quality, blurry, watermark"
```
**Parameter Reference**:
| Parameter | Shorthand | Default | Options |
|-----------|-----------|---------|---------|
| `prompt` | - | Nano Banana | Prompt string |
| `--negative_prompt` | `-n` | None | Negative prompt |
| `--aspect_ratio` | - | `1:1` | `1:1`, `16:9`, `4:3`, `3:2`, `9:16`, `21:9`, etc. |
| `--image_size` | - | `4K` | `1K`, `2K`, `4K` |
| `--output` | `-o` | Current working directory | Image save directory |
**Environment Variable Configuration**:
Set the following environment variables before use:
```bash
# Required: Gemini API Key
export GEMINI_API_KEY="YOUR_GEMINI_API_KEY"
# Optional: Custom API endpoint (for proxy services)
export GEMINI_BASE_URL="YOUR_API_BASE_URL"
```
> **Tip**: You can add environment variables to `~/.zshrc` or `~/.bashrc` for persistent configuration.
**Dependencies**:
```bash
pip install google-genai
```
---
### 15. embed_icons.py — SVG Icon Embedding Tool
Replaces icon placeholders (`<use ...>`) in SVG files with actual icon path data, achieving "zero-dependency" icon embedding.
**Features**:
- Scan `<use data-icon="...">` placeholders
- Read corresponding SVG icons from the icon library (default `templates/icons/`)
- Embed as `<g>` groups with applied position, size, and color
- Support batch processing
**Usage**:
```bash
# Process a single file
python3 scripts/embed_icons.py output.svg
# Process an entire directory
python3 scripts/embed_icons.py svg_output/*.svg
# Preview mode
python3 scripts/embed_icons.py --dry-run svg_output/*.svg
```
---
## Workflow Integration
### Typical Workflow
1. **Create a new project**
```bash
python3 scripts/project_manager.py init my_project --format ppt169
```
2. **Edit the Design Specification & Content Outline**
Edit the generated `design_spec.md` file
3. **Generate SVG files**
Use AI roles (Strategist → Executor → Optimizer) to generate SVGs and save to `svg_output/`
4. **Post-processing (runs all steps by default)**
```bash
# Just run it, no extra parameters needed
python3 scripts/finalize_svg.py projects/my_project_ppt169_20251116
```
5. **Export to PPTX**
```bash
python3 scripts/svg_to_pptx.py projects/my_project_ppt169_20251116 -s final
```
6. **(Optional) Validate the project**
```bash
python3 scripts/project_manager.py validate projects/my_project_ppt169_20251116
```
### Batch Operations
**Batch validate projects**:
```bash
# Validate all example projects
python3 scripts/batch_validate.py examples
# Validate and export report
python3 scripts/batch_validate.py examples --export
```
**Batch check SVG quality**:
```bash
# Check SVGs in all example projects
python3 scripts/svg_quality_checker.py --all examples
# Export quality report
python3 scripts/svg_quality_checker.py --all examples --export
```
## Dependency Requirements
Most tools use the Python 3 standard library and require no additional dependencies.
**Minimum Python Version**: Python 3.6+
**Optional Dependencies**:
- `python-pptx` — Required for SVG to PPTX conversion
- `Pillow` — Required for image aspect ratio fixing and watermark removal
- `numpy` — Required for watermark removal
Install dependencies:
```bash
pip install -r requirements.txt
# Or install individually
pip install python-pptx
```
## Troubleshooting
### Issue: Project Validation Failed
**Solution**:
1. Run `python3 scripts/project_manager.py validate <path>` to see detailed errors
2. Fix missing files or directories based on the error messages
3. Refer to `projects/README.md` for the standard structure
### Issue: SVG Preview Not Displaying Correctly
**Solution**:
1. Ensure the SVG file path is correct
2. Check that SVG filenames follow the naming convention (`slide_XX_name.svg`)
3. Use a local server for preview: `python3 -m http.server --directory <svg_output_path> 8000`
## Related Documentation
- [Workflow Tutorial](../../AGENTS.md)
- [Quick Reference](../../AGENTS.md)
- [AGENTS Guide](../AGENTS.md)
---
_Last updated: 2026-02-03_
_nano_banana_gen.py documentation updated: 2026-02-03_
_gemini_watermark_remover.py documentation updated: 2025-12-20_
scripts/rotate_images.py
#!/usr/bin/env python3
"""
PPT Master - Image Orientation Management Tool
Provides visual image orientation filtering, fix code generation,
and batch image rotation functionality.
Usage:
python3 scripts/rotate_images.py gen <images_directory>
python3 scripts/rotate_images.py fix <fixes.json>
"""
import os
import sys
import json
import re
from pathlib import Path
from typing import List, Dict, Union, Any, Optional
from PIL import Image, ExifTags
ORIENTATION_TAG_ID = 274 # 0x0112
class ImageRotator:
"""Image orientation manager"""
def __init__(self):
"""Initialize the manager"""
pass
@staticmethod
def _repo_root() -> Path:
# scripts/rotate_images.py -> skills/ppt-master/
return Path(__file__).resolve().parent.parent
@staticmethod
def _normalize_task_path(path_str: str) -> str:
p = (path_str or "").strip()
if not p:
return p
# common copy/paste artifacts
p = re.sub(r"^file:(?:///?)+", "", p, flags=re.IGNORECASE)
p = p.replace("\\", "/")
p = re.sub(r"^\\./", "", p)
return p
@staticmethod
def _natural_sort_key(s: Union[str, Path]) -> List[Union[int, str]]:
"""Natural sort key generator"""
return [int(text) if text.isdigit() else text.lower()
for text in re.split(r'(\d+)', str(s))]
def _save_in_place(
self,
img: Image.Image,
file_path: Path,
src_format: Optional[str],
*,
exif_bytes: Optional[bytes] = None,
icc_profile: Optional[bytes] = None,
) -> None:
fmt = (src_format or "").upper()
save_kwargs: Dict[str, Any] = {}
if icc_profile:
save_kwargs["icc_profile"] = icc_profile
if exif_bytes:
save_kwargs["exif"] = exif_bytes
# Avoid passing unsupported params to formats (e.g. PNG doesn't take `quality`).
if fmt in {"JPEG", "JPG"}:
save_kwargs["quality"] = 95
# keep it simple; avoid Pillow-version-specific kwargs like optimize/subsampling
if img.mode not in {"RGB", "L"}:
img = img.convert("RGB")
elif fmt == "WEBP":
save_kwargs["quality"] = 95
try:
img.save(file_path, **save_kwargs)
except TypeError:
# Fallback: drop metadata kwargs that some formats/plugins may reject.
save_kwargs.pop("exif", None)
save_kwargs.pop("icc_profile", None)
img.save(file_path, **save_kwargs)
def auto_fix_exif(self, target_dir: Union[str, Path]) -> int:
"""Auto-fix EXIF orientation for all images in the directory
Args:
target_dir: Target directory
Returns:
Number of images fixed
"""
target_path = Path(target_dir)
if not target_path.exists():
return 0
print(f"[AUTO] Checking EXIF orientation information...")
fixed_count = 0
valid_exts = {'.jpg', '.jpeg', '.webp'} # PNG typically does not carry rotation EXIF
# Pre-collect file list to avoid issues caused by modifying during iteration
files = [f for f in target_path.iterdir() if f.is_file() and f.suffix.lower() in valid_exts]
for f in files:
if self._fix_single_exif(f):
fixed_count += 1
if fixed_count > 0:
print(f"[OK] Auto-fixed EXIF orientation for {fixed_count} image(s)")
else:
print(f"[INFO] No images requiring EXIF correction found")
return fixed_count
def generate_html_tool(self, target_dir: str, output_filename: str = "image_orientation_tool.html") -> str:
"""Generate the image filtering HTML tool
Automatically performs EXIF correction before generating.
"""
target_path = Path(target_dir).resolve()
repo_root = self._repo_root()
if not target_path.exists():
raise FileNotFoundError(f"Directory not found: {target_path}")
# 1. Perform automatic EXIF correction first
self.auto_fix_exif(target_path)
# 2. Generate HTML
# Tool is generated in the parent directory (projects/)
project_root = target_path.parent
html_output_path = project_root / output_filename
# Collect images
images = []
valid_exts = {'.jpg', '.jpeg', '.png', '.webp', '.bmp'}
print(f"[SCAN] Scanning directory to generate webpage: {target_path}")
files = sorted(target_path.iterdir(), key=lambda p: self._natural_sort_key(p.name))
for f in files:
if f.is_file() and f.suffix.lower() in valid_exts:
try:
# src is used for HTML display, keep path relative to the HTML file (e.g. "images/1.jpg")
src_rel_path = f.relative_to(project_root).as_posix()
# path is used for JSON data, using path relative to the working directory (usually repo root)
# e.g. "projects/Name/images/1.jpg"
# We assume the script is run from the repo root, or target_path is already absolute
# The safest approach is to compute a path relative to the repo root (avoids CWD changes making fixes.json unusable)
try:
repo_rel_path = f.relative_to(repo_root).as_posix()
except ValueError:
# If the file is not under CWD, fall back to absolute path
repo_rel_path = str(f.resolve())
images.append({'src': src_rel_path, 'path': repo_rel_path})
except ValueError:
print(f"[WARN] Warning: {f.name} cannot compute relative path, skipped")
continue
if not images:
raise ValueError("No image files found")
json_data = json.dumps(images)
# Embed HTML template
html_content = self._get_html_template().replace('__IMAGES__', json_data)
with open(html_output_path, 'w', encoding='utf-8') as f:
f.write(html_content)
return str(html_output_path)
def apply_fixes(self, json_source: Union[str, List[Dict]]) -> Dict[str, int]:
"""Apply image rotation fixes"""
tasks = []
json_file_dir: Optional[Path] = None
# Parse input
if isinstance(json_source, str):
if json_source.endswith('.json') or os.path.exists(json_source):
json_file_dir = Path(json_source).resolve().parent
with open(json_source, 'r', encoding='utf-8') as f:
tasks = json.load(f)
else:
try:
tasks = json.loads(json_source)
except json.JSONDecodeError:
raise ValueError("Invalid input: not a file path nor a valid JSON string")
elif isinstance(json_source, list):
tasks = json_source
print(f"[WORK] Starting {len(tasks)} manual rotation task(s)...")
print("=" * 60)
cwd = Path(os.getcwd())
repo_root = self._repo_root()
stats = {'total': len(tasks), 'success': 0}
for task in tasks:
rel_path = self._normalize_task_path(task.get('path', ''))
rotation = task.get('rotation')
if not rel_path or rotation is None:
continue
# Absolute paths should stay absolute; repo-relative paths should resolve from repo root.
target_file = Path(rel_path)
if not target_file.is_absolute():
# Prefer repo root (stable); also allow CWD and fixes.json location as fallbacks.
candidates = [
repo_root / rel_path,
cwd / rel_path,
]
if json_file_dir:
candidates.append(json_file_dir / rel_path)
# Compatibility with legacy logic / bare filenames (try finding under the projects directory)
candidates.append(repo_root / 'projects' / rel_path)
candidates.append(cwd / 'projects' / rel_path)
if json_file_dir:
candidates.append(json_file_dir / 'projects' / rel_path)
target_file = next((c for c in candidates if c.exists()), candidates[0])
if not target_file.exists():
print(f"[SKIP] File not found: {rel_path}")
continue
try:
self._rotate_single_image(target_file, rotation)
print(f"[OK] {target_file.name} rotated {rotation} degrees")
stats['success'] += 1
except Exception as e:
print(f"[ERROR] {target_file.name}: {e}")
return stats
def _fix_single_exif(self, file_path: Path) -> bool:
"""Check and fix EXIF orientation for a single image"""
try:
fixed_img: Optional[Image.Image] = None
exif_bytes: Optional[bytes] = None
icc_profile: Optional[bytes] = None
src_format: Optional[str] = None
with Image.open(file_path) as img:
exif = img.getexif()
orientation = exif.get(ORIENTATION_TAG_ID, 1) if exif else None
if not orientation or orientation == 1:
return False
print(f" [EXIF] Fixing: {file_path.name} (Orientation={orientation})")
# Apply rotation
fixed_img = self._apply_exif_orientation(img, orientation)
fixed_img.load()
# Remove the specific Orientation tag, keep other EXIF data
if exif:
exif[ORIENTATION_TAG_ID] = 1
exif_bytes = exif.tobytes()
icc_profile = img.info.get('icc_profile')
src_format = img.format
# Must save after the original file is closed (Windows requirement)
if fixed_img is None:
return False
self._save_in_place(
fixed_img,
file_path,
src_format,
exif_bytes=exif_bytes,
icc_profile=icc_profile,
)
return True
except Exception as e:
print(f" [WARN] Failed to read EXIF for {file_path.name}: {e}")
return False
def _get_exif_orientation(self, img: Image.Image) -> Optional[int]:
"""Get the Orientation value"""
try:
exif = img._getexif()
if exif:
for tag, value in exif.items():
if ExifTags.TAGS.get(tag) == 'Orientation':
return value
except Exception:
pass
return None
def _apply_exif_orientation(self, img: Image.Image, orientation: int) -> Image.Image:
"""Rotate image according to the Orientation value"""
T = getattr(Image, "Transpose", Image)
if orientation == 2:
return img.transpose(T.FLIP_LEFT_RIGHT)
if orientation == 3:
return img.transpose(T.ROTATE_180)
if orientation == 4:
return img.transpose(T.FLIP_TOP_BOTTOM)
if orientation == 5:
return img.transpose(T.TRANSPOSE)
if orientation == 6:
return img.transpose(T.ROTATE_270)
if orientation == 7:
return img.transpose(T.TRANSVERSE)
if orientation == 8:
return img.transpose(T.ROTATE_90)
return img
def _rotate_single_image(self, file_path: Path, rotation_deg: int):
"""Manually rotate a single image"""
T = getattr(Image, "Transpose", Image)
with Image.open(file_path) as img:
ccw_angle = (360 - int(rotation_deg)) % 360
if ccw_angle == 0:
return
if ccw_angle == 90:
rotated = img.transpose(T.ROTATE_90)
elif ccw_angle == 180:
rotated = img.transpose(T.ROTATE_180)
elif ccw_angle == 270:
rotated = img.transpose(T.ROTATE_270)
else:
rotated = img.rotate(ccw_angle, expand=True)
rotated.load()
exif = img.getexif()
exif_bytes: Optional[bytes] = None
if exif:
exif[ORIENTATION_TAG_ID] = 1
exif_bytes = exif.tobytes()
icc_profile = img.info.get('icc_profile')
src_format = img.format
self._save_in_place(
rotated,
file_path,
src_format,
exif_bytes=exif_bytes,
icc_profile=icc_profile,
)
def _get_html_template(self) -> str:
"""Get HTML template content"""
return """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Image Orientation Tool</title>
<style>
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; padding: 20px; background: #f0f2f5; color: #333; }
.header {
position: sticky; top: 0; background: rgba(255,255,255,0.95); padding: 20px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08); z-index: 100;
border-radius: 12px; margin-bottom: 20px;
backdrop-filter: blur(10px);
display: flex; justify-content: space-between; align-items: center;
}
h2 { margin: 0; font-size: 1.5rem; color: #1a1a1a; }
.instructions { color: #666; margin-top: 5px; font-size: 0.9rem; }
.grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 15px;
}
.card {
background: white; border-radius: 12px; overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.05); text-align: center;
cursor: pointer; transition: all 0.2s ease;
position: relative; border: 2px solid transparent;
}
.card:hover { transform: translateY(-4px); box-shadow: 0 8px 16px rgba(0,0,0,0.1); }
.card.modified { border-color: #007bff; background: #f8fbff; }
.img-wrapper {
height: 180px; width: 100%; display: flex; align-items: center; justify-content: center;
background: #e9ecef; overflow: hidden; position: relative;
}
img {
max-width: 100%; max-height: 100%; object-fit: contain;
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.info { padding: 10px; font-size: 11px; color: #555; word-break: break-all; border-top: 1px solid #eee; }
.badge {
position: absolute; top: 10px; right: 10px;
background: #007bff; color: white; padding: 4px 8px;
border-radius: 20px; font-size: 11px; font-weight: bold;
opacity: 0; transform: scale(0.8); transition: all 0.2s;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.card.modified .badge { opacity: 1; transform: scale(1); }
.btn {
background: #007bff; color: white; border: none; padding: 10px 24px;
border-radius: 8px; font-weight: 600; cursor: pointer; transition: background 0.2s;
}
.btn:hover { background: #0056b3; }
#output-modal {
display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.5); z-index: 1000; align-items: center; justify-content: center;
}
.modal-content {
background: white; padding: 30px; border-radius: 16px; width: 80%; max-width: 600px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}
textarea {
width: 100%; height: 200px; padding: 10px; border: 1px solid #ddd; border-radius: 8px;
font-family: inherit; resize: vertical; margin: 15px 0;
background: #f8f9fa;
}
</style>
</head>
<body>
<div class="header">
<div>
<h2>Image Orientation Fix</h2>
<div class="instructions">Click an image to rotate (90 -> 180 -> 270 -> 0). Natural order sorting.</div>
</div>
<button class="btn" onclick="showCode()">Generate Fix Code</button>
</div>
<div class="grid" id="grid"></div>
<div id="output-modal" onclick="if(event.target===this)this.style.display='none'">
<div class="modal-content">
<h3>Copy the code below</h3>
<p style="color:#666; font-size: 0.9em;">Copy this JSON content and send it to the AI assistant, or save it as 'fixes.json'.</p>
<textarea id="output-area" readonly></textarea>
<div style="text-align: right;">
<button class="btn" onclick="document.getElementById('output-modal').style.display='none'">Close</button>
</div>
</div>
</div>
<script>
const images = __IMAGES__;
const grid = document.getElementById('grid');
images.forEach(item => {
const card = document.createElement('div');
card.className = 'card';
card.setAttribute('data-rotation', 0);
// use stable path for the data attribute
card.setAttribute('data-path', item.path);
const filename = item.src.split('/').pop();
card.innerHTML = `
<div class="img-wrapper">
<img src="${item.src}" alt="${filename}" loading="lazy">
<div class="badge">0°</div>
</div>
<div class="info">${filename}</div>
`;
card.onclick = function() {
let rot = parseInt(this.getAttribute('data-rotation'));
rot = (rot + 90) % 360;
this.setAttribute('data-rotation', rot);
const img = this.querySelector('img');
img.style.transform = `rotate(${rot}deg)`;
const badge = this.querySelector('.badge');
badge.innerText = rot + '°';
if (rot > 0) {
this.classList.add('modified');
} else {
this.classList.remove('modified');
}
};
grid.appendChild(card);
});
function showCode() {
const tasks = [];
document.querySelectorAll('.card').forEach(card => {
const rot = parseInt(card.getAttribute('data-rotation'));
if (rot > 0) {
tasks.push({
path: card.getAttribute('data-path'),
rotation: rot
});
}
});
const jsonStr = JSON.stringify(tasks, null, 2);
const modal = document.getElementById('output-modal');
const area = document.getElementById('output-area');
modal.style.display = 'flex';
area.value = jsonStr;
area.select();
try { document.execCommand('copy'); } catch(e){}
}
</script>
</body>
</html>
"""
def main():
"""Main function"""
if len(sys.argv) < 2:
print(__doc__)
sys.exit(1)
command = sys.argv[1]
rotator = ImageRotator()
if command == 'gen':
if len(sys.argv) < 3:
print("[ERROR] Error: images directory path is required")
print("Usage: python3 scripts/rotate_images.py gen <images_directory>")
sys.exit(1)
target_dir = sys.argv[2]
try:
output_path = rotator.generate_html_tool(target_dir)
print(f"[OK] HTML tool created: {output_path}")
print(f"[LINK] Open in browser: file:///{Path(output_path).as_posix()}")
except Exception as e:
print(f"[ERROR] Generation failed: {e}")
sys.exit(1)
elif command == 'fix':
if len(sys.argv) < 3:
print("[ERROR] Error: fixes.json path is required")
print("Usage: python3 scripts/rotate_images.py fix <fixes.json>")
sys.exit(1)
json_file = sys.argv[2]
try:
stats = rotator.apply_fixes(json_file)
print(f"\n[DONE] Processing complete: {stats['success']} succeeded / {stats['total']} total")
except Exception as e:
print(f"[ERROR] Execution failed: {e}")
sys.exit(1)
elif command == 'auto':
if len(sys.argv) < 3:
print("[ERROR] Error: images directory path is required")
print("Usage: python3 scripts/rotate_images.py auto <images_directory>")
sys.exit(1)
target_dir = sys.argv[2]
try:
# Only perform automatic EXIF fix
count = rotator.auto_fix_exif(Path(target_dir))
if count == 0:
print("[INFO] No images requiring automatic fix found")
except Exception as e:
print(f"[ERROR] Automatic fix failed: {e}")
sys.exit(1)
else:
print(f"[ERROR] Error: unknown command '{command}'")
print(__doc__)
sys.exit(1)
if __name__ == '__main__':
main()
scripts/svg_position_calculator.py
#!/usr/bin/env python3
"""
PPT Master - SVG Position Calculation and Validation Tool
Provides pre-calculation and post-validation of chart coordinates,
outputting clear coordinate tables.
======================================================================
Common Commands (can be copied and used directly)
======================================================================
1. Analyze all coordinates in an SVG file:
python scripts/svg_position_calculator.py analyze <svg_file>
2. Interactive calculation mode:
python scripts/svg_position_calculator.py interactive
3. Calculate from JSON config file:
python scripts/svg_position_calculator.py from-json <config.json>
4. Quick calculation:
python scripts/svg_position_calculator.py calc bar --data "East:185,South:142"
python scripts/svg_position_calculator.py calc pie --data "A:35,B:25,C:20"
python scripts/svg_position_calculator.py calc line --data "0:50,10:80,20:120"
python scripts/svg_position_calculator.py calc grid --rows 2 --cols 3
======================================================================
"""
import sys
import re
import math
import argparse
from pathlib import Path
from typing import Dict, List, Tuple, Optional, Any
from dataclasses import dataclass
# Fix garbled Chinese output on Windows
if sys.platform == 'win32':
try:
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
except AttributeError:
# Python < 3.7
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
# Import canvas format configuration
try:
from project_utils import CANVAS_FORMATS
except ImportError:
# Use built-in definitions if import fails
CANVAS_FORMATS = {
'ppt169': {'name': 'PPT 16:9', 'dimensions': '1280×720', 'viewbox': '0 0 1280 720'},
'ppt43': {'name': 'PPT 4:3', 'dimensions': '1024×768', 'viewbox': '0 0 1024 768'},
'facebook_tiktok': {'name': 'Facebook/TikTok', 'dimensions': '1242×1660', 'viewbox': '0 0 1242 1660'},
'social_square': {'name': 'Zalo/Instagram', 'dimensions': '1080×1080', 'viewbox': '0 0 1080 1080'},
}
# =============================================================================
# Coordinate System Base Classes
# =============================================================================
@dataclass
class ChartArea:
"""Chart area definition"""
x_min: float
y_min: float
x_max: float
y_max: float
@property
def width(self) -> float:
return self.x_max - self.x_min
@property
def height(self) -> float:
return self.y_max - self.y_min
@property
def center(self) -> Tuple[float, float]:
return ((self.x_min + self.x_max) / 2, (self.y_min + self.y_max) / 2)
class CoordinateSystem:
"""Coordinate system - maps data domain to SVG canvas coordinates"""
def __init__(self, canvas_format: str = 'ppt169', chart_area: Optional[ChartArea] = None):
"""
Initialize the coordinate system
Args:
canvas_format: Canvas format (ppt169, ppt43, facebook_tiktok, social_square, etc.)
chart_area: Chart area; uses default values if not specified
"""
self.canvas_format = canvas_format
# Parse canvas dimensions
if canvas_format in CANVAS_FORMATS:
viewbox = CANVAS_FORMATS[canvas_format]['viewbox']
parts = viewbox.split()
self.canvas_width = int(parts[2])
self.canvas_height = int(parts[3])
else:
self.canvas_width = 1280
self.canvas_height = 720
# Set chart area (default with margins)
if chart_area:
self.chart_area = chart_area
else:
# Default chart area: left/right margin 140px, top/bottom margin 150px
self.chart_area = ChartArea(
x_min=140,
y_min=150,
x_max=self.canvas_width - 120,
y_max=self.canvas_height - 120
)
def data_to_svg_x(self, data_x: float, x_range: Tuple[float, float]) -> float:
"""
Map data X value to SVG X coordinate
Args:
data_x: Data X value
x_range: X axis data range (min, max)
"""
x_min, x_max = x_range
if x_max == x_min:
return self.chart_area.x_min
ratio = (data_x - x_min) / (x_max - x_min)
return self.chart_area.x_min + ratio * self.chart_area.width
def data_to_svg_y(self, data_y: float, y_range: Tuple[float, float]) -> float:
"""
Map data Y value to SVG Y coordinate (note: SVG Y axis points downward)
Args:
data_y: Data Y value
y_range: Y axis data range (min, max)
"""
y_min, y_max = y_range
if y_max == y_min:
return self.chart_area.y_max
ratio = (data_y - y_min) / (y_max - y_min)
# SVG Y axis points downward, so invert
return self.chart_area.y_max - ratio * self.chart_area.height
def data_to_svg(self, data_x: float, data_y: float,
x_range: Tuple[float, float], y_range: Tuple[float, float]) -> Tuple[float, float]:
"""Map data point to SVG coordinates"""
return (self.data_to_svg_x(data_x, x_range), self.data_to_svg_y(data_y, y_range))
# =============================================================================
# Bar Chart Calculator
# =============================================================================
@dataclass
class BarPosition:
"""Bar position information"""
index: int
label: str
value: float
x: float
y: float
width: float
height: float
label_x: float # Label X position
label_y: float # Label Y position (below bar)
value_x: float # Value X position
value_y: float # Value Y position (above bar)
class BarChartCalculator:
"""Bar chart coordinate calculator"""
def __init__(self, coord_system: CoordinateSystem):
self.coord = coord_system
def calculate(self, data: Dict[str, float],
bar_width: float = 50,
gap_ratio: float = 0.3,
y_min: float = 0,
y_max: Optional[float] = None,
horizontal: bool = False) -> List[BarPosition]:
"""
Calculate bar chart positions
Args:
data: Data dictionary {label: value}
bar_width: Bar width (auto-calculated if None)
gap_ratio: Gap ratio between bars (relative to bar width)
y_min: Y axis minimum value
y_max: Y axis maximum value (uses data maximum if None)
horizontal: Whether to use horizontal bar chart
"""
labels = list(data.keys())
values = list(data.values())
n = len(labels)
if n == 0:
return []
# Calculate Y axis range
if y_max is None:
y_max = max(values) * 1.1 # Leave 10% headroom
area = self.coord.chart_area
if horizontal:
# Horizontal bar chart
return self._calculate_horizontal(labels, values, bar_width, gap_ratio, y_min, y_max)
# Calculate bar layout
total_width = area.width
if bar_width is None:
# Auto-calculate bar width: total width / (bar count * (1 + gap ratio))
bar_width = total_width / (n * (1 + gap_ratio))
gap = bar_width * gap_ratio
total_bars_width = n * bar_width + (n - 1) * gap
start_x = area.x_min + (area.width - total_bars_width) / 2
results = []
for i, (label, value) in enumerate(zip(labels, values)):
# Bar X position
x = start_x + i * (bar_width + gap)
# Bar height and Y position
ratio = (value - y_min) / (y_max - y_min) if y_max > y_min else 0
height = ratio * area.height
y = area.y_max - height # SVG Y axis points downward
# Label and value positions
center_x = x + bar_width / 2
results.append(BarPosition(
index=i + 1,
label=label,
value=value,
x=round(x, 1),
y=round(y, 1),
width=round(bar_width, 1),
height=round(height, 1),
label_x=round(center_x, 1),
label_y=round(area.y_max + 30, 1),
value_x=round(center_x, 1),
value_y=round(y - 15, 1)
))
return results
def _calculate_horizontal(self, labels: List[str], values: List[float],
bar_height: float, gap_ratio: float,
x_min: float, x_max: float) -> List[BarPosition]:
"""Calculate horizontal bar chart"""
n = len(labels)
area = self.coord.chart_area
if bar_height is None:
bar_height = area.height / (n * (1 + gap_ratio))
gap = bar_height * gap_ratio
total_bars_height = n * bar_height + (n - 1) * gap
start_y = area.y_min + (area.height - total_bars_height) / 2
results = []
for i, (label, value) in enumerate(zip(labels, values)):
y = start_y + i * (bar_height + gap)
ratio = (value - x_min) / (x_max - x_min) if x_max > x_min else 0
width = ratio * area.width
x = area.x_min
center_y = y + bar_height / 2
results.append(BarPosition(
index=i + 1,
label=label,
value=value,
x=round(x, 1),
y=round(y, 1),
width=round(width, 1),
height=round(bar_height, 1),
label_x=round(area.x_min - 10, 1),
label_y=round(center_y, 1),
value_x=round(x + width + 10, 1),
value_y=round(center_y, 1)
))
return results
def format_table(self, positions: List[BarPosition]) -> str:
"""Format as table output"""
lines = []
lines.append("Index Label Value X Y Width Height")
lines.append("---- ---------- -------- ------- ------- ------- -------")
for p in positions:
lines.append(f"{p.index:4d} {p.label:<10s} {p.value:>8.1f} {p.x:>7.1f} {p.y:>7.1f} {p.width:>7.1f} {p.height:>7.1f}")
return "\n".join(lines)
# =============================================================================
# Pie / Donut Chart Calculator
# =============================================================================
@dataclass
class PieSlice:
"""Pie chart slice information"""
index: int
label: str
value: float
percentage: float
start_angle: float # Start angle (degrees)
end_angle: float # End angle (degrees)
path_d: str # SVG path d attribute
label_x: float # Label X position
label_y: float # Label Y position
# Arc endpoint coordinates (relative to center)
start_x: float
start_y: float
end_x: float
end_y: float
class PieChartCalculator:
"""Pie / donut chart calculator"""
def __init__(self, center: Tuple[float, float] = (420, 400), radius: float = 200):
self.cx, self.cy = center
self.radius = radius
def calculate(self, data: Dict[str, float],
start_angle: float = -90,
inner_radius: float = 0) -> List[PieSlice]:
"""
Calculate pie chart slices
Args:
data: Data dictionary {label: value}
start_angle: Start angle (degrees, -90 means starting from 12 o'clock)
inner_radius: Inner radius (0 for pie chart, > 0 for donut chart)
"""
labels = list(data.keys())
values = list(data.values())
total = sum(values)
if total == 0:
return []
results = []
current_angle = start_angle
for i, (label, value) in enumerate(zip(labels, values)):
percentage = value / total * 100
angle_span = value / total * 360
end_angle = current_angle + angle_span
# Calculate arc endpoints
start_rad = math.radians(current_angle)
end_rad = math.radians(end_angle)
start_x = self.radius * math.cos(start_rad)
start_y = self.radius * math.sin(start_rad)
end_x = self.radius * math.cos(end_rad)
end_y = self.radius * math.sin(end_rad)
# Generate path
large_arc = 1 if angle_span > 180 else 0
if inner_radius > 0:
# Donut chart
inner_start_x = inner_radius * math.cos(start_rad)
inner_start_y = inner_radius * math.sin(start_rad)
inner_end_x = inner_radius * math.cos(end_rad)
inner_end_y = inner_radius * math.sin(end_rad)
path_d = (
f"M {inner_start_x:.2f},{inner_start_y:.2f} "
f"L {start_x:.2f},{start_y:.2f} "
f"A {self.radius},{self.radius} 0 {large_arc},1 {end_x:.2f},{end_y:.2f} "
f"L {inner_end_x:.2f},{inner_end_y:.2f} "
f"A {inner_radius},{inner_radius} 0 {large_arc},0 {inner_start_x:.2f},{inner_start_y:.2f} Z"
)
else:
# Pie chart
path_d = (
f"M 0,0 "
f"L {start_x:.2f},{start_y:.2f} "
f"A {self.radius},{self.radius} 0 {large_arc},1 {end_x:.2f},{end_y:.2f} Z"
)
# Label position (70% of radius in the direction of slice center)
mid_angle = (current_angle + end_angle) / 2
mid_rad = math.radians(mid_angle)
label_distance = self.radius * 0.7
label_x = self.cx + label_distance * math.cos(mid_rad)
label_y = self.cy + label_distance * math.sin(mid_rad)
results.append(PieSlice(
index=i + 1,
label=label,
value=value,
percentage=round(percentage, 1),
start_angle=round(current_angle, 1),
end_angle=round(end_angle, 1),
path_d=path_d,
label_x=round(label_x, 1),
label_y=round(label_y, 1),
start_x=round(start_x, 2),
start_y=round(start_y, 2),
end_x=round(end_x, 2),
end_y=round(end_y, 2)
))
current_angle = end_angle
return results
def format_table(self, slices: List[PieSlice]) -> str:
"""Format as table output"""
lines = []
lines.append(f"Center: ({self.cx}, {self.cy}) | Radius: {self.radius}")
lines.append("")
lines.append("Index Label Pct Start End LabelX LabelY")
lines.append("---- ---------- -------- -------- -------- ------- -------")
for s in slices:
lines.append(
f"{s.index:4d} {s.label:<10s} {s.percentage:>6.1f}% {s.start_angle:>8.1f} "
f"{s.end_angle:>8.1f} {s.label_x:>7.1f} {s.label_y:>7.1f}"
)
lines.append("")
lines.append("=== Arc Endpoint Coordinates (relative to center) ===")
lines.append("Index StartX StartY EndX EndY")
lines.append("---- --------- --------- --------- ---------")
for s in slices:
lines.append(
f"{s.index:4d} {s.start_x:>9.2f} {s.start_y:>9.2f} {s.end_x:>9.2f} {s.end_y:>9.2f}"
)
lines.append("")
lines.append("=== Path d Attribute ===")
for s in slices:
lines.append(f"{s.index}. {s.label}: {s.path_d}")
return "\n".join(lines)
# =============================================================================
# Radar Chart Calculator
# =============================================================================
@dataclass
class RadarPoint:
"""Radar chart data point"""
index: int
label: str
value: float
percentage: float # Percentage relative to max value
angle: float # Angle (degrees)
x: float # X relative to center
y: float # Y relative to center
abs_x: float # Absolute X coordinate
abs_y: float # Absolute Y coordinate
label_x: float # Label X position
label_y: float # Label Y position
class RadarChartCalculator:
"""Radar chart calculator"""
def __init__(self, center: Tuple[float, float] = (640, 400), radius: float = 200):
self.cx, self.cy = center
self.radius = radius
def calculate(self, data: Dict[str, float],
max_value: Optional[float] = None,
start_angle: float = -90) -> List[RadarPoint]:
"""
Calculate radar chart vertex coordinates
Args:
data: Data dictionary {dimension_name: value}
max_value: Maximum value (for normalization); uses data maximum if None
start_angle: Start angle (degrees, -90 means starting from 12 o'clock)
"""
labels = list(data.keys())
values = list(data.values())
n = len(labels)
if n == 0:
return []
if max_value is None:
max_value = max(values)
angle_step = 360 / n
results = []
for i, (label, value) in enumerate(zip(labels, values)):
angle = start_angle + i * angle_step
rad = math.radians(angle)
# Calculate normalized radius
percentage = (value / max_value * 100) if max_value > 0 else 0
point_radius = self.radius * (value / max_value) if max_value > 0 else 0
# Calculate coordinates
x = point_radius * math.cos(rad)
y = point_radius * math.sin(rad)
# Label position (outside the outermost ring)
label_distance = self.radius + 30
label_x = self.cx + label_distance * math.cos(rad)
label_y = self.cy + label_distance * math.sin(rad)
results.append(RadarPoint(
index=i + 1,
label=label,
value=value,
percentage=round(percentage, 1),
angle=round(angle, 1),
x=round(x, 2),
y=round(y, 2),
abs_x=round(self.cx + x, 2),
abs_y=round(self.cy + y, 2),
label_x=round(label_x, 1),
label_y=round(label_y, 1)
))
return results
def calculate_grid(self, levels: int = 5) -> List[List[Tuple[float, float]]]:
"""Calculate grid layer coordinates (for drawing background polygons)"""
n = 6 # Assume 6 dimensions
grids = []
for level in range(1, levels + 1):
level_radius = self.radius * level / levels
points = []
angle_step = 360 / n
for i in range(n):
angle = -90 + i * angle_step
rad = math.radians(angle)
x = level_radius * math.cos(rad)
y = level_radius * math.sin(rad)
points.append((round(x, 2), round(y, 2)))
grids.append(points)
return grids
def format_table(self, points: List[RadarPoint]) -> str:
"""Format as table output"""
lines = []
lines.append(f"Center: ({self.cx}, {self.cy}) | Radius: {self.radius}")
lines.append("")
lines.append("Index Dimension Value Pct Angle X Y Abs_X Abs_Y")
lines.append("---- ---------- ------ -------- ------ ------- ------- ------- -------")
for p in points:
lines.append(
f"{p.index:4d} {p.label:<10s} {p.value:>6.1f} {p.percentage:>6.1f}% "
f"{p.angle:>6.1f} {p.x:>7.2f} {p.y:>7.2f} {p.abs_x:>7.1f} {p.abs_y:>7.1f}"
)
# Generate polygon points attribute
lines.append("")
lines.append("=== SVG Polygon Points ===")
points_str = " ".join([f"{p.x},{p.y}" for p in points])
lines.append(f'points="{points_str}"')
return "\n".join(lines)
# =============================================================================
# Line / Scatter Chart Calculator
# =============================================================================
@dataclass
class DataPoint:
"""Data point"""
index: int
x_value: float
y_value: float
svg_x: float
svg_y: float
label: Optional[str] = None
class LineChartCalculator:
"""Line / scatter chart calculator"""
def __init__(self, coord_system: CoordinateSystem):
self.coord = coord_system
def calculate(self, data: List[Tuple[float, float]],
x_range: Optional[Tuple[float, float]] = None,
y_range: Optional[Tuple[float, float]] = None,
labels: Optional[List[str]] = None) -> List[DataPoint]:
"""
Calculate data point coordinates
Args:
data: Data point list [(x1, y1), (x2, y2), ...]
x_range: X axis range; auto-calculated if None
y_range: Y axis range; auto-calculated if None
labels: Point label list
"""
if not data:
return []
x_values = [p[0] for p in data]
y_values = [p[1] for p in data]
if x_range is None:
x_range = (min(x_values), max(x_values))
if y_range is None:
y_min = 0
y_max = max(y_values) * 1.1
y_range = (y_min, y_max)
results = []
for i, (x, y) in enumerate(data):
svg_x, svg_y = self.coord.data_to_svg(x, y, x_range, y_range)
results.append(DataPoint(
index=i + 1,
x_value=x,
y_value=y,
svg_x=round(svg_x, 1),
svg_y=round(svg_y, 1),
label=labels[i] if labels and i < len(labels) else None
))
return results
def generate_path(self, points: List[DataPoint], closed: bool = False) -> str:
"""Generate SVG path d attribute"""
if not points:
return ""
parts = [f"M {points[0].svg_x},{points[0].svg_y}"]
for p in points[1:]:
parts.append(f"L {p.svg_x},{p.svg_y}")
if closed:
parts.append("Z")
return " ".join(parts)
def format_table(self, points: List[DataPoint]) -> str:
"""Format as table output"""
lines = []
area = self.coord.chart_area
lines.append(f"Chart area: ({area.x_min}, {area.y_min}) - ({area.x_max}, {area.y_max})")
lines.append("")
lines.append("Index X_Value Y_Value SVG_X SVG_Y")
lines.append("---- --------- --------- -------- --------")
for p in points:
label_part = f" ({p.label})" if p.label else ""
lines.append(
f"{p.index:4d} {p.x_value:>9.2f} {p.y_value:>9.2f} {p.svg_x:>8.1f} {p.svg_y:>8.1f}{label_part}"
)
lines.append("")
lines.append("=== SVG Path ===")
lines.append(self.generate_path(points))
return "\n".join(lines)
# =============================================================================
# Grid Layout Calculator
# =============================================================================
@dataclass
class GridCell:
"""Grid cell"""
row: int
col: int
index: int # 1-based index
x: float
y: float
width: float
height: float
center_x: float
center_y: float
class GridLayoutCalculator:
"""Grid layout calculator"""
def __init__(self, coord_system: CoordinateSystem):
self.coord = coord_system
def calculate(self, rows: int, cols: int,
padding: float = 20,
gap: float = 20) -> List[GridCell]:
"""
Calculate grid layout
Args:
rows: Number of rows
cols: Number of columns
padding: Chart area inner padding
gap: Cell spacing
"""
area = self.coord.chart_area
# Calculate available area
available_width = area.width - 2 * padding - (cols - 1) * gap
available_height = area.height - 2 * padding - (rows - 1) * gap
cell_width = available_width / cols
cell_height = available_height / rows
results = []
index = 1
for row in range(rows):
for col in range(cols):
x = area.x_min + padding + col * (cell_width + gap)
y = area.y_min + padding + row * (cell_height + gap)
results.append(GridCell(
row=row + 1,
col=col + 1,
index=index,
x=round(x, 1),
y=round(y, 1),
width=round(cell_width, 1),
height=round(cell_height, 1),
center_x=round(x + cell_width / 2, 1),
center_y=round(y + cell_height / 2, 1)
))
index += 1
return results
def format_table(self, cells: List[GridCell]) -> str:
"""Format as table output"""
lines = []
area = self.coord.chart_area
lines.append(f"Chart area: ({area.x_min}, {area.y_min}) - ({area.x_max}, {area.y_max})")
lines.append("")
lines.append("Index Row Col X Y Width Height CenterX CenterY")
lines.append("---- ---- ---- ------- ------- ------- ------- ------- -------")
for c in cells:
lines.append(
f"{c.index:4d} {c.row:4d} {c.col:4d} {c.x:>7.1f} {c.y:>7.1f} "
f"{c.width:>7.1f} {c.height:>7.1f} {c.center_x:>7.1f} {c.center_y:>7.1f}"
)
return "\n".join(lines)
# =============================================================================
# SVG Validator
# =============================================================================
@dataclass
class ValidationResult:
"""Validation result"""
element_type: str
element_id: str
attribute: str
expected: float
actual: float
deviation: float
passed: bool
class SVGPositionValidator:
"""SVG position validator"""
def __init__(self, tolerance: float = 1.0):
"""
Initialize the validator
Args:
tolerance: Allowed deviation (pixels)
"""
self.tolerance = tolerance
def validate_from_file(self, svg_file: str,
expected_coords: Dict[str, Dict[str, float]]) -> List[ValidationResult]:
"""
Validate coordinates from file
Args:
svg_file: SVG file path
expected_coords: Expected coordinates {element_ID: {attribute: value}}
"""
svg_path = Path(svg_file)
if not svg_path.exists():
raise FileNotFoundError(f"SVG file does not exist: {svg_file}")
with open(svg_path, 'r', encoding='utf-8') as f:
content = f.read()
return self.validate_content(content, expected_coords)
def validate_content(self, svg_content: str,
expected_coords: Dict[str, Dict[str, float]]) -> List[ValidationResult]:
"""Validate coordinates in SVG content"""
results = []
for element_id, attrs in expected_coords.items():
for attr, expected in attrs.items():
actual = self._extract_attribute(svg_content, element_id, attr)
if actual is not None:
deviation = abs(actual - expected)
passed = deviation <= self.tolerance
results.append(ValidationResult(
element_type=self._guess_element_type(element_id),
element_id=element_id,
attribute=attr,
expected=expected,
actual=actual,
deviation=round(deviation, 2),
passed=passed
))
else:
results.append(ValidationResult(
element_type=self._guess_element_type(element_id),
element_id=element_id,
attribute=attr,
expected=expected,
actual=float('nan'),
deviation=float('inf'),
passed=False
))
return results
def _extract_attribute(self, content: str, element_id: str, attr: str) -> Optional[float]:
"""Extract attribute value from SVG content"""
# Find element containing the ID
pattern = rf'id="{element_id}"[^>]*{attr}="([^"]+)"'
match = re.search(pattern, content)
if not match:
# Try reverse order
pattern = rf'{attr}="([^"]+)"[^>]*id="{element_id}"'
match = re.search(pattern, content)
if match:
try:
return float(match.group(1))
except ValueError:
return None
return None
def _guess_element_type(self, element_id: str) -> str:
"""Guess element type based on ID"""
id_lower = element_id.lower()
if 'bar' in id_lower or 'rect' in id_lower:
return 'rect'
elif 'circle' in id_lower or 'dot' in id_lower:
return 'circle'
elif 'path' in id_lower or 'slice' in id_lower:
return 'path'
elif 'line' in id_lower:
return 'line'
elif 'text' in id_lower or 'label' in id_lower:
return 'text'
return 'unknown'
def extract_all_positions(self, svg_content: str) -> Dict[str, Dict[str, float]]:
"""Extract position information of all elements in SVG"""
positions = {}
# Extract rect elements
rect_pattern = r'<rect[^>]*(?:id="([^"]*)")?[^>]*x="([^"]*)"[^>]*y="([^"]*)"[^>]*(?:width="([^"]*)")?[^>]*(?:height="([^"]*)")?'
for match in re.finditer(rect_pattern, svg_content):
id_val = match.group(1) or f"rect_{len(positions)}"
positions[id_val] = {
'x': float(match.group(2)) if match.group(2) else 0,
'y': float(match.group(3)) if match.group(3) else 0,
}
if match.group(4):
positions[id_val]['width'] = float(match.group(4))
if match.group(5):
positions[id_val]['height'] = float(match.group(5))
# Extract circle elements
circle_pattern = r'<circle[^>]*(?:id="([^"]*)")?[^>]*cx="([^"]*)"[^>]*cy="([^"]*)"'
for match in re.finditer(circle_pattern, svg_content):
id_val = match.group(1) or f"circle_{len(positions)}"
positions[id_val] = {
'cx': float(match.group(2)),
'cy': float(match.group(3)),
}
return positions
def format_results(self, results: List[ValidationResult]) -> str:
"""Format validation results"""
lines = []
lines.append("=== SVG Position Validation Results ===")
lines.append(f"Tolerance: {self.tolerance}px")
lines.append("")
lines.append("Status Element_ID Attr Expected Actual Deviation")
lines.append("---- -------------- ------ -------- -------- ------")
passed_count = 0
for r in results:
status = "[OK]" if r.passed else "[X]"
if r.passed:
passed_count += 1
actual_str = f"{r.actual:.1f}" if not math.isnan(r.actual) else "N/A"
deviation_str = f"{r.deviation:.2f}" if not math.isinf(r.deviation) else "N/A"
lines.append(
f"{status} {r.element_id:<14s} {r.attribute:<6s} "
f"{r.expected:>8.1f} {actual_str:>8s} {deviation_str:>6s}"
)
lines.append("")
lines.append(f"Passed: {passed_count}/{len(results)} ({passed_count/len(results)*100:.1f}%)")
return "\n".join(lines)
# =============================================================================
# Command Line Interface
# =============================================================================
def parse_data_string(data_str: str) -> Dict[str, float]:
"""Parse data string in 'label1:value1,label2:value2' format"""
result = {}
for item in data_str.split(','):
item = item.strip()
if not item:
continue
if ':' in item:
label, value = item.split(':', 1)
try:
result[label.strip()] = float(value.strip())
except ValueError:
print(f"[Warning] Unable to parse value: '{value.strip()}', skipped")
else:
print(f"[Warning] Invalid format (expected 'label:value'): '{item}'")
return result
def parse_xy_data_string(data_str: str) -> List[Tuple[float, float]]:
"""Parse XY data string in 'x1:y1,x2:y2' format"""
result = []
for item in data_str.split(','):
item = item.strip()
if not item:
continue
if ':' in item:
x, y = item.split(':', 1)
try:
result.append((float(x.strip()), float(y.strip())))
except ValueError:
print(f"[Warning] Unable to parse coordinates: '{item}', skipped")
else:
print(f"[Warning] Invalid format (expected 'x:y'): '{item}'")
return result
def parse_tuple(s: str) -> Tuple[float, ...]:
"""Parse comma-separated numeric tuple"""
return tuple(float(x.strip()) for x in s.split(','))
def extract_attr(element: str, attr_name: str) -> Optional[str]:
"""Extract attribute value from element string (attribute order independent)"""
pattern = rf'{attr_name}="([^"]*)"'
match = re.search(pattern, element)
return match.group(1) if match else None
def analyze_svg_file(svg_file: str) -> None:
"""Analyze all chart elements in an SVG file"""
svg_path = Path(svg_file)
if not svg_path.exists():
print(f"[Error] File does not exist: {svg_file}")
return
with open(svg_path, 'r', encoding='utf-8') as f:
content = f.read()
print(f"\n{'='*70}")
print(f"SVG File Analysis: {svg_path.name}")
print(f"{'='*70}")
# Extract viewBox
viewbox_match = re.search(r'viewBox="([^"]+)"', content)
if viewbox_match:
print(f"Canvas viewBox: {viewbox_match.group(1)}")
# Use more robust element extraction (attribute order independent)
# Extract all rect elements
rect_elements = re.findall(r'<rect[^>]*/?>', content)
rects = []
for elem in rect_elements:
x = extract_attr(elem, 'x')
y = extract_attr(elem, 'y')
w = extract_attr(elem, 'width')
h = extract_attr(elem, 'height')
if x is not None and y is not None:
rects.append((x, y, w, h))
# Extract all circle elements
circle_elements = re.findall(r'<circle[^>]*/?>', content)
circles = []
for elem in circle_elements:
cx = extract_attr(elem, 'cx')
cy = extract_attr(elem, 'cy')
r = extract_attr(elem, 'r')
if cx is not None and cy is not None:
circles.append((cx, cy, r))
# Extract all polyline/polygon elements
polylines = re.findall(r'<(?:polyline|polygon)[^>]*points="([^"]*)"', content)
# Extract path elements
paths = re.findall(r'<path[^>]*d="([^"]*)"', content)
print(f"\nElement statistics:")
print(f" - rect (rectangle): {len(rects)}")
print(f" - circle: {len(circles)}")
print(f" - polyline/polygon: {len(polylines)}")
print(f" - path: {len(paths)}")
# List rect elements in detail
if rects:
print(f"\n=== Rectangle Elements (rect) ===")
print(f"{'Index':<6}{'X':<8} {'Y':<8} {'Width':<8} {'Height':<8}")
print("-" * 45)
for i, (x, y, w, h) in enumerate(rects[:20], 1): # Only show first 20
w_str = w if w else '-'
h_str = h if h else '-'
print(f"{i:<6}{x:<8} {y:<8} {w_str:<8} {h_str:<8}")
if len(rects) > 20:
print(f"... and {len(rects) - 20} more rectangle(s)")
# List circle elements in detail
if circles:
print(f"\n=== Circle Elements (circle) ===")
print(f"{'Index':<6}{'CX':<10} {'CY':<10} {'Radius':<8}")
print("-" * 40)
for i, (cx, cy, r) in enumerate(circles[:20], 1):
r_str = r if r else '-'
print(f"{i:<6}{cx:<10} {cy:<10} {r_str:<8}")
if len(circles) > 20:
print(f"... and {len(circles) - 20} more circle(s)")
# List polyline points
if polylines:
print(f"\n=== Polyline/Polygon (polyline/polygon) ===")
for i, points in enumerate(polylines, 1):
point_list = points.strip().split()
print(f"\nPolyline {i} ({len(point_list)} points):")
# Parse and show first few points
parsed_points = []
for p in point_list[:5]:
if ',' in p:
x, y = p.split(',')
parsed_points.append(f"({x},{y})")
print(f" Start points: {' -> '.join(parsed_points)}")
if len(point_list) > 5:
print(f" ... {len(point_list)} points total")
print(f"\n{'='*70}")
def interactive_mode() -> None:
"""Interactive calculation mode"""
print("\n" + "="*60)
print("SVG Position Calculator - Interactive Mode")
print("="*60)
print("\nSelect chart type:")
print(" 1. Bar chart (bar)")
print(" 2. Pie chart (pie)")
print(" 3. Radar chart (radar)")
print(" 4. Line chart (line)")
print(" 5. Grid layout (grid)")
print(" 6. Custom line (custom)")
print(" 0. Exit")
while True:
try:
choice = input("\nSelect [1-6, 0 to exit]: ").strip()
if choice == '0':
print("Exiting interactive mode")
break
elif choice == '1':
print("\n=== Bar Chart Calculation ===")
data_str = input("Enter data (format: label1:value1,label2:value2): ").strip()
if not data_str:
print("Example: East:185,South:142,North:128")
continue
canvas = input("Canvas format [ppt169]: ").strip() or 'ppt169'
coord = CoordinateSystem(canvas)
calc = BarChartCalculator(coord)
data = parse_data_string(data_str)
positions = calc.calculate(data)
print()
print(calc.format_table(positions))
elif choice == '2':
print("\n=== Pie Chart Calculation ===")
data_str = input("Enter data (format: label1:value1,label2:value2): ").strip()
if not data_str:
print("Example: A:35,B:25,C:20,D:12,Other:8")
continue
center_str = input("Center coordinates [420,400]: ").strip() or '420,400'
radius = float(input("Radius [200]: ").strip() or '200')
center = parse_tuple(center_str)
calc = PieChartCalculator(center, radius)
data = parse_data_string(data_str)
slices = calc.calculate(data)
print()
print(calc.format_table(slices))
elif choice == '3':
print("\n=== Radar Chart Calculation ===")
data_str = input("Enter data (format: dim1:value1,dim2:value2): ").strip()
if not data_str:
print("Example: Performance:90,Security:85,Usability:75,Price:70")
continue
center_str = input("Center coordinates [640,400]: ").strip() or '640,400'
radius = float(input("Radius [200]: ").strip() or '200')
center = parse_tuple(center_str)
calc = RadarChartCalculator(center, radius)
data = parse_data_string(data_str)
points = calc.calculate(data)
print()
print(calc.format_table(points))
elif choice == '4':
print("\n=== Line Chart Calculation ===")
data_str = input("Enter data (format: x1:y1,x2:y2): ").strip()
if not data_str:
print("Example: 0:50,10:80,20:120,30:95")
continue
canvas = input("Canvas format [ppt169]: ").strip() or 'ppt169'
coord = CoordinateSystem(canvas)
calc = LineChartCalculator(coord)
data = parse_xy_data_string(data_str)
points = calc.calculate(data)
print()
print(calc.format_table(points))
elif choice == '5':
print("\n=== Grid Layout Calculation ===")
rows = int(input("Rows: ").strip() or '2')
cols = int(input("Columns: ").strip() or '3')
canvas = input("Canvas format [ppt169]: ").strip() or 'ppt169'
coord = CoordinateSystem(canvas)
calc = GridLayoutCalculator(coord)
cells = calc.calculate(rows, cols)
print()
print(calc.format_table(cells))
elif choice == '6':
print("\n=== Custom Line Calculation ===")
print("For custom formula line charts, such as price index charts")
base_x = float(input("X start value [170]: ").strip() or '170')
step_x = float(input("X step [40]: ").strip() or '40')
base_y = float(input("Y baseline [595]: ").strip() or '595')
scale_y = float(input("Y scale factor [20]: ").strip() or '20')
ref_value = float(input("Reference baseline value [100]: ").strip() or '100')
print(f"\nFormula: X = {base_x} + index * {step_x}")
print(f" Y = {base_y} - (value - {ref_value}) * {scale_y}")
data_str = input("\nEnter data (comma-separated values): ").strip()
if data_str:
values = [float(v.strip()) for v in data_str.split(',')]
print(f"\n{'Index':<6}{'Value':<10} {'X':<8} {'Y':<8}")
print("-" * 35)
for i, v in enumerate(values, 1):
x = base_x + i * step_x
y = base_y - (v - ref_value) * scale_y
print(f"{i:<6}{v:<10.1f} {x:<8.0f} {y:<8.0f}")
# Generate polyline points
points_list = []
for i, v in enumerate(values, 1):
x = base_x + i * step_x
y = base_y - (v - ref_value) * scale_y
points_list.append(f"{int(x)},{int(y)}")
print(f"\npolyline points:")
print(" ".join(points_list))
else:
print("Invalid selection, please enter 1-6 or 0")
except KeyboardInterrupt:
print("\nExiting interactive mode")
break
except Exception as e:
print(f"Error: {e}")
def from_json_config(config_file: str) -> None:
"""Read and calculate from JSON config file"""
import json
config_path = Path(config_file)
if not config_path.exists():
print(f"[Error] Config file does not exist: {config_file}")
return
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
chart_type = config.get('type', 'bar')
data = config.get('data', {})
print(f"\nLoaded from config file: {config_path.name}")
print(f"Chart type: {chart_type}")
if chart_type == 'bar':
canvas = config.get('canvas', 'ppt169')
coord = CoordinateSystem(canvas)
calc = BarChartCalculator(coord)
positions = calc.calculate(data)
print(calc.format_table(positions))
elif chart_type == 'pie':
center = tuple(config.get('center', [420, 400]))
radius = config.get('radius', 200)
calc = PieChartCalculator(center, radius)
slices = calc.calculate(data)
print(calc.format_table(slices))
elif chart_type == 'line':
canvas = config.get('canvas', 'ppt169')
coord = CoordinateSystem(canvas)
calc = LineChartCalculator(coord)
# data should be list of [x, y] pairs
points_data = [(p[0], p[1]) for p in data]
points = calc.calculate(points_data)
print(calc.format_table(points))
elif chart_type == 'custom_line':
# Custom line chart
base_x = config.get('base_x', 170)
step_x = config.get('step_x', 40)
base_y = config.get('base_y', 595)
scale_y = config.get('scale_y', 20)
ref_value = config.get('ref_value', 100)
values = config.get('values', [])
print(f"\nFormula: X = {base_x} + index * {step_x}")
print(f" Y = {base_y} - (value - {ref_value}) * {scale_y}")
print(f"\n{'Index':<6}{'Value':<10} {'X':<8} {'Y':<8}")
print("-" * 35)
points_list = []
for i, v in enumerate(values, 1):
x = base_x + i * step_x
y = base_y - (v - ref_value) * scale_y
print(f"{i:<6}{v:<10.1f} {x:<8.0f} {y:<8.0f}")
points_list.append(f"{int(x)},{int(y)}")
print(f"\npolyline points:")
print(" ".join(points_list))
def main():
parser = argparse.ArgumentParser(
description='SVG Position Calculation and Validation Tool',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Common commands:
# Analyze SVG file
python svg_position_calculator.py analyze example.svg
# Interactive mode
python svg_position_calculator.py interactive
# Calculate from JSON config
python svg_position_calculator.py from-json config.json
# Quick calculation
python svg_position_calculator.py calc bar --data "East:185,South:142"
python svg_position_calculator.py calc pie --data "A:35,B:25,C:20"
python svg_position_calculator.py calc line --data "0:50,10:80,20:120"
"""
)
subparsers = parser.add_subparsers(dest='command', help='Command')
# calc subcommand
calc_parser = subparsers.add_parser('calc', help='Calculate coordinates')
calc_subparsers = calc_parser.add_subparsers(dest='chart_type', help='Chart type')
# Bar chart
bar_parser = calc_subparsers.add_parser('bar', help='Bar chart')
bar_parser.add_argument('--data', required=True, help='Data "label1:value1,label2:value2"')
bar_parser.add_argument('--canvas', default='ppt169', help='Canvas format')
bar_parser.add_argument('--area', help='Chart area "x_min,y_min,x_max,y_max"')
bar_parser.add_argument('--bar-width', type=float, default=50, help='Bar width')
bar_parser.add_argument('--horizontal', action='store_true', help='Horizontal bar chart')
# Pie chart
pie_parser = calc_subparsers.add_parser('pie', help='Pie / donut chart')
pie_parser.add_argument('--data', required=True, help='Data "label1:value1,label2:value2"')
pie_parser.add_argument('--center', default='420,400', help='Center "x,y"')
pie_parser.add_argument('--radius', type=float, default=200, help='Radius')
pie_parser.add_argument('--inner-radius', type=float, default=0, help='Inner radius (donut chart)')
pie_parser.add_argument('--start-angle', type=float, default=-90, help='Start angle')
# Radar chart
radar_parser = calc_subparsers.add_parser('radar', help='Radar chart')
radar_parser.add_argument('--data', required=True, help='Data "dim1:value1,dim2:value2"')
radar_parser.add_argument('--center', default='640,400', help='Center "x,y"')
radar_parser.add_argument('--radius', type=float, default=200, help='Radius')
radar_parser.add_argument('--max-value', type=float, help='Maximum value')
# Line / scatter chart
line_parser = calc_subparsers.add_parser('line', help='Line / scatter chart')
line_parser.add_argument('--data', required=True, help='Data "x1:y1,x2:y2"')
line_parser.add_argument('--canvas', default='ppt169', help='Canvas format')
line_parser.add_argument('--area', help='Chart area "x_min,y_min,x_max,y_max"')
line_parser.add_argument('--x-range', help='X axis range "min,max"')
line_parser.add_argument('--y-range', help='Y axis range "min,max"')
# Grid layout
grid_parser = calc_subparsers.add_parser('grid', help='Grid layout')
grid_parser.add_argument('--rows', type=int, required=True, help='Number of rows')
grid_parser.add_argument('--cols', type=int, required=True, help='Number of columns')
grid_parser.add_argument('--canvas', default='ppt169', help='Canvas format')
grid_parser.add_argument('--area', help='Chart area "x_min,y_min,x_max,y_max"')
grid_parser.add_argument('--padding', type=float, default=20, help='Inner padding')
grid_parser.add_argument('--gap', type=float, default=20, help='Spacing')
# validate subcommand
validate_parser = subparsers.add_parser('validate', help='Validate SVG')
validate_parser.add_argument('svg_file', help='SVG file path')
validate_parser.add_argument('--extract', action='store_true', help='Extract all position information')
validate_parser.add_argument('--tolerance', type=float, default=1.0, help='Tolerance (pixels)')
# analyze subcommand - analyze SVG file
analyze_parser = subparsers.add_parser('analyze', help='Analyze chart elements in SVG file')
analyze_parser.add_argument('svg_file', help='SVG file path')
# interactive subcommand - interactive mode
subparsers.add_parser('interactive', help='Interactive calculation mode')
# from-json subcommand - read from config file
json_parser = subparsers.add_parser('from-json', help='Calculate from JSON config file')
json_parser.add_argument('config_file', help='JSON config file path')
args = parser.parse_args()
if args.command == 'calc':
# Parse chart area
chart_area = None
if hasattr(args, 'area') and args.area:
parts = parse_tuple(args.area)
chart_area = ChartArea(parts[0], parts[1], parts[2], parts[3])
if args.chart_type == 'bar':
canvas = args.canvas if hasattr(args, 'canvas') else 'ppt169'
coord = CoordinateSystem(canvas, chart_area)
calc = BarChartCalculator(coord)
data = parse_data_string(args.data)
positions = calc.calculate(data, bar_width=args.bar_width, horizontal=args.horizontal)
print(f"\n=== Bar Chart Coordinate Calculation ===")
print(f"Canvas: {CANVAS_FORMATS.get(canvas, {}).get('dimensions', canvas)}")
print(f"Chart area: ({coord.chart_area.x_min}, {coord.chart_area.y_min}) - "
f"({coord.chart_area.x_max}, {coord.chart_area.y_max})")
print()
print(calc.format_table(positions))
elif args.chart_type == 'pie':
center = parse_tuple(args.center)
calc = PieChartCalculator(center, args.radius)
data = parse_data_string(args.data)
slices = calc.calculate(data, start_angle=args.start_angle, inner_radius=args.inner_radius)
print(f"\n=== Pie Chart Slice Calculation ===")
print(calc.format_table(slices))
elif args.chart_type == 'radar':
center = parse_tuple(args.center)
calc = RadarChartCalculator(center, args.radius)
data = parse_data_string(args.data)
points = calc.calculate(data, max_value=args.max_value)
print(f"\n=== Radar Chart Vertex Calculation ===")
print(calc.format_table(points))
elif args.chart_type == 'line':
canvas = args.canvas if hasattr(args, 'canvas') else 'ppt169'
coord = CoordinateSystem(canvas, chart_area)
calc = LineChartCalculator(coord)
data = parse_xy_data_string(args.data)
x_range = parse_tuple(args.x_range) if args.x_range else None
y_range = parse_tuple(args.y_range) if args.y_range else None
points = calc.calculate(data, x_range, y_range)
print(f"\n=== Line / Scatter Chart Coordinate Calculation ===")
print(f"Canvas: {CANVAS_FORMATS.get(canvas, {}).get('dimensions', canvas)}")
print(calc.format_table(points))
elif args.chart_type == 'grid':
canvas = args.canvas if hasattr(args, 'canvas') else 'ppt169'
coord = CoordinateSystem(canvas, chart_area)
calc = GridLayoutCalculator(coord)
cells = calc.calculate(args.rows, args.cols, args.padding, args.gap)
print(f"\n=== Grid Layout Calculation ({args.rows}x{args.cols}) ===")
print(f"Canvas: {CANVAS_FORMATS.get(canvas, {}).get('dimensions', canvas)}")
print(calc.format_table(cells))
else:
parser.print_help()
elif args.command == 'validate':
validator = SVGPositionValidator(tolerance=args.tolerance)
if args.extract:
# Extract mode
with open(args.svg_file, 'r', encoding='utf-8') as f:
content = f.read()
positions = validator.extract_all_positions(content)
print(f"\n=== Extracted Element Positions ===")
print(f"File: {args.svg_file}")
print()
for element_id, attrs in positions.items():
print(f"{element_id}:")
for attr, value in attrs.items():
print(f" {attr}: {value}")
else:
print("Validation mode requires an expected coordinates file; use --extract to extract coordinates first")
elif args.command == 'analyze':
analyze_svg_file(args.svg_file)
elif args.command == 'interactive':
interactive_mode()
elif args.command == 'from-json':
from_json_config(args.config_file)
else:
parser.print_help()
if __name__ == '__main__':
main()
scripts/svg_quality_checker.py
#!/usr/bin/env python3
"""
PPT Master - SVG Quality Check Tool
Checks whether SVG files comply with project technical specifications.
Usage:
python3 scripts/svg_quality_checker.py <svg_file>
python3 scripts/svg_quality_checker.py <directory>
python3 scripts/svg_quality_checker.py --all examples
"""
import sys
import re
from pathlib import Path
from typing import List, Dict, Tuple
from collections import defaultdict
try:
from project_utils import CANVAS_FORMATS
from error_helper import ErrorHelper
except ImportError:
print("Warning: Unable to import dependency modules")
CANVAS_FORMATS = {}
ErrorHelper = None
class SVGQualityChecker:
"""SVG quality checker"""
def __init__(self):
self.results = []
self.summary = {
'total': 0,
'passed': 0,
'warnings': 0,
'errors': 0
}
self.issue_types = defaultdict(int)
def check_file(self, svg_file: str, expected_format: str = None) -> Dict:
"""
Check a single SVG file
Args:
svg_file: SVG file path
expected_format: Expected canvas format (e.g., 'ppt169')
Returns:
Check result dictionary
"""
svg_path = Path(svg_file)
if not svg_path.exists():
return {
'file': str(svg_file),
'exists': False,
'errors': ['File does not exist'],
'warnings': [],
'passed': False
}
result = {
'file': svg_path.name,
'path': str(svg_path),
'exists': True,
'errors': [],
'warnings': [],
'info': {},
'passed': True
}
try:
with open(svg_path, 'r', encoding='utf-8') as f:
content = f.read()
# 1. Check viewBox
self._check_viewbox(content, result, expected_format)
# 2. Check forbidden elements
self._check_forbidden_elements(content, result)
# 3. Check fonts
self._check_fonts(content, result)
# 4. Check width/height consistency with viewBox
self._check_dimensions(content, result)
# 5. Check text wrapping methods
self._check_text_elements(content, result)
# Determine pass/fail
result['passed'] = len(result['errors']) == 0
except Exception as e:
result['errors'].append(f"Failed to read file: {e}")
result['passed'] = False
# Update statistics
self.summary['total'] += 1
if result['passed']:
if result['warnings']:
self.summary['warnings'] += 1
else:
self.summary['passed'] += 1
else:
self.summary['errors'] += 1
# Categorize issue types
for error in result['errors']:
self.issue_types[self._categorize_issue(error)] += 1
self.results.append(result)
return result
def _check_viewbox(self, content: str, result: Dict, expected_format: str = None):
"""Check viewBox attribute"""
viewbox_match = re.search(r'viewBox="([^"]+)"', content)
if not viewbox_match:
result['errors'].append("Missing viewBox attribute")
return
viewbox = viewbox_match.group(1)
result['info']['viewbox'] = viewbox
# Check format
if not re.match(r'0 0 \d+ \d+', viewbox):
result['warnings'].append(f"Unusual viewBox format: {viewbox}")
# Check if it matches expected format
if expected_format and expected_format in CANVAS_FORMATS:
expected_viewbox = CANVAS_FORMATS[expected_format]['viewbox']
if viewbox != expected_viewbox:
result['errors'].append(
f"viewBox mismatch: expected '{expected_viewbox}', got '{viewbox}'"
)
def _check_forbidden_elements(self, content: str, result: Dict):
"""Check forbidden elements (blocklist)"""
content_lower = content.lower()
# ============================================================
# Forbidden elements blocklist - PPT incompatible
# ============================================================
# Clipping / masking
if '<clippath' in content_lower:
result['errors'].append("Detected forbidden <clipPath> element (PPT does not support SVG clip paths)")
if '<mask' in content_lower:
result['errors'].append("Detected forbidden <mask> element (PPT does not support SVG masks)")
# Style system
if '<style' in content_lower:
result['errors'].append("Detected forbidden <style> element (use inline attributes instead)")
if re.search(r'\bclass\s*=', content):
result['errors'].append("Detected forbidden class attribute (use inline styles instead)")
# id attribute: only report error when <style> also exists (id is harmful only with CSS selectors)
# id inside <defs> for linearGradient/filter etc. is required, Inkscape also auto-adds id to elements,
# standalone id attributes have no impact on PPT export
if '<style' in content_lower and re.search(r'\bid\s*=', content):
result['errors'].append(
"Detected id attribute used with <style> (CSS selectors forbidden, use inline styles instead)"
)
if re.search(r'<\?xml-stylesheet\b', content_lower):
result['errors'].append("Detected forbidden xml-stylesheet (external CSS references forbidden)")
if re.search(r'<link[^>]*rel\s*=\s*["\']stylesheet["\']', content_lower):
result['errors'].append("Detected forbidden <link rel=\"stylesheet\"> (external CSS references forbidden)")
if re.search(r'@import\s+', content_lower):
result['errors'].append("Detected forbidden @import (external CSS references forbidden)")
# Structure / nesting
if '<foreignobject' in content_lower:
result['errors'].append(
"Detected forbidden <foreignObject> element (use <tspan> for manual line breaks)")
has_symbol = '<symbol' in content_lower
has_use = re.search(r'<use\b', content_lower) is not None
if has_symbol and has_use:
result['errors'].append("Detected forbidden <symbol> + <use> complex usage (use basic shapes or simple <use> instead)")
if '<marker' in content_lower:
result['errors'].append("Detected forbidden <marker> element (PPT does not support SVG markers)")
if re.search(r'\bmarker-end\s*=', content_lower):
result['errors'].append("Detected forbidden marker-end attribute (use line + polygon instead)")
# Text / fonts
if '<textpath' in content_lower:
result['errors'].append("Detected forbidden <textPath> element (path text is incompatible with PPT)")
if '@font-face' in content_lower:
result['errors'].append("Detected forbidden @font-face (use system font stack)")
# Animation / interaction
if re.search(r'<animate', content_lower):
result['errors'].append("Detected forbidden SMIL animation element <animate*> (SVG animations are not exported)")
if re.search(r'<set\b', content_lower):
result['errors'].append("Detected forbidden SMIL animation element <set> (SVG animations are not exported)")
if '<script' in content_lower:
result['errors'].append("Detected forbidden <script> element (scripts and event handlers forbidden)")
if re.search(r'\bon\w+\s*=', content): # onclick, onload etc.
result['errors'].append("Detected forbidden event attributes (e.g., onclick, onload)")
# Other discouraged elements
if '<iframe' in content_lower:
result['errors'].append("Detected <iframe> element (should not appear in SVG)")
if re.search(r'rgba\s*\(', content_lower):
result['errors'].append("Detected forbidden rgba() color (use fill-opacity/stroke-opacity instead)")
if re.search(r'<g[^>]*\sopacity\s*=', content_lower):
result['errors'].append("Detected forbidden <g opacity> (set opacity on each child element individually)")
if re.search(r'<image[^>]*\sopacity\s*=', content_lower):
result['errors'].append("Detected forbidden <image opacity> (use overlay mask approach)")
def _check_fonts(self, content: str, result: Dict):
"""Check font usage"""
# Find font-family declarations
font_matches = re.findall(
r'font-family[:\s]*["\']([^"\']+)["\']', content, re.IGNORECASE)
if font_matches:
result['info']['fonts'] = list(set(font_matches))
# Check if system UI font stack is used
recommended_fonts = [
'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI']
for font_family in font_matches:
has_recommended = any(
rec in font_family for rec in recommended_fonts)
if not has_recommended:
result['warnings'].append(
f"Recommend using system UI font stack, current: {font_family}"
)
break # Only warn once
def _check_dimensions(self, content: str, result: Dict):
"""Check width/height consistency with viewBox"""
width_match = re.search(r'width="(\d+)"', content)
height_match = re.search(r'height="(\d+)"', content)
if width_match and height_match:
width = width_match.group(1)
height = height_match.group(1)
result['info']['dimensions'] = f"{width}x{height}"
# Check consistency with viewBox
if 'viewbox' in result['info']:
viewbox_parts = result['info']['viewbox'].split()
if len(viewbox_parts) == 4:
vb_width, vb_height = viewbox_parts[2], viewbox_parts[3]
if width != vb_width or height != vb_height:
result['warnings'].append(
f"width/height ({width}x{height}) does not match viewBox "
f"({vb_width}x{vb_height})"
)
def _check_text_elements(self, content: str, result: Dict):
"""Check text elements and wrapping methods"""
# Count text and tspan elements
text_count = content.count('<text')
tspan_count = content.count('<tspan')
result['info']['text_elements'] = text_count
result['info']['tspan_elements'] = tspan_count
# Check for overly long single-line text (may need wrapping)
text_matches = re.findall(r'<text[^>]*>([^<]{100,})</text>', content)
if text_matches:
result['warnings'].append(
f"Detected {len(text_matches)} potentially overly long single-line text(s) (consider using tspan for wrapping)"
)
def _categorize_issue(self, error_msg: str) -> str:
"""Categorize issue type"""
if 'viewBox' in error_msg:
return 'viewBox issues'
elif 'foreignObject' in error_msg:
return 'foreignObject'
elif 'font' in error_msg.lower():
return 'Font issues'
else:
return 'Other'
def check_directory(self, directory: str, expected_format: str = None) -> List[Dict]:
"""
Check all SVG files in a directory
Args:
directory: Directory path
expected_format: Expected canvas format
Returns:
List of check results
"""
dir_path = Path(directory)
if not dir_path.exists():
print(f"[ERROR] Directory does not exist: {directory}")
return []
# Find all SVG files
if dir_path.is_file():
svg_files = [dir_path]
else:
svg_output = dir_path / \
'svg_output' if (
dir_path / 'svg_output').exists() else dir_path
svg_files = sorted(svg_output.glob('*.svg'))
if not svg_files:
print(f"[WARN] No SVG files found")
return []
print(f"\n[SCAN] Checking {len(svg_files)} SVG file(s)...\n")
for svg_file in svg_files:
result = self.check_file(str(svg_file), expected_format)
self._print_result(result)
return self.results
def _print_result(self, result: Dict):
"""Print check result for a single file"""
if result['passed']:
if result['warnings']:
icon = "[WARN]"
status = "Passed (with warnings)"
else:
icon = "[OK]"
status = "Passed"
else:
icon = "[ERROR]"
status = "Failed"
print(f"{icon} {result['file']} - {status}")
# Display basic info
if result['info']:
info_items = []
if 'viewbox' in result['info']:
info_items.append(f"viewBox: {result['info']['viewbox']}")
if info_items:
print(f" {' | '.join(info_items)}")
# Display errors
if result['errors']:
for error in result['errors']:
print(f" [ERROR] {error}")
# Display warnings
if result['warnings']:
for warning in result['warnings'][:2]: # Only show first 2 warnings
print(f" [WARN] {warning}")
if len(result['warnings']) > 2:
print(f" ... and {len(result['warnings']) - 2} more warning(s)")
print()
def print_summary(self):
"""Print check summary"""
print("=" * 80)
print("[SUMMARY] Check Summary")
print("=" * 80)
print(f"\nTotal files: {self.summary['total']}")
print(
f" [OK] Fully passed: {self.summary['passed']} ({self._percentage(self.summary['passed'])}%)")
print(
f" [WARN] With warnings: {self.summary['warnings']} ({self._percentage(self.summary['warnings'])}%)")
print(
f" [ERROR] With errors: {self.summary['errors']} ({self._percentage(self.summary['errors'])}%)")
if self.issue_types:
print(f"\nIssue categories:")
for issue_type, count in sorted(self.issue_types.items(), key=lambda x: x[1], reverse=True):
print(f" {issue_type}: {count}")
# Fix suggestions
if self.summary['errors'] > 0 or self.summary['warnings'] > 0:
print(f"\n[TIP] Common fixes:")
print(f" 1. viewBox issues: Ensure consistency with canvas format (see references/canvas-formats.md)")
print(f" 2. foreignObject: Use <text> + <tspan> for manual line breaks")
print(f" 3. Font issues: Use system UI font stack")
def _percentage(self, count: int) -> int:
"""Calculate percentage"""
if self.summary['total'] == 0:
return 0
return int(count / self.summary['total'] * 100)
def export_report(self, output_file: str = 'svg_quality_report.txt'):
"""Export check report"""
with open(output_file, 'w', encoding='utf-8') as f:
f.write("PPT Master SVG Quality Check Report\n")
f.write("=" * 80 + "\n\n")
for result in self.results:
status = "[OK] Passed" if result['passed'] else "[ERROR] Failed"
f.write(f"{status} - {result['file']}\n")
f.write(f"Path: {result.get('path', 'N/A')}\n")
if result['info']:
f.write(f"Info: {result['info']}\n")
if result['errors']:
f.write(f"\nErrors:\n")
for error in result['errors']:
f.write(f" - {error}\n")
if result['warnings']:
f.write(f"\nWarnings:\n")
for warning in result['warnings']:
f.write(f" - {warning}\n")
f.write("\n" + "-" * 80 + "\n\n")
# Write summary
f.write("\n" + "=" * 80 + "\n")
f.write("Check Summary\n")
f.write("=" * 80 + "\n\n")
f.write(f"Total files: {self.summary['total']}\n")
f.write(f"Fully passed: {self.summary['passed']}\n")
f.write(f"With warnings: {self.summary['warnings']}\n")
f.write(f"With errors: {self.summary['errors']}\n")
print(f"\n[REPORT] Check report exported: {output_file}")
def main():
"""Main function"""
if len(sys.argv) < 2:
print("PPT Master - SVG Quality Check Tool\n")
print("Usage:")
print(" python3 scripts/svg_quality_checker.py <svg_file>")
print(" python3 scripts/svg_quality_checker.py <directory>")
print(" python3 scripts/svg_quality_checker.py --all examples")
print("\nExamples:")
print(" python3 scripts/svg_quality_checker.py examples/project/svg_output/slide_01.svg")
print(" python3 scripts/svg_quality_checker.py examples/project/svg_output")
print(" python3 scripts/svg_quality_checker.py examples/project")
sys.exit(0)
checker = SVGQualityChecker()
# Parse arguments
target = sys.argv[1]
expected_format = None
if '--format' in sys.argv:
idx = sys.argv.index('--format')
if idx + 1 < len(sys.argv):
expected_format = sys.argv[idx + 1]
# Execute check
if target == '--all':
# Check all example projects
base_dir = sys.argv[2] if len(sys.argv) > 2 else 'examples'
from project_utils import find_all_projects
projects = find_all_projects(base_dir)
for project in projects:
print(f"\n{'=' * 80}")
print(f"Checking project: {project.name}")
print('=' * 80)
checker.check_directory(str(project))
else:
checker.check_directory(target, expected_format)
# Print summary
checker.print_summary()
# Export report (if specified)
if '--export' in sys.argv:
output_file = 'svg_quality_report.txt'
if '--output' in sys.argv:
idx = sys.argv.index('--output')
if idx + 1 < len(sys.argv):
output_file = sys.argv[idx + 1]
checker.export_report(output_file)
# Return exit code
if checker.summary['errors'] > 0:
sys.exit(1)
else:
sys.exit(0)
if __name__ == '__main__':
main()
scripts/svg_rect_to_path.py
#!/usr/bin/env python3
"""
PPT Master - SVG Rounded Rectangle to Path Tool
Solves the issue of rounded corners being lost when using "Convert to Shape" in PowerPoint:
Converts <rect> elements with rx/ry to equivalent <path> elements.
Usage:
python3 scripts/svg_rect_to_path.py <SVG file or directory>
python3 scripts/svg_rect_to_path.py <project_path> -s output
python3 scripts/svg_rect_to_path.py <project_path> -s final -o svg_rounded
Examples:
python3 scripts/svg_rect_to_path.py examples/ppt169_demo
python3 scripts/svg_rect_to_path.py examples/ppt169_demo/svg_output/01_cover.svg
Output:
- Directory mode: outputs to svg_rounded/ subdirectory
- File mode: outputs to <filename>_rounded.svg
"""
import sys
import re
import argparse
from pathlib import Path
from typing import Tuple, Dict, List
from xml.etree import ElementTree as ET
def rect_to_rounded_path(x: float, y: float, width: float, height: float,
rx: float, ry: float) -> str:
"""
Convert a rounded rectangle to an SVG path string.
Uses elliptical arc commands to draw rounded corners.
"""
# Limit corner radius to half of width/height
rx = min(rx, width / 2)
ry = min(ry, height / 2)
# Calculate key points
x1 = x + rx
x2 = x + width - rx
y1 = y + ry
y2 = y + height - ry
# Build path
path = (
f"M{x1:.2f},{y:.2f} "
f"H{x2:.2f} "
f"A{rx:.2f},{ry:.2f} 0 0 1 {x + width:.2f},{y1:.2f} "
f"V{y2:.2f} "
f"A{rx:.2f},{ry:.2f} 0 0 1 {x2:.2f},{y + height:.2f} "
f"H{x1:.2f} "
f"A{rx:.2f},{ry:.2f} 0 0 1 {x:.2f},{y2:.2f} "
f"V{y1:.2f} "
f"A{rx:.2f},{ry:.2f} 0 0 1 {x1:.2f},{y:.2f} "
f"Z"
)
# Clean up excess decimals
path = re.sub(r'\.00(?=\s|,|[A-Za-z]|$)', '', path)
return path
def parse_float(val: str, default: float = 0.0) -> float:
"""Safely parse a float value."""
if not val:
return default
try:
# Remove units
val = re.sub(r'(px|pt|em|%|rem)$', '', val.strip())
return float(val)
except ValueError:
return default
def process_svg(content: str, verbose: bool = False) -> Tuple[str, int]:
"""
Process SVG content, converting rounded rectangles to paths.
Returns (processed content, conversion count).
"""
converted_count = 0
# Save original XML declaration
xml_declaration = ''
if content.strip().startswith('<?xml'):
match = re.match(r'(<\?xml[^?]*\?>)', content)
if match:
xml_declaration = match.group(1) + '\n'
# Register SVG namespaces
ET.register_namespace('', 'http://www.w3.org/2000/svg')
ET.register_namespace('xlink', 'http://www.w3.org/1999/xlink')
try:
root = ET.fromstring(content)
except ET.ParseError as e:
if verbose:
print(f" XML parse error: {e}")
return content, 0
# Get default namespace
ns = ''
if root.tag.startswith('{'):
ns = root.tag.split('}')[0] + '}'
def get_tag_name(tag):
"""Get tag name without namespace."""
if tag.startswith('{'):
return tag.split('}')[1]
return tag
def process_element(elem):
"""Process a single element."""
nonlocal converted_count
tag_name = get_tag_name(elem.tag)
# Process rounded rectangles
if tag_name == 'rect':
rx = parse_float(elem.get('rx', '0'))
ry = parse_float(elem.get('ry', '0'))
# If only one is specified, the other takes the same value
if rx == 0 and ry > 0:
rx = ry
elif ry == 0 and rx > 0:
ry = rx
if rx > 0 or ry > 0:
x = parse_float(elem.get('x', '0'))
y = parse_float(elem.get('y', '0'))
width = parse_float(elem.get('width', '0'))
height = parse_float(elem.get('height', '0'))
if width > 0 and height > 0:
# Generate path
path_d = rect_to_rounded_path(x, y, width, height, rx, ry)
# rect-specific attributes
rect_attrs = {'x', 'y', 'width', 'height', 'rx', 'ry'}
# Change element to path
elem.tag = ns + 'path' if ns else 'path'
elem.set('d', path_d)
# Remove rect-specific attributes
for attr in rect_attrs:
if attr in elem.attrib:
del elem.attrib[attr]
converted_count += 1
if verbose:
print(f" Converted rounded rect: rx={rx}, ry={ry}")
# Recursively process child elements
for child in elem:
process_element(child)
# Process all elements
process_element(root)
# Convert back to string
result = ET.tostring(root, encoding='unicode')
# Add XML declaration (if originally present)
if xml_declaration:
result = xml_declaration + result
return result, converted_count
def process_svg_file(input_path: Path, output_path: Path, verbose: bool = False) -> Tuple[bool, int]:
"""Process a single SVG file."""
try:
with open(input_path, 'r', encoding='utf-8') as f:
content = f.read()
processed, count = process_svg(content, verbose)
# Ensure output directory exists
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, 'w', encoding='utf-8') as f:
f.write(processed)
return True, count
except Exception as e:
if verbose:
print(f" Error: {e}")
return False, 0
def find_svg_files(project_path: Path, source: str = 'output') -> Tuple[List[Path], str]:
"""Find SVG files in a project."""
dir_map = {
'output': 'svg_output',
'final': 'svg_final',
'flat': 'svg_output_flattext',
'final_flat': 'svg_final_flattext',
}
dir_name = dir_map.get(source, source)
svg_dir = project_path / dir_name
if not svg_dir.exists():
if (project_path / 'svg_output').exists():
dir_name = 'svg_output'
svg_dir = project_path / dir_name
elif project_path.is_dir():
svg_dir = project_path
dir_name = project_path.name
if not svg_dir.exists():
return [], ''
return sorted(svg_dir.glob('*.svg')), dir_name
def main():
parser = argparse.ArgumentParser(
description='PPT Master - SVG Rounded Rectangle to Path Tool',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
Examples:
%(prog)s examples/ppt169_demo
%(prog)s examples/ppt169_demo -s final
%(prog)s examples/ppt169_demo/svg_output/01_cover.svg
What it does:
Converts <rect> elements with rx/ry to equivalent <path> elements.
Processed SVGs preserve rounded corners when using "Convert to Shape" in PowerPoint.
'''
)
parser.add_argument('path', type=str, help='SVG file or project directory path')
parser.add_argument('-s', '--source', type=str, default='output',
help='SVG source: output/final/flat/final_flat or subdirectory name (default: output)')
parser.add_argument('-o', '--output', type=str, default='svg_rounded',
help='Output directory name (default: svg_rounded)')
parser.add_argument('-v', '--verbose', action='store_true', help='Verbose output')
parser.add_argument('-q', '--quiet', action='store_true', help='Quiet mode')
args = parser.parse_args()
input_path = Path(args.path)
if not input_path.exists():
print(f"Error: Path not found: {input_path}")
sys.exit(1)
verbose = args.verbose and not args.quiet
quiet = args.quiet
if not quiet:
print("PPT Master - SVG Rounded Rectangle to Path Tool")
print("=" * 50)
total_converted = 0
if input_path.is_file() and input_path.suffix.lower() == '.svg':
# Single file mode
output_path = input_path.with_stem(input_path.stem + '_rounded')
if not quiet:
print(f" Input: {input_path}")
print(f" Output: {output_path}")
print()
success, count = process_svg_file(input_path, output_path, verbose)
total_converted = count
if success:
if not quiet:
print(f"[DONE] Saved: {output_path}")
else:
print(f"[FAIL] Processing failed")
sys.exit(1)
else:
# Directory/project mode
svg_files, source_dir = find_svg_files(input_path, args.source)
if not svg_files:
print("Error: No SVG files found")
sys.exit(1)
output_dir = input_path / args.output
if not quiet:
print(f" Project path: {input_path}")
print(f" SVG source: {source_dir}")
print(f" Output directory: {args.output}")
print(f" File count: {len(svg_files)}")
print()
success_count = 0
for i, svg_file in enumerate(svg_files, 1):
output_path = output_dir / svg_file.name
if verbose:
print(f" [{i}/{len(svg_files)}] {svg_file.name}")
success, count = process_svg_file(svg_file, output_path, verbose)
if success:
success_count += 1
total_converted += count
if not verbose and not quiet:
print(f" [{i}/{len(svg_files)}] {svg_file.name} OK")
else:
if not quiet:
print(f" [{i}/{len(svg_files)}] {svg_file.name} FAILED")
if not quiet:
print()
print(f"[DONE] Succeeded: {success_count}/{len(svg_files)}")
print(f" Output directory: {output_dir}")
# Show statistics
if not quiet:
print()
print(f"Conversion stats: rounded rect -> path: {total_converted}")
sys.exit(0)
if __name__ == '__main__':
main()
scripts/svg_to_pptx.py
#!/usr/bin/env python3
"""
PPT Master - SVG to PPTX Conversion Tool
Batch converts SVG files in a project into a PowerPoint presentation.
Each SVG file corresponds to one slide, with SVG embedded in native vector format.
Usage:
python3 scripts/svg_to_pptx.py <project_path>
python3 scripts/svg_to_pptx.py <project_path> -o output.pptx
python3 scripts/svg_to_pptx.py <project_path> --use-final
Examples:
python3 scripts/svg_to_pptx.py examples/ppt169_demo
python3 scripts/svg_to_pptx.py examples/ppt169_demo -o presentation.pptx
python3 scripts/svg_to_pptx.py examples/ppt169_demo --use-final
Dependencies:
pip install python-pptx
Notes:
- SVG is embedded in native vector format, preserving editability
- Requires PowerPoint 2016+ to display correctly
"""
import sys
import os
import argparse
import re
import zipfile
import shutil
import tempfile
from pathlib import Path
from typing import Optional, Tuple, List
from xml.etree import ElementTree as ET
# Check if python-pptx is installed
try:
from pptx import Presentation
from pptx.util import Emu
except ImportError:
print("Error: python-pptx library is missing")
print("Please run: pip install python-pptx")
sys.exit(1)
# Import project utility modules
sys.path.insert(0, str(Path(__file__).parent))
try:
from project_utils import get_project_info
from config import CANVAS_FORMATS
except ImportError:
CANVAS_FORMATS = {
'ppt169': {'name': 'PPT 16:9', 'dimensions': '1280×720', 'viewbox': '0 0 1280 720'},
}
def get_project_info(path):
return {'format': 'unknown', 'name': Path(path).name}
# Import animation module
try:
from pptx_animations import create_transition_xml, TRANSITIONS
ANIMATIONS_AVAILABLE = True
except ImportError:
ANIMATIONS_AVAILABLE = False
TRANSITIONS = {}
# Import native shape converter
try:
from svg_to_shapes import convert_svg_to_slide_shapes
NATIVE_SHAPES_AVAILABLE = True
except ImportError:
NATIVE_SHAPES_AVAILABLE = False
# SVG to PNG library detection (for Office compatibility mode)
# Prefer CairoSVG (better rendering quality), fall back to svglib
PNG_RENDERER = None # 'cairosvg' | 'svglib' | None
try:
import cairosvg
PNG_RENDERER = 'cairosvg'
except (ImportError, OSError):
try:
from svglib.svglib import svg2rlg
from reportlab.graphics import renderPM
PNG_RENDERER = 'svglib'
except (ImportError, OSError):
pass
def get_png_renderer_info() -> tuple:
"""Get PNG renderer information"""
if PNG_RENDERER == 'cairosvg':
return ('cairosvg', '(full gradient/filter support)', None)
elif PNG_RENDERER == 'svglib':
return ('svglib', '(some gradients may be lost)', 'Install cairosvg for better results: pip install cairosvg')
else:
return (None, '(not installed)', 'Install via: pip install cairosvg or pip install svglib reportlab')
# EMU conversion constants
EMU_PER_INCH = 914400
EMU_PER_PIXEL = EMU_PER_INCH / 96
# XML namespaces
NAMESPACES = {
'a': 'http://schemas.openxmlformats.org/drawingml/2006/main',
'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
'p': 'http://schemas.openxmlformats.org/presentationml/2006/main',
'asvg': 'http://schemas.microsoft.com/office/drawing/2016/SVG/main',
}
# Register namespaces
for prefix, uri in NAMESPACES.items():
ET.register_namespace(prefix, uri)
def get_slide_dimensions(canvas_format: str, custom_pixels: Optional[Tuple[int, int]] = None) -> Tuple[int, int]:
"""Get slide dimensions (in EMU units)"""
if custom_pixels:
width_px, height_px = custom_pixels
else:
if canvas_format not in CANVAS_FORMATS:
canvas_format = 'ppt169'
dimensions = CANVAS_FORMATS[canvas_format]['dimensions']
match = re.match(r'(\d+)[×x](\d+)', dimensions)
if match:
width_px = int(match.group(1))
height_px = int(match.group(2))
else:
width_px, height_px = 1280, 720
return int(width_px * EMU_PER_PIXEL), int(height_px * EMU_PER_PIXEL)
def get_pixel_dimensions(canvas_format: str, custom_pixels: Optional[Tuple[int, int]] = None) -> Tuple[int, int]:
"""Get canvas pixel dimensions"""
if custom_pixels:
return custom_pixels
if canvas_format not in CANVAS_FORMATS:
canvas_format = 'ppt169'
dimensions = CANVAS_FORMATS[canvas_format]['dimensions']
match = re.match(r'(\d+)[×x](\d+)', dimensions)
if match:
return int(match.group(1)), int(match.group(2))
return 1280, 720
def get_viewbox_dimensions(svg_path: Path) -> Optional[Tuple[int, int]]:
"""Extract pixel dimensions from SVG viewBox (returns integers)"""
try:
with open(svg_path, 'r', encoding='utf-8') as f:
content = f.read(2000)
match = re.search(r'viewBox="([^"]+)"', content)
if not match:
return None
parts = re.split(r'[\s,]+', match.group(1).strip())
if len(parts) < 4:
return None
width = float(parts[2])
height = float(parts[3])
if width <= 0 or height <= 0:
return None
return int(round(width)), int(round(height))
except Exception:
return None
def detect_format_from_svg(svg_path: Path) -> Optional[str]:
"""Detect canvas format from SVG file's viewBox"""
try:
with open(svg_path, 'r', encoding='utf-8') as f:
content = f.read(2000)
match = re.search(r'viewBox="([^"]+)"', content)
if match:
viewbox = match.group(1)
for fmt_key, fmt_info in CANVAS_FORMATS.items():
if fmt_info['viewbox'] == viewbox:
return fmt_key
except Exception:
pass
return None
def convert_svg_to_png(svg_path: Path, png_path: Path, width: int = None, height: int = None) -> bool:
"""
Convert SVG to PNG
Args:
svg_path: SVG file path
png_path: Output PNG file path
width: Output width (pixels)
height: Output height (pixels)
Returns:
Whether the conversion was successful
"""
if PNG_RENDERER is None:
return False
try:
if PNG_RENDERER == 'cairosvg':
# Use CairoSVG (better rendering quality)
cairosvg.svg2png(
url=str(svg_path),
write_to=str(png_path),
output_width=width,
output_height=height
)
return True
elif PNG_RENDERER == 'svglib':
# Use svglib (lightweight, but limited gradient support)
drawing = svg2rlg(str(svg_path))
if drawing is None:
print(f" Warning: Unable to parse SVG ({svg_path.name})")
return False
# Render to PNG
renderPM.drawToFile(
drawing,
str(png_path),
fmt="PNG",
configPIL={'quality': 95}
)
return True
except Exception as e:
print(f" Warning: SVG to PNG conversion failed ({svg_path.name}): {e}")
return False
return False
def find_svg_files(project_path: Path, source: str = 'output') -> Tuple[List[Path], str]:
"""
Find SVG files in the project
Args:
project_path: Project directory path
source: SVG source directory
- 'output': svg_output (original version)
- 'final': svg_final (post-processed, recommended)
- or any subdirectory name
Returns:
(list of SVG files, actual directory name used)
"""
# Predefined directory mapping
dir_map = {
'output': 'svg_output',
'final': 'svg_final',
}
# Get directory name (supports predefined aliases or direct directory names)
dir_name = dir_map.get(source, source)
svg_dir = project_path / dir_name
if not svg_dir.exists():
print(f" Warning: {dir_name} directory does not exist, trying svg_output")
dir_name = 'svg_output'
svg_dir = project_path / dir_name
if not svg_dir.exists():
# Search directly in the specified directory
if project_path.is_dir():
svg_dir = project_path
dir_name = project_path.name
else:
return [], ''
return sorted(svg_dir.glob('*.svg')), dir_name
def find_notes_files(project_path: Path, svg_files: List[Path] = None) -> dict:
"""
Find notes files in the project
Supports two matching modes (mixed matching supported):
1. Match by filename (priority): notes/01_cover.md corresponds to 01_cover.svg
2. Match by index (backward compatible): notes/slide01.md corresponds to the 1st SVG
Args:
project_path: Project directory path
svg_files: SVG file list (for filename matching)
Returns:
Dictionary where key is SVG filename (without extension) and value is notes content
"""
notes_dir = project_path / 'notes'
notes = {}
if not notes_dir.exists():
return notes
svg_stems_mapping = {}
svg_index_mapping = {}
if svg_files:
for i, svg_path in enumerate(svg_files, 1):
svg_stems_mapping[svg_path.stem] = i
svg_index_mapping[i] = svg_path.stem
# Collect all notes file information
for notes_file in notes_dir.glob('*.md'):
try:
with open(notes_file, 'r', encoding='utf-8') as f:
content = f.read().strip()
if not content:
continue
stem = notes_file.stem
# Try to extract index (backward compatible with slide01.md format)
match = re.search(r'slide[_]?(\d+)', stem)
if match:
index = int(match.group(1))
mapped_stem = svg_index_mapping.get(index)
if mapped_stem:
notes[mapped_stem] = content
# Match by filename (overrides backward compatible format)
if stem in svg_stems_mapping:
notes[stem] = content
except Exception:
pass
return notes
def markdown_to_plain_text(md_content: str) -> str:
"""
Convert Markdown notes to plain text (for PPTX notes)
Args:
md_content: Markdown formatted notes content
Returns:
Plain text content
"""
def strip_inline_bold(text: str) -> str:
# Remove Markdown bold markers while keeping content
text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
text = re.sub(r'__(.+?)__', r'\1', text)
return text
lines = []
for line in md_content.split('\n'):
# Skip heading lines (starting with #)
if line.startswith('#'):
# Extract heading text
text = re.sub(r'^#+\s*', '', line).strip()
text = strip_inline_bold(text)
if text:
lines.append(text)
lines.append('') # Empty line
# Handle list items (starting with -)
elif line.strip().startswith('- '):
item_text = line.strip()[2:]
item_text = strip_inline_bold(item_text)
lines.append('• ' + item_text)
# Regular lines
elif line.strip():
text = strip_inline_bold(line.strip())
lines.append(text)
else:
lines.append('')
# Merge consecutive empty lines
result = []
prev_empty = False
for line in lines:
if line == '':
if not prev_empty:
result.append(line)
prev_empty = True
else:
result.append(line)
prev_empty = False
return '\n'.join(result).strip()
def create_notes_slide_xml(slide_num: int, notes_text: str) -> str:
"""
Create notes slide XML
Args:
slide_num: Slide number
notes_text: Notes text (plain text format)
Returns:
Notes slide XML string
"""
# Escape XML special characters
notes_text = notes_text.replace('&', '&').replace('<', '<').replace('>', '>')
# Convert line breaks to <a:p> paragraphs
paragraphs = []
for para in notes_text.split('\n'):
if para.strip():
paragraphs.append(f'''<a:p>
<a:r>
<a:rPr lang="zh-CN" dirty="0"/>
<a:t>{para}</a:t>
</a:r>
</a:p>''')
else:
paragraphs.append('<a:p><a:endParaRPr lang="zh-CN" dirty="0"/></a:p>')
paragraphs_xml = '\n '.join(paragraphs) if paragraphs else '<a:p><a:endParaRPr lang="zh-CN" dirty="0"/></a:p>'
return f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:notes xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:spTree>
<p:nvGrpSpPr>
<p:cNvPr id="1" name=""/>
<p:cNvGrpSpPr/>
<p:nvPr/>
</p:nvGrpSpPr>
<p:grpSpPr>
<a:xfrm>
<a:off x="0" y="0"/>
<a:ext cx="0" cy="0"/>
<a:chOff x="0" y="0"/>
<a:chExt cx="0" cy="0"/>
</a:xfrm>
</p:grpSpPr>
<p:sp>
<p:nvSpPr>
<p:cNvPr id="2" name="Slide Image Placeholder 1"/>
<p:cNvSpPr>
<a:spLocks noGrp="1" noRot="1" noChangeAspect="1"/>
</p:cNvSpPr>
<p:nvPr>
<p:ph type="sldImg"/>
</p:nvPr>
</p:nvSpPr>
<p:spPr/>
</p:sp>
<p:sp>
<p:nvSpPr>
<p:cNvPr id="3" name="Notes Placeholder 2"/>
<p:cNvSpPr>
<a:spLocks noGrp="1"/>
</p:cNvSpPr>
<p:nvPr>
<p:ph type="body" idx="1"/>
</p:nvPr>
</p:nvSpPr>
<p:spPr/>
<p:txBody>
<a:bodyPr/>
<a:lstStyle/>
{paragraphs_xml}
</p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
<p:clrMapOvr>
<a:masterClrMapping/>
</p:clrMapOvr>
</p:notes>'''
def create_notes_slide_rels_xml(slide_num: int) -> str:
"""
Create notes slide relationship file XML
Args:
slide_num: Slide number
Returns:
Relationship file XML string
"""
return f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster" Target="../notesMasters/notesMaster1.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="../slides/slide{slide_num}.xml"/>
</Relationships>'''
def create_slide_xml_with_svg(
slide_num: int,
png_rid: str,
svg_rid: str,
width_emu: int,
height_emu: int,
transition: Optional[str] = None,
transition_duration: float = 0.5,
auto_advance: Optional[float] = None,
use_compat_mode: bool = True
) -> str:
"""
Create slide XML containing an SVG image
Args:
slide_num: Slide number
png_rid: PNG fallback image relationship ID
svg_rid: SVG relationship ID
width_emu: Width (EMU)
height_emu: Height (EMU)
transition: Transition effect name
transition_duration: Transition duration (seconds)
auto_advance: Auto-advance interval (seconds)
use_compat_mode: Whether to use compatibility mode (PNG + SVG dual format)
"""
# Generate transition effect XML
transition_xml = ''
if transition and ANIMATIONS_AVAILABLE:
transition_xml = '\n' + create_transition_xml(
effect=transition,
duration=transition_duration,
advance_after=auto_advance
)
# Compatibility mode: PNG main image + SVG extension (Office official recommendation)
if use_compat_mode:
blip_xml = f'''<a:blip r:embed="{png_rid}">
<a:extLst>
<a:ext uri="{{96DAC541-7B7A-43D3-8B79-37D633B846F1}}">
<asvg:svgBlip xmlns:asvg="http://schemas.microsoft.com/office/drawing/2016/SVG/main" r:embed="{svg_rid}"/>
</a:ext>
</a:extLst>
</a:blip>'''
else:
# Pure SVG mode (only supported by newer Office versions)
blip_xml = f'<a:blip r:embed="{svg_rid}"/>'
return f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:spTree>
<p:nvGrpSpPr>
<p:cNvPr id="1" name=""/>
<p:cNvGrpSpPr/>
<p:nvPr/>
</p:nvGrpSpPr>
<p:grpSpPr>
<a:xfrm>
<a:off x="0" y="0"/>
<a:ext cx="0" cy="0"/>
<a:chOff x="0" y="0"/>
<a:chExt cx="0" cy="0"/>
</a:xfrm>
</p:grpSpPr>
<p:pic>
<p:nvPicPr>
<p:cNvPr id="2" name="SVG Image {slide_num}"/>
<p:cNvPicPr>
<a:picLocks noChangeAspect="1"/>
</p:cNvPicPr>
<p:nvPr/>
</p:nvPicPr>
<p:blipFill>
{blip_xml}
<a:stretch>
<a:fillRect/>
</a:stretch>
</p:blipFill>
<p:spPr>
<a:xfrm>
<a:off x="0" y="0"/>
<a:ext cx="{width_emu}" cy="{height_emu}"/>
</a:xfrm>
<a:prstGeom prst="rect">
<a:avLst/>
</a:prstGeom>
</p:spPr>
</p:pic>
</p:spTree>
</p:cSld>
<p:clrMapOvr>
<a:masterClrMapping/>
</p:clrMapOvr>{transition_xml}
</p:sld>'''
def create_slide_rels_xml(png_rid: str, png_filename: str, svg_rid: str, svg_filename: str, use_compat_mode: bool = True) -> str:
"""
Create slide relationship file XML
Args:
png_rid: PNG image relationship ID
png_filename: PNG filename
svg_rid: SVG relationship ID
svg_filename: SVG filename
use_compat_mode: Whether to use compatibility mode
"""
if use_compat_mode:
return f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
<Relationship Id="{png_rid}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/{png_filename}"/>
<Relationship Id="{svg_rid}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/{svg_filename}"/>
</Relationships>'''
else:
return f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
<Relationship Id="{svg_rid}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/{svg_filename}"/>
</Relationships>'''
def create_pptx_with_native_svg(
svg_files: List[Path],
output_path: Path,
canvas_format: Optional[str] = None,
verbose: bool = True,
transition: Optional[str] = None,
transition_duration: float = 0.5,
auto_advance: Optional[float] = None,
use_compat_mode: bool = True,
notes: Optional[dict] = None,
enable_notes: bool = True,
use_native_shapes: bool = False
) -> bool:
"""
Create a PPTX file with native SVG
Args:
svg_files: List of SVG files
output_path: Output path
canvas_format: Canvas format
verbose: Whether to output detailed information
transition: Transition effect (fade/push/wipe/split/reveal/cover/random)
transition_duration: Transition duration (seconds)
auto_advance: Auto-advance interval (seconds)
use_compat_mode: Use Office compatibility mode (PNG + SVG dual format, enabled by default)
notes: Notes dictionary, key is slide number, value is notes content
enable_notes: Whether to enable notes embedding (enabled by default)
use_native_shapes: Convert SVG to native DrawingML shapes (directly editable)
"""
if not svg_files:
print("Error: No SVG files found")
return False
# Native shapes mode takes priority over compat mode
if use_native_shapes:
if not NATIVE_SHAPES_AVAILABLE:
print("Error: svg_to_shapes module not available, cannot use native shapes mode")
print(" Falling back to SVG embedding mode")
use_native_shapes = False
else:
# Native shapes mode doesn't use compat/PNG
use_compat_mode = False
# Check compatibility mode dependencies
renderer_name, renderer_status, renderer_hint = get_png_renderer_info()
if not use_native_shapes and use_compat_mode and PNG_RENDERER is None:
print("Warning: No PNG rendering library installed, cannot use compatibility mode")
print(f" {renderer_hint}")
print(" Will use pure SVG mode (may not display in Office LTSC 2021 and similar versions)")
use_compat_mode = False
# Auto-detect canvas format or get dimensions from viewBox
custom_pixels: Optional[Tuple[int, int]] = None
if canvas_format is None:
canvas_format = detect_format_from_svg(svg_files[0])
if canvas_format and verbose:
format_name = CANVAS_FORMATS.get(canvas_format, {}).get('name', canvas_format)
print(f" Detected canvas format: {format_name}")
if canvas_format is None:
custom_pixels = get_viewbox_dimensions(svg_files[0])
if custom_pixels and verbose:
print(f" Using SVG viewBox dimensions: {custom_pixels[0]} x {custom_pixels[1]} px")
if canvas_format is None and custom_pixels is None:
canvas_format = 'ppt169'
if verbose:
print(f" Using default format: PPT 16:9")
width_emu, height_emu = get_slide_dimensions(canvas_format or 'ppt169', custom_pixels)
pixel_width, pixel_height = get_pixel_dimensions(canvas_format or 'ppt169', custom_pixels)
if verbose:
print(f" Slide dimensions: {pixel_width} x {pixel_height} px")
print(f" SVG file count: {len(svg_files)}")
if use_native_shapes:
print(f" Mode: Native DrawingML shapes (directly editable)")
elif use_compat_mode:
print(f" Compatibility mode: Enabled (PNG + SVG dual format)")
print(f" PNG renderer: {renderer_name} {renderer_status}")
else:
print(f" Compatibility mode: Disabled (pure SVG)")
if transition:
trans_name = TRANSITIONS.get(transition, {}).get('name', transition) if TRANSITIONS else transition
print(f" Transition effect: {trans_name}")
if enable_notes and notes:
print(f" Speaker notes: {len(notes)} page(s)")
elif enable_notes:
print(f" Speaker notes: Enabled (no notes files found)")
else:
print(f" Speaker notes: Disabled")
print()
# Create temporary directory
temp_dir = Path(tempfile.mkdtemp())
try:
# First create base PPTX with python-pptx
prs = Presentation()
prs.slide_width = width_emu
prs.slide_height = height_emu
# Add blank slides as placeholders
blank_layout = prs.slide_layouts[6]
for _ in svg_files:
prs.slides.add_slide(blank_layout)
# Save base PPTX
base_pptx = temp_dir / 'base.pptx'
prs.save(str(base_pptx))
# Extract PPTX
extract_dir = temp_dir / 'pptx_content'
with zipfile.ZipFile(base_pptx, 'r') as zf:
zf.extractall(extract_dir)
# Create media directory
media_dir = extract_dir / 'ppt' / 'media'
media_dir.mkdir(exist_ok=True)
# Process each SVG file
success_count = 0
any_png_generated = False
for i, svg_path in enumerate(svg_files, 1):
slide_num = i
try:
# ---- Native shapes mode ----
if use_native_shapes:
slide_xml, media_files_dict, rel_entries = convert_svg_to_slide_shapes(
svg_path, slide_num=slide_num, verbose=verbose
)
# Add transition if specified
if transition and ANIMATIONS_AVAILABLE:
transition_xml = '\n' + create_transition_xml(
effect=transition,
duration=transition_duration,
advance_after=auto_advance
)
slide_xml = slide_xml.replace(
'</p:sld>',
transition_xml + '\n</p:sld>'
)
# Write slide XML
slide_xml_path = extract_dir / 'ppt' / 'slides' / f'slide{slide_num}.xml'
with open(slide_xml_path, 'w', encoding='utf-8') as f:
f.write(slide_xml)
# Write media files (from embedded images)
for media_name, media_data in media_files_dict.items():
with open(media_dir / media_name, 'wb') as f:
f.write(media_data)
# Build relationships XML
rels_dir = extract_dir / 'ppt' / 'slides' / '_rels'
rels_dir.mkdir(exist_ok=True)
rels_path = rels_dir / f'slide{slide_num}.xml.rels'
extra_rels = ''
for rel in rel_entries:
extra_rels += f'\n <Relationship Id="{rel["id"]}" Type="{rel["type"]}" Target="{rel["target"]}"/>'
rels_xml = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>{extra_rels}
</Relationships>'''
with open(rels_path, 'w', encoding='utf-8') as f:
f.write(rels_xml)
# Track image formats for Content_Types
for media_name in media_files_dict:
ext = media_name.rsplit('.', 1)[-1].lower()
if ext in ('png', 'jpg', 'jpeg', 'gif', 'webp'):
any_png_generated = True # reuse flag for any image
# ---- Legacy SVG embedding mode ----
else:
svg_filename = f'image{i}.svg'
png_filename = f'image{i}.png'
png_rid = 'rId2'
svg_rid = 'rId3' if use_compat_mode else 'rId2'
# Copy SVG to media directory
shutil.copy(svg_path, media_dir / svg_filename)
# Compatibility mode: generate PNG fallback image
slide_has_png = False
if use_compat_mode:
png_path = media_dir / png_filename
png_success = convert_svg_to_png(
svg_path,
png_path,
width=pixel_width,
height=pixel_height
)
if png_success:
slide_has_png = True
any_png_generated = True
else:
# PNG generation failed, fall back to pure SVG
if verbose:
print(f" [{i}/{len(svg_files)}] {svg_path.name} - PNG generation failed, using pure SVG")
svg_rid = 'rId2'
# Update slide XML
slide_xml_path = extract_dir / 'ppt' / 'slides' / f'slide{slide_num}.xml'
slide_xml = create_slide_xml_with_svg(
slide_num,
png_rid=png_rid,
svg_rid=svg_rid,
width_emu=width_emu,
height_emu=height_emu,
transition=transition,
transition_duration=transition_duration,
auto_advance=auto_advance,
use_compat_mode=(use_compat_mode and slide_has_png)
)
with open(slide_xml_path, 'w', encoding='utf-8') as f:
f.write(slide_xml)
# Create/update relationship file
rels_dir = extract_dir / 'ppt' / 'slides' / '_rels'
rels_dir.mkdir(exist_ok=True)
rels_path = rels_dir / f'slide{slide_num}.xml.rels'
rels_xml = create_slide_rels_xml(
png_rid=png_rid,
png_filename=png_filename,
svg_rid=svg_rid,
svg_filename=svg_filename,
use_compat_mode=(use_compat_mode and slide_has_png)
)
with open(rels_path, 'w', encoding='utf-8') as f:
f.write(rels_xml)
# Process notes (shared between native and legacy mode)
notes_content = ''
if enable_notes:
# Match by filename (new logic) or by index (backward compatible)
svg_stem = svg_path.stem
notes_content = notes.get(svg_stem, '') if notes else ''
if notes_content:
notes_text = markdown_to_plain_text(notes_content)
else:
notes_text = '' # Empty notes
# Create notesSlides directory
notes_slides_dir = extract_dir / 'ppt' / 'notesSlides'
notes_slides_dir.mkdir(exist_ok=True)
# Create notes slide XML
notes_xml_path = notes_slides_dir / f'notesSlide{slide_num}.xml'
notes_xml = create_notes_slide_xml(slide_num, notes_text)
with open(notes_xml_path, 'w', encoding='utf-8') as f:
f.write(notes_xml)
# Create notes slide relationship file
notes_rels_dir = notes_slides_dir / '_rels'
notes_rels_dir.mkdir(exist_ok=True)
notes_rels_path = notes_rels_dir / f'notesSlide{slide_num}.xml.rels'
notes_rels_xml = create_notes_slide_rels_xml(slide_num)
with open(notes_rels_path, 'w', encoding='utf-8') as f:
f.write(notes_rels_xml)
# Update slide.xml.rels to add notes association
with open(rels_path, 'r', encoding='utf-8') as f:
slide_rels_content = f.read()
notes_rel = f' <Relationship Id="rId10" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide" Target="../notesSlides/notesSlide{slide_num}.xml"/>'
slide_rels_content = slide_rels_content.replace('</Relationships>', notes_rel + '\n</Relationships>')
with open(rels_path, 'w', encoding='utf-8') as f:
f.write(slide_rels_content)
if verbose:
if use_native_shapes:
mode_str = " (Native)"
elif use_compat_mode and not use_native_shapes:
mode_str = " (PNG+SVG)" if any_png_generated else " (SVG)"
else:
mode_str = " (SVG)"
has_notes = enable_notes and bool(notes_content)
notes_str = " +notes" if has_notes else ""
print(f" [{i}/{len(svg_files)}] {svg_path.name}{mode_str}{notes_str}")
success_count += 1
except Exception as e:
if verbose:
print(f" [{i}/{len(svg_files)}] {svg_path.name} - Error: {e}")
# Update [Content_Types].xml to add media types
content_types_path = extract_dir / '[Content_Types].xml'
with open(content_types_path, 'r', encoding='utf-8') as f:
content_types = f.read()
types_to_add = []
if not use_native_shapes:
# Legacy mode: need SVG extension type
if 'Extension="svg"' not in content_types:
types_to_add.append(' <Default Extension="svg" ContentType="image/svg+xml"/>')
if any_png_generated and 'Extension="png"' not in content_types:
types_to_add.append(' <Default Extension="png" ContentType="image/png"/>')
# Add jpg support for native mode embedded images
if use_native_shapes and 'Extension="jpg"' not in content_types:
types_to_add.append(' <Default Extension="jpg" ContentType="image/jpeg"/>')
if use_native_shapes and 'Extension="jpeg"' not in content_types:
types_to_add.append(' <Default Extension="jpeg" ContentType="image/jpeg"/>')
if types_to_add:
content_types = content_types.replace(
'</Types>',
'\n'.join(types_to_add) + '\n</Types>'
)
with open(content_types_path, 'w', encoding='utf-8') as f:
f.write(content_types)
# Add notesSlides content types
if enable_notes:
for i in range(1, len(svg_files) + 1):
override = f' <Override PartName="/ppt/notesSlides/notesSlide{i}.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml"/>'
if override not in content_types:
content_types = content_types.replace('</Types>', override + '\n</Types>')
with open(content_types_path, 'w', encoding='utf-8') as f:
f.write(content_types)
# Repackage PPTX
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zf:
for file_path in extract_dir.rglob('*'):
if file_path.is_file():
arcname = file_path.relative_to(extract_dir)
zf.write(file_path, arcname)
if verbose:
print()
print(f"[Done] Saved: {output_path}")
print(f" Succeeded: {success_count}, Failed: {len(svg_files) - success_count}")
if use_compat_mode and any_png_generated:
print(f" Mode: Office compatibility mode (supports all Office versions)")
# If using svglib, provide upgrade hint
if PNG_RENDERER == 'svglib' and renderer_hint:
print(f" [Tip] {renderer_hint}")
return success_count == len(svg_files)
finally:
# Clean up temporary directory
shutil.rmtree(temp_dir, ignore_errors=True)
def main():
# Build transition effect option list
transition_choices = list(TRANSITIONS.keys()) if TRANSITIONS else ['fade', 'push', 'wipe', 'split', 'reveal', 'cover', 'random']
parser = argparse.ArgumentParser(
description='PPT Master - SVG to PPTX Tool (Office Compatibility Mode)',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=f'''
Examples:
%(prog)s examples/ppt169_demo -s final # Recommended: use post-processed version
%(prog)s examples/ppt169_demo # Use original version
%(prog)s examples/ppt169_demo -o presentation.pptx
%(prog)s examples/ppt169_demo --no-compat # Disable compatibility mode (pure SVG only)
%(prog)s examples/ppt169_demo --native # Native DrawingML shapes (directly editable)
# Add page transition effects
%(prog)s examples/ppt169_demo --transition fade
%(prog)s examples/ppt169_demo -t push --transition-duration 1.0
SVG source directory (-s):
output - svg_output (original version)
final - svg_final (post-processed, recommended)
<any> - Specify a subdirectory name directly
Transition effects (-t/--transition):
{', '.join(transition_choices)}
Compatibility mode (enabled by default):
- Automatically generates PNG fallback images, SVG embedded as extension
- Compatible with all Office versions (including Office LTSC 2021)
- Newer Office still displays SVG (editable), older versions display PNG
- Requires svglib: pip install svglib reportlab
- Use --no-compat to disable (only Office 2019+ supported)
Speaker notes (enabled by default):
- Automatically reads Markdown notes files from the notes/ directory
- Supports two naming conventions:
1. Match by filename (recommended): 01_cover.md corresponds to 01_cover.svg
2. Match by index: slide01.md corresponds to the 1st SVG (backward compatible)
- Use --no-notes to disable
'''
)
parser.add_argument('project_path', type=str, help='Project directory path')
parser.add_argument('-o', '--output', type=str, default=None, help='Output file path')
parser.add_argument('-s', '--source', type=str, default='output',
help='SVG source: output/final or any subdirectory name (recommended: final)')
parser.add_argument('-f', '--format', type=str, choices=list(CANVAS_FORMATS.keys()), default=None, help='Specify canvas format')
parser.add_argument('-q', '--quiet', action='store_true', help='Quiet mode')
# Compatibility mode arguments
parser.add_argument('--no-compat', action='store_true',
help='Disable Office compatibility mode (pure SVG only, requires Office 2019+)')
# Native shapes mode arguments
parser.add_argument('--native', action='store_true', default=False,
help='Convert SVG to native DrawingML shapes (directly editable without manual conversion)')
# Transition effect arguments
parser.add_argument('-t', '--transition', type=str, choices=transition_choices, default=None,
help='Page transition effect (default: none)')
parser.add_argument('--transition-duration', type=float, default=0.5,
help='Transition duration in seconds (default: 0.5)')
parser.add_argument('--auto-advance', type=float, default=None,
help='Auto-advance interval in seconds (default: manual advance)')
# Notes arguments
parser.add_argument('--no-notes', action='store_true',
help='Disable speaker notes embedding (enabled by default)')
args = parser.parse_args()
project_path = Path(args.project_path)
if not project_path.exists():
print(f"Error: Path does not exist: {project_path}")
sys.exit(1)
try:
project_info = get_project_info(str(project_path))
project_name = project_info.get('name', project_path.name)
detected_format = project_info.get('format')
except Exception:
project_name = project_path.name
detected_format = None
canvas_format = args.format
if canvas_format is None and detected_format and detected_format != 'unknown':
canvas_format = detected_format
svg_files, source_dir_name = find_svg_files(project_path, args.source)
if not svg_files:
print("Error: No SVG files found")
sys.exit(1)
if args.output:
output_path = Path(args.output)
else:
# Default with timestamp to avoid overwriting previous versions
from datetime import datetime
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output_path = project_path / f"{project_name}_{timestamp}.pptx"
output_path.parent.mkdir(parents=True, exist_ok=True)
verbose = not args.quiet
# Read notes files
enable_notes = not args.no_notes
notes = {}
if enable_notes:
notes = find_notes_files(project_path, svg_files)
if verbose:
print("PPT Master - SVG to PPTX Tool (Native SVG)")
print("=" * 50)
print(f" Project path: {project_path}")
print(f" SVG directory: {source_dir_name}")
print(f" Output file: {output_path}")
print()
success = create_pptx_with_native_svg(
svg_files,
output_path,
canvas_format=canvas_format,
verbose=verbose,
transition=args.transition,
transition_duration=args.transition_duration,
auto_advance=args.auto_advance,
use_compat_mode=not args.no_compat,
notes=notes,
enable_notes=enable_notes,
use_native_shapes=args.native
)
sys.exit(0 if success else 1)
if __name__ == '__main__':
main()
scripts/svg_to_shapes.py
#!/usr/bin/env python3
"""
PPT Master - SVG to DrawingML Native Shapes Converter
Converts SVG elements into native PowerPoint DrawingML shapes,
so the resulting PPTX is directly editable without manual "Convert to Shape".
This module handles the SVG subset used by PPT Master (after finalize_svg.py processing):
- rect, circle, line, path, polygon, polyline, text, g, image
- linearGradient, radialGradient (in defs)
- filter (shadow effects via feGaussianBlur + feOffset)
- transform (translate, scale, rotate)
Usage:
from svg_to_shapes import convert_svg_to_slide_shapes
slide_xml, media_files, rel_entries = convert_svg_to_slide_shapes(svg_path, slide_num=1)
"""
import math
import re
import base64
from pathlib import Path
from typing import Optional, Tuple, List, Dict, Any
from xml.etree import ElementTree as ET
from dataclasses import dataclass, field
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
SVG_NS = 'http://www.w3.org/2000/svg'
XLINK_NS = 'http://www.w3.org/1999/xlink'
# 1 SVG pixel = 9525 EMU (at 96 DPI)
EMU_PER_PX = 9525
# DrawingML font size unit: 1/100 of a point. 1px = 0.75pt at 96 DPI.
FONT_PX_TO_HUNDREDTHS_PT = 75
# DrawingML angle unit: 60000ths of a degree
ANGLE_UNIT = 60000
# Known East Asian fonts
EA_FONTS = {
'PingFang SC', 'PingFang TC', 'PingFang HK',
'Microsoft YaHei', 'Microsoft JhengHei',
'SimSun', 'SimHei', 'FangSong', 'KaiTi', 'STKaiti',
'STHeiti', 'STSong', 'STFangsong', 'STXihei', 'STZhongsong',
'Hiragino Sans', 'Hiragino Sans GB', 'Hiragino Mincho ProN',
'Noto Sans SC', 'Noto Sans TC', 'Noto Serif SC', 'Noto Serif TC',
'Source Han Sans SC', 'Source Han Sans TC',
'Source Han Serif SC', 'Source Han Serif TC',
'WenQuanYi Micro Hei', 'WenQuanYi Zen Hei',
'YouYuan', 'LiSu', 'HuaWenKaiTi',
}
SYSTEM_FONTS = {'system-ui', '-apple-system', 'BlinkMacSystemFont'}
# Preset dash patterns: SVG stroke-dasharray -> DrawingML prstDash
DASH_PRESETS = {
'4,4': 'dash',
'4 4': 'dash',
'6,3': 'dash',
'6 3': 'dash',
'2,2': 'sysDot',
'2 2': 'sysDot',
'8,4': 'lgDash',
'8 4': 'lgDash',
'8,4,2,4': 'lgDashDot',
'8 4 2 4': 'lgDashDot',
}
# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------
@dataclass
class ConvertContext:
"""Shared context passed through the conversion pipeline."""
defs: Dict[str, ET.Element] = field(default_factory=dict)
id_counter: int = 2 # start at 2 (1 is reserved for spTree root)
slide_num: int = 1 # slide number for unique media filenames
translate_x: float = 0.0
translate_y: float = 0.0
scale_x: float = 1.0
scale_y: float = 1.0
filter_id: Optional[str] = None # inherited filter from parent <g>
media_files: Dict[str, bytes] = field(default_factory=dict) # filename -> data
rel_entries: List[Dict[str, str]] = field(default_factory=list)
rel_id_counter: int = 2 # rId1 reserved for slideLayout
def next_id(self) -> int:
cid = self.id_counter
self.id_counter += 1
return cid
def next_rel_id(self) -> str:
rid = f'rId{self.rel_id_counter}'
self.rel_id_counter += 1
return rid
def child(self, dx: float = 0, dy: float = 0,
sx: float = 1.0, sy: float = 1.0,
filter_id: Optional[str] = None) -> 'ConvertContext':
"""Create child context with accumulated translation and scale."""
return ConvertContext(
defs=self.defs,
id_counter=self.id_counter,
slide_num=self.slide_num,
translate_x=self.translate_x + dx,
translate_y=self.translate_y + dy,
scale_x=self.scale_x * sx,
scale_y=self.scale_y * sy,
filter_id=filter_id or self.filter_id,
media_files=self.media_files,
rel_entries=self.rel_entries,
rel_id_counter=self.rel_id_counter,
)
def sync_from_child(self, child_ctx: 'ConvertContext'):
"""Sync counters back from child context."""
self.id_counter = child_ctx.id_counter
self.rel_id_counter = child_ctx.rel_id_counter
# ---------------------------------------------------------------------------
# Coordinate helpers
# ---------------------------------------------------------------------------
def px_to_emu(px: float) -> int:
"""Convert SVG pixels to EMU."""
return round(px * EMU_PER_PX)
def _f(val: Optional[str], default: float = 0.0) -> float:
"""Parse a float attribute, returning default if missing."""
if val is None:
return default
try:
return float(val)
except (ValueError, TypeError):
return default
def ctx_x(val: float, ctx: 'ConvertContext') -> float:
"""Apply context scale + translate to an x coordinate."""
return val * ctx.scale_x + ctx.translate_x
def ctx_y(val: float, ctx: 'ConvertContext') -> float:
"""Apply context scale + translate to a y coordinate."""
return val * ctx.scale_y + ctx.translate_y
def ctx_w(val: float, ctx: 'ConvertContext') -> float:
"""Apply context scale to a width value."""
return val * ctx.scale_x
def ctx_h(val: float, ctx: 'ConvertContext') -> float:
"""Apply context scale to a height value."""
return val * ctx.scale_y
# ---------------------------------------------------------------------------
# Color / style parsing
# ---------------------------------------------------------------------------
def parse_hex_color(color_str: str) -> Optional[str]:
"""Parse '#RRGGBB' or '#RGB' to 'RRGGBB'. Returns None on failure."""
if not color_str:
return None
color_str = color_str.strip()
if color_str.startswith('#'):
color_str = color_str[1:]
if len(color_str) == 3:
color_str = ''.join(c * 2 for c in color_str)
if len(color_str) == 6 and all(c in '0123456789abcdefABCDEF' for c in color_str):
return color_str.upper()
return None
def parse_stop_style(style_str: str) -> Tuple[Optional[str], float]:
"""Parse stop element's style attribute: 'stop-color:#XXX;stop-opacity:N'"""
color = None
opacity = 1.0
if not style_str:
return color, opacity
for part in style_str.split(';'):
part = part.strip()
if part.startswith('stop-color:'):
color = parse_hex_color(part.split(':', 1)[1].strip())
elif part.startswith('stop-opacity:'):
try:
opacity = float(part.split(':', 1)[1].strip())
except ValueError:
pass
return color, opacity
def resolve_url_id(url_str: str) -> Optional[str]:
"""Extract ID from 'url(#someId)' reference."""
if not url_str:
return None
m = re.match(r'url\(#([^)]+)\)', url_str.strip())
return m.group(1) if m else None
def get_effective_filter_id(elem: ET.Element, ctx: ConvertContext) -> Optional[str]:
"""Get the filter ID for an element, considering inherited context."""
filt = elem.get('filter')
if filt:
return resolve_url_id(filt)
return ctx.filter_id
# ---------------------------------------------------------------------------
# Font parsing
# ---------------------------------------------------------------------------
def parse_font_family(font_family_str: str) -> Dict[str, str]:
"""Parse CSS font-family to latin/ea typefaces."""
if not font_family_str:
return {'latin': 'Segoe UI', 'ea': 'Microsoft YaHei'}
fonts = [f.strip().strip("'\"") for f in font_family_str.split(',')]
latin_font = None
ea_font = None
for font in fonts:
if font in SYSTEM_FONTS or font in ('sans-serif', 'serif', 'monospace'):
continue
if font in EA_FONTS:
ea_font = ea_font or font
else:
latin_font = latin_font or font
# If no latin font found but we have EA, use EA as latin too
# (PPT renders CJK text via latin typeface when ea doesn't match)
if not latin_font and ea_font:
latin_font = ea_font
return {
'latin': latin_font or 'Segoe UI',
'ea': ea_font or latin_font or 'Microsoft YaHei',
}
def is_cjk_char(ch: str) -> bool:
"""Check if a character is CJK."""
cp = ord(ch)
return (0x4E00 <= cp <= 0x9FFF or 0x3400 <= cp <= 0x4DBF or
0x2E80 <= cp <= 0x2EFF or 0x3000 <= cp <= 0x303F or
0xFF00 <= cp <= 0xFFEF or 0xF900 <= cp <= 0xFAFF or
0x20000 <= cp <= 0x2A6DF)
def estimate_text_width(text: str, font_size: float, font_weight: str = '400') -> float:
"""Estimate text width in SVG pixels."""
width = 0.0
for ch in text:
if is_cjk_char(ch):
width += font_size
elif ch == ' ':
width += font_size * 0.3
elif ch in 'mMwWOQ':
width += font_size * 0.75
elif ch in 'iIlj1!|':
width += font_size * 0.3
else:
width += font_size * 0.55
# Bold text is slightly wider
if font_weight in ('bold', '600', '700', '800', '900'):
width *= 1.05
return width
# ---------------------------------------------------------------------------
# DrawingML XML builders
# ---------------------------------------------------------------------------
def _xml_escape(text: str) -> str:
"""Escape XML special characters."""
return (text.replace('&', '&')
.replace('<', '<')
.replace('>', '>')
.replace('"', '"'))
def build_solid_fill(color: str, opacity: Optional[float] = None) -> str:
"""Build <a:solidFill> XML."""
alpha = ''
if opacity is not None and opacity < 1.0:
alpha = f'<a:alpha val="{int(opacity * 100000)}"/>'
return f'<a:solidFill><a:srgbClr val="{color}">{alpha}</a:srgbClr></a:solidFill>'
def build_gradient_fill(grad_elem: ET.Element,
opacity: Optional[float] = None) -> str:
"""Build <a:gradFill> from SVG linearGradient or radialGradient element."""
tag = grad_elem.tag.replace(f'{{{SVG_NS}}}', '')
# Parse stops
stops_xml = []
for child in grad_elem:
child_tag = child.tag.replace(f'{{{SVG_NS}}}', '')
if child_tag != 'stop':
continue
offset_str = child.get('offset', '0').strip().rstrip('%')
try:
offset = float(offset_str)
# If percentage (most common), offset is 0-100
if offset > 1.0:
offset = offset / 100.0
except ValueError:
offset = 0.0
pos = int(offset * 100000)
# Parse color from style attribute or direct attributes
style = child.get('style', '')
color, stop_opacity = parse_stop_style(style)
if not color:
color = parse_hex_color(child.get('stop-color', '#000000'))
if color is None:
color = '000000'
# Also check direct stop-opacity attribute (overrides style)
direct_stop_op = child.get('stop-opacity')
if direct_stop_op is not None:
try:
stop_opacity = float(direct_stop_op)
except ValueError:
pass
alpha_xml = ''
effective_opacity = stop_opacity
if opacity is not None:
effective_opacity *= opacity
if effective_opacity < 1.0:
alpha_xml = f'<a:alpha val="{int(effective_opacity * 100000)}"/>'
stops_xml.append(
f'<a:gs pos="{pos}"><a:srgbClr val="{color}">{alpha_xml}</a:srgbClr></a:gs>'
)
if not stops_xml:
return ''
gs_list = '\n'.join(stops_xml)
if tag == 'linearGradient':
# Calculate angle from x1,y1 -> x2,y2
# Values can be fractions (0-1) or percentages (0%-100%)
def parse_grad_coord(val_str: str, default: float = 0.0) -> float:
val_str = val_str.strip()
if val_str.endswith('%'):
return float(val_str.rstrip('%')) / 100.0
v = float(val_str)
# Heuristic: if > 1, treat as percentage
return v / 100.0 if v > 1.0 else v
x1 = parse_grad_coord(grad_elem.get('x1', '0'))
y1 = parse_grad_coord(grad_elem.get('y1', '0'))
x2 = parse_grad_coord(grad_elem.get('x2', '1'))
y2 = parse_grad_coord(grad_elem.get('y2', '1'))
angle_rad = math.atan2(y2 - y1, x2 - x1)
angle_deg = math.degrees(angle_rad)
# DrawingML angle: 0 = right, rotates clockwise, in 60000ths of degree
# SVG gradient: angle from (x1,y1) to (x2,y2) in standard math direction
# DrawingML lin ang: measured from top, clockwise
dml_angle = int(((90 + angle_deg) % 360) * ANGLE_UNIT)
return f'''<a:gradFill>
<a:gsLst>{gs_list}</a:gsLst>
<a:lin ang="{dml_angle}" scaled="1"/>
</a:gradFill>'''
elif tag == 'radialGradient':
return f'''<a:gradFill>
<a:gsLst>{gs_list}</a:gsLst>
<a:path path="circle">
<a:fillToRect l="50000" t="50000" r="50000" b="50000"/>
</a:path>
</a:gradFill>'''
return ''
def build_fill_xml(elem: ET.Element, ctx: ConvertContext,
opacity: Optional[float] = None) -> str:
"""Build fill XML for a shape element."""
fill = elem.get('fill')
if fill is None:
# SVG default fill is black
fill = '#000000'
if fill == 'none':
return '<a:noFill/>'
# Check for gradient reference
grad_id = resolve_url_id(fill)
if grad_id and grad_id in ctx.defs:
return build_gradient_fill(ctx.defs[grad_id], opacity)
# Solid color
color = parse_hex_color(fill)
if color:
return build_solid_fill(color, opacity)
return '<a:noFill/>'
def build_stroke_xml(elem: ET.Element, opacity: Optional[float] = None) -> str:
"""Build <a:ln> XML for stroke."""
stroke = elem.get('stroke')
if not stroke or stroke == 'none':
return '<a:ln><a:noFill/></a:ln>'
color = parse_hex_color(stroke)
if not color:
return '<a:ln><a:noFill/></a:ln>'
width = _f(elem.get('stroke-width'), 1.0)
width_emu = px_to_emu(width)
# Dash pattern
dash_xml = ''
dasharray = elem.get('stroke-dasharray')
if dasharray and dasharray != 'none':
preset = DASH_PRESETS.get(dasharray.strip())
if preset:
dash_xml = f'<a:prstDash val="{preset}"/>'
else:
dash_xml = '<a:prstDash val="dash"/>'
# Line cap
cap_map = {'round': 'rnd', 'square': 'sq', 'butt': 'flat'}
cap_attr = ''
linecap = elem.get('stroke-linecap')
if linecap and linecap in cap_map:
cap_attr = f' cap="{cap_map[linecap]}"'
alpha_xml = ''
if opacity is not None and opacity < 1.0:
alpha_xml = f'<a:alpha val="{int(opacity * 100000)}"/>'
return f'''<a:ln w="{width_emu}"{cap_attr}>
<a:solidFill><a:srgbClr val="{color}">{alpha_xml}</a:srgbClr></a:solidFill>{dash_xml}
</a:ln>'''
def build_shadow_xml(filter_elem: ET.Element) -> str:
"""Build <a:effectLst> with <a:outerShdw> from SVG filter element."""
if filter_elem is None:
return ''
std_dev = 4.0
dx = 0.0
dy = 4.0
shadow_opacity = 0.3
for child in filter_elem.iter():
tag = child.tag.replace(f'{{{SVG_NS}}}', '')
if tag == 'feGaussianBlur':
std_dev = _f(child.get('stdDeviation'), 4.0)
elif tag == 'feOffset':
dx = _f(child.get('dx'), 0.0)
dy = _f(child.get('dy'), 4.0)
elif tag == 'feFlood':
shadow_opacity = _f(child.get('flood-opacity'), 0.3)
elif tag == 'feFuncA':
# feComponentTransfer > feFuncA type="linear" slope="0.3"
if child.get('type') == 'linear':
shadow_opacity = _f(child.get('slope'), 0.3)
blur_rad = px_to_emu(std_dev * 2)
dist = px_to_emu(math.sqrt(dx * dx + dy * dy))
# Direction angle: atan2(dy, dx), converted to DrawingML (from top, CW)
dir_angle = int(((90 + math.degrees(math.atan2(dy, max(dx, 0.001)))) % 360) * ANGLE_UNIT)
alpha_val = int(shadow_opacity * 100000)
return f'''<a:effectLst>
<a:outerShdw blurRad="{blur_rad}" dist="{dist}" dir="{dir_angle}" algn="tl" rotWithShape="0">
<a:srgbClr val="000000"><a:alpha val="{alpha_val}"/></a:srgbClr>
</a:outerShdw>
</a:effectLst>'''
def get_element_opacity(elem: ET.Element) -> Optional[float]:
"""Get opacity value from element, returns None if 1.0 or not set."""
op = elem.get('opacity')
if op is None:
return None
try:
val = float(op)
return val if val < 1.0 else None
except ValueError:
return None
def get_fill_opacity(elem: ET.Element) -> Optional[float]:
"""
Get effective fill opacity combining 'opacity' and 'fill-opacity'.
Returns None if fully opaque.
"""
base = 1.0
op = elem.get('opacity')
if op:
try:
base = float(op)
except ValueError:
pass
fill_op = elem.get('fill-opacity')
if fill_op:
try:
base *= float(fill_op)
except ValueError:
pass
return base if base < 1.0 else None
def get_stroke_opacity(elem: ET.Element) -> Optional[float]:
"""
Get effective stroke opacity combining 'opacity' and 'stroke-opacity'.
Returns None if fully opaque.
"""
base = 1.0
op = elem.get('opacity')
if op:
try:
base = float(op)
except ValueError:
pass
stroke_op = elem.get('stroke-opacity')
if stroke_op:
try:
base *= float(stroke_op)
except ValueError:
pass
return base if base < 1.0 else None
# ---------------------------------------------------------------------------
# SVG Path Parser
# ---------------------------------------------------------------------------
@dataclass
class PathCommand:
cmd: str # M, L, C, Z, etc. (uppercase = absolute)
args: List[float] = field(default_factory=list)
def parse_svg_path(d: str) -> List[PathCommand]:
"""Parse SVG path d attribute into a list of PathCommands."""
if not d:
return []
commands = []
# Tokenize: split into commands and numbers
# Handle negative numbers and decimals correctly
tokens = re.findall(r'[MmLlHhVvCcSsQqTtAaZz]|[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?', d)
current_cmd = None
current_args = []
def flush():
nonlocal current_cmd, current_args
if current_cmd is not None:
# Some commands can have implicit repeats
arg_counts = {
'M': 2, 'm': 2, 'L': 2, 'l': 2,
'H': 1, 'h': 1, 'V': 1, 'v': 1,
'C': 6, 'c': 6, 'S': 4, 's': 4,
'Q': 4, 'q': 4, 'T': 2, 't': 2,
'A': 7, 'a': 7, 'Z': 0, 'z': 0,
}
n = arg_counts.get(current_cmd, 0)
if n == 0:
commands.append(PathCommand(current_cmd, []))
elif n > 0 and len(current_args) >= n:
# Split into multiple commands if there are extra args
i = 0
while i + n <= len(current_args):
commands.append(PathCommand(current_cmd, current_args[i:i + n]))
# After first M, implicit commands become L
if current_cmd == 'M':
current_cmd = 'L'
elif current_cmd == 'm':
current_cmd = 'l'
i += n
current_args = []
for token in tokens:
if token in 'MmLlHhVvCcSsQqTtAaZz':
flush()
current_cmd = token
current_args = []
else:
try:
current_args.append(float(token))
except ValueError:
pass
flush()
return commands
def svg_path_to_absolute(commands: List[PathCommand]) -> List[PathCommand]:
"""Convert all relative path commands to absolute."""
result = []
cx, cy = 0.0, 0.0 # Current point
sx, sy = 0.0, 0.0 # Subpath start
for cmd in commands:
a = cmd.args
if cmd.cmd == 'M':
cx, cy = a[0], a[1]
sx, sy = cx, cy
result.append(PathCommand('M', [cx, cy]))
elif cmd.cmd == 'm':
cx += a[0]
cy += a[1]
sx, sy = cx, cy
result.append(PathCommand('M', [cx, cy]))
elif cmd.cmd == 'L':
cx, cy = a[0], a[1]
result.append(PathCommand('L', [cx, cy]))
elif cmd.cmd == 'l':
cx += a[0]
cy += a[1]
result.append(PathCommand('L', [cx, cy]))
elif cmd.cmd == 'H':
cx = a[0]
result.append(PathCommand('L', [cx, cy]))
elif cmd.cmd == 'h':
cx += a[0]
result.append(PathCommand('L', [cx, cy]))
elif cmd.cmd == 'V':
cy = a[0]
result.append(PathCommand('L', [cx, cy]))
elif cmd.cmd == 'v':
cy += a[0]
result.append(PathCommand('L', [cx, cy]))
elif cmd.cmd == 'C':
result.append(PathCommand('C', list(a)))
cx, cy = a[4], a[5]
elif cmd.cmd == 'c':
abs_args = [
cx + a[0], cy + a[1],
cx + a[2], cy + a[3],
cx + a[4], cy + a[5],
]
result.append(PathCommand('C', abs_args))
cx, cy = abs_args[4], abs_args[5]
elif cmd.cmd == 'S':
result.append(PathCommand('S', list(a)))
cx, cy = a[2], a[3]
elif cmd.cmd == 's':
abs_args = [cx + a[0], cy + a[1], cx + a[2], cy + a[3]]
result.append(PathCommand('S', abs_args))
cx, cy = abs_args[2], abs_args[3]
elif cmd.cmd == 'Q':
result.append(PathCommand('Q', list(a)))
cx, cy = a[2], a[3]
elif cmd.cmd == 'q':
abs_args = [cx + a[0], cy + a[1], cx + a[2], cy + a[3]]
result.append(PathCommand('Q', abs_args))
cx, cy = abs_args[2], abs_args[3]
elif cmd.cmd == 'T':
result.append(PathCommand('T', list(a)))
cx, cy = a[0], a[1]
elif cmd.cmd == 't':
abs_args = [cx + a[0], cy + a[1]]
result.append(PathCommand('T', abs_args))
cx, cy = abs_args[0], abs_args[1]
elif cmd.cmd == 'A':
result.append(PathCommand('A', list(a)))
cx, cy = a[5], a[6]
elif cmd.cmd == 'a':
abs_args = [a[0], a[1], a[2], a[3], a[4], cx + a[5], cy + a[6]]
result.append(PathCommand('A', abs_args))
cx, cy = abs_args[5], abs_args[6]
elif cmd.cmd in ('Z', 'z'):
result.append(PathCommand('Z', []))
cx, cy = sx, sy
return result
def _reflect_control_point(cp_x: float, cp_y: float,
cx: float, cy: float) -> Tuple[float, float]:
"""Reflect a control point through the current point."""
return 2 * cx - cp_x, 2 * cy - cp_y
def _quad_to_cubic(qp_x: float, qp_y: float,
p0_x: float, p0_y: float,
p3_x: float, p3_y: float) -> List[float]:
"""Convert quadratic bezier control point to cubic bezier control points."""
cp1_x = p0_x + 2.0 / 3.0 * (qp_x - p0_x)
cp1_y = p0_y + 2.0 / 3.0 * (qp_y - p0_y)
cp2_x = p3_x + 2.0 / 3.0 * (qp_x - p3_x)
cp2_y = p3_y + 2.0 / 3.0 * (qp_y - p3_y)
return [cp1_x, cp1_y, cp2_x, cp2_y, p3_x, p3_y]
def _arc_to_cubic_beziers(cx_: float, cy_: float,
rx: float, ry: float,
phi: float,
large_arc: int, sweep: int,
x2: float, y2: float) -> List[PathCommand]:
"""
Convert SVG arc (endpoint parameterization) to cubic bezier curves.
Uses the algorithm from the SVG spec (F.6.5) to convert endpoint to center
parameterization, then approximates each arc segment with cubic beziers.
"""
x1, y1 = cx_, cy_
# If endpoints are the same, skip
if abs(x1 - x2) < 1e-10 and abs(y1 - y2) < 1e-10:
return []
# Ensure radii are positive
rx = abs(rx)
ry = abs(ry)
if rx < 1e-10 or ry < 1e-10:
return [PathCommand('L', [x2, y2])]
phi_rad = math.radians(phi)
cos_phi = math.cos(phi_rad)
sin_phi = math.sin(phi_rad)
# Step 1: Compute (x1', y1')
dx = (x1 - x2) / 2.0
dy = (y1 - y2) / 2.0
x1p = cos_phi * dx + sin_phi * dy
y1p = -sin_phi * dx + cos_phi * dy
# Step 2: Compute (cx', cy')
x1p2 = x1p * x1p
y1p2 = y1p * y1p
rx2 = rx * rx
ry2 = ry * ry
# Ensure radii are large enough
lam = x1p2 / rx2 + y1p2 / ry2
if lam > 1:
lam_sqrt = math.sqrt(lam)
rx *= lam_sqrt
ry *= lam_sqrt
rx2 = rx * rx
ry2 = ry * ry
num = max(rx2 * ry2 - rx2 * y1p2 - ry2 * x1p2, 0)
den = rx2 * y1p2 + ry2 * x1p2
sq = math.sqrt(num / den) if den > 1e-10 else 0.0
if large_arc == sweep:
sq = -sq
cxp = sq * rx * y1p / ry
cyp = -sq * ry * x1p / rx
# Step 3: Compute (cx, cy)
arc_cx = cos_phi * cxp - sin_phi * cyp + (x1 + x2) / 2.0
arc_cy = sin_phi * cxp + cos_phi * cyp + (y1 + y2) / 2.0
# Step 4: Compute theta1 and dtheta
def angle_between(ux, uy, vx, vy):
n = math.sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy))
if n < 1e-10:
return 0
c = (ux * vx + uy * vy) / n
c = max(-1, min(1, c))
a = math.acos(c)
if ux * vy - uy * vx < 0:
a = -a
return a
theta1 = angle_between(1, 0, (x1p - cxp) / rx, (y1p - cyp) / ry)
dtheta = angle_between(
(x1p - cxp) / rx, (y1p - cyp) / ry,
(-x1p - cxp) / rx, (-y1p - cyp) / ry
)
if sweep == 0 and dtheta > 0:
dtheta -= 2 * math.pi
elif sweep == 1 and dtheta < 0:
dtheta += 2 * math.pi
# Split arc into segments of at most 90 degrees
n_segs = max(1, int(math.ceil(abs(dtheta) / (math.pi / 2))))
d_per_seg = dtheta / n_segs
result = []
alpha = 4.0 / 3.0 * math.tan(d_per_seg / 4.0)
for i in range(n_segs):
t1 = theta1 + i * d_per_seg
t2 = theta1 + (i + 1) * d_per_seg
cos_t1 = math.cos(t1)
sin_t1 = math.sin(t1)
cos_t2 = math.cos(t2)
sin_t2 = math.sin(t2)
# Control points in unit circle
ep1_x = cos_t1 - alpha * sin_t1
ep1_y = sin_t1 + alpha * cos_t1
ep2_x = cos_t2 + alpha * sin_t2
ep2_y = sin_t2 - alpha * cos_t2
ep_x = cos_t2
ep_y = sin_t2
# Scale by radii, rotate by phi, translate to center
def transform_pt(px, py):
x = rx * px
y = ry * py
xr = cos_phi * x - sin_phi * y + arc_cx
yr = sin_phi * x + cos_phi * y + arc_cy
return xr, yr
cp1 = transform_pt(ep1_x, ep1_y)
cp2 = transform_pt(ep2_x, ep2_y)
ep = transform_pt(ep_x, ep_y)
result.append(PathCommand('C', [cp1[0], cp1[1], cp2[0], cp2[1], ep[0], ep[1]]))
return result
def normalize_path_commands(commands: List[PathCommand]) -> List[PathCommand]:
"""
Normalize path commands:
- Convert S/s to C (smooth cubic → explicit cubic)
- Convert Q/q to C (quadratic → cubic)
- Convert T/t to C (smooth quadratic → explicit cubic)
- Convert A/a to C sequences (arc → cubic bezier approximation)
"""
result = []
cx, cy = 0.0, 0.0
last_cp_x, last_cp_y = 0.0, 0.0 # Last control point for S/T
last_cmd = ''
for cmd in commands:
a = cmd.args
if cmd.cmd == 'M':
cx, cy = a[0], a[1]
last_cp_x, last_cp_y = cx, cy
result.append(cmd)
elif cmd.cmd == 'L':
cx, cy = a[0], a[1]
last_cp_x, last_cp_y = cx, cy
result.append(cmd)
elif cmd.cmd == 'C':
last_cp_x, last_cp_y = a[2], a[3] # Second control point
cx, cy = a[4], a[5]
result.append(cmd)
elif cmd.cmd == 'S':
# Reflect last cubic control point
if last_cmd in ('C', 'S'):
rcp_x, rcp_y = _reflect_control_point(last_cp_x, last_cp_y, cx, cy)
else:
rcp_x, rcp_y = cx, cy
last_cp_x, last_cp_y = a[0], a[1]
new_cx, new_cy = a[2], a[3]
result.append(PathCommand('C', [rcp_x, rcp_y, a[0], a[1], new_cx, new_cy]))
cx, cy = new_cx, new_cy
elif cmd.cmd == 'Q':
cubic = _quad_to_cubic(a[0], a[1], cx, cy, a[2], a[3])
last_cp_x, last_cp_y = a[0], a[1]
result.append(PathCommand('C', cubic))
cx, cy = a[2], a[3]
elif cmd.cmd == 'T':
# Reflect last quadratic control point
if last_cmd in ('Q', 'T'):
qp_x, qp_y = _reflect_control_point(last_cp_x, last_cp_y, cx, cy)
else:
qp_x, qp_y = cx, cy
last_cp_x, last_cp_y = qp_x, qp_y
cubic = _quad_to_cubic(qp_x, qp_y, cx, cy, a[0], a[1])
result.append(PathCommand('C', cubic))
cx, cy = a[0], a[1]
elif cmd.cmd == 'A':
arc_beziers = _arc_to_cubic_beziers(
cx, cy, a[0], a[1], a[2], int(a[3]), int(a[4]), a[5], a[6]
)
for bc in arc_beziers:
result.append(bc)
cx, cy = a[5], a[6]
last_cp_x, last_cp_y = cx, cy
elif cmd.cmd == 'Z':
result.append(cmd)
else:
result.append(cmd)
last_cmd = cmd.cmd
return result
def path_commands_to_drawingml(commands: List[PathCommand],
offset_x: float = 0, offset_y: float = 0,
scale_x: float = 1.0, scale_y: float = 1.0) -> Tuple[str, float, float, float, float]:
"""
Convert normalized path commands to DrawingML <a:path> inner XML.
Returns: (path_xml, min_x, min_y, width, height) in scaled+offset coordinates.
"""
if not commands:
return '', 0, 0, 0, 0
# First pass: calculate bounding box (applying scale + offset)
points = []
for cmd in commands:
if cmd.cmd == 'M' or cmd.cmd == 'L':
points.append((cmd.args[0] * scale_x + offset_x,
cmd.args[1] * scale_y + offset_y))
elif cmd.cmd == 'C':
for i in range(0, 6, 2):
points.append((cmd.args[i] * scale_x + offset_x,
cmd.args[i + 1] * scale_y + offset_y))
if not points:
return '', 0, 0, 0, 0
min_x = min(p[0] for p in points)
min_y = min(p[1] for p in points)
max_x = max(p[0] for p in points)
max_y = max(p[1] for p in points)
width = max(max_x - min_x, 1)
height = max(max_y - min_y, 1)
# Second pass: generate DrawingML path commands
# Coordinates are in EMU, relative to shape's position
parts = []
for cmd in commands:
if cmd.cmd == 'M':
x_emu = px_to_emu(cmd.args[0] * scale_x + offset_x - min_x)
y_emu = px_to_emu(cmd.args[1] * scale_y + offset_y - min_y)
parts.append(f'<a:moveTo><a:pt x="{x_emu}" y="{y_emu}"/></a:moveTo>')
elif cmd.cmd == 'L':
x_emu = px_to_emu(cmd.args[0] * scale_x + offset_x - min_x)
y_emu = px_to_emu(cmd.args[1] * scale_y + offset_y - min_y)
parts.append(f'<a:lnTo><a:pt x="{x_emu}" y="{y_emu}"/></a:lnTo>')
elif cmd.cmd == 'C':
pts = []
for i in range(0, 6, 2):
x_emu = px_to_emu(cmd.args[i] * scale_x + offset_x - min_x)
y_emu = px_to_emu(cmd.args[i + 1] * scale_y + offset_y - min_y)
pts.append(f'<a:pt x="{x_emu}" y="{y_emu}"/>')
parts.append(f'<a:cubicBezTo>{"".join(pts)}</a:cubicBezTo>')
elif cmd.cmd == 'Z':
parts.append('<a:close/>')
path_inner = '\n'.join(parts)
return path_inner, min_x, min_y, width, height
# ---------------------------------------------------------------------------
# Element converters
# ---------------------------------------------------------------------------
def _wrap_shape(shape_id: int, name: str, off_x: int, off_y: int,
ext_cx: int, ext_cy: int,
geom_xml: str, fill_xml: str, stroke_xml: str,
effect_xml: str = '', extra_xml: str = '',
rot: int = 0) -> str:
"""Wrap DrawingML content into a <p:sp> shape element."""
rot_attr = f' rot="{rot}"' if rot else ''
return f'''<p:sp>
<p:nvSpPr>
<p:cNvPr id="{shape_id}" name="{_xml_escape(name)}"/>
<p:cNvSpPr/><p:nvPr/>
</p:nvSpPr>
<p:spPr>
<a:xfrm{rot_attr}><a:off x="{off_x}" y="{off_y}"/><a:ext cx="{ext_cx}" cy="{ext_cy}"/></a:xfrm>
{geom_xml}
{fill_xml}
{stroke_xml}
{effect_xml}
</p:spPr>
{extra_xml}
</p:sp>'''
def convert_rect(elem: ET.Element, ctx: ConvertContext) -> str:
"""Convert SVG <rect> to DrawingML shape."""
x = ctx_x(_f(elem.get('x')), ctx)
y = ctx_y(_f(elem.get('y')), ctx)
w = ctx_w(_f(elem.get('width')), ctx)
h = ctx_h(_f(elem.get('height')), ctx)
if w <= 0 or h <= 0:
return ''
fill_op = get_fill_opacity(elem)
stroke_op = get_stroke_opacity(elem)
fill = build_fill_xml(elem, ctx, fill_op)
stroke = build_stroke_xml(elem, stroke_op)
# Shadow
effect = ''
filt_id = get_effective_filter_id(elem, ctx)
if filt_id and filt_id in ctx.defs:
effect = build_shadow_xml(ctx.defs[filt_id])
geom = '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>'
shape_id = ctx.next_id()
return _wrap_shape(
shape_id, f'Rectangle {shape_id}',
px_to_emu(x), px_to_emu(y), px_to_emu(w), px_to_emu(h),
geom, fill, stroke, effect
)
def convert_circle(elem: ET.Element, ctx: ConvertContext) -> str:
"""Convert SVG <circle> to DrawingML ellipse shape."""
cx_ = ctx_x(_f(elem.get('cx')), ctx)
cy_ = ctx_y(_f(elem.get('cy')), ctx)
r_x = _f(elem.get('r')) * ctx.scale_x
r_y = _f(elem.get('r')) * ctx.scale_y
if r_x <= 0 or r_y <= 0:
return ''
x = cx_ - r_x
y = cy_ - r_y
w = r_x * 2
h = r_y * 2
fill_op = get_fill_opacity(elem)
stroke_op = get_stroke_opacity(elem)
fill = build_fill_xml(elem, ctx, fill_op)
stroke = build_stroke_xml(elem, stroke_op)
effect = ''
filt_id = get_effective_filter_id(elem, ctx)
if filt_id and filt_id in ctx.defs:
effect = build_shadow_xml(ctx.defs[filt_id])
geom = '<a:prstGeom prst="ellipse"><a:avLst/></a:prstGeom>'
shape_id = ctx.next_id()
return _wrap_shape(
shape_id, f'Ellipse {shape_id}',
px_to_emu(x), px_to_emu(y), px_to_emu(w), px_to_emu(h),
geom, fill, stroke, effect
)
def convert_line(elem: ET.Element, ctx: ConvertContext) -> str:
"""Convert SVG <line> to DrawingML custom geometry shape."""
x1 = ctx_x(_f(elem.get('x1')), ctx)
y1 = ctx_y(_f(elem.get('y1')), ctx)
x2 = ctx_x(_f(elem.get('x2')), ctx)
y2 = ctx_y(_f(elem.get('y2')), ctx)
min_x = min(x1, x2)
min_y = min(y1, y2)
w = max(abs(x2 - x1), 1)
h = max(abs(y2 - y1), 1)
w_emu = px_to_emu(w)
h_emu = px_to_emu(h)
lx1 = px_to_emu(x1 - min_x)
ly1 = px_to_emu(y1 - min_y)
lx2 = px_to_emu(x2 - min_x)
ly2 = px_to_emu(y2 - min_y)
geom = f'''<a:custGeom>
<a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/>
<a:rect l="l" t="t" r="r" b="b"/>
<a:pathLst><a:path w="{w_emu}" h="{h_emu}">
<a:moveTo><a:pt x="{lx1}" y="{ly1}"/></a:moveTo>
<a:lnTo><a:pt x="{lx2}" y="{ly2}"/></a:lnTo>
</a:path></a:pathLst>
</a:custGeom>'''
stroke_op = get_stroke_opacity(elem)
stroke = build_stroke_xml(elem, stroke_op)
shape_id = ctx.next_id()
return _wrap_shape(
shape_id, f'Line {shape_id}',
px_to_emu(min_x), px_to_emu(min_y), w_emu, h_emu,
geom, '<a:noFill/>', stroke
)
def convert_path(elem: ET.Element, ctx: ConvertContext) -> str:
"""Convert SVG <path> to DrawingML custom geometry shape."""
d = elem.get('d', '')
if not d:
return ''
# Parse, absolutize, normalize
commands = parse_svg_path(d)
commands = svg_path_to_absolute(commands)
commands = normalize_path_commands(commands)
# Handle transform on the path element itself
tx, ty = 0.0, 0.0
rot = 0
transform = elem.get('transform')
if transform:
t_match = re.search(r'translate\(\s*([-\d.]+)[\s,]+([-\d.]+)\s*\)', transform)
if t_match:
tx = float(t_match.group(1))
ty = float(t_match.group(2))
r_match = re.search(r'rotate\(\s*([-\d.]+)', transform)
if r_match:
rot = int(float(r_match.group(1)) * ANGLE_UNIT)
path_xml, min_x, min_y, width, height = path_commands_to_drawingml(
commands, ctx.translate_x + tx, ctx.translate_y + ty,
ctx.scale_x, ctx.scale_y
)
if not path_xml:
return ''
w_emu = px_to_emu(width)
h_emu = px_to_emu(height)
geom = f'''<a:custGeom>
<a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/>
<a:rect l="l" t="t" r="r" b="b"/>
<a:pathLst><a:path w="{w_emu}" h="{h_emu}">
{path_xml}
</a:path></a:pathLst>
</a:custGeom>'''
fill_op = get_fill_opacity(elem)
stroke_op = get_stroke_opacity(elem)
fill = build_fill_xml(elem, ctx, fill_op)
stroke = build_stroke_xml(elem, stroke_op)
effect = ''
filt_id = get_effective_filter_id(elem, ctx)
if filt_id and filt_id in ctx.defs:
effect = build_shadow_xml(ctx.defs[filt_id])
shape_id = ctx.next_id()
return _wrap_shape(
shape_id, f'Freeform {shape_id}',
px_to_emu(min_x), px_to_emu(min_y), w_emu, h_emu,
geom, fill, stroke, effect, rot=rot
)
def convert_polygon(elem: ET.Element, ctx: ConvertContext) -> str:
"""Convert SVG <polygon> to DrawingML custom geometry shape."""
points_str = elem.get('points', '')
if not points_str:
return ''
# Parse points
nums = re.findall(r'[-+]?(?:\d+\.?\d*|\.\d+)', points_str)
if len(nums) < 4:
return ''
points = []
for i in range(0, len(nums) - 1, 2):
points.append((float(nums[i]), float(nums[i + 1])))
# Build path commands
commands = [PathCommand('M', [points[0][0], points[0][1]])]
for px_, py_ in points[1:]:
commands.append(PathCommand('L', [px_, py_]))
commands.append(PathCommand('Z', []))
path_xml, min_x, min_y, width, height = path_commands_to_drawingml(
commands, ctx.translate_x, ctx.translate_y,
ctx.scale_x, ctx.scale_y
)
if not path_xml:
return ''
w_emu = px_to_emu(width)
h_emu = px_to_emu(height)
geom = f'''<a:custGeom>
<a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/>
<a:rect l="l" t="t" r="r" b="b"/>
<a:pathLst><a:path w="{w_emu}" h="{h_emu}">
{path_xml}
</a:path></a:pathLst>
</a:custGeom>'''
fill_op = get_fill_opacity(elem)
stroke_op = get_stroke_opacity(elem)
fill = build_fill_xml(elem, ctx, fill_op)
stroke = build_stroke_xml(elem, stroke_op)
shape_id = ctx.next_id()
return _wrap_shape(
shape_id, f'Polygon {shape_id}',
px_to_emu(min_x), px_to_emu(min_y), w_emu, h_emu,
geom, fill, stroke
)
def convert_polyline(elem: ET.Element, ctx: ConvertContext) -> str:
"""Convert SVG <polyline> to DrawingML custom geometry shape."""
points_str = elem.get('points', '')
if not points_str:
return ''
nums = re.findall(r'[-+]?(?:\d+\.?\d*|\.\d+)', points_str)
if len(nums) < 4:
return ''
points = []
for i in range(0, len(nums) - 1, 2):
points.append((float(nums[i]), float(nums[i + 1])))
commands = [PathCommand('M', [points[0][0], points[0][1]])]
for px_, py_ in points[1:]:
commands.append(PathCommand('L', [px_, py_]))
# No close for polyline
path_xml, min_x, min_y, width, height = path_commands_to_drawingml(
commands, ctx.translate_x, ctx.translate_y,
ctx.scale_x, ctx.scale_y
)
if not path_xml:
return ''
w_emu = px_to_emu(width)
h_emu = px_to_emu(height)
geom = f'''<a:custGeom>
<a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/>
<a:rect l="l" t="t" r="r" b="b"/>
<a:pathLst><a:path w="{w_emu}" h="{h_emu}">
{path_xml}
</a:path></a:pathLst>
</a:custGeom>'''
fill_op = get_fill_opacity(elem)
stroke_op = get_stroke_opacity(elem)
fill = build_fill_xml(elem, ctx, fill_op)
stroke = build_stroke_xml(elem, stroke_op)
shape_id = ctx.next_id()
return _wrap_shape(
shape_id, f'Polyline {shape_id}',
px_to_emu(min_x), px_to_emu(min_y), w_emu, h_emu,
geom, '<a:noFill/>', stroke
)
def convert_text(elem: ET.Element, ctx: ConvertContext) -> str:
"""Convert SVG <text> to DrawingML text shape."""
# Get text content (including children like tspan)
text_content = ''.join(elem.itertext()).strip()
if not text_content:
return ''
x = ctx_x(_f(elem.get('x')), ctx)
y = ctx_y(_f(elem.get('y')), ctx)
font_size = _f(elem.get('font-size'), 16) * ctx.scale_y
font_weight = elem.get('font-weight', '400')
font_family_str = elem.get('font-family', '')
text_anchor = elem.get('text-anchor', 'start')
fill_color = parse_hex_color(elem.get('fill', '#000000')) or '000000'
opacity = get_fill_opacity(elem)
fonts = parse_font_family(font_family_str)
# Estimate text dimensions (generous to avoid clipping)
text_width = estimate_text_width(text_content, font_size, font_weight) * 1.15
text_height = font_size * 1.5
padding = font_size * 0.1
# Adjust position based on text-anchor
if text_anchor == 'middle':
box_x = x - text_width / 2 - padding
elif text_anchor == 'end':
box_x = x - text_width - padding
else: # start
box_x = x - padding
# y in SVG is baseline, move up by ~80% of font size for top of text
box_y = y - font_size * 0.85
box_w = text_width + padding * 2
box_h = text_height + padding
# DrawingML font size in hundredths of a point
sz = round(font_size * FONT_PX_TO_HUNDREDTHS_PT)
# Bold
b_attr = ' b="1"' if font_weight in ('bold', '600', '700', '800', '900') else ''
# Italic
font_style = elem.get('font-style', '')
i_attr = ' i="1"' if font_style == 'italic' else ''
# Letter spacing: SVG px -> DrawingML hundredths of a point (spc)
spc_attr = ''
letter_spacing = elem.get('letter-spacing')
if letter_spacing:
try:
spc_val = float(letter_spacing) * 100 # px to hundredths of pt (approx)
spc_attr = f' spc="{int(spc_val)}"'
except ValueError:
pass
# Text rotation (transform="rotate(...)" on text element)
text_rot = 0
text_transform = elem.get('transform', '')
if text_transform:
rot_match = re.search(r'rotate\(\s*([-\d.]+)', text_transform)
if rot_match:
text_rot = int(float(rot_match.group(1)) * ANGLE_UNIT)
# Alignment
algn_map = {'start': 'l', 'middle': 'ctr', 'end': 'r'}
algn = algn_map.get(text_anchor, 'l')
# Alpha
alpha_xml = ''
if opacity is not None and opacity < 1.0:
alpha_xml = f'<a:alpha val="{int(opacity * 100000)}"/>'
shape_id = ctx.next_id()
rot_attr = f' rot="{text_rot}"' if text_rot else ''
return f'''<p:sp>
<p:nvSpPr>
<p:cNvPr id="{shape_id}" name="TextBox {shape_id}"/>
<p:cNvSpPr txBox="1"/><p:nvPr/>
</p:nvSpPr>
<p:spPr>
<a:xfrm{rot_attr}><a:off x="{px_to_emu(box_x)}" y="{px_to_emu(box_y)}"/>
<a:ext cx="{px_to_emu(box_w)}" cy="{px_to_emu(box_h)}"/></a:xfrm>
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
<a:noFill/>
<a:ln><a:noFill/></a:ln>
</p:spPr>
<p:txBody>
<a:bodyPr wrap="none" lIns="0" tIns="0" rIns="0" bIns="0" anchor="t" anchorCtr="0">
<a:spAutoFit/>
</a:bodyPr>
<a:lstStyle/>
<a:p>
<a:pPr algn="{algn}"/>
<a:r>
<a:rPr lang="zh-CN" sz="{sz}"{b_attr}{i_attr}{spc_attr} dirty="0">
<a:solidFill><a:srgbClr val="{fill_color}">{alpha_xml}</a:srgbClr></a:solidFill>
<a:latin typeface="{_xml_escape(fonts['latin'])}"/>
<a:ea typeface="{_xml_escape(fonts['ea'])}"/>
<a:cs typeface="{_xml_escape(fonts['latin'])}"/>
</a:rPr>
<a:t>{_xml_escape(text_content)}</a:t>
</a:r>
</a:p>
</p:txBody>
</p:sp>'''
def convert_image(elem: ET.Element, ctx: ConvertContext) -> str:
"""Convert SVG <image> to DrawingML picture element."""
href = elem.get('href') or elem.get(f'{{{XLINK_NS}}}href')
if not href:
return ''
x = ctx_x(_f(elem.get('x')), ctx)
y = ctx_y(_f(elem.get('y')), ctx)
w = ctx_w(_f(elem.get('width')), ctx)
h = ctx_h(_f(elem.get('height')), ctx)
if w <= 0 or h <= 0:
return ''
# Extract base64 data
if href.startswith('data:'):
# data:image/png;base64,iVBOR...
match = re.match(r'data:image/(\w+);base64,(.+)', href, re.DOTALL)
if not match:
return ''
img_format = match.group(1).lower()
if img_format == 'jpeg':
img_format = 'jpg'
img_data = base64.b64decode(match.group(2))
else:
# External file reference - skip in native mode
return ''
# Generate filename and relationship
img_idx = len(ctx.media_files) + 1
img_filename = f's{ctx.slide_num}_img{img_idx}.{img_format}'
ctx.media_files[img_filename] = img_data
r_id = ctx.next_rel_id()
ctx.rel_entries.append({
'id': r_id,
'type': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image',
'target': f'../media/{img_filename}',
})
shape_id = ctx.next_id()
return f'''<p:pic>
<p:nvPicPr>
<p:cNvPr id="{shape_id}" name="Image {shape_id}"/>
<p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr>
<p:nvPr/>
</p:nvPicPr>
<p:blipFill>
<a:blip r:embed="{r_id}"/>
<a:stretch><a:fillRect/></a:stretch>
</p:blipFill>
<p:spPr>
<a:xfrm><a:off x="{px_to_emu(x)}" y="{px_to_emu(y)}"/>
<a:ext cx="{px_to_emu(w)}" cy="{px_to_emu(h)}"/></a:xfrm>
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
</p:spPr>
</p:pic>'''
def convert_ellipse(elem: ET.Element, ctx: ConvertContext) -> str:
"""Convert SVG <ellipse> to DrawingML ellipse shape."""
cx_ = ctx_x(_f(elem.get('cx')), ctx)
cy_ = ctx_y(_f(elem.get('cy')), ctx)
rx = _f(elem.get('rx')) * ctx.scale_x
ry = _f(elem.get('ry')) * ctx.scale_y
if rx <= 0 or ry <= 0:
return ''
x = cx_ - rx
y = cy_ - ry
w = rx * 2
h = ry * 2
fill_op = get_fill_opacity(elem)
stroke_op = get_stroke_opacity(elem)
fill = build_fill_xml(elem, ctx, fill_op)
stroke = build_stroke_xml(elem, stroke_op)
geom = '<a:prstGeom prst="ellipse"><a:avLst/></a:prstGeom>'
shape_id = ctx.next_id()
return _wrap_shape(
shape_id, f'Ellipse {shape_id}',
px_to_emu(x), px_to_emu(y), px_to_emu(w), px_to_emu(h),
geom, fill, stroke
)
# ---------------------------------------------------------------------------
# Group handling
# ---------------------------------------------------------------------------
def parse_transform(transform_str: str) -> Tuple[float, float, float, float]:
"""Parse transform string, extract translate and scale. Returns (dx, dy, sx, sy)."""
if not transform_str:
return 0.0, 0.0, 1.0, 1.0
dx, dy = 0.0, 0.0
sx, sy = 1.0, 1.0
m = re.search(r'translate\(\s*([-\d.]+)[\s,]+([-\d.]+)\s*\)', transform_str)
if m:
dx = float(m.group(1))
dy = float(m.group(2))
m = re.search(r'scale\(\s*([-\d.]+)(?:[\s,]+([-\d.]+))?\s*\)', transform_str)
if m:
sx = float(m.group(1))
sy = float(m.group(2)) if m.group(2) else sx
return dx, dy, sx, sy
def convert_g(elem: ET.Element, ctx: ConvertContext) -> str:
"""Convert SVG <g> by expanding translate and scale into child coordinates."""
transform = elem.get('transform', '')
dx, dy, sx, sy = parse_transform(transform)
# Check for filter on the group
filter_id = resolve_url_id(elem.get('filter', ''))
# Check for fill attribute on the group (used by icons)
group_fill = elem.get('fill')
child_ctx = ctx.child(dx, dy, sx, sy, filter_id)
shapes = []
for child in elem:
# Propagate group fill to children that don't have their own fill
if group_fill and not child.get('fill'):
child.set('fill', group_fill)
shape_xml = convert_element(child, child_ctx)
if shape_xml:
shapes.append(shape_xml)
ctx.sync_from_child(child_ctx)
return '\n'.join(shapes)
# ---------------------------------------------------------------------------
# SVG parsing and main dispatch
# ---------------------------------------------------------------------------
def collect_defs(root: ET.Element) -> Dict[str, ET.Element]:
"""Collect all <defs> children into an {id: element} dictionary."""
defs = {}
for defs_elem in root.iter(f'{{{SVG_NS}}}defs'):
for child in defs_elem:
elem_id = child.get('id')
if elem_id:
defs[elem_id] = child
# Also check for defs without namespace (some SVGs)
for defs_elem in root.iter('defs'):
for child in defs_elem:
elem_id = child.get('id')
if elem_id:
defs[elem_id] = child
return defs
def convert_element(elem: ET.Element, ctx: ConvertContext) -> str:
"""Dispatch SVG element to appropriate converter."""
tag = elem.tag.replace(f'{{{SVG_NS}}}', '')
converters = {
'rect': convert_rect,
'circle': convert_circle,
'ellipse': convert_ellipse,
'line': convert_line,
'path': convert_path,
'polygon': convert_polygon,
'polyline': convert_polyline,
'text': convert_text,
'image': convert_image,
'g': convert_g,
}
converter = converters.get(tag)
if converter:
try:
return converter(elem, ctx)
except Exception as e:
print(f' Warning: Failed to convert <{tag}>: {e}')
return ''
# Skip known non-visual elements silently
if tag in ('defs', 'title', 'desc', 'metadata', 'style'):
return ''
return ''
def convert_svg_to_slide_shapes(
svg_path: Path,
slide_num: int = 1,
verbose: bool = False,
) -> Tuple[str, Dict[str, bytes], List[Dict[str, str]]]:
"""
Convert an SVG file to a complete DrawingML slide XML.
Args:
svg_path: Path to the SVG file
slide_num: Slide number (for naming)
verbose: Print progress info
Returns:
(slide_xml, media_files, rel_entries)
- slide_xml: Complete slide XML string
- media_files: Dict of {filename: bytes} for media to write
- rel_entries: List of relationship entries to add
"""
tree = ET.parse(str(svg_path))
root = tree.getroot()
# Collect defs
defs = collect_defs(root)
# Create context
ctx = ConvertContext(defs=defs, slide_num=slide_num)
# Convert all top-level elements
shapes = []
converted = 0
skipped = 0
for child in root:
tag = child.tag.replace(f'{{{SVG_NS}}}', '')
if tag == 'defs':
continue
result = convert_element(child, ctx)
if result:
shapes.append(result)
converted += 1
else:
if tag not in ('title', 'desc', 'metadata', 'style', 'defs'):
skipped += 1
if verbose:
print(f' Converted {converted} elements, skipped {skipped}')
shapes_xml = '\n'.join(shapes)
# Build complete slide XML
slide_xml = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:spTree>
<p:nvGrpSpPr>
<p:cNvPr id="1" name=""/>
<p:cNvGrpSpPr/><p:nvPr/>
</p:nvGrpSpPr>
<p:grpSpPr>
<a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/>
<a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm>
</p:grpSpPr>
{shapes_xml}
</p:spTree>
</p:cSld>
<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
</p:sld>'''
return slide_xml, ctx.media_files, ctx.rel_entries
scripts/total_md_split.py
#!/usr/bin/env python3
"""
PPT Master - Speaker Notes Splitting Tool
Splits the total.md speaker notes file into multiple individual notes files,
each corresponding to one SVG page.
Usage:
python3 scripts/total_md_split.py <project_path>
python3 scripts/total_md_split.py <project_path> -o output_dir
Examples:
python3 scripts/total_md_split.py projects/<svg_title>_ppt169_YYYYMMDD
python3 scripts/total_md_split.py projects/<svg_title>_ppt169_YYYYMMDD -o notes
Dependencies:
None (only uses standard library)
Notes:
- Checks the one-to-one mapping between SVG files and speaker notes
- Outputs a notice if any SVG file has no corresponding notes
- Split documents do not include the level-1 heading
- Split document names match the SVG filenames with .md extension
"""
import sys
import os
import argparse
import re
from pathlib import Path
from typing import List, Dict, Tuple, Optional
HEADING_RE = re.compile(r'^(#{1,6})\s*(.+?)\s*$')
HR_RE = re.compile(r'^\s*[-*]{3,}\s*$')
def normalize_title(title: str) -> str:
"""Normalize titles for fuzzy matching with SVG stems."""
if not title:
return ''
text = title.strip()
# Replace any non-alnum / non-CJK run with underscore
text = re.sub(r'[^0-9A-Za-z\u4e00-\u9fff]+', '_', text)
text = re.sub(r'_+', '_', text).strip('_')
return text.lower()
def extract_leading_number(text: str) -> Optional[int]:
"""Extract leading slide number if present."""
if not text:
return None
# Try 1: Start with digits (standard)
m = re.match(r'^(\d{1,3})', text.strip())
if m:
return int(m.group(1))
# Try 2: Common prefixes (Slide X, Page X, 第X页)
# Case insensitive for English
text_lower = text.lower().strip()
# Slide/Page X
m = re.match(r'^(?:slide|page|p)\s*[-_:]?\s*(\d{1,3})', text_lower)
if m:
return int(m.group(1))
# 第X页/张
m = re.match(r'^第\s*(\d{1,3})\s*[页张]', text_lower)
if m:
return int(m.group(1))
return None
def build_match_maps(svg_stems: List[str]) -> Tuple[set, Dict[str, List[str]], Dict[int, List[str]]]:
exact = set(svg_stems)
norm_map: Dict[str, List[str]] = {}
num_map: Dict[int, List[str]] = {}
for stem in svg_stems:
norm = normalize_title(stem)
if norm:
norm_map.setdefault(norm, []).append(stem)
num = extract_leading_number(stem)
if num is not None:
num_map.setdefault(num, []).append(stem)
return exact, norm_map, num_map
def match_title(
raw_title: str,
exact: set,
norm_map: Dict[str, List[str]],
num_map: Dict[int, List[str]],
svg_stems: Optional[List[str]] = None,
) -> Optional[str]:
if raw_title in exact:
return raw_title
norm = normalize_title(raw_title)
if norm in norm_map and len(norm_map[norm]) == 1:
return norm_map[norm][0]
num = extract_leading_number(raw_title)
if num is not None and num in num_map and len(num_map[num]) == 1:
return num_map[num][0]
if norm and svg_stems:
candidates = [s for s in svg_stems if norm in normalize_title(s)]
if len(candidates) == 1:
return candidates[0]
return None
def find_svg_files(project_path: Path) -> List[Path]:
"""
Find SVG files in the project
Args:
project_path: Project directory path
Returns:
List of SVG files (sorted by filename)
"""
svg_dir = project_path / 'svg_output'
if not svg_dir.exists():
print(f"Error: {svg_dir} directory does not exist")
return []
return sorted(svg_dir.glob('*.svg'))
def parse_total_md(md_path: Path, svg_stems: Optional[List[str]] = None, verbose: bool = True) -> Dict[str, str]:
"""
Parse total.md file and extract speaker notes content for each level-1 heading
Args:
md_path: Path to total.md file
Returns:
Dictionary where key is the level-1 heading (without #) and value is the notes content
"""
if not md_path.exists():
print(f"Error: {md_path} file does not exist")
return {}
try:
with open(md_path, 'r', encoding='utf-8') as f:
content = f.read()
except Exception as e:
print(f"Error: Unable to read file {md_path}: {e}")
return {}
svg_stems = svg_stems or []
exact, norm_map, num_map = build_match_maps(svg_stems)
# Parse by headings (supports # / ## / ###)
notes: Dict[str, str] = {}
current_key: Optional[str] = None
current_lines: List[str] = []
unmatched_headings: List[str] = []
lines = content.splitlines()
for line in lines:
m = HEADING_RE.match(line)
if m:
raw_title = m.group(2).strip()
matched = match_title(raw_title, exact, norm_map, num_map, svg_stems)
if matched:
if current_key is not None:
text = '\n'.join(current_lines).strip()
if current_key in notes and text:
notes[current_key] = (notes[current_key].rstrip() + "\n\n" + text).strip()
elif current_key not in notes:
notes[current_key] = text
current_key = matched
current_lines = []
continue
unmatched_headings.append(raw_title)
if HR_RE.match(line):
continue
if current_key is not None:
current_lines.append(line)
if current_key is not None:
text = '\n'.join(current_lines).strip()
if current_key in notes and text:
notes[current_key] = (notes[current_key].rstrip() + "\n\n" + text).strip()
elif current_key not in notes:
notes[current_key] = text
if verbose and unmatched_headings:
print("\n[Notice] Found unmatched headings (ignored):")
for t in unmatched_headings[:10]:
print(f" - {t}")
if len(unmatched_headings) > 10:
print(f" ... and {len(unmatched_headings) - 10} more")
return notes
def check_svg_note_mapping(svg_files: List[Path], notes: Dict[str, str]) -> Tuple[bool, List[str]]:
"""
Check the mapping between SVG files and speaker notes
Args:
svg_files: List of SVG files
notes: Notes dictionary (key is heading)
Returns:
(whether all matched, list of missing notes headings)
"""
missing_notes = []
for svg_path in svg_files:
# Extract SVG filename (without extension)
svg_stem = svg_path.stem
# Check if a corresponding heading exists in the notes
if svg_stem not in notes:
missing_notes.append(svg_stem)
return len(missing_notes) == 0, missing_notes
def split_notes(notes: Dict[str, str], output_dir: Path, verbose: bool = True) -> bool:
"""
Split and save notes dictionary into multiple files
Args:
notes: Notes dictionary (key is heading, value is content)
output_dir: Output directory
verbose: Whether to output detailed information
Returns:
Whether successful
"""
if not notes:
print("Error: No notes content found")
return False
output_dir.mkdir(parents=True, exist_ok=True)
success_count = 0
for title, content in notes.items():
# Generate output filename (same name as SVG file, with .md extension)
output_path = output_dir / f"{title}.md"
try:
with open(output_path, 'w', encoding='utf-8') as f:
f.write(content)
if verbose:
print(f" Generated: {output_path.name}")
success_count += 1
except Exception as e:
if verbose:
print(f" Error: Unable to write file {output_path}: {e}")
if verbose:
print(f"\n[Done] Successfully generated {success_count}/{len(notes)} file(s)")
return success_count == len(notes)
def main():
parser = argparse.ArgumentParser(
description='PPT Master - Speaker Notes Splitting Tool',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
Examples:
%(prog)s projects/<svg_title>_ppt169_YYYYMMDD
%(prog)s projects/<svg_title>_ppt169_YYYYMMDD -o notes
%(prog)s projects/<svg_title>_ppt169_YYYYMMDD -q
Features:
- Reads the total.md speaker notes file
- Checks the mapping between SVG files and notes
- Splits notes into multiple individual files
- Output filenames match SVG filenames
'''
)
parser.add_argument('project_path', type=str, help='Project directory path')
parser.add_argument('-o', '--output', type=str, default=None, help='Output directory path (default: notes directory under project)')
parser.add_argument('-q', '--quiet', action='store_true', help='Quiet mode')
args = parser.parse_args()
project_path = Path(args.project_path)
if not project_path.exists():
print(f"Error: Path does not exist: {project_path}")
sys.exit(1)
# Determine output directory
if args.output:
output_dir = Path(args.output)
else:
output_dir = project_path / 'notes'
verbose = not args.quiet
if verbose:
print("PPT Master - Speaker Notes Splitting Tool")
print("=" * 50)
print(f" Project path: {project_path}")
print(f" Output directory: {output_dir}")
print()
# Find SVG files
svg_files = find_svg_files(project_path)
if not svg_files:
print("Error: No SVG files found")
sys.exit(1)
if verbose:
print(f" Found {len(svg_files)} SVG file(s)")
# Parse total.md
total_md_path = project_path / 'notes' / 'total.md'
svg_stems = [p.stem for p in svg_files]
notes = parse_total_md(total_md_path, svg_stems, verbose)
if not notes:
print("Error: No notes content found")
sys.exit(1)
if verbose:
print(f" Found {len(notes)} notes section(s)")
print()
# Check mapping
all_match, missing_notes = check_svg_note_mapping(svg_files, notes)
if not all_match:
print("Error: SVG files and notes do not match")
print(f" Missing notes: {', '.join(missing_notes)}")
print("\nPlease regenerate the notes file to ensure every SVG has corresponding notes.")
sys.exit(1)
if verbose:
print("[OK] SVG files and notes have one-to-one correspondence")
print()
# Split notes
success = split_notes(notes, output_dir, verbose)
if success:
if verbose:
print(f"\n[Done] Notes splitting complete")
sys.exit(0)
else:
print(f"\n[Failed] Notes splitting failed")
sys.exit(1)
if __name__ == '__main__':
main()
scripts/web_to_md.cjs
#!/usr/bin/env node
/**
* web_to_md.cjs - Web Page to Markdown Converter (Enhanced)
*
* Usage:
* node skills/ppt-master/scripts/web_to_md.cjs <url> # Single URL
* node skills/ppt-master/scripts/web_to_md.cjs <url1> <url2> ... # Multiple URLs
* node skills/ppt-master/scripts/web_to_md.cjs -f urls.txt # Read URLs from file
* node skills/ppt-master/scripts/web_to_md.cjs <url> -o output.md # Specify output filename
*/
const fs = require("fs").promises;
const path = require("path");
const https = require("https");
const http = require("http");
// ============ Config ============
const CONFIG = {
outputDir: "./projects",
timeout: 30000,
userAgent:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
};
// ============ HTTP Fetch with encoding detection ============
async function fetchUrl(url) {
return new Promise((resolve, reject) => {
const client = url.startsWith("https") ? https : http;
const options = {
headers: {
"User-Agent": CONFIG.userAgent,
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
},
timeout: CONFIG.timeout,
};
const req = client.get(url, options, (res) => {
// Handle redirects
if (
res.statusCode >= 300 &&
res.statusCode < 400 &&
res.headers.location
) {
const redirectUrl = new URL(res.headers.location, url).href;
return fetchUrl(redirectUrl).then(resolve).catch(reject);
}
if (res.statusCode !== 200) {
reject(new Error("HTTP " + res.statusCode));
return;
}
const chunks = [];
res.on("data", (chunk) => chunks.push(chunk));
res.on("end", () => {
const buffer = Buffer.concat(chunks);
// Detect encoding from Content-Type header or meta tag
let encoding = "utf-8";
const contentType = res.headers["content-type"] || "";
const charsetMatch = contentType.match(/charset=([^;]+)/i);
if (charsetMatch) {
encoding = charsetMatch[1].trim().toLowerCase();
}
// Try UTF-8 first, then check for charset in HTML
let html = buffer.toString("utf-8");
// Check for meta charset in HTML
const metaCharsetMatch = html.match(
/<meta[^>]*charset=["']?([^"'\s>]+)/i
);
if (metaCharsetMatch) {
const declaredEncoding = metaCharsetMatch[1].toLowerCase();
if (
declaredEncoding === "gbk" ||
declaredEncoding === "gb2312" ||
declaredEncoding === "gb18030"
) {
// For Chinese GBK encoding, we'd need iconv-lite
// For now, UTF-8 usually works for modern sites
}
}
resolve(html);
});
});
req.on("error", reject);
req.on("timeout", () => {
req.destroy();
reject(new Error("Request timeout"));
});
});
}
// ============ Enhanced HTML Parser ============
function parseHTML(html) {
// Extract title
const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
let title = titleMatch ? decodeHTMLEntities(titleMatch[1].trim()) : "";
// Clean title - remove site name suffix (e.g. Chinese government portal names)
title = title.replace(/[-_|].*?(政府|门户|网站|委员会).*$/g, "").trim();
// Extract meta tags
const metas = {};
const metaRegex = /<meta[^>]+>/gi;
let metaMatch;
while ((metaMatch = metaRegex.exec(html)) !== null) {
const tag = metaMatch[0];
const nameMatch = tag.match(/(?:name|property)=["']([^"']+)["']/i);
const contentMatch = tag.match(/content=["']([^"']+)["']/i);
if (nameMatch && contentMatch) {
metas[nameMatch[1].toLowerCase()] = decodeHTMLEntities(contentMatch[1]);
}
}
// Enhanced content extraction - try multiple strategies
let content = extractMainContent(html);
return { title, metas, content };
}
// ============ Enhanced Main Content Extraction ============
function extractMainContent(html) {
// Remove unwanted elements first
let cleanHtml = html;
// Remove script, style, nav, header, footer, sidebar, comments
cleanHtml = cleanHtml.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "");
cleanHtml = cleanHtml.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "");
cleanHtml = cleanHtml.replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, "");
cleanHtml = cleanHtml.replace(/<header[^>]*>[\s\S]*?<\/header>/gi, "");
cleanHtml = cleanHtml.replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, "");
cleanHtml = cleanHtml.replace(/<aside[^>]*>[\s\S]*?<\/aside>/gi, "");
cleanHtml = cleanHtml.replace(/<!--[\s\S]*?-->/g, "");
// Common content container patterns for Chinese government websites
const contentPatterns = [
// WeChat Public Account
/<div[^>]*class=["'][^"']*rich_media_content[^"']*["'][^>]*>([\s\S]*?)<\/div>/i,
/<div[^>]*id=["']js_content["'][^>]*>([\s\S]*?)<\/div>/i,
// Hunan province gov sites
/<div[^>]*class=["'][^"']*tys-main-zt-show[^"']*["'][^>]?>([\s\S]*?)<\/div>\s*<\/div>\s*<\/div>/i,
/<div[^>]*class=["'][^"']*tys-main-zt-show[^"']*["'][^>]*>([\s\S]*?)<\/div>/i,
/<div[^>]*class=["'][^"']*tys-main[^"']*["'][^>]*>([\s\S]*?)<\/div>/i,
// Exact class matches for government sites
/<div[^>]*class=["'][^"']*TRS_Editor[^"']*["'][^>]*>([\s\S]*?)<\/div>\s*(?:<\/div>)?/i,
/<div[^>]*class=["'][^"']*TRS_UEDITOR[^"']*["'][^>]*>([\s\S]*?)<\/div>/i,
/<div[^>]*class=["'][^"']*ucontent[^"']*["'][^>]*>([\s\S]*?)<\/div>/i,
/<div[^>]*class=["'][^"']*article-content[^"']*["'][^>]*>([\s\S]*?)<\/div>/i,
/<div[^>]*class=["'][^"']*news-content[^"']*["'][^>]*>([\s\S]*?)<\/div>/i,
/<div[^>]*class=["'][^"']*detail-content[^"']*["'][^>]*>([\s\S]*?)<\/div>/i,
/<div[^>]*class=["'][^"']*content-text[^"']*["'][^>]*>([\s\S]*?)<\/div>/i,
/<div[^>]*class=["'][^"']*pages_content[^"']*["'][^>]*>([\s\S]*?)<\/div>/i,
/<div[^>]*class=["'][^"']*zwgk_content[^"']*["'][^>]*>([\s\S]*?)<\/div>/i,
/<div[^>]*class=["'][^"']*content_detail[^"']*["'][^>]*>([\s\S]*?)<\/div>/i,
/<div[^>]*class=["'][^"']*text_content[^"']*["'][^>]*>([\s\S]*?)<\/div>/i,
/<div[^>]*class=["'][^"']*main-content[^"']*["'][^>]*>([\s\S]*?)<\/div>/i,
/<div[^>]*class=["'][^"']*main_content[^"']*["'][^>]*>([\s\S]*?)<\/div>/i,
/<div[^>]*class=["'][^"']*view-content[^"']*["'][^>]*>([\s\S]*?)<\/div>/i,
/<div[^>]*class=["'][^"']*info-content[^"']*["'][^>]*>([\s\S]*?)<\/div>/i,
/<div[^>]*id=["']?Zoom["']?[^>]*>([\s\S]*?)<\/div>/i,
/<div[^>]*id=["']?content["']?[^>]*>([\s\S]*?)<\/div>/i,
/<div[^>]*id=["']?article["']?[^>]*>([\s\S]*?)<\/div>/i,
// ID-based patterns
/<div[^>]*id=["'][^"']*content[^"']*["'][^>]*>([\s\S]*?)<\/div>/i,
/<div[^>]*id=["'][^"']*article[^"']*["'][^>]*>([\s\S]*?)<\/div>/i,
// Standard semantic elements
/<article[^>]*>([\s\S]*?)<\/article>/i,
/<main[^>]*>([\s\S]*?)<\/main>/i,
// Generic content divs (less specific)
/<div[^>]*class=["'][^"']*content[^"']*["'][^>]*>([\s\S]*?)<\/div>/i,
];
// Try each pattern and find the one with most text content
let bestContent = "";
let bestTextLength = 0;
for (const pattern of contentPatterns) {
// Use a greedy matching approach for nested divs
const matches = findAllMatches(cleanHtml, pattern);
for (const match of matches) {
const textContent = stripTags(match).trim();
// Prefer content with Chinese characters (for gov sites)
const chineseChars = (textContent.match(/[\u4e00-\u9fa5]/g) || []).length;
const score = textContent.length + chineseChars * 2;
if (score > bestTextLength && textContent.length > 200) {
bestContent = match;
bestTextLength = score;
}
}
}
// If still not found, try to find content between common markers
if (!bestContent || bestTextLength < 500) {
// Look for content area by finding dense paragraph areas
const bodyMatch = cleanHtml.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
if (bodyMatch) {
const bodyContent = bodyMatch[1];
// Find the area with most <p> tags
const areas = bodyContent.split(/<div[^>]*>/i);
for (const area of areas) {
const pCount = (area.match(/<p[^>]*>/gi) || []).length;
const textLen = stripTags(area).length;
if (pCount >= 2 && textLen > bestTextLength) {
bestContent = area;
bestTextLength = textLen;
}
}
}
}
// Fallback to body
if (!bestContent) {
const bodyMatch = cleanHtml.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
bestContent = bodyMatch ? bodyMatch[1] : cleanHtml;
}
return bestContent;
}
// Helper function to find all regex matches
function findAllMatches(html, pattern) {
const results = [];
const globalPattern = new RegExp(pattern.source, "gi");
let match;
while ((match = globalPattern.exec(html)) !== null) {
if (match[1]) {
results.push(match[1]);
}
}
return results;
}
// Helper function to strip HTML tags for text length calculation
function stripTags(html) {
return html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ");
}
// ============ HTML Entity Decoder ============
function decodeHTMLEntities(str) {
const entities = {
"&": "&",
"<": "<",
">": ">",
""": '"',
"'": "'",
" ": " ",
"—": "\u2014",
"–": "\u2013",
"©": "\u00A9",
"®": "\u00AE",
"™": "\u2122",
"…": "\u2026",
"“": "\u201C",
"”": "\u201D",
"‘": "\u2018",
"’": "\u2019",
" ": " ",
" ": " ",
"·": "\u00B7",
"•": "\u2022",
"×": "\u00D7",
"÷": "\u00F7",
"±": "\u00B1",
"«": "\u00AB",
"»": "\u00BB",
};
// Handle numeric entities
str = str.replace(/&#(\d+);/g, function (match, dec) {
return String.fromCharCode(dec);
});
str = str.replace(/&#x([0-9a-fA-F]+);/g, function (match, hex) {
return String.fromCharCode(parseInt(hex, 16));
});
// Handle named entities
return str.replace(/&[a-zA-Z]+;/g, function (match) {
return entities[match] || match;
});
}
// ============ Enhanced HTML to Markdown Converter ============
function htmlToMarkdown(html) {
let md = html;
// First pass: remove unwanted elements
md = md.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "");
md = md.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "");
md = md.replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, "");
md = md.replace(/<header[^>]*>[\s\S]*?<\/header>/gi, "");
md = md.replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, "");
md = md.replace(/<!--[\s\S]*?-->/g, "");
// Convert headings
md = md.replace(/<h1[^>]*>([\s\S]*?)<\/h1>/gi, "\n# $1\n");
md = md.replace(/<h2[^>]*>([\s\S]*?)<\/h2>/gi, "\n## $1\n");
md = md.replace(/<h3[^>]*>([\s\S]*?)<\/h3>/gi, "\n### $1\n");
md = md.replace(/<h4[^>]*>([\s\S]*?)<\/h4>/gi, "\n#### $1\n");
md = md.replace(/<h5[^>]*>([\s\S]*?)<\/h5>/gi, "\n##### $1\n");
md = md.replace(/<h6[^>]*>([\s\S]*?)<\/h6>/gi, "\n###### $1\n");
// Convert paragraphs
md = md.replace(/<p[^>]*>([\s\S]*?)<\/p>/gi, "\n$1\n");
md = md.replace(/<div[^>]*>([\s\S]*?)<\/div>/gi, "\n$1\n");
md = md.replace(/<br\s*\/?>/gi, "\n");
md = md.replace(/<hr\s*\/?>/gi, "\n---\n");
// Convert text formatting
md = md.replace(/<(strong|b)[^>]*>([\s\S]*?)<\/\1>/gi, "**$2**");
md = md.replace(/<(em|i)[^>]*>([\s\S]*?)<\/\1>/gi, "*$2*");
md = md.replace(/<u[^>]*>([\s\S]*?)<\/u>/gi, "$1");
md = md.replace(/<del[^>]*>([\s\S]*?)<\/del>/gi, "~~$1~~");
md = md.replace(/<s[^>]*>([\s\S]*?)<\/s>/gi, "~~$1~~");
md = md.replace(/<span[^>]*>([\s\S]*?)<\/span>/gi, "$1");
// Convert links - handle nested content properly
md = md.replace(
/<a[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi,
function (match, url, text) {
const cleanText = text.replace(/<[^>]+>/g, "").trim();
if (!cleanText || url.startsWith("javascript:")) {
return cleanText;
}
return "[" + cleanText + "](" + url + ")";
}
);
// Convert images
md = md.replace(
/<img[^>]*src=["']([^"']+)["'][^>]*alt=["']([^"']*)["'][^>]*\/?>/gi,
""
);
md = md.replace(
/<img[^>]*alt=["']([^"']*)["'][^>]*src=["']([^"']+)["'][^>]*\/?>/gi,
""
);
md = md.replace(/<img[^>]*src=["']([^"']+)["'][^>]*\/?>/gi, "");
// Convert lists
md = md.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, function (match, content) {
const cleanContent = content.replace(/<[^>]+>/g, "").trim();
return "- " + cleanContent + "\n";
});
md = md.replace(/<\/?[uo]l[^>]*>/gi, "\n");
// Convert code
md = md.replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, "`$1`");
md = md.replace(/<pre[^>]*>([\s\S]*?)<\/pre>/gi, "\n```\n$1\n```\n");
// Convert blockquotes
md = md.replace(
/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi,
function (match, content) {
const lines = content.replace(/<[^>]+>/g, "").split("\n");
return (
"\n" +
lines
.map(function (line) {
return "> " + line.trim();
})
.filter(function (l) {
return l.trim() !== ">";
})
.join("\n") +
"\n"
);
}
);
// Convert tables
md = md.replace(
/<table[^>]*>([\s\S]*?)<\/table>/gi,
function (match, content) {
let result = "\n";
const rows = content.match(/<tr[^>]*>[\s\S]*?<\/tr>/gi) || [];
rows.forEach(function (row, index) {
const cells = row.match(/<t[hd][^>]*>([\s\S]*?)<\/t[hd]>/gi) || [];
const cellContents = cells.map(function (cell) {
const cellMatch = cell.match(/<t[hd][^>]*>([\s\S]*?)<\/t[hd]>/i);
return cellMatch ? cellMatch[1].replace(/<[^>]+>/g, "").trim() : "";
});
if (cellContents.length > 0) {
result += "| " + cellContents.join(" | ") + " |\n";
if (index === 0) {
result +=
"| " +
cellContents
.map(function () {
return "---";
})
.join(" | ") +
" |\n";
}
}
});
return result;
}
);
// Clean remaining HTML tags
md = md.replace(/<[^>]+>/g, "");
// Decode HTML entities
md = decodeHTMLEntities(md);
// Clean up whitespace
md = md.replace(/\r\n/g, "\n");
md = md.replace(/\n{3,}/g, "\n\n");
md = md.replace(/[ \t]+$/gm, "");
md = md.replace(/^[ \t]+/gm, "");
// Remove lines that are just whitespace or single characters
md = md
.split("\n")
.filter(function (line) {
const trimmed = line.trim();
return trimmed.length > 1 || trimmed === "" || /^[-#>*|]/.test(trimmed);
})
.join("\n");
md = md.trim();
return md;
}
// ============ Extract Metadata ============
function extractMetadata(parsed, url) {
const title = parsed.title;
const metas = parsed.metas;
// Try to extract date from multiple sources
let date =
metas["article:published_time"] ||
metas["og:published_time"] ||
metas["pubdate"] ||
metas["publishdate"] ||
metas["date"] ||
"";
// Try to extract date from HTML content
if (!date) {
const content = parsed.content;
// Common date patterns in Chinese gov sites
const datePatterns = [
/发布[时日]间[::]\s*(\d{4}[-\/年]\d{1,2}[-\/月]\d{1,2}[日]?)/,
/日期[::]\s*(\d{4}[-\/年]\d{1,2}[-\/月]\d{1,2}[日]?)/,
/(\d{4}[-\/年]\d{1,2}[-\/月]\d{1,2}[日]?)\s*(?:发布|来源)/,
/时间[::]\s*(\d{4}[-\/]\d{1,2}[-\/]\d{1,2})/,
];
for (const pattern of datePatterns) {
const match = content.match(pattern);
if (match) {
date = match[1].replace(/[年月]/g, "-").replace(/[日]/g, "");
break;
}
}
}
// Try to extract date from URL
if (!date) {
const urlDateMatch = url.match(/(\d{4})(\d{2})[\/_](?:t\d+_)?/i);
if (urlDateMatch) {
date = urlDateMatch[1] + "-" + urlDateMatch[2];
} else {
const urlDateMatch2 = url.match(/(\d{4})[-\/](\d{2})[-\/](\d{2})/);
if (urlDateMatch2) {
date =
urlDateMatch2[1] + "-" + urlDateMatch2[2] + "-" + urlDateMatch2[3];
}
}
}
const description =
metas["description"] ||
metas["og:description"] ||
metas["twitter:description"] ||
"";
const author = metas["author"] || metas["article:author"] || "";
const keywords = metas["keywords"] || "";
// Extract source/publisher from content
let source = "";
const sourcePatterns = [
/来源[::]\s*([^\s<]+)/,
/发布(?:单位|机构)[::]\s*([^\s<]+)/,
];
for (const pattern of sourcePatterns) {
const match = parsed.content.match(pattern);
if (match) {
source = match[1];
break;
}
}
return {
title: title || metas["og:title"] || "",
date: date,
description: description,
author: author || source,
keywords: keywords,
sourceUrl: url,
};
}
// ============ Sanitize Filename ============
function sanitizeFilename(name) {
return name
.replace(/\s+/g, "_")
.replace(/[^\u4e00-\u9fa5a-zA-Z0-9_]/g, "")
.replace(/_+/g, "_")
.substring(0, 80);
}
// ============ Image Downloader & Processor ============
function downloadImage(url, savePath) {
return new Promise((resolve, reject) => {
const client = url.startsWith("https") ? https : http;
const req = client.get(
url,
{
headers: {
"User-Agent": CONFIG.userAgent,
Accept: "image/*,*/*",
},
timeout: CONFIG.timeout,
},
(res) => {
if (res.statusCode !== 200) {
res.resume();
return reject(new Error("Status " + res.statusCode));
}
const fileStream = require("fs").createWriteStream(savePath);
res.pipe(fileStream);
fileStream.on("finish", () => {
fileStream.close();
resolve();
});
fileStream.on("error", (err) => {
reject(err);
});
}
);
req.on("error", (err) => reject(err));
req.on("timeout", () => {
req.destroy();
reject(new Error("Timeout"));
});
});
}
function buildImageFilename(urlStr, index) {
try {
const u = new URL(urlStr);
const basename = path.basename(u.pathname);
let ext = path.extname(basename);
let name = path.basename(basename, ext);
name = sanitizeFilename(name);
if (!name) name = "image_" + index;
// Basic extension check
if (!ext || ext.length > 5 || !/^\.[a-z0-9]+$/i.test(ext)) ext = ".jpg";
return name + ext;
} catch (e) {
return "image_" + index + ".jpg";
}
}
async function processImages(html, pageUrl, imageDir, relPrefix) {
const imgRegex = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
const matches = [...html.matchAll(imgRegex)];
if (matches.length === 0) return html;
console.log(" [Processing] Found " + matches.length + " images...");
// Ensure image dir exists
await fs.mkdir(imageDir, { recursive: true });
const urlMap = new Map();
const uniqueUrls = new Set();
for (const m of matches) {
if (m[1]) uniqueUrls.add(m[1]);
}
let idx = 0;
for (const src of uniqueUrls) {
if (src.startsWith("data:") || src.startsWith("#")) continue;
try {
// Resolve absolute URL
const absUrl = new URL(src, pageUrl).href;
// Generate filename
const filename = buildImageFilename(absUrl, idx++);
// Avoid overwriting existing files with same name but different content?
// For now, simpler collision avoidance
let saveName = filename;
let counter = 1;
// Note: This collision check is basic.
while (true) {
try {
await fs.access(path.join(imageDir, saveName));
const parsed = path.parse(filename);
saveName = parsed.name + "_" + counter++ + parsed.ext;
} catch {
break; // file does not exist
}
}
const savePath = path.join(imageDir, saveName);
await downloadImage(absUrl, savePath);
// Relative path for Markdown
const relPath = relPrefix
? path.join(relPrefix, saveName)
: saveName;
// Force forward slashes for Markdown compatibility
urlMap.set(src, relPath.replace(/\\/g, "/"));
} catch (e) {
console.log(" [WARN] Image fail " + src + ": " + e.message);
}
}
// Replace src in HTML
return html.replace(imgRegex, (match, src) => {
if (urlMap.has(src)) {
// Replace only the src part of the match
// Simplest robust way is to replace the exact src string within the match
// but we must be careful not to replace other attributes' values if identical.
// A safer way is to reconstruct, but regex is tricky.
// Current approach: replace the FIRST occurrence of src in the match?
// No, src is what we extracted.
// Let's replace `src="URL"` with `src="NEW_URL"`?
// Match structure is roughly `<img ... src="URL" ...>`
// We can look for `src=["']URL["']` inside `match`.
// Create a regex for this specific src in this tag
// Escape special regex chars in src
const escapedSrc = src.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const srcReplaceRegex = new RegExp(`(src=["'])(${escapedSrc})(["'])`, "i");
return match.replace(srcReplaceRegex, `$1${urlMap.get(src)}$3`);
}
return match;
});
}
// ============ Process Single URL ============
// ============ Process Single URL ============
async function processUrl(url, outputPath) {
console.log("\n[Fetching] " + url);
try {
const html = await fetchUrl(url);
console.log(" [OK] Fetched (" + (html.length / 1024).toFixed(1) + " KB)");
const parsed = parseHTML(html);
const metadata = extractMetadata(parsed, url);
console.log(" [OK] Title: " + (metadata.title || "(not found)"));
if (metadata.date) console.log(" [OK] Date: " + metadata.date);
// Determine output path EARLY so we can determine image path
if (!outputPath) {
await fs.mkdir(CONFIG.outputDir, { recursive: true });
const filename = sanitizeFilename(metadata.title || "untitled") + ".md";
outputPath = path.join(CONFIG.outputDir, filename);
}
// Set up image directory
const outputDir = path.dirname(outputPath);
await fs.mkdir(outputDir, { recursive: true });
const baseName = path.basename(outputPath, path.extname(outputPath));
const imageDirName = baseName + "_files"; // Using string concatenation
const imageDir = path.join(outputDir, imageDirName);
const relImagePrefix = imageDirName;
// Process Images
let contentToConvert = parsed.content;
try {
contentToConvert = await processImages(parsed.content, url, imageDir, relImagePrefix);
} catch (e) {
console.warn(" [WARN] Image processing failed, continuing: " + e.message);
}
const markdown = htmlToMarkdown(contentToConvert);
console.log(" [OK] Content: " + markdown.length + " chars");
// Generate output
let output = "";
output += "<!-- \n";
output += " Source: " + url + "\n";
output += " Crawled: " + new Date().toISOString() + "\n";
if (metadata.date) output += " Published: " + metadata.date + "\n";
if (metadata.author) output += " Author: " + metadata.author + "\n";
output += "-->\n\n";
if (metadata.title) {
output += "# " + metadata.title + "\n\n";
}
if (metadata.description) {
output += "> " + metadata.description + "\n\n";
}
output += markdown;
await fs.writeFile(outputPath, output, "utf-8");
console.log(" [OK] Saved: " + outputPath);
return {
success: true,
url: url,
outputPath: outputPath,
metadata: metadata,
};
} catch (error) {
console.error(" [ERROR] " + error.message);
return { success: false, url: url, error: error.message };
}
}
// ============ Main Function ============
async function main() {
const args = process.argv.slice(2);
if (args.length === 0) {
console.log("");
console.log("=".repeat(60));
console.log(" Web to Markdown - Enhanced Web Page Crawler & Converter");
console.log("=".repeat(60));
console.log("");
console.log(" Usage:");
console.log(" node skills/ppt-master/scripts/web_to_md.cjs <url>");
console.log(" node skills/ppt-master/scripts/web_to_md.cjs <url1> <url2> ...");
console.log(" node skills/ppt-master/scripts/web_to_md.cjs -f urls.txt");
console.log(" node skills/ppt-master/scripts/web_to_md.cjs <url> -o output.md");
console.log("");
console.log(" Options:");
console.log(" -f, --file <file> Read URLs from file (one per line)");
console.log(
" -o, --output <file> Specify output filename (single URL only)"
);
console.log(
" -d, --dir <dir> Specify output directory (default: ./output)"
);
console.log(" -h, --help Show help");
console.log("");
console.log("=".repeat(60));
process.exit(0);
}
let urls = [];
let outputFile = null;
// Parse arguments
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "-h" || arg === "--help") {
args.length = 0;
await main();
return;
}
if (arg === "-f" || arg === "--file") {
const filePath = args[++i];
if (!filePath) {
console.error("Error: -f requires a file path");
process.exit(1);
}
const content = await fs.readFile(filePath, "utf-8");
const lines = content
.split("\n")
.map(function (l) {
return l.trim();
})
.filter(function (l) {
return l && !l.startsWith("#");
});
urls = urls.concat(lines);
continue;
}
if (arg === "-o" || arg === "--output") {
outputFile = args[++i];
continue;
}
if (arg === "-d" || arg === "--dir") {
CONFIG.outputDir = args[++i];
continue;
}
if (arg.startsWith("http://") || arg.startsWith("https://")) {
urls.push(arg);
}
}
if (urls.length === 0) {
console.error("Error: No valid URLs provided");
process.exit(1);
}
console.log("\n[Start] Processing " + urls.length + " URL(s)...");
const results = [];
for (let i = 0; i < urls.length; i++) {
const result = await processUrl(
urls[i],
urls.length === 1 ? outputFile : null
);
results.push(result);
}
// Summary
const success = results.filter(function (r) {
return r.success;
}).length;
const failed = results.filter(function (r) {
return !r.success;
}).length;
console.log("\n" + "=".repeat(50));
console.log(
"[Done] Success: " + success + "/" + urls.length + ", Failed: " + failed
);
if (failed > 0) {
console.log("\n[Failed URLs]:");
results
.filter(function (r) {
return !r.success;
})
.forEach(function (r) {
console.log(" - " + r.url + ": " + r.error);
});
}
}
main().catch(console.error);
scripts/web_to_md.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
web_to_md.py - Web Page to Markdown Converter (Python Version)
Usage:
python scripts/web_to_md.py <url>
python scripts/web_to_md.py <url1> <url2> ...
python scripts/web_to_md.py -f urls.txt
python scripts/web_to_md.py <url> -o output.md
Dependencies:
pip install requests beautifulsoup4
Note:
Some websites (e.g., WeChat mp.weixin.qq.com) block Python's 'requests' library
based on TLS fingerprints (JA3). If you encounter 403 errors or connection issues
with such sites, please use the Node.js version (scripts/web_to_md.cjs) or
modify this script to use 'curl_cffi'.
"""
import argparse
import sys
import os
import re
import time
import datetime
from urllib.parse import urljoin, urlparse
try:
import requests
from bs4 import BeautifulSoup, NavigableString, Tag
except ImportError:
print("Error: This script requires 'requests' and 'beautifulsoup4'.")
print("Please run: pip install requests beautifulsoup4")
sys.exit(1)
try:
from PIL import Image
import io
PILLOW_AVAILABLE = True
except ImportError:
PILLOW_AVAILABLE = False
print("[WARN] Pillow not installed. WebP images will not be converted to PNG.")
print(" Run: pip install Pillow")
# ============ Config ============
CONFIG = {
"output_dir": "./projects",
"timeout": 30,
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
# Specific content identifiers often found in Chinese CMS (Gov/News)
"content_selectors": [
{"class_": re.compile(r"tys-main-zt-show", re.I)},
{"class_": re.compile(r"tys-main", re.I)},
{"class_": "TRS_Editor"},
{"class_": "TRS_UEDITOR"},
{"class_": "ucontent"},
{"class_": "article-content"},
{"class_": "news-content"},
{"class_": "detail-content"},
{"class_": "content-text"},
{"class_": "pages_content"},
{"class_": "zwgk_content"},
{"class_": "content_detail"},
{"class_": "text_content"},
{"class_": "main-content"},
{"class_": "main_content"},
{"class_": "view-content"},
{"class_": "info-content"},
{"id": "Zoom"},
{"id": "content"},
{"id": "article"},
{"class_": "content"},
{"name": "article"}, # tag name
{"name": "main"}, # tag name
]
}
def fetch_url(url):
"""
Fetches the URL handling headers, timeout and encoding detection.
"""
headers = {
"User-Agent": CONFIG["user_agent"],
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8"
}
try:
response = requests.get(url, headers=headers,
timeout=CONFIG["timeout"], verify=False)
response.raise_for_status()
# Enhanced encoding detection (requests handles this well usually, but we force apparent_encoding for Chinese)
response.encoding = response.apparent_encoding
return response.text
except Exception as e:
raise Exception(f"Failed to fetch {url}: {str(e)}")
def clean_title(title):
"""Removes common suffixes from titles."""
if not title:
return ""
# Remove site name suffixes often found in Chinese titles
clean = re.sub(r"[-_|].*?(政府|门户|网站|委员会).*$", "", title)
return clean.strip()
def sanitize_filename(name):
"""Sanitizes the filename for file system."""
# Replace whitespace with underscore first
clean = re.sub(r'\s+', '_', name)
# Remove all except Chinese, English, Numbers, Underscore
clean = re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9_]', '', clean)
# Collapse repeating underscores
clean = re.sub(r'_+', '_', clean)
return clean[:80] # Truncate
def derive_base_name(title, url):
"""Derive a safe, non-empty basename from title or URL."""
base = sanitize_filename(title or "")
if base:
return base
parsed = urlparse(url)
path = parsed.path.strip('/')
if path:
candidate = f"{parsed.netloc}_{path}"
else:
candidate = parsed.netloc or "untitled"
base = sanitize_filename(candidate)
if base:
return base
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
return f"untitled_{ts}"
def build_image_filename(abs_url, seq, content_type=None):
"""Builds a safe image filename using URL and optional content-type hint."""
parsed = urlparse(abs_url)
basename = os.path.basename(parsed.path).split('?')[0]
stem, ext = os.path.splitext(basename)
if not ext or len(ext) > 5 or '/' in ext:
ext = ""
if not ext and content_type:
ctype = content_type.split(';')[0].lower()
ext_map = {
"image/jpeg": ".jpg",
"image/jpg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
}
ext = ext_map.get(ctype, "")
if not ext:
ext = ".jpg"
stem = sanitize_filename(stem) if stem else f"image_{seq}"
return f"{stem}{ext}"
def download_and_rewrite_images(content_element, page_url, image_dir, rel_prefix):
"""Download images under content_element and rewrite src to local relative paths."""
if content_element is None:
return 0
images = list(content_element.find_all("img"))
if not images:
return 0
os.makedirs(image_dir, exist_ok=True)
downloaded = {}
saved = 0
for idx, img in enumerate(images):
src = img.get("src")
if not src or src.startswith("data:"):
continue
abs_url = urljoin(page_url, src)
if abs_url in downloaded:
saved_name = downloaded[abs_url]
else:
try:
resp = requests.get(
abs_url,
headers={"User-Agent": CONFIG["user_agent"]},
timeout=CONFIG["timeout"],
verify=False,
)
resp.raise_for_status()
filename = build_image_filename(
abs_url, idx, resp.headers.get("Content-Type"))
# Check if image is webp and convert to png
stem, ext = os.path.splitext(filename)
content_type = resp.headers.get("Content-Type", "").lower()
is_webp = ext.lower() == ".webp" or "webp" in content_type
if is_webp and PILLOW_AVAILABLE:
# Convert webp to png (optimized)
try:
img_data = io.BytesIO(resp.content)
pil_image = Image.open(img_data)
# Update filename to .png
filename = f"{stem}.png"
local_path = os.path.join(image_dir, filename)
# Avoid accidental overwrites if filenames collide
counter = 1
while os.path.exists(local_path):
local_path = os.path.join(
image_dir, f"{stem}_{counter}.png")
filename = os.path.basename(local_path)
counter += 1
# Save as PNG directly (Pillow auto-converts, no need for explicit mode conversion)
pil_image.save(local_path, 'PNG', optimize=False)
pil_image.close()
print(f" [INFO] Converted webp to png: {filename}")
except Exception as convert_err:
print(
f" [WARN] Failed to convert webp: {convert_err}, saving as-is")
local_path = os.path.join(image_dir, filename)
counter = 1
stem, ext = os.path.splitext(filename)
while os.path.exists(local_path):
local_path = os.path.join(
image_dir, f"{stem}_{counter}{ext}")
filename = os.path.basename(local_path)
counter += 1
with open(local_path, "wb") as f:
f.write(resp.content)
else:
local_path = os.path.join(image_dir, filename)
# Avoid accidental overwrites if filenames collide
counter = 1
stem, ext = os.path.splitext(filename)
while os.path.exists(local_path):
local_path = os.path.join(
image_dir, f"{stem}_{counter}{ext}")
filename = os.path.basename(local_path)
counter += 1
with open(local_path, "wb") as f:
f.write(resp.content)
downloaded[abs_url] = filename
saved_name = filename
saved += 1
except Exception as e:
print(f" [WARN] Skip image {abs_url}: {e}")
continue
rel_path = os.path.join(
rel_prefix, saved_name) if rel_prefix else saved_name
img["src"] = rel_path
return saved
def extract_metadata(soup, url):
"""Extracts metadata like title, date, author from Soup."""
# 1. Title
title_tag = soup.title
title = clean_title(title_tag.string if title_tag else "")
# 2. Meta tags
metas = {}
for meta in soup.find_all("meta"):
name = meta.get("name") or meta.get("property")
content = meta.get("content")
if name and content:
metas[name.lower()] = content.strip()
# 3. Date Extraction Strategies
date = (
metas.get("article:published_time") or
metas.get("og:published_time") or
metas.get("pubdate") or
metas.get("publishdate") or
metas.get("date")
)
if not date:
# Try matching date patterns in the text
text_content = soup.get_text()
date_patterns = [
r"发布[时日]间[::]\s*(\d{4}[-\/年]\d{1,2}[-\/月]\d{1,2}[日]?)",
r"日期[::]\s*(\d{4}[-\/年]\d{1,2}[-\/月]\d{1,2}[日]?)",
r"(\d{4}[-\/年]\d{1,2}[-\/月]\d{1,2}[日]?)\s*(?:发布|来源)",
r"时间[::]\s*(\d{4}[-\/]\d{1,2}[-\/]\d{1,2})"
]
for pattern in date_patterns:
match = re.search(pattern, text_content)
if match:
date = match.group(1).replace(
"年", "-").replace("月", "-").replace("日", "")
break
if not date:
# Try URL matching
match = re.search(r"(\d{4})(\d{2})[\/_](?:t\d+_)?", url)
if match:
date = f"{match.group(1)}-{match.group(2)}"
else:
match = re.search(r"(\d{4})[-\/](\d{2})[-\/](\d{2})", url)
if match:
date = f"{match.group(1)}-{match.group(2)}-{match.group(3)}"
# 4. Description
description = (
metas.get("description") or
metas.get("og:description") or
metas.get("twitter:description") or
""
)
# 5. Author/Source
author = metas.get("author") or metas.get("article:author")
if not author:
# Try common patterns
source_patterns = [
r"来源[::]\s*([^\s<]+)",
r"发布(?:单位|机构)[::]\s*([^\s<]+)"
]
for pattern in source_patterns:
match = re.search(pattern, soup.get_text())
if match:
author = match.group(1)
break
return {
"title": title or metas.get("og:title") or "Untitled",
"date": date or "",
"description": description,
"author": author or "",
"source_url": url
}
def find_main_content(soup):
"""
Tries to find the main content area using reliable heuristics for Chinese gov/news sites.
"""
# 1. Clean up first (remove known clutter)
for tag in soup(["script", "style", "nav", "header", "footer", "aside", "noscript", "iframe"]):
tag.decompose()
best_element = None
max_score = 0
# 2. Strategy A: Check specific classes/ids
for selector in CONFIG["content_selectors"]:
if "name" in selector:
# Tag name match (article, main)
elements = soup.find_all(selector["name"])
else:
# Class or ID match
elements = soup.find_all(attrs=selector)
for el in elements:
# Score based on text length and chinese character count
text = el.get_text(strip=True)
length = len(text)
if length < 100:
continue
chinese_count = len(re.findall(r'[\u4e00-\u9fa5]', text))
score = length + (chinese_count * 2)
if score > max_score:
max_score = score
best_element = el
# 3. Strategy B: If no specific container found, look for dense text areas with paragraphs
if not best_element or max_score < 200:
for div in soup.find_all("div"):
p_count = len(div.find_all("p", recursive=False))
# recursive=False ensures we don't just pick the top-level body by accident
# but sometimes content is nested deep
if p_count == 0:
# Check if it has lots of text even without p tags (br tags?)
pass
text = div.get_text(strip=True)
if len(text) > 200 and p_count >= 1:
# Recalculate deep score
chinese_count = len(re.findall(r'[\u4e00-\u9fa5]', text))
score = len(text) + (chinese_count * 2) + (p_count * 50)
if score > max_score:
max_score = score
best_element = div
# Fallback to body
return best_element if best_element else soup.body
def element_to_markdown(element):
"""
Recursive function to convert a BS4 element to Markdown.
"""
if element is None:
return ""
if isinstance(element, NavigableString):
text = str(element).strip()
return text if text else ""
tag_name = element.name.lower()
# Skip hidden/unwanted tags
if tag_name in ['script', 'style', 'meta', 'link', 'input', 'button', 'select']:
return ""
content = ""
for child in element.children:
content += element_to_markdown(child)
# Add spacing logic here if needed, but usually block elements handle it
# Block handlers
if tag_name in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']:
level = int(tag_name[1])
return f"\n{'#' * level} {content}\n\n"
elif tag_name == 'p':
# Clean up internal whitespace
content = re.sub(r'\s+', ' ', content).strip()
return f"\n{content}\n\n" if content else ""
elif tag_name == 'br':
return " \n"
elif tag_name == 'hr':
return "\n---\n"
elif tag_name == 'div':
return f"\n{content}\n"
elif tag_name == 'blockquote':
lines = content.strip().split('\n')
quoted = '\n'.join([f"> {line}" for line in lines if line.strip()])
return f"\n{quoted}\n\n"
elif tag_name in ['ul', 'ol']:
# This is tricky without "state" (knowing we are in a list)
# For simplicity in this recursive version, we rely on LI handling
return f"\n{content}\n"
elif tag_name == 'li':
# Simple list handling
clean_content = content.strip()
return f"- {clean_content}\n"
elif tag_name == 'pre':
return f"\n```\n{content}\n```\n\n"
elif tag_name == 'code':
# If parent is pre, handle in pre. If inline:
parent = element.parent
if parent and parent.name == 'pre':
return content
return f"`{content}`"
elif tag_name == 'a':
href = element.get('href', '')
if href and not href.startswith('javascript:'):
return f"[{content}]({href})"
return content
elif tag_name == 'img':
src = element.get('src', '')
alt = element.get('alt', '')
if src:
return f""
return ""
elif tag_name == 'table':
# Basic table text extraction, full markdown table support is complex
# Leaving as raw text or simplistic conversion for now
# Ideally, we'd parse TRs and TDs
return f"\n{content}\n"
elif tag_name == 'tr':
return f"{content}|\n"
elif tag_name in ['td', 'th']:
return f"| {content.strip()} "
# Style formatting
elif tag_name in ['strong', 'b']:
return f"**{content}**"
elif tag_name in ['em', 'i']:
return f"*{content}*"
elif tag_name in ['del', 's', 'strike']:
return f"~~{content}~~"
# Default for span, section, etc.
return f"{content} "
def simple_html_to_markdown_traversal(soup):
"""
A more robust HTML to Markdown converter using BS4 traversal.
We handle block elements by adding newlines.
"""
lines = []
def traverse(node):
if isinstance(node, NavigableString):
text = str(node)
# Normalize whitespace but keep single spaces
text = re.sub(r'\s+', ' ', text)
if text.strip():
return text
return ""
if node.name in ['script', 'style', 'comment', 'meta', 'link']:
return ""
# Handle Block Elements
is_block = node.name in ['p', 'div', 'h1', 'h2', 'h3', 'h4',
'h5', 'h6', 'li', 'blockquote', 'pre', 'hr', 'table', 'tr']
# Pre-processing
prefix = ""
suffix = ""
if node.name in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']:
level = int(node.name[1])
prefix = f"\n\n{'#' * level} "
suffix = "\n\n"
elif node.name == 'p':
prefix = "\n\n"
suffix = "\n\n"
elif node.name == 'li':
prefix = "\n- "
elif node.name == 'blockquote':
prefix = "\n> "
suffix = "\n"
elif node.name == 'hr':
return "\n\n---\n\n"
elif node.name == 'br':
return " \n"
elif node.name == 'pre':
# Extract raw text from pre to preserve formatting
return f"\n\n```\n{node.get_text()}\n```\n\n"
# Inline formatting
if node.name in ['strong', 'b']:
prefix, suffix = "**", "**"
elif node.name in ['em', 'i']:
prefix, suffix = "*", "*"
elif node.name == 'code' and node.parent.name != 'pre':
prefix, suffix = "`", "`"
elif node.name == 'a':
href = node.get('href')
if href and not href.startswith('javascript:'):
prefix = "["
suffix = f"]({href})"
else:
prefix, suffix = "", ""
elif node.name == 'img':
src = node.get('src')
alt = node.get('alt', '')
if src:
return f""
return ""
# Recurse
inner_text = ""
for child in node.children:
res = traverse(child)
if res:
inner_text += res
# Post-processing for tables (simplified)
if node.name == 'tr':
# count tds
cells = [c.get_text(strip=True) for c in node.find_all(
['td', 'th'], recursive=False)]
return f"| {' | '.join(cells)} |\n"
if node.name == 'table':
# Try to add a separator line after first row if it looks like a header
rows = inner_text.strip().split('\n')
if rows:
cols_count = rows[0].count('|') - 1
if cols_count > 0:
# rough approx
sep = "| " + " | ".join(["---"] * int(cols_count/2)) + " |"
# Actually, the traverse of TR returns newline terminated strings.
# Let's just return what we gathered.
pass
return f"\n\n{inner_text}\n\n"
return f"{prefix}{inner_text}{suffix}"
# Actually, a simpler approach for this script is "just get string" but with markers?
# Let's use a simplified approach: use get_text but with 'separator' logic?
# text = soup.get_text(separator='\n\n')
# But that loses links and boldness.
# Recommendation: Let's stick to the traversal above which constructs a string.
md = traverse(soup)
# Cleanup Markdown
if md:
# Remove excessive newlines
md = re.sub(r'\n{3,}', '\n\n', md)
md = md.strip()
return md or ""
def process_url(url, output_file=None):
print(f"\n[Fetching] {url}")
try:
html = fetch_url(url)
soup = BeautifulSoup(html, 'html.parser')
# Extract Metadata
metadata = extract_metadata(soup, url)
print(f" [OK] Title: {metadata['title']}")
if metadata['date']:
print(f" [OK] Date: {metadata['date']}")
# Determine output path and image directory upfront
if output_file:
output_path = output_file
else:
base_name = derive_base_name(metadata['title'], url)
filename = f"{base_name}.md"
output_path = os.path.join(CONFIG["output_dir"], filename)
output_dirname = os.path.dirname(output_path) or "."
os.makedirs(output_dirname, exist_ok=True)
base_name = os.path.splitext(os.path.basename(output_path))[0]
image_dir = os.path.join(output_dirname, f"{base_name}_files")
rel_image_prefix = os.path.relpath(image_dir, output_dirname)
# Extract Content
content_div = find_main_content(soup)
# Download images and rewrite src before markdown conversion
image_count = download_and_rewrite_images(
content_div, url, image_dir, rel_image_prefix)
if image_count:
print(f" [OK] Images: {image_count} saved to {image_dir}")
# Convert to MD
# Note: We pass the element to our traversal function
markdown_text = simple_html_to_markdown_traversal(content_div)
print(f" [OK] Content: {len(markdown_text)} chars")
# Construct content
final_output = []
final_output.append("<!--")
final_output.append(f" Source: {url}")
final_output.append(
f" Crawled: {datetime.datetime.now().isoformat()}")
if metadata['date']:
final_output.append(f" Published: {metadata['date']}")
if metadata['author']:
final_output.append(f" Author: {metadata['author']}")
final_output.append("-->\n")
if metadata['title']:
final_output.append(f"# {metadata['title']}\n")
if metadata['description']:
final_output.append(f"> {metadata['description']}\n")
final_output.append(markdown_text)
full_content = "\n".join(final_output)
with open(output_path, 'w', encoding='utf-8') as f:
f.write(full_content)
print(f" [OK] Saved: {output_path}")
return True, url, None
except Exception as e:
print(f" [ERROR] {str(e)}")
return False, url, str(e)
def main():
parser = argparse.ArgumentParser(
description="Web to Markdown Converter (Python)")
parser.add_argument("urls", nargs="*", help="URLs to process")
parser.add_argument(
"-f", "--file", help="File containing URLs (one per line)")
parser.add_argument("-o", "--output", help="Output file (single URL only)")
parser.add_argument("-d", "--dir", help="Output directory")
args = parser.parse_args()
if args.dir:
CONFIG["output_dir"] = args.dir
targets = []
if args.urls:
targets.extend(args.urls)
if args.file:
if os.path.exists(args.file):
with open(args.file, 'r', encoding='utf-8') as f:
lines = [l.strip() for l in f if l.strip()
and not l.strip().startswith("#")]
targets.extend(lines)
else:
print(f"Error: File {args.file} not found")
if not targets:
parser.print_help()
sys.exit(0)
results = []
for i, url in enumerate(targets):
# Allow specific output file only if 1 URL
out = args.output if (len(targets) == 1 and args.output) else None
success, url, err = process_url(url, out)
results.append((success, url, err))
# Summary
success_count = sum(1 for r in results if r[0])
fail_count = len(results) - success_count
print("\n" + "="*50)
print(
f"[Done] Success: {success_count}/{len(results)}, Failed: {fail_count}")
if fail_count > 0:
print("\n[Failed URLs]:")
for r in results:
if not r[0]:
print(f" - {r[1]}: {r[2]}")
if __name__ == "__main__":
# Disable warnings for verify=False if needed, though often useful to see
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
main()
SKILL.md
---
name: ppt-master
description: >
AI-driven multi-format SVG content generation system. Converts source documents
(PDF/DOCX/URL/Markdown) into high-quality SVG pages and exports to PPTX through
multi-role collaboration. Use when user asks to "create PPT", "make presentation",
"tạo PPT", "làm slide", "tạo bài thuyết trình", or mentions "ppt-master".
---
# PPT Master Skill
> AI-driven multi-format SVG content generation system. Converts source documents into high-quality SVG pages through multi-role collaboration and exports to PPTX.
**Core Pipeline**: `Source Document → Create Project → Template Option → Strategist → [Image_Generator] → Executor → Post-processing → Export`
> [!CAUTION]
> ## 🚨 Global Execution Discipline (MANDATORY)
>
> **This workflow is a strict serial pipeline. The following rules have the highest priority — violating any one of them constitutes execution failure:**
>
> 1. **SERIAL EXECUTION** — Steps MUST be executed in order; the output of each step is the input for the next. Non-BLOCKING adjacent steps may proceed continuously once prerequisites are met, without waiting for the user to say "continue"
> 2. **BLOCKING = HARD STOP** — Steps marked ⛔ BLOCKING require a full stop; the AI MUST wait for an explicit user response before proceeding and MUST NOT make any decisions on behalf of the user
> 3. **NO CROSS-PHASE BUNDLING** — Cross-phase bundling is FORBIDDEN. (Note: the Eight Confirmations in Step 4 are ⛔ BLOCKING — the AI MUST present recommendations and wait for explicit user confirmation before proceeding. Once the user confirms, all subsequent non-BLOCKING steps — design spec output, SVG generation, speaker notes, and post-processing — may proceed automatically without further user confirmation)
> 4. **GATE BEFORE ENTRY** — Each Step has prerequisites (🚧 GATE) listed at the top; these MUST be verified before starting that Step
> 5. **NO SPECULATIVE EXECUTION** — "Pre-preparing" content for subsequent Steps is FORBIDDEN (e.g., writing SVG code during the Strategist phase)
> [!IMPORTANT]
> ## 🌐 Language & Communication Rule
>
> - **Response language**: Always match the language of the user's input and provided source materials. For example, if the user asks in Chinese, respond in Chinese; if the source material is in English, respond in English.
> - **Explicit override**: If the user explicitly requests a specific language (e.g., "Reply in English" or "Trả lời bằng tiếng Việt"), use that language instead.
> - **Template format**: The `design_spec.md` file MUST always follow its original English template structure (section headings, field names), regardless of the conversation language. Content values within the template may be in the user's language.
## Main Pipeline Scripts
| Script | Purpose |
|--------|---------|
| `${SKILL_DIR}/scripts/pdf_to_md.py` | PDF to Markdown |
| `${SKILL_DIR}/scripts/doc_to_md.py` | Documents to Markdown via Pandoc (DOCX, EPUB, HTML, LaTeX, RST, etc.) |
| `${SKILL_DIR}/scripts/web_to_md.py` | Web page to Markdown |
| `${SKILL_DIR}/scripts/web_to_md.cjs` | Zalo OA / trang bảo mật cao to Markdown |
| `${SKILL_DIR}/scripts/project_manager.py` | Project init / validate / manage |
| `${SKILL_DIR}/scripts/analyze_images.py` | Image analysis |
| `${SKILL_DIR}/scripts/nano_banana_gen.py` | AI image generation (Gemini API) |
| `${SKILL_DIR}/scripts/svg_quality_checker.py` | SVG quality check |
| `${SKILL_DIR}/scripts/total_md_split.py` | Speaker notes splitting |
| `${SKILL_DIR}/scripts/finalize_svg.py` | SVG post-processing (unified entry) |
| `${SKILL_DIR}/scripts/svg_to_pptx.py` | Export to PPTX |
For complete tool documentation, see `${SKILL_DIR}/scripts/README.md`.
## Template Index
| Index | Path | Purpose |
|-------|------|---------|
| Layout templates | `${SKILL_DIR}/templates/layouts/layouts_index.json` | Query available page layout templates |
| Chart templates | `${SKILL_DIR}/templates/charts/charts_index.json` | Query available chart SVG templates |
| Icon library | `${SKILL_DIR}/templates/icons/icons_index.json` | Query available icon names and categories |
## Standalone Workflows
| Workflow | Path | Purpose |
|----------|------|---------|
| `create-template` | `workflows/create-template.md` | Standalone template creation workflow |
---
## Workflow
### Step 1: Source Content Processing
🚧 **GATE**: User has provided source material (PDF / DOCX / EPUB / URL / Markdown file / text description / conversation content — any form is acceptable).
When the user provides non-Markdown content, convert immediately:
| User Provides | Command |
|---------------|---------|
| PDF file | `python3 ${SKILL_DIR}/scripts/pdf_to_md.py <file>` |
| DOCX / Word / Office document | `python3 ${SKILL_DIR}/scripts/doc_to_md.py <file>` |
| EPUB / HTML / LaTeX / RST / other | `python3 ${SKILL_DIR}/scripts/doc_to_md.py <file>` |
| Web link | `python3 ${SKILL_DIR}/scripts/web_to_md.py <URL>` |
| Zalo OA / trang bảo mật cao | `node ${SKILL_DIR}/scripts/web_to_md.cjs <URL>` |
| Markdown | Read directly |
**✅ Checkpoint — Confirm source content is ready, proceed to Step 2.**
---
### Step 2: Project Initialization
🚧 **GATE**: Step 1 complete; source content is ready (Markdown file, user-provided text, or requirements described in conversation are all valid).
```bash
python3 ${SKILL_DIR}/scripts/project_manager.py init <project_name> --format <format>
```
Format options: `ppt169` (default), `ppt43`, `xhs`, `story`, etc. For the full format list, see `references/canvas-formats.md`.
Import source content (choose based on the situation):
| Situation | Action |
|-----------|--------|
| Has source files (PDF/MD/etc.) | `python3 ${SKILL_DIR}/scripts/project_manager.py import-sources <project_path> <source_files...> --move` |
| User provided text directly in conversation | No import needed — content is already in conversation context; subsequent steps can reference it directly |
> ⚠️ **MUST use `--move`**: All source files (original PDF / MD / images) MUST be **moved** (not copied) into `sources/` for archiving.
> - Markdown files generated in Step 1, original PDFs, original MDs — **all** must be moved into the project via `import-sources --move`
> - Intermediate artifacts (e.g., `_files/` directories) are handled automatically by `import-sources`
> - After execution, source files no longer exist at their original location
**✅ Checkpoint — Confirm project structure created successfully, `sources/` contains all source files, converted materials are ready. Proceed to Step 3.**
---
### Step 3: Template Selection
🚧 **GATE**: Step 2 complete; project directory structure is ready.
⛔ **BLOCKING**: If the user has not yet clearly expressed whether to use a template, you MUST present options and **wait for an explicit user response** before proceeding. If the user has previously stated "no template" or specified a particular template, skip this prompt and proceed directly.
**⚡ Early-exit**: If the user has already stated "no template" / "không dùng mẫu" / "thiết kế tự do" (or equivalent) at any prior point in the conversation, **do NOT query `layouts_index.json`** — skip directly to Step 4. This avoids unnecessary token consumption.
**Template recommendation flow** (only when the user has NOT yet decided):
Query `${SKILL_DIR}/templates/layouts/layouts_index.json` to list available templates and their style descriptions.
**When presenting options, you MUST provide a professional recommendation based on the current PPT topic and content** (recommend a specific template or free design, with reasoning), then ask the user:
> 💡 **AI Recommendation**: Based on your content topic (brief summary), I recommend **[specific template / free design]** because...
>
> Which approach would you prefer?
> **A) Use an existing template** (please specify template name or style preference)
> **B) No template** — free design
After the user confirms option A, copy template files to the project directory:
```bash
cp ${SKILL_DIR}/templates/layouts/<template_name>/*.svg <project_path>/templates/
cp ${SKILL_DIR}/templates/layouts/<template_name>/design_spec.md <project_path>/templates/
cp ${SKILL_DIR}/templates/layouts/<template_name>/*.png <project_path>/images/ 2>/dev/null || true
cp ${SKILL_DIR}/templates/layouts/<template_name>/*.jpg <project_path>/images/ 2>/dev/null || true
```
After the user confirms option B, proceed directly to Step 4.
> To create a new template, Read `references/template-designer.md`
**✅ Checkpoint — User has responded with template selection, template files copied (if option A). Proceed to Step 4.**
---
### Step 4: Strategist Phase (MANDATORY — cannot be skipped)
🚧 **GATE**: Step 3 complete; user has confirmed template selection.
First, read the role definitions:
```
Read references/strategist.md
Read references/shared-standards.md
```
**Must complete the Eight Confirmations** (refer to `templates/design_spec_reference.md` for the template structure):
⛔ **BLOCKING**: The Eight Confirmations MUST be presented to the user as a bundled set of recommendations, and you MUST **wait for the user to confirm or modify** before outputting the Design Specification & Content Outline. This is one of only two core confirmation points in the workflow (the other is template selection). Once confirmed, all subsequent script execution and slide generation should proceed fully automatically.
1. Canvas format
2. Page count range
3. Target audience
4. Style objective
5. Color scheme
6. Icon usage approach
7. Typography plan
8. Image usage approach
If the user has provided images, run the analysis script **before outputting the design spec** (do NOT directly read/open image files — use the script output only):
```bash
python3 ${SKILL_DIR}/scripts/analyze_images.py <project_path>/images
```
> ⚠️ **Image handling rule**: The AI must NEVER directly read, open, or view image files (`.jpg`, `.png`, etc.). All image information must come from the `analyze_images.py` script output or the Design Specification's Image Resource List.
**Output**: `<project_path>/design_spec.md`
**✅ Checkpoint — Phase deliverables complete, auto-proceed to next step**:
```markdown
## ✅ Strategist Phase Complete
- [x] Eight Confirmations completed (user confirmed)
- [x] Design Specification & Content Outline generated
- [ ] **Next**: Auto-proceed to [Image_Generator / Executor] phase
```
---
### Step 5: Image_Generator Phase (Conditional)
🚧 **GATE**: Step 4 complete; Design Specification & Content Outline generated and user confirmed.
> **Trigger condition**: Image approach includes "AI generation". If not triggered, skip directly to Step 6 (Step 6 GATE must still be satisfied).
Read `references/image-generator.md`
1. Extract all images with status "pending generation" from the design spec
2. Generate prompt document → `<project_path>/images/image_prompts.md`
3. Generate images (CLI tool recommended):
```bash
python3 ${SKILL_DIR}/scripts/nano_banana_gen.py "prompt" --aspect_ratio 16:9 --image_size 1K -o <project_path>/images
```
**✅ Checkpoint — Confirm all images are ready, proceed to Step 6**:
```markdown
## ✅ Image_Generator Phase Complete
- [x] Prompt document created
- [x] All images saved to images/
```
---
### Step 6: Executor Phase
🚧 **GATE**: Step 4 (and Step 5 if triggered) complete; all prerequisite deliverables are ready.
Read the role definition based on the selected style:
```
Read references/executor-base.md # REQUIRED: common guidelines
Read references/executor-general.md # General flexible style
Read references/executor-consultant.md # Consulting style
Read references/executor-consultant-top.md # Top consulting style (MBB level)
```
> Only need to read executor-base + one style file.
**Design Parameter Confirmation (Mandatory)**: Before generating the first SVG, the Executor MUST review and output key design parameters from the Design Specification (canvas dimensions, color scheme, font plan, body font size) to ensure spec adherence. See executor-base.md Section 2 for details.
**Visual Construction Phase**:
- Batch-generate SVG pages → `<project_path>/svg_output/`
**Logic Construction Phase**:
- Generate speaker notes → `<project_path>/notes/total.md`
**✅ Checkpoint — Confirm all SVGs and notes are fully generated. Proceed directly to Step 7 post-processing**:
```markdown
## ✅ Executor Phase Complete
- [x] All SVGs generated to svg_output/
- [x] Speaker notes generated at notes/total.md
```
---
### Step 7: Post-processing & Export
🚧 **GATE**: Step 6 complete; all SVGs generated to `svg_output/`; speaker notes `notes/total.md` generated.
> ⚠️ The following three sub-steps MUST be **executed individually one at a time**. Each command must complete and be confirmed successful before running the next.
> ❌ **NEVER** put all three commands in a single code block or single shell invocation.
**Step 7.1** — Split speaker notes:
```bash
python3 ${SKILL_DIR}/scripts/total_md_split.py <project_path>
```
**Step 7.2** — SVG post-processing (icon embedding / image crop & embed / text flattening / rounded rect to path):
```bash
python3 ${SKILL_DIR}/scripts/finalize_svg.py <project_path>
```
**Step 7.3** — Export PPTX (embeds speaker notes by default):
```bash
python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path> -s final
# Fallback: add --native if no PowerPoint available for manual "Convert to Shape"
```
> ❌ **NEVER** use `cp` as a substitute for `finalize_svg.py` — it performs multiple critical processing steps
> ❌ **NEVER** export directly from `svg_output/` — MUST use `-s final` to export from `svg_final/`
> ❌ **NEVER** add extra flags like `--only`
---
### Step 8: Optimizer Phase (Optional)
🚧 **GATE**: Step 7 complete.
> **Trigger condition**: User requests optimization, or design quality needs improvement
Read `references/optimizer-crap.md`
1. Optimizer analyzes and optimizes the specified SVG files in `svg_output/`
2. Optimized files are saved with `yh_` prefix in `svg_output/`
3. After all optimizations are complete, **MUST re-run the entire post-processing pipeline** (Step 7.1 → 7.2 → 7.3), following the same sequential execution rules as Step 7
> ⚠️ Do NOT skip re-processing — optimized SVGs still need icon embedding, image processing, and PPTX export.
---
## Role Switching Protocol
Before switching roles, you **MUST first read** the corresponding reference file — skipping is FORBIDDEN. Output marker:
```markdown
## [Role Switch: <Role Name>]
📖 Reading role definition: references/<filename>.md
📋 Current task: <brief description>
```
---
## Reference Resources
| Resource | Path |
|----------|------|
| Shared technical constraints | `references/shared-standards.md` |
| Canvas format specification | `references/canvas-formats.md` |
| Design guidelines | `references/design-guidelines.md` |
| Image layout specification | `references/image-layout-spec.md` |
| SVG image embedding | `references/svg-image-embedding.md` |
---
## Notes
- Do NOT add extra flags like `--only` to the post-processing commands — run them as-is
- If Optimizer optimization was performed, re-run post-processing and export
- Local preview: `python3 -m http.server -d <project_path>/svg_final 8000`
templates/charts/area_chart.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" width="1280" height="720">
<!--
面积图模板
用途: 趋势变化、累积数据展示
适用场景: 流量趋势、销售累积、库存变化、用户增长
支持: 12个时间点 × 2系列(可调整)
与折线图区别: 填充面积,更强调量的累积和变化
-->
<defs>
<!-- 面积填充渐变(蓝色系列) -->
<linearGradient id="areaGrad1" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:0.6" />
<stop offset="100%" style="stop-color:#2196F3;stop-opacity:0.1" />
</linearGradient>
<!-- 面积填充渐变(绿色系列) -->
<linearGradient id="areaGrad2" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#4CAF50;stop-opacity:0.5" />
<stop offset="100%" style="stop-color:#4CAF50;stop-opacity:0.05" />
</linearGradient>
<!-- 数据点阴影 -->
<filter id="dotShadow">
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
<feOffset dx="0" dy="1"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.3"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- 背景 -->
<rect width="1280" height="720" fill="#FFFFFF"/>
<!-- 标题 -->
<text x="60" y="70" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="32" font-weight="bold" fill="#2C3E50">
<tspan>网站流量趋势分析</tspan>
</text>
<text x="60" y="105" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" fill="#7F8C8D">
<tspan>2025年度月度数据 · 单位:万次访问</tspan>
</text>
<!-- 图例 -->
<g id="legend" transform="translate(950, 70)">
<line x1="0" y1="10" x2="30" y2="10" stroke="#2196F3" stroke-width="3"/>
<circle cx="15" cy="10" r="5" fill="#2196F3"/>
<text x="40" y="15" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="15" fill="#5D6D7E">
<tspan>PC端访问</tspan>
</text>
<line x1="150" y1="10" x2="180" y2="10" stroke="#4CAF50" stroke-width="3"/>
<circle cx="165" cy="10" r="5" fill="#4CAF50"/>
<text x="190" y="15" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="15" fill="#5D6D7E">
<tspan>移动端访问</tspan>
</text>
</g>
<!-- 图表区域 -->
<g id="chartArea">
<!-- Y轴网格线 -->
<line x1="120" y1="560" x2="1200" y2="560" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="120" y1="470" x2="1200" y2="470" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="120" y1="380" x2="1200" y2="380" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="120" y1="290" x2="1200" y2="290" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="120" y1="200" x2="1200" y2="200" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<!-- Y轴刻度标签 -->
<text x="105" y="565" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end">
<tspan>0</tspan>
</text>
<text x="105" y="475" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end">
<tspan>200</tspan>
</text>
<text x="105" y="385" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end">
<tspan>400</tspan>
</text>
<text x="105" y="295" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end">
<tspan>600</tspan>
</text>
<text x="105" y="205" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end">
<tspan>800</tspan>
</text>
<!-- 坐标轴 -->
<line x1="120" y1="160" x2="120" y2="560" stroke="#95A5A6" stroke-width="2"/>
<line x1="120" y1="560" x2="1200" y2="560" stroke="#95A5A6" stroke-width="2"/>
<!-- X轴标签(12个月) -->
<!-- 每个月间距: (1200-120)/12 = 90px -->
<text x="165" y="590" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle"><tspan>1月</tspan></text>
<text x="255" y="590" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle"><tspan>2月</tspan></text>
<text x="345" y="590" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle"><tspan>3月</tspan></text>
<text x="435" y="590" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle"><tspan>4月</tspan></text>
<text x="525" y="590" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle"><tspan>5月</tspan></text>
<text x="615" y="590" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle"><tspan>6月</tspan></text>
<text x="705" y="590" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle"><tspan>7月</tspan></text>
<text x="795" y="590" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle"><tspan>8月</tspan></text>
<text x="885" y="590" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle"><tspan>9月</tspan></text>
<text x="975" y="590" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle"><tspan>10月</tspan></text>
<text x="1065" y="590" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle"><tspan>11月</tspan></text>
<text x="1155" y="590" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle"><tspan>12月</tspan></text>
<!--
数据点计算:
X: 165, 255, 345, 435, 525, 615, 705, 795, 885, 975, 1065, 1155
Y: 560 - (数值/200)*90 (每200单位=90px)
PC端数据: 320, 280, 350, 420, 480, 520, 580, 620, 550, 490, 460, 520
移动端数据: 180, 160, 210, 280, 350, 420, 480, 540, 500, 460, 430, 490
-->
<!-- 面积1: PC端(蓝色) -->
<path d="M 165,560
L 165,416 L 255,434 L 345,402.5 L 435,371 L 525,344 L 615,326 L 705,299 L 795,281 L 885,312.5 L 975,339.5 L 1065,353 L 1155,326
L 1155,560 Z"
fill="url(#areaGrad1)"/>
<!-- 折线1: PC端 -->
<path d="M 165,416 L 255,434 L 345,402.5 L 435,371 L 525,344 L 615,326 L 705,299 L 795,281 L 885,312.5 L 975,339.5 L 1065,353 L 1155,326"
fill="none" stroke="#2196F3" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<!-- 面积2: 移动端(绿色) -->
<path d="M 165,560
L 165,479 L 255,488 L 345,465.5 L 435,434 L 525,402.5 L 615,371 L 705,344 L 795,317 L 885,335 L 975,353 L 1065,366.5 L 1155,339.5
L 1155,560 Z"
fill="url(#areaGrad2)"/>
<!-- 折线2: 移动端 -->
<path d="M 165,479 L 255,488 L 345,465.5 L 435,434 L 525,402.5 L 615,371 L 705,344 L 795,317 L 885,335 L 975,353 L 1065,366.5 L 1155,339.5"
fill="none" stroke="#4CAF50" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<!-- PC端数据点 -->
<circle cx="165" cy="416" r="5" fill="#FFFFFF" stroke="#2196F3" stroke-width="2" filter="url(#dotShadow)"/>
<circle cx="255" cy="434" r="5" fill="#FFFFFF" stroke="#2196F3" stroke-width="2" filter="url(#dotShadow)"/>
<circle cx="345" cy="402.5" r="5" fill="#FFFFFF" stroke="#2196F3" stroke-width="2" filter="url(#dotShadow)"/>
<circle cx="435" cy="371" r="5" fill="#FFFFFF" stroke="#2196F3" stroke-width="2" filter="url(#dotShadow)"/>
<circle cx="525" cy="344" r="5" fill="#FFFFFF" stroke="#2196F3" stroke-width="2" filter="url(#dotShadow)"/>
<circle cx="615" cy="326" r="5" fill="#FFFFFF" stroke="#2196F3" stroke-width="2" filter="url(#dotShadow)"/>
<circle cx="705" cy="299" r="5" fill="#FFFFFF" stroke="#2196F3" stroke-width="2" filter="url(#dotShadow)"/>
<circle cx="795" cy="281" r="6" fill="#2196F3" stroke="#FFFFFF" stroke-width="2" filter="url(#dotShadow)"/>
<circle cx="885" cy="312.5" r="5" fill="#FFFFFF" stroke="#2196F3" stroke-width="2" filter="url(#dotShadow)"/>
<circle cx="975" cy="339.5" r="5" fill="#FFFFFF" stroke="#2196F3" stroke-width="2" filter="url(#dotShadow)"/>
<circle cx="1065" cy="353" r="5" fill="#FFFFFF" stroke="#2196F3" stroke-width="2" filter="url(#dotShadow)"/>
<circle cx="1155" cy="326" r="5" fill="#FFFFFF" stroke="#2196F3" stroke-width="2" filter="url(#dotShadow)"/>
<!-- 移动端数据点 -->
<circle cx="165" cy="479" r="5" fill="#FFFFFF" stroke="#4CAF50" stroke-width="2" filter="url(#dotShadow)"/>
<circle cx="255" cy="488" r="5" fill="#FFFFFF" stroke="#4CAF50" stroke-width="2" filter="url(#dotShadow)"/>
<circle cx="345" cy="465.5" r="5" fill="#FFFFFF" stroke="#4CAF50" stroke-width="2" filter="url(#dotShadow)"/>
<circle cx="435" cy="434" r="5" fill="#FFFFFF" stroke="#4CAF50" stroke-width="2" filter="url(#dotShadow)"/>
<circle cx="525" cy="402.5" r="5" fill="#FFFFFF" stroke="#4CAF50" stroke-width="2" filter="url(#dotShadow)"/>
<circle cx="615" cy="371" r="5" fill="#FFFFFF" stroke="#4CAF50" stroke-width="2" filter="url(#dotShadow)"/>
<circle cx="705" cy="344" r="5" fill="#FFFFFF" stroke="#4CAF50" stroke-width="2" filter="url(#dotShadow)"/>
<circle cx="795" cy="317" r="6" fill="#4CAF50" stroke="#FFFFFF" stroke-width="2" filter="url(#dotShadow)"/>
<circle cx="885" cy="335" r="5" fill="#FFFFFF" stroke="#4CAF50" stroke-width="2" filter="url(#dotShadow)"/>
<circle cx="975" cy="353" r="5" fill="#FFFFFF" stroke="#4CAF50" stroke-width="2" filter="url(#dotShadow)"/>
<circle cx="1065" cy="366.5" r="5" fill="#FFFFFF" stroke="#4CAF50" stroke-width="2" filter="url(#dotShadow)"/>
<circle cx="1155" cy="339.5" r="5" fill="#FFFFFF" stroke="#4CAF50" stroke-width="2" filter="url(#dotShadow)"/>
<!-- 峰值标注(8月) -->
<g>
<rect x="755" y="225" width="80" height="45" rx="6" fill="#2196F3"/>
<text x="795" y="245" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#FFFFFF" text-anchor="middle">
<tspan>峰值</tspan>
</text>
<text x="795" y="262" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#FFFFFF" text-anchor="middle">
<tspan>620万</tspan>
</text>
<path d="M 795,270 L 790,281 L 800,281 Z" fill="#2196F3"/>
</g>
</g>
<!-- 统计卡片 -->
<g id="statsCards" transform="translate(60, 620)">
<!-- 卡片1: 总访问量 -->
<rect x="0" y="0" width="200" height="70" rx="8" fill="#F8F9FA" stroke="#E0E0E0" stroke-width="1"/>
<text x="20" y="25" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#7F8C8D">
<tspan>年度总访问量</tspan>
</text>
<text x="20" y="55" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="24" font-weight="bold" fill="#2C3E50">
<tspan>10,240万</tspan>
</text>
<!-- 卡片2: PC端占比 -->
<rect x="220" y="0" width="180" height="70" rx="8" fill="#E3F2FD" stroke="#2196F3" stroke-width="1"/>
<text x="240" y="25" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#1976D2">
<tspan>PC端占比</tspan>
</text>
<text x="240" y="55" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="24" font-weight="bold" fill="#2196F3">
<tspan>55.8%</tspan>
</text>
<!-- 卡片3: 移动端占比 -->
<rect x="420" y="0" width="180" height="70" rx="8" fill="#E8F5E9" stroke="#4CAF50" stroke-width="1"/>
<text x="440" y="25" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#388E3C">
<tspan>移动端占比</tspan>
</text>
<text x="440" y="55" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="24" font-weight="bold" fill="#4CAF50">
<tspan>44.2%</tspan>
</text>
<!-- 卡片4: 同比增长 -->
<rect x="620" y="0" width="180" height="70" rx="8" fill="#FFF3E0" stroke="#FF9800" stroke-width="1"/>
<text x="640" y="25" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#F57C00">
<tspan>同比增长</tspan>
</text>
<text x="640" y="55" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="24" font-weight="bold" fill="#FF9800">
<tspan>+23.5%</tspan>
</text>
</g>
<!-- 数据来源 -->
<text x="1220" y="695" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#95A5A6" text-anchor="end">
<tspan>数据来源: 网站分析平台</tspan>
</text>
</svg>
templates/charts/bar_chart.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" width="1280" height="720">
<!--
柱状图模板
用途: 类别数据对比
适用场景: 销售额对比、区域排名、年度数据对比
支持: 6个柱子(可调整为3-8个)
-->
<defs>
<!-- 柱状图渐变 -->
<linearGradient id="barGrad1" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1976D2;stop-opacity:1" />
</linearGradient>
<linearGradient id="barGrad2" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#4CAF50;stop-opacity:1" />
<stop offset="100%" style="stop-color:#388E3C;stop-opacity:1" />
</linearGradient>
<linearGradient id="barGrad3" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#FF9800;stop-opacity:1" />
<stop offset="100%" style="stop-color:#F57C00;stop-opacity:1" />
</linearGradient>
<!-- 阴影 -->
<filter id="barShadow">
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
<feOffset dx="0" dy="1"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.3"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- 背景 -->
<rect width="1280" height="720" fill="#FFFFFF"/>
<!-- 标题 -->
<text x="60" y="80" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="32" font-weight="bold" fill="#2C3E50">
<tspan>各区域销售额对比</tspan>
</text>
<text x="60" y="115" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" fill="#7F8C8D">
<tspan>2025年第三季度 · 单位:百万元</tspan>
</text>
<!-- 图表区域 -->
<g id="chartArea">
<!-- Y轴网格线 -->
<line x1="140" y1="550" x2="1160" y2="550" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="140" y1="450" x2="1160" y2="450" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="140" y1="350" x2="1160" y2="350" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="140" y1="250" x2="1160" y2="250" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="140" y1="150" x2="1160" y2="150" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<!-- Y轴刻度标签 -->
<text x="125" y="555" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end">
<tspan>0</tspan>
</text>
<text x="125" y="455" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end">
<tspan>50</tspan>
</text>
<text x="125" y="355" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end">
<tspan>100</tspan>
</text>
<text x="125" y="255" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end">
<tspan>150</tspan>
</text>
<text x="125" y="155" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end">
<tspan>200</tspan>
</text>
<!-- X轴和Y轴 -->
<line x1="140" y1="150" x2="140" y2="550" stroke="#95A5A6" stroke-width="2"/>
<line x1="140" y1="550" x2="1160" y2="550" stroke="#95A5A6" stroke-width="2"/>
<!-- 柱子数据 (柱宽50px, 间距130px, 总间距180px) -->
<!-- 柱子1: 华东 - 185M (370px高度, 每100M=200px) -->
<rect x="220" y="180" width="50" height="370" rx="4" fill="url(#barGrad1)" filter="url(#barShadow)"/>
<text x="245" y="165" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>185</tspan>
</text>
<text x="245" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" fill="#5D6D7E" text-anchor="middle">
<tspan>华东</tspan>
</text>
<!-- 柱子2: 华南 - 142M -->
<rect x="400" y="266" width="50" height="284" rx="4" fill="url(#barGrad1)" filter="url(#barShadow)"/>
<text x="425" y="251" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>142</tspan>
</text>
<text x="425" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" fill="#5D6D7E" text-anchor="middle">
<tspan>华南</tspan>
</text>
<!-- 柱子3: 华北 - 128M -->
<rect x="580" y="294" width="50" height="256" rx="4" fill="url(#barGrad1)" filter="url(#barShadow)"/>
<text x="605" y="279" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>128</tspan>
</text>
<text x="605" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" fill="#5D6D7E" text-anchor="middle">
<tspan>华北</tspan>
</text>
<!-- 柱子4: 西南 - 96M -->
<rect x="760" y="358" width="50" height="192" rx="4" fill="url(#barGrad2)" filter="url(#barShadow)"/>
<text x="785" y="343" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>96</tspan>
</text>
<text x="785" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" fill="#5D6D7E" text-anchor="middle">
<tspan>西南</tspan>
</text>
<!-- 柱子5: 华中 - 73M -->
<rect x="940" y="404" width="50" height="146" rx="4" fill="url(#barGrad2)" filter="url(#barShadow)"/>
<text x="965" y="389" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>73</tspan>
</text>
<text x="965" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" fill="#5D6D7E" text-anchor="middle">
<tspan>华中</tspan>
</text>
<!-- 柱子6: 东北 - 52M -->
<rect x="1120" y="446" width="50" height="104" rx="4" fill="url(#barGrad3)" filter="url(#barShadow)"/>
<text x="1145" y="431" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>52</tspan>
</text>
<text x="1145" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" fill="#5D6D7E" text-anchor="middle">
<tspan>东北</tspan>
</text>
</g>
<!-- 图例 -->
<g id="legend">
<rect x="920" y="620" width="16" height="16" rx="2" fill="url(#barGrad1)"/>
<text x="945" y="632" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#5D6D7E">
<tspan>高增长区域 (>100M)</tspan>
</text>
<rect x="920" y="645" width="16" height="16" rx="2" fill="url(#barGrad2)"/>
<text x="945" y="657" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#5D6D7E">
<tspan>稳定区域 (50-100M)</tspan>
</text>
<rect x="920" y="670" width="16" height="16" rx="2" fill="url(#barGrad3)"/>
<text x="945" y="682" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#5D6D7E">
<tspan>潜力区域 (<50M)</tspan>
</text>
</g>
<!-- 数据来源 -->
<text x="60" y="695" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#95A5A6">
<tspan>数据来源: 销售部门统计</tspan>
</text>
</svg>
templates/charts/box_plot_chart.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" width="1280" height="720">
<!--
箱线图模板
用途: 数据分布可视化、统计分析
适用场景: 薪资分布、成绩分析、质量控制、异常值检测
支持: 5组箱线图对比
-->
<defs>
<!-- 箱体渐变 -->
<linearGradient id="boxGrad1" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#42A5F5;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1976D2;stop-opacity:1" />
</linearGradient>
<linearGradient id="boxGrad2" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#66BB6A;stop-opacity:1" />
<stop offset="100%" style="stop-color:#388E3C;stop-opacity:1" />
</linearGradient>
<linearGradient id="boxGrad3" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#FFB74D;stop-opacity:1" />
<stop offset="100%" style="stop-color:#F57C00;stop-opacity:1" />
</linearGradient>
<linearGradient id="boxGrad4" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#BA68C8;stop-opacity:1" />
<stop offset="100%" style="stop-color:#7B1FA2;stop-opacity:1" />
</linearGradient>
<linearGradient id="boxGrad5" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#EF5350;stop-opacity:1" />
<stop offset="100%" style="stop-color:#D32F2F;stop-opacity:1" />
</linearGradient>
<!-- 阴影 -->
<filter id="boxShadow">
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
<feOffset dx="0" dy="1"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.2"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- 背景 -->
<rect width="1280" height="720" fill="#FFFFFF"/>
<!-- 标题 -->
<text x="60" y="55" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="32" font-weight="bold" fill="#2C3E50">
<tspan>各部门薪资分布分析</tspan>
</text>
<text x="60" y="90" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" fill="#7F8C8D">
<tspan>2025年度薪酬报告 · 单位:万元/年</tspan>
</text>
<!-- 图表区域 -->
<g id="chartArea">
<!-- Y轴网格线 -->
<line x1="140" y1="550" x2="1000" y2="550" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="140" y1="480" x2="1000" y2="480" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="140" y1="410" x2="1000" y2="410" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="140" y1="340" x2="1000" y2="340" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="140" y1="270" x2="1000" y2="270" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="140" y1="200" x2="1000" y2="200" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="140" y1="130" x2="1000" y2="130" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<!-- Y轴刻度标签 -->
<text x="125" y="555" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="end"><tspan>0</tspan></text>
<text x="125" y="485" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="end"><tspan>10</tspan></text>
<text x="125" y="415" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="end"><tspan>20</tspan></text>
<text x="125" y="345" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="end"><tspan>30</tspan></text>
<text x="125" y="275" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="end"><tspan>40</tspan></text>
<text x="125" y="205" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="end"><tspan>50</tspan></text>
<text x="125" y="135" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="end"><tspan>60</tspan></text>
<!-- Y轴标题 -->
<text x="45" y="340" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="15" fill="#5D6D7E" text-anchor="middle" transform="rotate(-90, 45, 340)">
<tspan>年薪(万元)</tspan>
</text>
<!-- 坐标轴 -->
<line x1="140" y1="130" x2="140" y2="550" stroke="#95A5A6" stroke-width="2"/>
<line x1="140" y1="550" x2="1000" y2="550" stroke="#95A5A6" stroke-width="2"/>
<!--
箱线图说明:
- 每个箱体宽度: 80px
- 箱体间距: 100px
- 每10万=70px
数据(最小值, Q1, 中位数, Q3, 最大值):
技术部: 15, 25, 35, 45, 58 + 异常值 8
产品部: 12, 22, 28, 38, 50
设计部: 10, 18, 24, 32, 42
市场部: 8, 15, 22, 30, 45 + 异常值 55
运营部: 8, 14, 20, 26, 38
-->
<!-- 箱线图1: 技术部 (蓝色) -->
<g id="box1" transform="translate(230, 0)">
<!-- 上须线 (max=58) -->
<line x1="0" y1="143.4" x2="0" y2="235" stroke="#1976D2" stroke-width="2"/>
<line x1="-20" y1="143.4" x2="20" y2="143.4" stroke="#1976D2" stroke-width="2"/>
<!-- 箱体 (Q1=25到Q3=45) -->
<rect x="-40" y="235" width="80" height="140" rx="4" fill="url(#boxGrad1)" filter="url(#boxShadow)"/>
<!-- 中位数线 (median=35) -->
<line x1="-40" y1="305" x2="40" y2="305" stroke="#FFFFFF" stroke-width="3"/>
<!-- 下须线 (min=15) -->
<line x1="0" y1="375" x2="0" y2="445" stroke="#1976D2" stroke-width="2"/>
<line x1="-20" y1="445" x2="20" y2="445" stroke="#1976D2" stroke-width="2"/>
<!-- 异常值 (outlier=8) -->
<circle cx="0" cy="494" r="6" fill="#FFFFFF" stroke="#1976D2" stroke-width="2"/>
<!-- 标签 -->
<text x="0" y="590" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="15" font-weight="600" fill="#1976D2" text-anchor="middle"><tspan>技术部</tspan></text>
<!-- 数值标注 -->
<text x="50" y="148" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#7F8C8D"><tspan>58</tspan></text>
<text x="50" y="240" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#7F8C8D"><tspan>45</tspan></text>
<text x="50" y="310" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#2196F3"><tspan>35</tspan></text>
<text x="50" y="380" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#7F8C8D"><tspan>25</tspan></text>
<text x="50" y="450" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#7F8C8D"><tspan>15</tspan></text>
</g>
<!-- 箱线图2: 产品部 (绿色) -->
<g id="box2" transform="translate(400, 0)">
<!-- 上须线 (max=50) -->
<line x1="0" y1="200" x2="0" y2="284" stroke="#388E3C" stroke-width="2"/>
<line x1="-20" y1="200" x2="20" y2="200" stroke="#388E3C" stroke-width="2"/>
<!-- 箱体 (Q1=22到Q3=38) -->
<rect x="-40" y="284" width="80" height="112" rx="4" fill="url(#boxGrad2)" filter="url(#boxShadow)"/>
<!-- 中位数线 (median=28) -->
<line x1="-40" y1="354" x2="40" y2="354" stroke="#FFFFFF" stroke-width="3"/>
<!-- 下须线 (min=12) -->
<line x1="0" y1="396" x2="0" y2="466" stroke="#388E3C" stroke-width="2"/>
<line x1="-20" y1="466" x2="20" y2="466" stroke="#388E3C" stroke-width="2"/>
<!-- 标签 -->
<text x="0" y="590" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="15" font-weight="600" fill="#388E3C" text-anchor="middle"><tspan>产品部</tspan></text>
</g>
<!-- 箱线图3: 设计部 (橙色) -->
<g id="box3" transform="translate(570, 0)">
<!-- 上须线 (max=42) -->
<line x1="0" y1="256" x2="0" y2="326" stroke="#F57C00" stroke-width="2"/>
<line x1="-20" y1="256" x2="20" y2="256" stroke="#F57C00" stroke-width="2"/>
<!-- 箱体 (Q1=18到Q3=32) -->
<rect x="-40" y="326" width="80" height="98" rx="4" fill="url(#boxGrad3)" filter="url(#boxShadow)"/>
<!-- 中位数线 (median=24) -->
<line x1="-40" y1="382" x2="40" y2="382" stroke="#FFFFFF" stroke-width="3"/>
<!-- 下须线 (min=10) -->
<line x1="0" y1="424" x2="0" y2="480" stroke="#F57C00" stroke-width="2"/>
<line x1="-20" y1="480" x2="20" y2="480" stroke="#F57C00" stroke-width="2"/>
<!-- 标签 -->
<text x="0" y="590" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="15" font-weight="600" fill="#F57C00" text-anchor="middle"><tspan>设计部</tspan></text>
</g>
<!-- 箱线图4: 市场部 (紫色) -->
<g id="box4" transform="translate(740, 0)">
<!-- 上须线 (max=45) -->
<line x1="0" y1="235" x2="0" y2="340" stroke="#7B1FA2" stroke-width="2"/>
<line x1="-20" y1="235" x2="20" y2="235" stroke="#7B1FA2" stroke-width="2"/>
<!-- 箱体 (Q1=15到Q3=30) -->
<rect x="-40" y="340" width="80" height="105" rx="4" fill="url(#boxGrad4)" filter="url(#boxShadow)"/>
<!-- 中位数线 (median=22) -->
<line x1="-40" y1="396" x2="40" y2="396" stroke="#FFFFFF" stroke-width="3"/>
<!-- 下须线 (min=8) -->
<line x1="0" y1="445" x2="0" y2="494" stroke="#7B1FA2" stroke-width="2"/>
<line x1="-20" y1="494" x2="20" y2="494" stroke="#7B1FA2" stroke-width="2"/>
<!-- 异常值 (outlier=55) -->
<circle cx="0" cy="165" r="6" fill="#FFFFFF" stroke="#7B1FA2" stroke-width="2"/>
<!-- 标签 -->
<text x="0" y="590" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="15" font-weight="600" fill="#7B1FA2" text-anchor="middle"><tspan>市场部</tspan></text>
</g>
<!-- 箱线图5: 运营部 (红色) -->
<g id="box5" transform="translate(910, 0)">
<!-- 上须线 (max=38) -->
<line x1="0" y1="284" x2="0" y2="368" stroke="#D32F2F" stroke-width="2"/>
<line x1="-20" y1="284" x2="20" y2="284" stroke="#D32F2F" stroke-width="2"/>
<!-- 箱体 (Q1=14到Q3=26) -->
<rect x="-40" y="368" width="80" height="84" rx="4" fill="url(#boxGrad5)" filter="url(#boxShadow)"/>
<!-- 中位数线 (median=20) -->
<line x1="-40" y1="410" x2="40" y2="410" stroke="#FFFFFF" stroke-width="3"/>
<!-- 下须线 (min=8) -->
<line x1="0" y1="452" x2="0" y2="494" stroke="#D32F2F" stroke-width="2"/>
<line x1="-20" y1="494" x2="20" y2="494" stroke="#D32F2F" stroke-width="2"/>
<!-- 标签 -->
<text x="0" y="590" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="15" font-weight="600" fill="#D32F2F" text-anchor="middle"><tspan>运营部</tspan></text>
</g>
</g>
<!-- 箱线图说明 -->
<g id="boxLegend" transform="translate(1040, 130)">
<rect x="0" y="0" width="200" height="240" rx="8" fill="#F8F9FA" stroke="#E0E0E0" stroke-width="1"/>
<text x="100" y="28" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" font-weight="600" fill="#2C3E50" text-anchor="middle"><tspan>图例说明</tspan></text>
<line x1="15" y1="42" x2="185" y2="42" stroke="#E0E0E0" stroke-width="1"/>
<!-- 示意图 -->
<g transform="translate(30, 60)">
<!-- 上须线 -->
<line x1="30" y1="0" x2="30" y2="25" stroke="#5D6D7E" stroke-width="2"/>
<line x1="20" y1="0" x2="40" y2="0" stroke="#5D6D7E" stroke-width="2"/>
<text x="70" y="5" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#7F8C8D"><tspan>最大值</tspan></text>
<!-- 箱体 -->
<rect x="10" y="25" width="40" height="60" rx="3" fill="#BDC3C7"/>
<text x="70" y="35" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#7F8C8D"><tspan>Q3 (75%)</tspan></text>
<!-- 中位数 -->
<line x1="10" y1="55" x2="50" y2="55" stroke="#2C3E50" stroke-width="2"/>
<text x="70" y="60" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#2C3E50"><tspan>中位数</tspan></text>
<text x="70" y="85" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#7F8C8D"><tspan>Q1 (25%)</tspan></text>
<!-- 下须线 -->
<line x1="30" y1="85" x2="30" y2="110" stroke="#5D6D7E" stroke-width="2"/>
<line x1="20" y1="110" x2="40" y2="110" stroke="#5D6D7E" stroke-width="2"/>
<text x="70" y="115" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#7F8C8D"><tspan>最小值</tspan></text>
<!-- 异常值 -->
<circle cx="30" cy="135" r="5" fill="#FFFFFF" stroke="#5D6D7E" stroke-width="2"/>
<text x="70" y="140" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#E91E63"><tspan>异常值</tspan></text>
</g>
<text x="15" y="220" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="10" fill="#95A5A6"><tspan>IQR = Q3 - Q1</tspan></text>
<text x="15" y="232" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="10" fill="#95A5A6"><tspan>异常值 > Q3+1.5*IQR</tspan></text>
</g>
<!-- 统计摘要 -->
<g id="statsSummary" transform="translate(1040, 390)">
<rect x="0" y="0" width="200" height="160" rx="8" fill="#E8F5E9" stroke="#4CAF50" stroke-width="1"/>
<text x="15" y="25" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" font-weight="600" fill="#2E7D32"><tspan>📊 统计摘要</tspan></text>
<text x="15" y="50" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="12" fill="#5D6D7E"><tspan>最高中位数: 技术部 35万</tspan></text>
<text x="15" y="70" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="12" fill="#5D6D7E"><tspan>最低中位数: 运营部 20万</tspan></text>
<text x="15" y="95" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="12" fill="#5D6D7E"><tspan>分布最广: 技术部</tspan></text>
<text x="15" y="115" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="12" fill="#5D6D7E"><tspan>分布最集中: 运营部</tspan></text>
<text x="15" y="140" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="12" fill="#E91E63"><tspan>发现异常值: 2个</tspan></text>
</g>
<!-- 数据来源 -->
<text x="60" y="695" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#95A5A6">
<tspan>数据来源: 人力资源部门薪酬统计</tspan>
</text>
</svg>
templates/charts/bubble_chart.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" width="1280" height="720">
<!--
气泡图模板
用途: 三维数据展示(X轴、Y轴、气泡大小)
适用场景: 市场规模vs增长率vs份额、风险评估、竞品分析
支持: 多个气泡,大小代表第三维度数据
-->
<defs>
<!-- 气泡渐变(蓝色) -->
<radialGradient id="bubbleGrad1" cx="30%" cy="30%">
<stop offset="0%" style="stop-color:#64B5F6;stop-opacity:0.9" />
<stop offset="100%" style="stop-color:#1976D2;stop-opacity:0.7" />
</radialGradient>
<!-- 气泡渐变(绿色) -->
<radialGradient id="bubbleGrad2" cx="30%" cy="30%">
<stop offset="0%" style="stop-color:#81C784;stop-opacity:0.9" />
<stop offset="100%" style="stop-color:#388E3C;stop-opacity:0.7" />
</radialGradient>
<!-- 气泡渐变(橙色) -->
<radialGradient id="bubbleGrad3" cx="30%" cy="30%">
<stop offset="0%" style="stop-color:#FFB74D;stop-opacity:0.9" />
<stop offset="100%" style="stop-color:#F57C00;stop-opacity:0.7" />
</radialGradient>
<!-- 气泡渐变(紫色) -->
<radialGradient id="bubbleGrad4" cx="30%" cy="30%">
<stop offset="0%" style="stop-color:#BA68C8;stop-opacity:0.9" />
<stop offset="100%" style="stop-color:#7B1FA2;stop-opacity:0.7" />
</radialGradient>
<!-- 气泡渐变(红色) -->
<radialGradient id="bubbleGrad5" cx="30%" cy="30%">
<stop offset="0%" style="stop-color:#EF5350;stop-opacity:0.9" />
<stop offset="100%" style="stop-color:#D32F2F;stop-opacity:0.7" />
</radialGradient>
<!-- 气泡阴影 -->
<filter id="bubbleShadow">
<feGaussianBlur in="SourceAlpha" stdDeviation="4"/>
<feOffset dx="0" dy="3"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.3"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- 背景 -->
<rect width="1280" height="720" fill="#FFFFFF"/>
<!-- 标题 -->
<text x="60" y="55" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="32" font-weight="bold" fill="#2C3E50">
<tspan>产品线市场定位分析</tspan>
</text>
<text x="60" y="90" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" fill="#7F8C8D">
<tspan>市场规模 vs 增长率 vs 市场份额 · 气泡大小代表市场份额</tspan>
</text>
<!-- 图表区域 -->
<g id="chartArea">
<!-- 象限背景 -->
<rect x="160" y="130" width="440" height="215" fill="#FFEBEE" opacity="0.3"/>
<rect x="600" y="130" width="440" height="215" fill="#E8F5E9" opacity="0.3"/>
<rect x="160" y="345" width="440" height="215" fill="#FFF3E0" opacity="0.3"/>
<rect x="600" y="345" width="440" height="215" fill="#E3F2FD" opacity="0.3"/>
<!-- 象限标签 -->
<text x="380" y="160" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#C62828" text-anchor="middle"><tspan>低规模 · 高增长</tspan></text>
<text x="380" y="178" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="12" fill="#E57373" text-anchor="middle"><tspan>明星产品 ⭐</tspan></text>
<text x="820" y="160" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#2E7D32" text-anchor="middle"><tspan>高规模 · 高增长</tspan></text>
<text x="820" y="178" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="12" fill="#81C784" text-anchor="middle"><tspan>金牛产品 🐂</tspan></text>
<text x="380" y="530" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#EF6C00" text-anchor="middle"><tspan>低规模 · 低增长</tspan></text>
<text x="380" y="548" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="12" fill="#FFB74D" text-anchor="middle"><tspan>问题产品 ❓</tspan></text>
<text x="820" y="530" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#1565C0" text-anchor="middle"><tspan>高规模 · 低增长</tspan></text>
<text x="820" y="548" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="12" fill="#64B5F6" text-anchor="middle"><tspan>现金牛 💰</tspan></text>
<!-- 网格线 -->
<line x1="160" y1="345" x2="1040" y2="345" stroke="#BDC3C7" stroke-width="2" stroke-dasharray="6,3"/>
<line x1="600" y1="130" x2="600" y2="560" stroke="#BDC3C7" stroke-width="2" stroke-dasharray="6,3"/>
<!-- Y轴网格线 -->
<line x1="160" y1="200" x2="1040" y2="200" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="160" y1="270" x2="1040" y2="270" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="160" y1="420" x2="1040" y2="420" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="160" y1="490" x2="1040" y2="490" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<!-- X轴网格线 -->
<line x1="280" y1="130" x2="280" y2="560" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="400" y1="130" x2="400" y2="560" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="520" y1="130" x2="520" y2="560" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="720" y1="130" x2="720" y2="560" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="840" y1="130" x2="840" y2="560" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="960" y1="130" x2="960" y2="560" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<!-- Y轴刻度标签 -->
<text x="145" y="565" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="end"><tspan>-10%</tspan></text>
<text x="145" y="495" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="end"><tspan>0%</tspan></text>
<text x="145" y="425" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="end"><tspan>10%</tspan></text>
<text x="145" y="350" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#95A5A6" text-anchor="end"><tspan>20%</tspan></text>
<text x="145" y="275" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="end"><tspan>30%</tspan></text>
<text x="145" y="205" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="end"><tspan>40%</tspan></text>
<text x="145" y="135" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="end"><tspan>50%</tspan></text>
<!-- Y轴标题 -->
<text x="35" y="345" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="15" fill="#5D6D7E" text-anchor="middle" transform="rotate(-90, 35, 345)">
<tspan>市场增长率 (%)</tspan>
</text>
<!-- X轴刻度标签 -->
<text x="160" y="590" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="middle"><tspan>0</tspan></text>
<text x="280" y="590" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="middle"><tspan>20</tspan></text>
<text x="400" y="590" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="middle"><tspan>40</tspan></text>
<text x="520" y="590" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="middle"><tspan>60</tspan></text>
<text x="600" y="590" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#95A5A6" text-anchor="middle"><tspan>80</tspan></text>
<text x="720" y="590" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="middle"><tspan>100</tspan></text>
<text x="840" y="590" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="middle"><tspan>120</tspan></text>
<text x="960" y="590" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="middle"><tspan>140</tspan></text>
<text x="1040" y="590" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="middle"><tspan>160</tspan></text>
<!-- X轴标题 -->
<text x="600" y="625" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="15" fill="#5D6D7E" text-anchor="middle">
<tspan>市场规模(亿元)</tspan>
</text>
<!-- 坐标轴 -->
<line x1="160" y1="130" x2="160" y2="560" stroke="#95A5A6" stroke-width="2"/>
<line x1="160" y1="560" x2="1040" y2="560" stroke="#95A5A6" stroke-width="2"/>
<!--
气泡数据:
X: 市场规模(亿元)0-160,每20亿=120px
Y: 增长率 -10%到50%,每10%=70px,基线(0%)在y=490
气泡大小: 市场份额 5%-35%,半径15-60px
产品A: 规模120亿, 增长35%, 份额28% -> x=820, y=245, r=50
产品B: 规模85亿, 增长25%, 份额18% -> x=630, y=315, r=38
产品C: 规模45亿, 增长42%, 份额12% -> x=430, y=196, r=28
产品D: 规模130亿, 增长8%, 份额25% -> x=880, y=434, r=45
产品E: 规模30亿, 增长-5%, 份额8% -> x=340, y=525, r=22
产品F: 规模65亿, 增长15%, 份额9% -> x=550, y=385, r=24
-->
<!-- 气泡F: 产品F (绿色,中等位置) -->
<circle cx="550" cy="385" r="24" fill="url(#bubbleGrad2)" filter="url(#bubbleShadow)"/>
<text x="550" y="390" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" font-weight="600" fill="#FFFFFF" text-anchor="middle"><tspan>F</tspan></text>
<!-- 气泡E: 产品E (红色,问题象限) -->
<circle cx="340" cy="525" r="22" fill="url(#bubbleGrad5)" filter="url(#bubbleShadow)"/>
<text x="340" y="530" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" font-weight="600" fill="#FFFFFF" text-anchor="middle"><tspan>E</tspan></text>
<!-- 气泡C: 产品C (橙色,明星象限) -->
<circle cx="430" cy="196" r="28" fill="url(#bubbleGrad3)" filter="url(#bubbleShadow)"/>
<text x="430" y="201" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" font-weight="600" fill="#FFFFFF" text-anchor="middle"><tspan>C</tspan></text>
<!-- 气泡B: 产品B (紫色,金牛象限边缘) -->
<circle cx="630" cy="315" r="38" fill="url(#bubbleGrad4)" filter="url(#bubbleShadow)"/>
<text x="630" y="320" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" font-weight="600" fill="#FFFFFF" text-anchor="middle"><tspan>B</tspan></text>
<!-- 气泡D: 产品D (蓝色,现金牛象限) -->
<circle cx="880" cy="434" r="45" fill="url(#bubbleGrad1)" filter="url(#bubbleShadow)"/>
<text x="880" y="439" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#FFFFFF" text-anchor="middle"><tspan>D</tspan></text>
<!-- 气泡A: 产品A (绿色,最大,金牛象限) -->
<circle cx="820" cy="245" r="50" fill="url(#bubbleGrad2)" filter="url(#bubbleShadow)"/>
<text x="820" y="250" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="15" font-weight="600" fill="#FFFFFF" text-anchor="middle"><tspan>A</tspan></text>
</g>
<!-- 产品列表 -->
<g id="productList" transform="translate(1060, 130)">
<rect x="0" y="0" width="180" height="320" rx="8" fill="#F8F9FA" stroke="#E0E0E0" stroke-width="1"/>
<text x="90" y="28" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" font-weight="600" fill="#2C3E50" text-anchor="middle"><tspan>产品列表</tspan></text>
<line x1="15" y1="42" x2="165" y2="42" stroke="#E0E0E0" stroke-width="1"/>
<!-- 产品A -->
<circle cx="30" cy="70" r="12" fill="url(#bubbleGrad2)"/>
<text x="50" y="66" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" font-weight="600" fill="#2C3E50"><tspan>产品A</tspan></text>
<text x="50" y="82" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#7F8C8D"><tspan>120亿 | +35% | 28%</tspan></text>
<!-- 产品B -->
<circle cx="30" cy="115" r="10" fill="url(#bubbleGrad4)"/>
<text x="50" y="111" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" font-weight="600" fill="#2C3E50"><tspan>产品B</tspan></text>
<text x="50" y="127" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#7F8C8D"><tspan>85亿 | +25% | 18%</tspan></text>
<!-- 产品C -->
<circle cx="30" cy="160" r="8" fill="url(#bubbleGrad3)"/>
<text x="50" y="156" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" font-weight="600" fill="#2C3E50"><tspan>产品C</tspan></text>
<text x="50" y="172" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#7F8C8D"><tspan>45亿 | +42% | 12%</tspan></text>
<!-- 产品D -->
<circle cx="30" cy="205" r="11" fill="url(#bubbleGrad1)"/>
<text x="50" y="201" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" font-weight="600" fill="#2C3E50"><tspan>产品D</tspan></text>
<text x="50" y="217" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#7F8C8D"><tspan>130亿 | +8% | 25%</tspan></text>
<!-- 产品E -->
<circle cx="30" cy="250" r="7" fill="url(#bubbleGrad5)"/>
<text x="50" y="246" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" font-weight="600" fill="#2C3E50"><tspan>产品E</tspan></text>
<text x="50" y="262" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#7F8C8D"><tspan>30亿 | -5% | 8%</tspan></text>
<!-- 产品F -->
<circle cx="30" cy="295" r="7" fill="url(#bubbleGrad2)"/>
<text x="50" y="291" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" font-weight="600" fill="#2C3E50"><tspan>产品F</tspan></text>
<text x="50" y="307" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#7F8C8D"><tspan>65亿 | +15% | 9%</tspan></text>
</g>
<!-- 气泡大小图例 -->
<g id="sizeLegend" transform="translate(1060, 470)">
<rect x="0" y="0" width="180" height="90" rx="8" fill="#F8F9FA" stroke="#E0E0E0" stroke-width="1"/>
<text x="90" y="25" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="12" fill="#7F8C8D" text-anchor="middle"><tspan>气泡大小 = 市场份额</tspan></text>
<circle cx="40" cy="60" r="10" fill="#BDC3C7"/>
<text x="40" y="82" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="10" fill="#7F8C8D" text-anchor="middle"><tspan>10%</tspan></text>
<circle cx="90" cy="55" r="18" fill="#BDC3C7"/>
<text x="90" y="82" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="10" fill="#7F8C8D" text-anchor="middle"><tspan>20%</tspan></text>
<circle cx="145" cy="48" r="25" fill="#BDC3C7"/>
<text x="145" y="82" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="10" fill="#7F8C8D" text-anchor="middle"><tspan>30%</tspan></text>
</g>
<!-- 数据来源 -->
<text x="60" y="695" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#95A5A6">
<tspan>数据来源: 市场研究部门</tspan>
</text>
</svg>
templates/charts/bullet_chart.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" width="1280" height="720">
<!--
Bullet Chart Template
Purpose: Target vs Actual comparison, more space-efficient than gauges
Use cases: KPI achievement, sales targets, budget execution, performance evaluation
Supports: 5 metrics displayed in parallel
-->
<defs>
<linearGradient id="bulletExcellent" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#E8F5E9;stop-opacity:1" />
<stop offset="100%" style="stop-color:#C8E6C9;stop-opacity:1" />
</linearGradient>
<linearGradient id="bulletGood" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#FFF8E1;stop-opacity:1" />
<stop offset="100%" style="stop-color:#FFE082;stop-opacity:1" />
</linearGradient>
<linearGradient id="bulletFair" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#FFEBEE;stop-opacity:1" />
<stop offset="100%" style="stop-color:#FFCDD2;stop-opacity:1" />
</linearGradient>
<linearGradient id="actualBar" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#1976D2;stop-opacity:1" />
<stop offset="100%" style="stop-color:#2196F3;stop-opacity:1" />
</linearGradient>
<linearGradient id="actualBarGreen" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#388E3C;stop-opacity:1" />
<stop offset="100%" style="stop-color:#4CAF50;stop-opacity:1" />
</linearGradient>
<linearGradient id="actualBarOrange" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#F57C00;stop-opacity:1" />
<stop offset="100%" style="stop-color:#FF9800;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="1280" height="720" fill="#FFFFFF"/>
<text x="640" y="50" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="32" font-weight="bold" fill="#2C3E50" text-anchor="middle">
<tspan>2025 Annual KPI Achievement Status</tspan>
</text>
<text x="640" y="85" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" fill="#7F8C8D" text-anchor="middle">
<tspan>Target vs Actual - As of Q4</tspan>
</text>
<!-- Legend -->
<g id="legend" transform="translate(900, 100)">
<rect x="0" y="0" width="20" height="12" fill="#FFCDD2" rx="2"/>
<text x="28" y="10" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="12" fill="#5D6D7E"><tspan>Fair (0-60%)</tspan></text>
<rect x="120" y="0" width="20" height="12" fill="#FFE082" rx="2"/>
<text x="148" y="10" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="12" fill="#5D6D7E"><tspan>Good (60-80%)</tspan></text>
<rect x="250" y="0" width="20" height="12" fill="#C8E6C9" rx="2"/>
<text x="278" y="10" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="12" fill="#5D6D7E"><tspan>Excellent (80-100%+)</tspan></text>
</g>
<!-- Bullet 1: Sales Revenue 92% -->
<g id="bullet1" transform="translate(80, 140)">
<text x="0" y="35" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" font-weight="600" fill="#2C3E50">
<tspan>Sales Revenue</tspan>
</text>
<text x="0" y="55" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D">
<tspan>Target: $100M</tspan>
</text>
<rect x="180" y="20" width="900" height="50" fill="url(#bulletFair)" rx="4"/>
<rect x="180" y="20" width="720" height="50" fill="url(#bulletGood)" rx="4"/>
<rect x="180" y="20" width="540" height="50" fill="url(#bulletExcellent)" rx="4"/>
<rect x="180" y="32" width="828" height="26" fill="url(#actualBarGreen)" rx="3"/>
<line x1="1080" y1="15" x2="1080" y2="75" stroke="#E74C3C" stroke-width="4"/>
<text x="1020" y="50" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#FFFFFF">
<tspan>$92M</tspan>
</text>
<text x="1120" y="50" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="bold" fill="#4CAF50">
<tspan>92%</tspan>
</text>
</g>
<!-- Bullet 2: New Customers 97% -->
<g id="bullet2" transform="translate(80, 240)">
<text x="0" y="35" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" font-weight="600" fill="#2C3E50">
<tspan>New Customers</tspan>
</text>
<text x="0" y="55" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D">
<tspan>Target: 500</tspan>
</text>
<rect x="180" y="20" width="900" height="50" fill="url(#bulletFair)" rx="4"/>
<rect x="180" y="20" width="720" height="50" fill="url(#bulletGood)" rx="4"/>
<rect x="180" y="20" width="540" height="50" fill="url(#bulletExcellent)" rx="4"/>
<rect x="180" y="32" width="873" height="26" fill="url(#actualBarGreen)" rx="3"/>
<line x1="1080" y1="15" x2="1080" y2="75" stroke="#E74C3C" stroke-width="4"/>
<text x="1050" y="50" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#FFFFFF">
<tspan>485</tspan>
</text>
<text x="1120" y="50" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="bold" fill="#4CAF50">
<tspan>97%</tspan>
</text>
</g>
<!-- Bullet 3: Customer Satisfaction 98% -->
<g id="bullet3" transform="translate(80, 340)">
<text x="0" y="35" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" font-weight="600" fill="#2C3E50">
<tspan>Customer Satisfaction</tspan>
</text>
<text x="0" y="55" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D">
<tspan>Target: 90%</tspan>
</text>
<rect x="180" y="20" width="900" height="50" fill="url(#bulletFair)" rx="4"/>
<rect x="180" y="20" width="720" height="50" fill="url(#bulletGood)" rx="4"/>
<rect x="180" y="20" width="540" height="50" fill="url(#bulletExcellent)" rx="4"/>
<rect x="180" y="32" width="880" height="26" fill="url(#actualBarGreen)" rx="3"/>
<line x1="990" y1="15" x2="990" y2="75" stroke="#E74C3C" stroke-width="4"/>
<text x="1050" y="50" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#FFFFFF">
<tspan>88%</tspan>
</text>
<text x="1120" y="50" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="bold" fill="#4CAF50">
<tspan>98%</tspan>
</text>
</g>
<!-- Bullet 4: Market Share 72% -->
<g id="bullet4" transform="translate(80, 440)">
<text x="0" y="35" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" font-weight="600" fill="#2C3E50">
<tspan>Market Share</tspan>
</text>
<text x="0" y="55" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D">
<tspan>Target: 25%</tspan>
</text>
<rect x="180" y="20" width="900" height="50" fill="url(#bulletFair)" rx="4"/>
<rect x="180" y="20" width="720" height="50" fill="url(#bulletGood)" rx="4"/>
<rect x="180" y="20" width="540" height="50" fill="url(#bulletExcellent)" rx="4"/>
<rect x="180" y="32" width="648" height="26" fill="url(#actualBarOrange)" rx="3"/>
<line x1="1080" y1="15" x2="1080" y2="75" stroke="#E74C3C" stroke-width="4"/>
<text x="815" y="50" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#FFFFFF">
<tspan>18%</tspan>
</text>
<text x="1120" y="50" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="bold" fill="#FF9800">
<tspan>72%</tspan>
</text>
</g>
<!-- Bullet 5: Cost Control 119% (Over budget) -->
<g id="bullet5" transform="translate(80, 540)">
<text x="0" y="35" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" font-weight="600" fill="#2C3E50">
<tspan>Cost Control</tspan>
</text>
<text x="0" y="55" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D">
<tspan>Budget: $80M</tspan>
</text>
<rect x="180" y="20" width="900" height="50" fill="url(#bulletExcellent)" rx="4"/>
<rect x="720" y="20" width="360" height="50" fill="url(#bulletGood)" rx="4"/>
<rect x="900" y="20" width="180" height="50" fill="url(#bulletFair)" rx="4"/>
<rect x="180" y="32" width="855" height="26" fill="#E74C3C" rx="3"/>
<line x1="720" y1="15" x2="720" y2="75" stroke="#4CAF50" stroke-width="4"/>
<text x="1020" y="50" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#FFFFFF">
<tspan>$95M</tspan>
</text>
<text x="1120" y="50" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="bold" fill="#E74C3C">
<tspan>119%</tspan>
</text>
</g>
<!-- Insight box -->
<g id="insight">
<rect x="80" y="630" width="1120" height="60" rx="6" fill="#E3F2FD" stroke="#2196F3" stroke-width="2"/>
<text x="100" y="655" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" font-weight="600" fill="#2C3E50">
<tspan>Summary:</tspan>
</text>
<text x="200" y="655" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="15" fill="#5D6D7E">
<tspan>3 KPIs exceeded targets. Market share growth needs strengthening. Cost control requires attention.</tspan>
</text>
<text x="100" y="678" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#7F8C8D">
<tspan>Recommendation: Prioritize cutting non-core project expenses while increasing marketing budget.</tspan>
</text>
</g>
</svg>
templates/charts/butterfly_chart.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" width="1280" height="720">
<!--
对比条形图(蝴蝶图)模板
用途: 左右对比、双向数据展示
适用场景: 人口金字塔、AB测试、男女对比、收支对比
支持: 8个类别,左右两组数据
-->
<defs>
<!-- 左侧渐变(蓝色) -->
<linearGradient id="leftGrad" x1="100%" y1="0%" x2="0%" y2="0%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1976D2;stop-opacity:1" />
</linearGradient>
<!-- 右侧渐变(粉色) -->
<linearGradient id="rightGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#E91E63;stop-opacity:1" />
<stop offset="100%" style="stop-color:#F06292;stop-opacity:1" />
</linearGradient>
<!-- 阴影 -->
<filter id="barShadow">
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
<feOffset dx="0" dy="1"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.2"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- 背景 -->
<rect width="1280" height="720" fill="#FFFFFF"/>
<!-- 标题 -->
<text x="640" y="55" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="32" font-weight="bold" fill="#2C3E50" text-anchor="middle">
<tspan>用户年龄分布对比</tspan>
</text>
<text x="640" y="90" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" fill="#7F8C8D" text-anchor="middle">
<tspan>2025年度用户画像分析 · 样本量 N=50,000</tspan>
</text>
<!-- 图例 -->
<g id="legend" transform="translate(480, 115)">
<rect x="0" y="0" width="20" height="20" rx="3" fill="url(#leftGrad)"/>
<text x="28" y="15" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="15" fill="#5D6D7E">
<tspan>男性用户</tspan>
</text>
<rect x="140" y="0" width="20" height="20" rx="3" fill="url(#rightGrad)"/>
<text x="168" y="15" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="15" fill="#5D6D7E">
<tspan>女性用户</tspan>
</text>
</g>
<!-- 图表区域 -->
<g id="chartArea">
<!-- 中轴线 -->
<line x1="640" y1="160" x2="640" y2="620" stroke="#BDC3C7" stroke-width="2"/>
<!-- 左侧X轴刻度 -->
<text x="160" y="645" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="middle"><tspan>20%</tspan></text>
<text x="280" y="645" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="middle"><tspan>15%</tspan></text>
<text x="400" y="645" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="middle"><tspan>10%</tspan></text>
<text x="520" y="645" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="middle"><tspan>5%</tspan></text>
<!-- 右侧X轴刻度 -->
<text x="760" y="645" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="middle"><tspan>5%</tspan></text>
<text x="880" y="645" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="middle"><tspan>10%</tspan></text>
<text x="1000" y="645" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="middle"><tspan>15%</tspan></text>
<text x="1120" y="645" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="middle"><tspan>20%</tspan></text>
<!-- 网格线 -->
<line x1="160" y1="160" x2="160" y2="620" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="280" y1="160" x2="280" y2="620" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="400" y1="160" x2="400" y2="620" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="520" y1="160" x2="520" y2="620" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="760" y1="160" x2="760" y2="620" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="880" y1="160" x2="880" y2="620" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="1000" y1="160" x2="1000" y2="620" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="1120" y1="160" x2="1120" y2="620" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<!--
数据说明:
每个年龄段的条形高度: 50px,间距: 6px
最大宽度: 480px (代表20%)
每1% = 24px
男性数据: 3%, 8%, 15%, 18%, 16%, 12%, 6%, 2%
女性数据: 4%, 10%, 17%, 15%, 14%, 10%, 5%, 3%
-->
<!-- 年龄段1: 60+ -->
<g transform="translate(0, 168)">
<!-- 左侧条形 (男性 3%) -->
<rect x="568" y="0" width="72" height="50" rx="4" fill="url(#leftGrad)" filter="url(#barShadow)"/>
<text x="555" y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#2196F3" text-anchor="end"><tspan>3%</tspan></text>
<!-- 中间标签 -->
<text x="640" y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="15" font-weight="600" fill="#2C3E50" text-anchor="middle"><tspan>60+</tspan></text>
<!-- 右侧条形 (女性 4%) -->
<rect x="640" y="0" width="96" height="50" rx="4" fill="url(#rightGrad)" filter="url(#barShadow)"/>
<text x="745" y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#E91E63"><tspan>4%</tspan></text>
</g>
<!-- 年龄段2: 50-59 -->
<g transform="translate(0, 224)">
<rect x="448" y="0" width="192" height="50" rx="4" fill="url(#leftGrad)" filter="url(#barShadow)"/>
<text x="435" y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#2196F3" text-anchor="end"><tspan>8%</tspan></text>
<text x="640" y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="15" font-weight="600" fill="#2C3E50" text-anchor="middle"><tspan>50-59</tspan></text>
<rect x="640" y="0" width="240" height="50" rx="4" fill="url(#rightGrad)" filter="url(#barShadow)"/>
<text x="890" y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#E91E63"><tspan>10%</tspan></text>
</g>
<!-- 年龄段3: 40-49 -->
<g transform="translate(0, 280)">
<rect x="280" y="0" width="360" height="50" rx="4" fill="url(#leftGrad)" filter="url(#barShadow)"/>
<text x="267" y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#2196F3" text-anchor="end"><tspan>15%</tspan></text>
<text x="640" y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="15" font-weight="600" fill="#2C3E50" text-anchor="middle"><tspan>40-49</tspan></text>
<rect x="640" y="0" width="408" height="50" rx="4" fill="url(#rightGrad)" filter="url(#barShadow)"/>
<text x="1058" y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#E91E63"><tspan>17%</tspan></text>
</g>
<!-- 年龄段4: 30-39 (峰值) -->
<g transform="translate(0, 336)">
<rect x="208" y="0" width="432" height="50" rx="4" fill="url(#leftGrad)" filter="url(#barShadow)"/>
<text x="195" y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="bold" fill="#2196F3" text-anchor="end"><tspan>18%</tspan></text>
<text x="640" y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="15" font-weight="600" fill="#2C3E50" text-anchor="middle"><tspan>30-39</tspan></text>
<rect x="640" y="0" width="360" height="50" rx="4" fill="url(#rightGrad)" filter="url(#barShadow)"/>
<text x="1010" y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#E91E63"><tspan>15%</tspan></text>
</g>
<!-- 年龄段5: 25-29 -->
<g transform="translate(0, 392)">
<rect x="256" y="0" width="384" height="50" rx="4" fill="url(#leftGrad)" filter="url(#barShadow)"/>
<text x="243" y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#2196F3" text-anchor="end"><tspan>16%</tspan></text>
<text x="640" y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="15" font-weight="600" fill="#2C3E50" text-anchor="middle"><tspan>25-29</tspan></text>
<rect x="640" y="0" width="336" height="50" rx="4" fill="url(#rightGrad)" filter="url(#barShadow)"/>
<text x="986" y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#E91E63"><tspan>14%</tspan></text>
</g>
<!-- 年龄段6: 20-24 -->
<g transform="translate(0, 448)">
<rect x="352" y="0" width="288" height="50" rx="4" fill="url(#leftGrad)" filter="url(#barShadow)"/>
<text x="339" y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#2196F3" text-anchor="end"><tspan>12%</tspan></text>
<text x="640" y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="15" font-weight="600" fill="#2C3E50" text-anchor="middle"><tspan>20-24</tspan></text>
<rect x="640" y="0" width="240" height="50" rx="4" fill="url(#rightGrad)" filter="url(#barShadow)"/>
<text x="890" y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#E91E63"><tspan>10%</tspan></text>
</g>
<!-- 年龄段7: 18-19 -->
<g transform="translate(0, 504)">
<rect x="496" y="0" width="144" height="50" rx="4" fill="url(#leftGrad)" filter="url(#barShadow)"/>
<text x="483" y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#2196F3" text-anchor="end"><tspan>6%</tspan></text>
<text x="640" y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="15" font-weight="600" fill="#2C3E50" text-anchor="middle"><tspan>18-19</tspan></text>
<rect x="640" y="0" width="120" height="50" rx="4" fill="url(#rightGrad)" filter="url(#barShadow)"/>
<text x="770" y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#E91E63"><tspan>5%</tspan></text>
</g>
<!-- 年龄段8: <18 -->
<g transform="translate(0, 560)">
<rect x="592" y="0" width="48" height="50" rx="4" fill="url(#leftGrad)" filter="url(#barShadow)"/>
<text x="579" y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#2196F3" text-anchor="end"><tspan>2%</tspan></text>
<text x="640" y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="15" font-weight="600" fill="#2C3E50" text-anchor="middle"><tspan><18</tspan></text>
<rect x="640" y="0" width="72" height="50" rx="4" fill="url(#rightGrad)" filter="url(#barShadow)"/>
<text x="722" y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#E91E63"><tspan>3%</tspan></text>
</g>
</g>
<!-- 统计摘要 -->
<g id="summary" transform="translate(60, 665)">
<rect x="0" y="0" width="280" height="40" rx="8" fill="#E3F2FD" stroke="#2196F3" stroke-width="1"/>
<text x="20" y="26" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#1976D2">
<tspan>👨 男性用户: 52% | 平均年龄: 34岁</tspan>
</text>
<rect x="300" y="0" width="280" height="40" rx="8" fill="#FCE4EC" stroke="#E91E63" stroke-width="1"/>
<text x="320" y="26" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#C2185B">
<tspan>👩 女性用户: 48% | 平均年龄: 32岁</tspan>
</text>
</g>
<!-- 数据来源 -->
<text x="1220" y="695" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#95A5A6" text-anchor="end">
<tspan>数据来源: 用户数据分析平台</tspan>
</text>
</svg>
templates/charts/charts_index.json
{
"meta": {
"total": 33,
"defaultViewBox": "0 0 1280 720",
"formats": ["ppt169"],
"filePattern": "{key}.svg",
"updated": "2026-03-22"
},
"categories": {
"comparison": {
"label": "Comparison",
"charts": ["bar_chart", "horizontal_bar_chart", "grouped_bar_chart", "stacked_bar_chart", "butterfly_chart", "bullet_chart", "dumbbell_chart", "waterfall_chart"]
},
"trend": {
"label": "Trend",
"charts": ["line_chart", "area_chart", "stacked_area_chart", "dual_axis_line_chart"]
},
"composition": {
"label": "Composition",
"charts": ["pie_chart", "donut_chart", "treemap_chart"]
},
"metrics": {
"label": "Metrics",
"charts": ["kpi_cards", "gauge_chart", "progress_bar_chart"]
},
"analysis": {
"label": "Analysis",
"charts": ["radar_chart", "scatter_chart", "funnel_chart", "matrix_2x2", "bubble_chart", "heatmap_chart", "pareto_chart", "box_plot_chart"]
},
"process": {
"label": "Project Management / Relationships",
"charts": ["gantt_chart", "timeline", "process_flow", "org_chart", "sankey_chart"]
},
"strategy": {
"label": "Strategic Frameworks",
"charts": ["swot_analysis", "porter_five_forces"]
}
},
"quickLookup": {
"ranking": ["horizontal_bar_chart", "bar_chart", "pareto_chart"],
"comparison": ["bar_chart", "grouped_bar_chart", "butterfly_chart", "dumbbell_chart"],
"trend": ["line_chart", "area_chart", "stacked_area_chart", "dual_axis_line_chart"],
"composition": ["donut_chart", "pie_chart", "treemap_chart"],
"kpi": ["kpi_cards", "bullet_chart", "gauge_chart", "progress_bar_chart"],
"conversion": ["funnel_chart", "sankey_chart", "waterfall_chart"],
"distribution": ["box_plot_chart", "heatmap_chart", "scatter_chart", "bubble_chart"],
"correlation": ["scatter_chart", "bubble_chart", "radar_chart"],
"roadmap": ["gantt_chart", "timeline", "process_flow"],
"relationship": ["org_chart", "sankey_chart", "matrix_2x2"],
"flow": ["process_flow", "sankey_chart", "waterfall_chart"],
"strategy": ["swot_analysis", "porter_five_forces", "matrix_2x2"]
},
"charts": {
"area_chart": {
"label": "Area Chart",
"summary": "Trend changes and cumulative data visualization",
"bestFor": ["Emphasizing total volume changes", "Cumulative trends for 1-2 series", "Continuous time series"],
"avoidFor": ["Precise category ranking", "More than 2 stacked series"],
"keywords": ["trend", "cumulative", "area chart", "time series"]
},
"bar_chart": {
"label": "Bar Chart",
"summary": "Category value comparison",
"bestFor": ["3-8 categories", "Single-series comparison", "Intuitive high-low difference display"],
"avoidFor": ["Time trends", "More than 12 long labels"],
"keywords": ["comparison", "ranking", "category", "bar chart"]
},
"box_plot_chart": {
"label": "Box Plot",
"summary": "Data distribution and outlier analysis",
"bestFor": ["Multi-group distribution comparison", "Showing median and dispersion", "Statistical analysis pages"],
"avoidFor": ["Single-value comparison in general business reports", "Front-page display when audience is unfamiliar with statistical charts"],
"keywords": ["distribution", "statistics", "outlier", "box plot"]
},
"bubble_chart": {
"label": "Bubble Chart",
"summary": "Three-dimensional data visualization",
"bestFor": ["Displaying 3 dimensions simultaneously", "Moderate sample size scatter distribution"],
"avoidFor": ["Scenarios requiring high precision readings", "Too many data points causing overlap"],
"keywords": ["correlation", "three-dimensional", "bubble chart", "competitive"]
},
"bullet_chart": {
"label": "Bullet Chart",
"summary": "Target vs actual value comparison",
"bestFor": ["Target vs actual", "Multiple metrics in parallel", "Space-limited dashboards"],
"avoidFor": ["Composition/proportion analysis", "Complex time series"],
"keywords": ["target", "actual", "performance", "bullet chart"]
},
"butterfly_chart": {
"label": "Butterfly Chart",
"summary": "Bidirectional left-right comparison",
"bestFor": ["Mirror-style bilateral comparison", "Two datasets for the same categories"],
"avoidFor": ["One-sided ranking", "Comparing more than 2 groups"],
"keywords": ["bidirectional comparison", "butterfly chart", "mirror", "demographics"]
},
"donut_chart": {
"label": "Donut Chart",
"summary": "Proportion and composition analysis",
"bestFor": ["3-6 part composition", "Center emphasis on key figures"],
"avoidFor": ["Too many subcategory proportions", "Precise ranking required"],
"keywords": ["proportion", "composition", "donut chart", "share"]
},
"dual_axis_line_chart": {
"label": "Dual-Axis Line Chart",
"summary": "Dual Y-axis trend comparison with different units",
"bestFor": ["Two trends with significantly different units", "Dual-metric analysis on the same time axis"],
"avoidFor": ["More than 2 core metrics", "When readers may misread dual axes"],
"keywords": ["dual axis", "line chart", "trend", "unit of measure"]
},
"dumbbell_chart": {
"label": "Dumbbell Chart",
"summary": "Multi-dimension scores or before-after state comparison",
"bestFor": ["Showing differences between two sets of values", "Before-and-after metric changes"],
"avoidFor": ["Side-by-side comparison of more than three groups", "Detailed time series"],
"keywords": ["difference", "before-after comparison", "dumbbell chart", "evaluation"]
},
"funnel_chart": {
"label": "Funnel Chart",
"summary": "Stage conversion and drop-off analysis",
"bestFor": ["3-5 stage conversion process", "Highlighting drop-off points"],
"avoidFor": ["Non-sequential category comparison", "Long processes with too many stages"],
"keywords": ["conversion", "funnel", "drop-off", "stage"]
},
"gantt_chart": {
"label": "Gantt Chart",
"summary": "Project scheduling and task management",
"bestFor": ["Scheduling 6-12 tasks", "Showing time spans and dependencies"],
"avoidFor": ["Pure milestone display", "Too many tasks"],
"keywords": ["scheduling", "Gantt chart", "project", "roadmap"]
},
"gauge_chart": {
"label": "Gauge Chart",
"summary": "Goal achievement visualization",
"bestFor": ["Single-metric achievement", "Emphasizing goal completion status"],
"avoidFor": ["Multi-category comparison", "Precise ranking analysis"],
"keywords": ["achievement", "gauge", "KPI", "progress"]
},
"grouped_bar_chart": {
"label": "Grouped Bar Chart",
"summary": "Multi-series grouped comparison",
"bestFor": ["2-4 series comparison", "Categories and series coexisting"],
"avoidFor": ["Too many series", "Long labels with too many categories"],
"keywords": ["grouped", "multi-series", "bar chart", "year-over-year"]
},
"heatmap_chart": {
"label": "Heatmap",
"summary": "Matrix intensity and pattern recognition",
"bestFor": ["Two-dimensional matrix data", "Identifying strong/weak patterns"],
"avoidFor": ["When precise cell values are needed", "Too many dimension labels making it hard to read"],
"keywords": ["heatmap", "matrix", "correlation", "intensity"]
},
"horizontal_bar_chart": {
"label": "Horizontal Bar Chart",
"summary": "Long-label ranking and category comparison",
"bestFor": ["5-12 item ranking", "Long-label categories"],
"avoidFor": ["Time trends", "Dual-unit analysis"],
"keywords": ["ranking", "horizontal bar chart", "long label", "comparison"]
},
"kpi_cards": {
"label": "KPI Cards",
"summary": "Key numeric metrics overview",
"bestFor": ["2x2 or 1x4 metrics overview", "Front-page summary"],
"avoidFor": ["Trend change display", "Complex multi-dimension correlation analysis"],
"keywords": ["KPI", "metric card", "overview", "dashboard"]
},
"line_chart": {
"label": "Line Chart",
"summary": "Time trend visualization",
"bestFor": ["Continuous time axis", "1-3 series trend comparison"],
"avoidFor": ["Discrete category ranking", "Too many series on a single page"],
"keywords": ["trend", "line chart", "growth", "time series"]
},
"matrix_2x2": {
"label": "2x2 Matrix",
"summary": "Dual-dimension positioning and priority analysis",
"bestFor": ["Two core dimensions", "Strategic trade-offs or classification positioning"],
"avoidFor": ["Single-dimension sorting", "Pages requiring precise numeric readings"],
"keywords": ["matrix", "quadrant", "priority", "positioning"]
},
"org_chart": {
"label": "Organization Chart",
"summary": "Organizational hierarchy and reporting relationships",
"bestFor": ["2-4 level organizational structure", "Clear relationship chain display"],
"avoidFor": ["Process step descriptions", "Complex network diagrams with many nodes"],
"keywords": ["organization", "org chart", "hierarchy", "reporting relationship"]
},
"pareto_chart": {
"label": "Pareto Chart",
"summary": "80/20 contribution analysis",
"bestFor": ["Identifying the vital few after sorting", "Bar-line combination showing cumulative contribution"],
"avoidFor": ["Data without clear top-heavy concentration", "Pure time trends"],
"keywords": ["Pareto", "80/20", "cumulative contribution", "priority"]
},
"pie_chart": {
"label": "Pie Chart",
"summary": "Basic proportion and composition",
"bestFor": ["3-6 part proportions", "Single-layer composition analysis"],
"avoidFor": ["Too many parts or very small differences", "Precise comparison of subtle differences needed"],
"keywords": ["proportion", "pie chart", "composition", "share"]
},
"porter_five_forces": {
"label": "Porter's Five Forces",
"summary": "Industry competitive structure analysis",
"bestFor": ["Strategic reports", "Industry entry and competitive landscape analysis"],
"avoidFor": ["Operational-level detail processes", "Pure data ranking pages"],
"keywords": ["strategy", "five forces", "industry analysis", "competition"]
},
"process_flow": {
"label": "Process Flow",
"summary": "Process steps and stage relationship visualization",
"bestFor": ["3-8 steps", "Sequential process description"],
"avoidFor": ["Complex network relationships", "Detailed scheduling"],
"keywords": ["process", "steps", "flowchart", "methodology"]
},
"progress_bar_chart": {
"label": "Progress Bar Chart",
"summary": "Multi-item progress and completion comparison",
"bestFor": ["Side-by-side comparison of multiple tasks", "Completion rate display"],
"avoidFor": ["Detailed time trends", "Complex composition analysis"],
"keywords": ["progress", "completion rate", "progress bar", "OKR"]
},
"radar_chart": {
"label": "Radar Chart",
"summary": "Multi-dimensional capability assessment",
"bestFor": ["4-8 dimensions", "Profile-based comprehensive comparison"],
"avoidFor": ["Too many dimensions", "Precise ranking required"],
"keywords": ["radar chart", "multi-dimensional assessment", "capability", "competitive"]
},
"sankey_chart": {
"label": "Sankey Diagram",
"summary": "Flow or resource direction visualization",
"bestFor": ["Three-level flow direction", "Emphasizing flow destination and loss"],
"avoidFor": ["Complex networks with too many nodes", "Precise ranking pages"],
"keywords": ["flow direction", "Sankey diagram", "conversion path", "resource allocation"]
},
"scatter_chart": {
"label": "Scatter Plot",
"summary": "Correlation and distribution analysis",
"bestFor": ["X/Y dual-dimension relationship", "Finding clusters and outliers"],
"avoidFor": ["Sequential processes", "Simple single-dimension ranking"],
"keywords": ["scatter plot", "correlation", "distribution", "outlier"]
},
"stacked_area_chart": {
"label": "Stacked Area Chart",
"summary": "Multi-series stacked trend",
"bestFor": ["Total and composition changing simultaneously", "2-4 series trends"],
"avoidFor": ["Too many series making it hard to read", "Precise comparison of subtle changes in middle layers"],
"keywords": ["stacked", "area chart", "trend", "composition change"]
},
"stacked_bar_chart": {
"label": "Stacked Bar Chart",
"summary": "Composition and total side-by-side comparison",
"bestFor": ["Category total + composition display", "2-4 composition parts"],
"avoidFor": ["Comparing subtle differences in middle segments", "Too many categories and parts"],
"keywords": ["stacked bar chart", "composition", "contribution", "total"]
},
"swot_analysis": {
"label": "SWOT Analysis",
"summary": "Strengths, weaknesses, opportunities, and threats analysis",
"bestFor": ["Four-quadrant strategic analysis", "Executive communication and strategy discussion"],
"avoidFor": ["Pure quantitative data ranking", "Detailed execution scheduling"],
"keywords": ["SWOT", "strategy", "strengths and weaknesses", "opportunities and threats"]
},
"timeline": {
"label": "Timeline",
"summary": "Milestones and historical evolution display",
"bestFor": ["3-8 key nodes", "Storytelling in chronological order"],
"avoidFor": ["Detailed task scheduling", "Complex relationship networks"],
"keywords": ["timeline", "milestone", "history", "evolution"]
},
"treemap_chart": {
"label": "Treemap",
"summary": "Hierarchical area proportion display",
"bestFor": ["Hierarchical proportion data", "Comparing size and grouping simultaneously"],
"avoidFor": ["Time trends", "Categories requiring strict sorting"],
"keywords": ["treemap", "area proportion", "hierarchy", "composition"]
},
"waterfall_chart": {
"label": "Waterfall Chart",
"summary": "Incremental change breakdown",
"bestFor": ["Start-to-end change decomposition", "Showing positive and negative contributions"],
"avoidFor": ["Time trend line analysis", "Pure proportion composition pages"],
"keywords": ["waterfall chart", "change breakdown", "contribution", "bridge analysis"]
}
}
}
templates/charts/donut_chart.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" width="1280" height="720">
<!--
环形图模板
用途: 占比和构成分析
适用场景: 市场份额、预算分配、结构分析
支持: 5个分块 (推荐3-6个)
-->
<defs>
<!-- 扇区阴影 -->
<filter id="sectorShadow">
<feGaussianBlur in="SourceAlpha" stdDeviation="3"/>
<feOffset dx="0" dy="2"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.25"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- 背景 -->
<rect width="1280" height="720" fill="#FFFFFF"/>
<!-- 标题 -->
<text x="60" y="80" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="32" font-weight="bold" fill="#2C3E50">
<tspan>产品线营收占比</tspan>
</text>
<text x="60" y="115" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" fill="#7F8C8D">
<tspan>2025年第三季度 · 总收入 5.2亿元</tspan>
</text>
<!-- 环形图中心 -->
<g id="donutChart" transform="translate(400, 410)">
<!--
圆心: (400, 410)
外半径: 180px
内半径: 100px
起始角度: -90° (12点方向)
数据:
1. 企业服务: 35% (蓝色 #2196F3) - 角度: 126°
2. 消费电子: 28% (绿色 #4CAF50) - 角度: 100.8°
3. 云计算: 20% (橙色 #FF9800) - 角度: 72°
4. 物联网: 12% (紫色 #9C27B0) - 角度: 43.2°
5. 其他: 5% (灰色 #95A5A6) - 角度: 18°
-->
<!-- 扇区1: 企业服务 35% (从-90°到36°) -->
<path d="M 0,-180 A 180,180 0 0,1 156.36,88.18 L 86.87,49.04 A 100,100 0 0,0 0,-100 Z"
fill="#2196F3" filter="url(#sectorShadow)"/>
<!-- 扇区2: 消费电子 28% (从36°到136.8°) -->
<path d="M 156.36,88.18 A 180,180 0 0,1 -123.43,130.14 L -68.57,72.30 A 100,100 0 0,0 86.87,49.04 Z"
fill="#4CAF50" filter="url(#sectorShadow)"/>
<!-- 扇区3: 云计算 20% (从136.8°到208.8°) -->
<path d="M -123.43,130.14 A 180,180 0 0,1 -175.36,-51.76 L -97.42,-28.76 A 100,100 0 0,0 -68.57,72.30 Z"
fill="#FF9800" filter="url(#sectorShadow)"/>
<!-- 扇区4: 物联网 12% (从208.8°到252°) -->
<path d="M -175.36,-51.76 A 180,180 0 0,1 -54.46,-171.54 L -30.26,-95.30 A 100,100 0 0,0 -97.42,-28.76 Z"
fill="#9C27B0" filter="url(#sectorShadow)"/>
<!-- 扇区5: 其他 5% (从252°到270°/360°-90°) -->
<path d="M -54.46,-171.54 A 180,180 0 0,1 0,-180 L 0,-100 A 100,100 0 0,0 -30.26,-95.30 Z"
fill="#95A5A6" filter="url(#sectorShadow)"/>
<!-- 中心白色圆 -->
<circle cx="0" cy="0" r="100" fill="#FFFFFF"/>
<!-- 中心文字 -->
<text x="0" y="-15" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="48" font-weight="bold" fill="#2C3E50" text-anchor="middle">
<tspan>5.2亿</tspan>
</text>
<text x="0" y="20" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" fill="#7F8C8D" text-anchor="middle">
<tspan>总收入</tspan>
</text>
</g>
<!-- 百分比标签 -->
<!-- 标签1: 企业服务 35% -->
<g>
<line x1="530" y1="295" x2="620" y2="250" stroke="#2196F3" stroke-width="1.5"/>
<circle cx="530" cy="295" r="3" fill="#2196F3"/>
<text x="630" y="255" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="600" fill="#2196F3">
<tspan>35%</tspan>
</text>
</g>
<!-- 标签2: 消费电子 28% -->
<g>
<line x1="510" y1="500" x2="620" y2="540" stroke="#4CAF50" stroke-width="1.5"/>
<circle cx="510" cy="500" r="3" fill="#4CAF50"/>
<text x="630" y="545" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="600" fill="#4CAF50">
<tspan>28%</tspan>
</text>
</g>
<!-- 标签3: 云计算 20% -->
<g>
<line x1="250" y1="480" x2="150" y2="520" stroke="#FF9800" stroke-width="1.5"/>
<circle cx="250" cy="480" r="3" fill="#FF9800"/>
<text x="140" y="525" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="600" fill="#FF9800" text-anchor="end">
<tspan>20%</tspan>
</text>
</g>
<!-- 标签4: 物联网 12% -->
<g>
<line x1="310" y1="265" x2="200" y2="220" stroke="#9C27B0" stroke-width="1.5"/>
<circle cx="310" cy="265" r="3" fill="#9C27B0"/>
<text x="190" y="225" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="600" fill="#9C27B0" text-anchor="end">
<tspan>12%</tspan>
</text>
</g>
<!-- 标签5: 其他 5% -->
<g>
<line x1="375" y1="240" x2="340" y2="180" stroke="#95A5A6" stroke-width="1.5"/>
<circle cx="375" cy="240" r="3" fill="#95A5A6"/>
<text x="330" y="185" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="600" fill="#95A5A6" text-anchor="end">
<tspan>5%</tspan>
</text>
</g>
<!-- 右侧详细列表 -->
<g id="detailList">
<!-- 列表标题 -->
<text x="750" y="220" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="22" font-weight="600" fill="#2C3E50">
<tspan>详细数据</tspan>
</text>
<!-- 项目1: 企业服务 -->
<rect x="750" y="250" width="8" height="40" rx="4" fill="#2196F3"/>
<text x="775" y="270" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" fill="#5D6D7E">
<tspan>企业服务</tspan>
</text>
<text x="1100" y="270" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="600" fill="#2C3E50" text-anchor="end">
<tspan>¥1.82亿</tspan>
</text>
<text x="1180" y="270" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" fill="#2196F3" text-anchor="end">
<tspan>35%</tspan>
</text>
<!-- 项目2: 消费电子 -->
<rect x="750" y="310" width="8" height="40" rx="4" fill="#4CAF50"/>
<text x="775" y="330" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" fill="#5D6D7E">
<tspan>消费电子</tspan>
</text>
<text x="1100" y="330" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="600" fill="#2C3E50" text-anchor="end">
<tspan>¥1.46亿</tspan>
</text>
<text x="1180" y="330" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" fill="#4CAF50" text-anchor="end">
<tspan>28%</tspan>
</text>
<!-- 项目3: 云计算 -->
<rect x="750" y="370" width="8" height="40" rx="4" fill="#FF9800"/>
<text x="775" y="390" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" fill="#5D6D7E">
<tspan>云计算</tspan>
</text>
<text x="1100" y="390" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="600" fill="#2C3E50" text-anchor="end">
<tspan>¥1.04亿</tspan>
</text>
<text x="1180" y="390" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" fill="#FF9800" text-anchor="end">
<tspan>20%</tspan>
</text>
<!-- 项目4: 物联网 -->
<rect x="750" y="430" width="8" height="40" rx="4" fill="#9C27B0"/>
<text x="775" y="450" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" fill="#5D6D7E">
<tspan>物联网</tspan>
</text>
<text x="1100" y="450" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="600" fill="#2C3E50" text-anchor="end">
<tspan>¥0.62亿</tspan>
</text>
<text x="1180" y="450" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" fill="#9C27B0" text-anchor="end">
<tspan>12%</tspan>
</text>
<!-- 项目5: 其他 -->
<rect x="750" y="490" width="8" height="40" rx="4" fill="#95A5A6"/>
<text x="775" y="510" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" fill="#5D6D7E">
<tspan>其他</tspan>
</text>
<text x="1100" y="510" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="600" fill="#2C3E50" text-anchor="end">
<tspan>¥0.26亿</tspan>
</text>
<text x="1180" y="510" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" fill="#95A5A6" text-anchor="end">
<tspan>5%</tspan>
</text>
<!-- 分隔线 -->
<line x1="750" y1="550" x2="1180" y2="550" stroke="#E0E0E0" stroke-width="2"/>
<!-- 合计 -->
<text x="775" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" font-weight="600" fill="#2C3E50">
<tspan>合计</tspan>
</text>
<text x="1100" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="bold" fill="#2C3E50" text-anchor="end">
<tspan>¥5.20亿</tspan>
</text>
<text x="1180" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" font-weight="600" fill="#2C3E50" text-anchor="end">
<tspan>100%</tspan>
</text>
</g>
<!-- 数据来源 -->
<text x="60" y="695" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#95A5A6">
<tspan>数据来源: 财务部门季度报告</tspan>
</text>
</svg>
templates/charts/dual_axis_line_chart.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" width="1280" height="720">
<!--
双Y轴折线图模板
用途: 展示两个不同量纲指标的时序变化
场景: 温湿度监控、销量与利润率对比、流量与转化率分析等
示例数据: 温度(°C) + 相对湿度(%)
配色: 黑白专业风格 | 左轴:圆点实线 | 右轴:方块虚线
画布: 1280×720 (16:9)
-->
<defs>
<!-- 数据点阴影 -->
<filter id="dotShadow">
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
<feOffset dx="0" dy="1"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.3"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- 背景 -->
<rect width="1280" height="720" fill="#FFFFFF"/>
<!-- 标题 -->
<text x="60" y="70" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="32" font-weight="bold" fill="#000000">
<tspan>温湿度测试循环曲线</tspan>
</text>
<text x="60" y="105" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" fill="#666666">
<tspan>环境测试 · 总时长:720分钟(12小时)</tspan>
</text>
<!-- 阶段标注背景 -->
<rect x="140" y="130" width="158" height="40" fill="#F5F5F5" stroke="#CCCCCC" stroke-width="1" rx="4"/>
<rect x="298" y="130" width="237" height="40" fill="#F5F5F5" stroke="#CCCCCC" stroke-width="1" rx="4"/>
<rect x="535" y="130" width="158" height="40" fill="#F5F5F5" stroke="#CCCCCC" stroke-width="1" rx="4"/>
<rect x="693" y="130" width="237" height="40" fill="#F5F5F5" stroke="#CCCCCC" stroke-width="1" rx="4"/>
<rect x="930" y="130" width="158" height="40" fill="#F5F5F5" stroke="#CCCCCC" stroke-width="1" rx="4"/>
<!-- 阶段文字标注 -->
<text x="219" y="155" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" font-weight="600" fill="#333333" text-anchor="middle">
<tspan>1.升温(60min)</tspan>
</text>
<text x="416.5" y="155" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" font-weight="600" fill="#333333" text-anchor="middle">
<tspan>2.高温保持(240min)</tspan>
</text>
<text x="614" y="155" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" font-weight="600" fill="#333333" text-anchor="middle">
<tspan>3.降温(120min)</tspan>
</text>
<text x="811.5" y="155" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" font-weight="600" fill="#333333" text-anchor="middle">
<tspan>4.低温保持(240min)</tspan>
</text>
<text x="1009" y="155" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" font-weight="600" fill="#333333" text-anchor="middle">
<tspan>5.复位(60min)</tspan>
</text>
<!-- 图表区域 -->
<g id="chartArea">
<!-- 阶段分隔线 -->
<line x1="298" y1="190" x2="298" y2="580" stroke="#BDBDBD" stroke-width="1.5" stroke-dasharray="5,5"/>
<line x1="535" y1="190" x2="535" y2="580" stroke="#BDBDBD" stroke-width="1.5" stroke-dasharray="5,5"/>
<line x1="693" y1="190" x2="693" y2="580" stroke="#BDBDBD" stroke-width="1.5" stroke-dasharray="5,5"/>
<line x1="930" y1="190" x2="930" y2="580" stroke="#BDBDBD" stroke-width="1.5" stroke-dasharray="5,5"/>
<!-- 水平网格线(与刻度精确对齐)-->
<!-- 底部:-60°C / 0% -->
<line x1="140" y1="580" x2="1088" y2="580" stroke="#E0E0E0" stroke-width="1.5"/>
<!-- -30°C / 20% -->
<line x1="140" y1="502" x2="1088" y2="502" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<!-- 0°C / 40% -->
<line x1="140" y1="424" x2="1088" y2="424" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<!-- 30°C / 60% -->
<line x1="140" y1="346" x2="1088" y2="346" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<!-- 60°C / 80% -->
<line x1="140" y1="268" x2="1088" y2="268" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<!-- 顶部:90°C / 100% -->
<line x1="140" y1="190" x2="1088" y2="190" stroke="#E0E0E0" stroke-width="1.5"/>
<!-- 左Y轴 - 温度刻度 (°C,间距30度) -->
<text x="50" y="585" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#000000" text-anchor="middle">
<tspan>-60</tspan>
</text>
<text x="50" y="507" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#333333" text-anchor="middle">
<tspan>-30</tspan>
</text>
<text x="50" y="429" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#333333" text-anchor="middle">
<tspan>0</tspan>
</text>
<text x="50" y="351" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#333333" text-anchor="middle">
<tspan>30</tspan>
</text>
<text x="50" y="273" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#333333" text-anchor="middle">
<tspan>60</tspan>
</text>
<text x="50" y="195" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#000000" text-anchor="middle">
<tspan>90</tspan>
</text>
<!-- 左Y轴标题 -->
<text x="30" y="385" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#000000" text-anchor="middle" transform="rotate(-90, 30, 385)">
<tspan>温度 (℃)</tspan>
</text>
<!-- 右Y轴 - 湿度刻度 (%,间距20%) -->
<text x="1190" y="585" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#000000" text-anchor="middle">
<tspan>0%</tspan>
</text>
<text x="1190" y="507" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#555555" text-anchor="middle">
<tspan>20%</tspan>
</text>
<text x="1190" y="429" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#555555" text-anchor="middle">
<tspan>40%</tspan>
</text>
<text x="1190" y="351" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#555555" text-anchor="middle">
<tspan>60%</tspan>
</text>
<text x="1190" y="273" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#555555" text-anchor="middle">
<tspan>80%</tspan>
</text>
<text x="1190" y="195" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#000000" text-anchor="middle">
<tspan>100%</tspan>
</text>
<!-- 右Y轴标题 -->
<text x="1250" y="385" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#000000" text-anchor="middle" transform="rotate(90, 1250, 385)">
<tspan>相对湿度 (%)</tspan>
</text>
<!-- 坐标轴 -->
<line x1="140" y1="190" x2="140" y2="580" stroke="#616161" stroke-width="2"/>
<line x1="140" y1="580" x2="1088" y2="580" stroke="#616161" stroke-width="2"/>
<line x1="1088" y1="190" x2="1088" y2="580" stroke="#616161" stroke-width="2"/>
<!-- X轴时间标签 -->
<text x="140" y="610" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#333333" text-anchor="middle">
<tspan>0</tspan>
</text>
<text x="298" y="610" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#333333" text-anchor="middle">
<tspan>60</tspan>
</text>
<text x="535" y="610" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#333333" text-anchor="middle">
<tspan>300</tspan>
</text>
<text x="693" y="610" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#333333" text-anchor="middle">
<tspan>420</tspan>
</text>
<text x="930" y="610" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#333333" text-anchor="middle">
<tspan>660</tspan>
</text>
<text x="1088" y="610" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#333333" text-anchor="middle">
<tspan>720</tspan>
</text>
<!-- X轴标题 -->
<text x="614" y="640" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#000000" text-anchor="middle">
<tspan>时间 (min)</tspan>
</text>
<!-- 温度曲线(黑色实线+圆点)-->
<!-- 数据点坐标精确计算:
温度范围:-60°C到90°C,跨度150°C
Y = 580 - ((temp+60)/150)*390
20°C → Y = 580 - (80/150)*390 = 580 - 208 = 372
85°C → Y = 580 - (145/150)*390 = 580 - 377 = 203
-40°C → Y = 580 - (20/150)*390 = 580 - 52 = 528
-->
<polyline points="140,372 298,203 535,203 693,528 930,528 1088,372"
fill="none" stroke="#000000" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<!-- 温度数据点(圆点)-->
<circle cx="140" cy="372" r="5" fill="#000000" stroke="#FFFFFF" stroke-width="2" filter="url(#dotShadow)"/>
<circle cx="298" cy="203" r="5" fill="#000000" stroke="#FFFFFF" stroke-width="2" filter="url(#dotShadow)"/>
<circle cx="535" cy="203" r="5" fill="#000000" stroke="#FFFFFF" stroke-width="2" filter="url(#dotShadow)"/>
<circle cx="693" cy="528" r="5" fill="#000000" stroke="#FFFFFF" stroke-width="2" filter="url(#dotShadow)"/>
<circle cx="930" cy="528" r="5" fill="#000000" stroke="#FFFFFF" stroke-width="2" filter="url(#dotShadow)"/>
<circle cx="1088" cy="372" r="5" fill="#000000" stroke="#FFFFFF" stroke-width="2" filter="url(#dotShadow)"/>
<!-- 湿度曲线(深灰色虚线+方块点)-->
<!-- 数据点坐标计算(0%-100%范围):
Y = 580 - (湿度% / 100) * 390
65% → Y=327, 85% → Y=248, 30% → Y=463
-->
<polyline points="140,327 298,248 535,248 693,463 930,463 1088,327"
fill="none" stroke="#666666" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="8,5"/>
<!-- 湿度数据点(方块)-->
<rect x="135" y="322" width="10" height="10" fill="#666666" stroke="#FFFFFF" stroke-width="2" filter="url(#dotShadow)"/>
<rect x="293" y="243" width="10" height="10" fill="#666666" stroke="#FFFFFF" stroke-width="2" filter="url(#dotShadow)"/>
<rect x="530" y="243" width="10" height="10" fill="#666666" stroke="#FFFFFF" stroke-width="2" filter="url(#dotShadow)"/>
<rect x="688" y="458" width="10" height="10" fill="#666666" stroke="#FFFFFF" stroke-width="2" filter="url(#dotShadow)"/>
<rect x="925" y="458" width="10" height="10" fill="#666666" stroke="#FFFFFF" stroke-width="2" filter="url(#dotShadow)"/>
<rect x="1083" y="322" width="10" height="10" fill="#666666" stroke="#FFFFFF" stroke-width="2" filter="url(#dotShadow)"/>
<!-- 关键数据标注(带背景框,避免重叠,与曲线坐标对齐)-->
<!-- 起始点 (x=140, 温度y=372) - 温度 -->
<rect x="145" y="357" width="42" height="20" fill="#FFFFFF" stroke="#000000" stroke-width="0.5" rx="3" opacity="0.9"/>
<text x="166" y="370" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" font-weight="600" fill="#000000" text-anchor="middle">
<tspan>20℃</tspan>
</text>
<!-- 起始点 (x=140, 湿度y=327) - 湿度 -->
<rect x="145" y="312" width="42" height="20" fill="#FFFFFF" stroke="#666666" stroke-width="0.5" rx="3" opacity="0.9"/>
<text x="166" y="325" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" font-weight="600" fill="#666666" text-anchor="middle">
<tspan>65%</tspan>
</text>
<!-- 高温点 (x=298, y=203) - 温度 -->
<rect x="303" y="188" width="42" height="20" fill="#FFFFFF" stroke="#000000" stroke-width="0.5" rx="3" opacity="0.9"/>
<text x="324" y="201" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" font-weight="600" fill="#000000" text-anchor="middle">
<tspan>85℃</tspan>
</text>
<!-- 高湿点 (y=248) - 湿度 -->
<rect x="395" y="233" width="42" height="20" fill="#FFFFFF" stroke="#666666" stroke-width="0.5" rx="3" opacity="0.9"/>
<text x="416" y="246" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" font-weight="600" fill="#666666" text-anchor="middle">
<tspan>85%</tspan>
</text>
<!-- 低温点 (x=693, y=528) - 温度 -->
<rect x="698" y="513" width="48" height="20" fill="#FFFFFF" stroke="#000000" stroke-width="0.5" rx="3" opacity="0.9"/>
<text x="722" y="526" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" font-weight="600" fill="#000000" text-anchor="middle">
<tspan>-40℃</tspan>
</text>
<!-- 低湿点 (y=463) - 湿度 -->
<rect x="790" y="448" width="42" height="20" fill="#FFFFFF" stroke="#666666" stroke-width="0.5" rx="3" opacity="0.9"/>
<text x="811" y="461" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" font-weight="600" fill="#666666" text-anchor="middle">
<tspan>30%</tspan>
</text>
<!-- 复位点 (x=1088, 温度y=372) - 温度 -->
<rect x="1020" y="357" width="42" height="20" fill="#FFFFFF" stroke="#000000" stroke-width="0.5" rx="3" opacity="0.9"/>
<text x="1041" y="370" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" font-weight="600" fill="#000000" text-anchor="middle">
<tspan>20℃</tspan>
</text>
<!-- 复位点 (湿度y=327) - 湿度 -->
<rect x="1020" y="312" width="42" height="20" fill="#FFFFFF" stroke="#666666" stroke-width="0.5" rx="3" opacity="0.9"/>
<text x="1041" y="325" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" font-weight="600" fill="#666666" text-anchor="middle">
<tspan>65%</tspan>
</text>
</g>
<!-- 图例 -->
<g id="legend">
<rect x="420" y="660" width="440" height="45" fill="#F5F5F5" stroke="#CCCCCC" stroke-width="1" rx="6"/>
<!-- 温度图例(黑色实线+圆点)-->
<line x1="450" y1="682" x2="490" y2="682" stroke="#000000" stroke-width="3"/>
<circle cx="470" cy="682" r="5" fill="#000000" stroke="#FFFFFF" stroke-width="2"/>
<text x="505" y="687" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="15" font-weight="600" fill="#000000">
<tspan>温度 (℃)</tspan>
</text>
<!-- 湿度图例(深灰虚线+方块)-->
<line x1="650" y1="682" x2="690" y2="682" stroke="#666666" stroke-width="2.5" stroke-dasharray="8,5"/>
<rect x="665" y="677" width="10" height="10" fill="#666666" stroke="#FFFFFF" stroke-width="2"/>
<text x="705" y="687" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="15" font-weight="600" fill="#666666">
<tspan>相对湿度 (%)</tspan>
</text>
</g>
</svg>
templates/charts/dumbbell_chart.svg
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" width="1280" height="720">
<!--
Dumbbell Chart / Dot Plot Template
Purpose: Multi-dimensional score comparison, index ranking
Scenarios: Country rankings, competitor evaluation, composite index display
Support: 8-12 categories x 3-5 dimensions
-->
<!-- Background -->
<rect width="1280" height="720" fill="#FFFFFF"/>
<!-- Title -->
<text x="60" y="55" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="28" font-weight="bold" fill="#1A1A1A">
<tspan>AI Economic Impact Index Rankings</tspan>
</text>
<text x="60" y="85" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" fill="#666666">
<tspan>Potential to realize AI benefits by economy - 2025 Report</tspan>
</text>
<!-- Legend -->
<g id="legend" transform="translate(60, 110)">
<circle cx="8" cy="8" r="6" fill="#1A1A1A"/>
<text x="22" y="12" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#333333">
<tspan>Overall Score</tspan>
</text>
<circle cx="138" cy="8" r="6" fill="#E91E8C"/>
<text x="152" y="12" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#333333">
<tspan>Innovation</tspan>
</text>
<circle cx="248" cy="8" r="6" fill="#00B8D4"/>
<text x="262" y="12" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#333333">
<tspan>Diffusion</tspan>
</text>
<circle cx="348" cy="8" r="6" fill="#FFD600"/>
<text x="362" y="12" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#333333">
<tspan>Adaptation</tspan>
</text>
</g>
<!-- Chart Area -->
<g id="chartArea" transform="translate(0, 140)">
<!-- X-axis grid lines -->
<line x1="200" y1="0" x2="200" y2="480" stroke="#E5E5E5" stroke-width="1"/>
<line x1="380" y1="0" x2="380" y2="480" stroke="#E5E5E5" stroke-width="1"/>
<line x1="560" y1="0" x2="560" y2="480" stroke="#E5E5E5" stroke-width="1"/>
<line x1="740" y1="0" x2="740" y2="480" stroke="#E5E5E5" stroke-width="1"/>
<line x1="920" y1="0" x2="920" y2="480" stroke="#E5E5E5" stroke-width="1"/>
<line x1="1100" y1="0" x2="1100" y2="480" stroke="#E5E5E5" stroke-width="1"/>
<!-- X-axis baseline -->
<line x1="200" y1="480" x2="1200" y2="480" stroke="#CCCCCC" stroke-width="1"/>
<!-- X-axis labels -->
<text x="200" y="505" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#999999" text-anchor="middle"><tspan>0</tspan></text>
<text x="380" y="505" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#999999" text-anchor="middle"><tspan>20</tspan></text>
<text x="560" y="505" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#999999" text-anchor="middle"><tspan>40</tspan></text>
<text x="740" y="505" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#999999" text-anchor="middle"><tspan>60</tspan></text>
<text x="920" y="505" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#999999" text-anchor="middle"><tspan>80</tspan></text>
<text x="1100" y="505" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#999999" text-anchor="middle"><tspan>100</tspan></text>
<!-- Data rows: x = 200 + (value/100) * 900 -->
<!-- Row 1: US - Overall:78 Innovation:68 Diffusion:75 Adaptation:80 -->
<g class="data-row" transform="translate(0, 30)">
<text x="190" y="5" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#333333" text-anchor="end"><tspan>US</tspan></text>
<line x1="812" y1="0" x2="920" y2="0" stroke="#CCCCCC" stroke-width="1.5"/>
<circle cx="812" cy="0" r="7" fill="#E91E8C"/>
<circle cx="875" cy="0" r="7" fill="#00B8D4"/>
<circle cx="920" cy="0" r="7" fill="#FFD600"/>
<circle cx="902" cy="0" r="7" fill="#1A1A1A"/>
</g>
<!-- Row 2: Singapore - Overall:72 Innovation:55 Diffusion:68 Adaptation:75 -->
<g class="data-row" transform="translate(0, 78)">
<text x="190" y="5" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#333333" text-anchor="end"><tspan>Singapore</tspan></text>
<line x1="695" y1="0" x2="875" y2="0" stroke="#CCCCCC" stroke-width="1.5"/>
<circle cx="695" cy="0" r="7" fill="#E91E8C"/>
<circle cx="812" cy="0" r="7" fill="#00B8D4"/>
<circle cx="875" cy="0" r="7" fill="#FFD600"/>
<circle cx="848" cy="0" r="7" fill="#1A1A1A"/>
</g>
<!-- Row 3: UK - Overall:70 Innovation:52 Diffusion:65 Adaptation:78 -->
<g class="data-row" transform="translate(0, 126)">
<text x="190" y="5" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#333333" text-anchor="end"><tspan>UK</tspan></text>
<line x1="668" y1="0" x2="902" y2="0" stroke="#CCCCCC" stroke-width="1.5"/>
<circle cx="668" cy="0" r="7" fill="#E91E8C"/>
<circle cx="785" cy="0" r="7" fill="#00B8D4"/>
<circle cx="902" cy="0" r="7" fill="#FFD600"/>
<circle cx="830" cy="0" r="7" fill="#1A1A1A"/>
</g>
<!-- Row 4: Switzerland - Overall:68 Innovation:50 Diffusion:62 Adaptation:76 -->
<g class="data-row" transform="translate(0, 174)">
<text x="190" y="5" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#333333" text-anchor="end"><tspan>Switzerland</tspan></text>
<line x1="650" y1="0" x2="884" y2="0" stroke="#CCCCCC" stroke-width="1.5"/>
<circle cx="650" cy="0" r="7" fill="#E91E8C"/>
<circle cx="758" cy="0" r="7" fill="#00B8D4"/>
<circle cx="884" cy="0" r="7" fill="#FFD600"/>
<circle cx="812" cy="0" r="7" fill="#1A1A1A"/>
</g>
<!-- Row 5: Sweden - Overall:65 Innovation:45 Diffusion:58 Adaptation:78 -->
<g class="data-row" transform="translate(0, 222)">
<text x="190" y="5" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#333333" text-anchor="end"><tspan>Sweden</tspan></text>
<line x1="605" y1="0" x2="902" y2="0" stroke="#CCCCCC" stroke-width="1.5"/>
<circle cx="605" cy="0" r="7" fill="#E91E8C"/>
<circle cx="722" cy="0" r="7" fill="#00B8D4"/>
<circle cx="902" cy="0" r="7" fill="#FFD600"/>
<circle cx="785" cy="0" r="7" fill="#1A1A1A"/>
</g>
<!-- Row 6: South Korea - Overall:62 Innovation:48 Diffusion:55 Adaptation:65 -->
<g class="data-row" transform="translate(0, 270)">
<text x="190" y="5" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#333333" text-anchor="end"><tspan>South Korea</tspan></text>
<line x1="632" y1="0" x2="785" y2="0" stroke="#CCCCCC" stroke-width="1.5"/>
<circle cx="632" cy="0" r="7" fill="#E91E8C"/>
<circle cx="695" cy="0" r="7" fill="#00B8D4"/>
<circle cx="785" cy="0" r="7" fill="#FFD600"/>
<circle cx="758" cy="0" r="7" fill="#1A1A1A"/>
</g>
<!-- Row 7: Germany - Overall:58 Innovation:42 Diffusion:52 Adaptation:68 -->
<g class="data-row" transform="translate(0, 318)">
<text x="190" y="5" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#333333" text-anchor="end"><tspan>Germany</tspan></text>
<line x1="578" y1="0" x2="812" y2="0" stroke="#CCCCCC" stroke-width="1.5"/>
<circle cx="578" cy="0" r="7" fill="#E91E8C"/>
<circle cx="668" cy="0" r="7" fill="#00B8D4"/>
<circle cx="812" cy="0" r="7" fill="#FFD600"/>
<circle cx="722" cy="0" r="7" fill="#1A1A1A"/>
</g>
<!-- Row 8: China - Overall:52 Innovation:38 Diffusion:45 Adaptation:55 -->
<g class="data-row" transform="translate(0, 366)">
<text x="190" y="5" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#333333" text-anchor="end"><tspan>China</tspan></text>
<line x1="542" y1="0" x2="695" y2="0" stroke="#CCCCCC" stroke-width="1.5"/>
<circle cx="542" cy="0" r="7" fill="#E91E8C"/>
<circle cx="605" cy="0" r="7" fill="#00B8D4"/>
<circle cx="695" cy="0" r="7" fill="#FFD600"/>
<circle cx="668" cy="0" r="7" fill="#1A1A1A"/>
</g>
<!-- Row 9: Brazil - Overall:35 Innovation:18 Diffusion:28 Adaptation:38 -->
<g class="data-row" transform="translate(0, 414)">
<text x="190" y="5" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#333333" text-anchor="end"><tspan>Brazil</tspan></text>
<line x1="362" y1="0" x2="542" y2="0" stroke="#CCCCCC" stroke-width="1.5"/>
<circle cx="362" cy="0" r="7" fill="#E91E8C"/>
<circle cx="452" cy="0" r="7" fill="#00B8D4"/>
<circle cx="542" cy="0" r="7" fill="#FFD600"/>
<circle cx="515" cy="0" r="7" fill="#1A1A1A"/>
</g>
<!-- Row 10: Argentina - Overall:28 Innovation:12 Diffusion:22 Adaptation:32 -->
<g class="data-row" transform="translate(0, 462)">
<text x="190" y="5" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#333333" text-anchor="end"><tspan>Argentina</tspan></text>
<line x1="308" y1="0" x2="488" y2="0" stroke="#CCCCCC" stroke-width="1.5"/>
<circle cx="308" cy="0" r="7" fill="#E91E8C"/>
<circle cx="398" cy="0" r="7" fill="#00B8D4"/>
<circle cx="488" cy="0" r="7" fill="#FFD600"/>
<circle cx="452" cy="0" r="7" fill="#1A1A1A"/>
</g>
</g>
<!-- Source -->
<text x="60" y="695" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="12" fill="#999999">
<tspan>Source: Capital Economics AI Economic Impact Index</tspan>
</text>
</svg>
templates/charts/funnel_chart.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" width="1280" height="720">
<!--
漏斗图模板
用途: 转化流程分析
适用场景: 销售漏斗、用户转化、流程效率
支持: 4个阶段 (推荐3-5个)
-->
<defs>
<!-- 漏斗渐变 -->
<linearGradient id="funnelGrad1" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#005587;stop-opacity:1" />
<stop offset="100%" style="stop-color:#003A5C;stop-opacity:1" />
</linearGradient>
<linearGradient id="funnelGrad2" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#0076A8;stop-opacity:1" />
<stop offset="100%" style="stop-color:#005587;stop-opacity:1" />
</linearGradient>
<linearGradient id="funnelGrad3" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#4A90A4;stop-opacity:1" />
<stop offset="100%" style="stop-color:#3B7A8F;stop-opacity:1" />
</linearGradient>
<linearGradient id="funnelGrad4" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#F5A623;stop-opacity:1" />
<stop offset="100%" style="stop-color:#E89207;stop-opacity:1" />
</linearGradient>
<!-- 阴影 -->
<filter id="funnelShadow">
<feGaussianBlur in="SourceAlpha" stdDeviation="3"/>
<feOffset dx="0" dy="2"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.3"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<!-- 箭头 -->
<marker id="arrowLoss" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#E74C3C" />
</marker>
</defs>
<!-- 背景 -->
<rect width="1280" height="720" fill="#FFFFFF"/>
<!-- 标题 -->
<text x="60" y="80" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="32" font-weight="bold" fill="#2C3E50">
<tspan>销售转化漏斗</tspan>
</text>
<text x="60" y="115" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" fill="#7F8C8D">
<tspan>2025年第三季度 · 总线索数 10,000</tspan>
</text>
<!-- 漏斗图 -->
<g id="funnelChart">
<!--
漏斗中心线: x=640
阶段间距: 110px
阶段高度: 80px
阶段数据:
1. 潜在客户: 10,000 (100%) - 宽度 600px
2. 意向客户: 4,500 (45%) - 宽度 450px
3. 商务洽谈: 1,800 (18%) - 宽度 320px
4. 成交客户: 650 (6.5%) - 宽度 180px
-->
<!-- 阶段1: 潜在客户 (y: 160-240) -->
<path d="M 340,160 L 940,160 L 895,240 L 385,240 Z"
fill="url(#funnelGrad1)" filter="url(#funnelShadow)"/>
<text x="640" y="205" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>潜在客户: 10,000</tspan>
</text>
<text x="640" y="228" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" fill="#FFFFFF" text-anchor="middle" opacity="0.9">
<tspan>100%</tspan>
</text>
<!-- 阶段2: 意向客户 (y: 270-350) -->
<path d="M 385,270 L 895,270 L 850,350 L 430,350 Z"
fill="url(#funnelGrad2)" filter="url(#funnelShadow)"/>
<text x="640" y="315" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>意向客户: 4,500</tspan>
</text>
<text x="640" y="338" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" fill="#FFFFFF" text-anchor="middle" opacity="0.9">
<tspan>45%</tspan>
</text>
<!-- 阶段3: 商务洽谈 (y: 380-460) -->
<path d="M 430,380 L 850,380 L 805,460 L 475,460 Z"
fill="url(#funnelGrad3)" filter="url(#funnelShadow)"/>
<text x="640" y="425" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>商务洽谈: 1,800</tspan>
</text>
<text x="640" y="448" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" fill="#FFFFFF" text-anchor="middle" opacity="0.9">
<tspan>18%</tspan>
</text>
<!-- 阶段4: 成交客户 (y: 490-570) -->
<path d="M 475,490 L 805,490 L 760,570 L 520,570 Z"
fill="url(#funnelGrad4)" filter="url(#funnelShadow)"/>
<text x="640" y="535" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="bold" fill="#2C3E50" text-anchor="middle">
<tspan>成交客户: 650</tspan>
</text>
<text x="640" y="558" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" fill="#2C3E50" text-anchor="middle">
<tspan>6.5%</tspan>
</text>
</g>
<!-- 右侧流失标注 -->
<g id="lossAnnotations">
<!-- 流失1: 10,000 -> 4,500 (流失5,500, 55%) -->
<line x1="950" y1="250" x2="1020" y2="250" stroke="#E74C3C" stroke-width="2" marker-end="url(#arrowLoss)"/>
<text x="1030" y="245" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" font-weight="600" fill="#E74C3C">
<tspan>流失 5,500</tspan>
</text>
<text x="1030" y="265" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="15" fill="#7F8C8D">
<tspan>转化率: 45%</tspan>
</text>
<!-- 流失2: 4,500 -> 1,800 (流失2,700, 40%) -->
<line x1="905" y1="360" x2="975" y2="360" stroke="#E74C3C" stroke-width="2" marker-end="url(#arrowLoss)"/>
<text x="985" y="355" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" font-weight="600" fill="#E74C3C">
<tspan>流失 2,700</tspan>
</text>
<text x="985" y="375" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="15" fill="#7F8C8D">
<tspan>转化率: 40%</tspan>
</text>
<!-- 流失3: 1,800 -> 650 (流失1,150, 36%) -->
<line x1="860" y1="470" x2="930" y2="470" stroke="#E74C3C" stroke-width="2" marker-end="url(#arrowLoss)"/>
<text x="940" y="465" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" font-weight="600" fill="#E74C3C">
<tspan>流失 1,150</tspan>
</text>
<text x="940" y="485" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="15" fill="#7F8C8D">
<tspan>转化率: 36%</tspan>
</text>
</g>
<!-- 左侧关键指标卡片 -->
<g id="keyMetrics">
<!-- 卡片背景 -->
<rect x="60" y="180" width="220" height="380" rx="8" fill="#F8F9FA" stroke="#E0E0E0" stroke-width="1"/>
<text x="170" y="215" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>关键指标</tspan>
</text>
<!-- 指标1: 总转化率 -->
<rect x="80" y="245" width="4" height="50" rx="2" fill="#F5A623"/>
<text x="95" y="260" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#5D6D7E">
<tspan>总转化率</tspan>
</text>
<text x="95" y="285" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="32" font-weight="bold" fill="#F5A623">
<tspan>6.5%</tspan>
</text>
<!-- 指标2: 平均客单价 -->
<rect x="80" y="320" width="4" height="50" rx="2" fill="#2196F3"/>
<text x="95" y="335" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#5D6D7E">
<tspan>平均客单价</tspan>
</text>
<text x="95" y="360" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="28" font-weight="bold" fill="#2196F3">
<tspan>¥125K</tspan>
</text>
<!-- 指标3: 总成交额 -->
<rect x="80" y="395" width="4" height="50" rx="2" fill="#4CAF50"/>
<text x="95" y="410" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#5D6D7E">
<tspan>总成交额</tspan>
</text>
<text x="95" y="435" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="28" font-weight="bold" fill="#4CAF50">
<tspan>¥81.3M</tspan>
</text>
<!-- 指标4: 转化周期 -->
<rect x="80" y="470" width="4" height="50" rx="2" fill="#9C27B0"/>
<text x="95" y="485" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#5D6D7E">
<tspan>平均周期</tspan>
</text>
<text x="95" y="510" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="28" font-weight="bold" fill="#9C27B0">
<tspan>42天</tspan>
</text>
</g>
<!-- 底部洞察 -->
<g id="insight">
<rect x="320" y="610" width="880" height="60" rx="6" fill="#FFF3E0" stroke="#F5A623" stroke-width="2"/>
<text x="340" y="635" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="600" fill="#2C3E50">
<tspan>💡 关键发现: </tspan>
</text>
<text x="445" y="635" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="15" fill="#5D6D7E">
<tspan>意向客户到商务洽谈阶段转化率偏低(40%),建议加强跟进频率和提案质量</tspan>
</text>
<text x="340" y="655" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="15" fill="#5D6D7E">
<tspan>与商务洽谈到成交的转化率(36%),需优化报价策略和商务条款灵活性</tspan>
</text>
</g>
<!-- 数据来源 -->
<text x="60" y="695" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#95A5A6">
<tspan>数据来源: CRM系统统计</tspan>
</text>
</svg>
templates/charts/gantt_chart.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" width="1280" height="720">
<!--
甘特图模板
用途: 项目进度规划、时间线管理
适用场景: 项目管理、里程碑跟踪、任务排期
支持: 8个任务 × 12周时间跨度(可调整)
-->
<defs>
<!-- 任务条渐变 -->
<linearGradient id="taskGrad1" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1976D2;stop-opacity:1" />
</linearGradient>
<linearGradient id="taskGrad2" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#4CAF50;stop-opacity:1" />
<stop offset="100%" style="stop-color:#388E3C;stop-opacity:1" />
</linearGradient>
<linearGradient id="taskGrad3" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#FF9800;stop-opacity:1" />
<stop offset="100%" style="stop-color:#F57C00;stop-opacity:1" />
</linearGradient>
<linearGradient id="taskGrad4" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#9C27B0;stop-opacity:1" />
<stop offset="100%" style="stop-color:#7B1FA2;stop-opacity:1" />
</linearGradient>
<!-- 阴影 -->
<filter id="ganttShadow">
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
<feOffset dx="0" dy="1"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.2"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<!-- 今日线图案 -->
<pattern id="todayPattern" patternUnits="userSpaceOnUse" width="4" height="4">
<line x1="0" y1="0" x2="4" y2="4" stroke="#E74C3C" stroke-width="1"/>
</pattern>
</defs>
<!-- 背景 -->
<rect width="1280" height="720" fill="#FFFFFF"/>
<!-- 标题 -->
<text x="60" y="55" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="28" font-weight="bold" fill="#2C3E50">
<tspan>项目进度计划</tspan>
</text>
<text x="60" y="85" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" fill="#7F8C8D">
<tspan>产品发布项目 · 2025年Q1-Q2</tspan>
</text>
<!-- 时间轴头部 -->
<g id="timeHeader">
<!-- 月份标签背景 -->
<rect x="280" y="105" width="960" height="30" fill="#F5F5F5"/>
<!-- 月份分隔线和标签 -->
<line x1="280" y1="105" x2="280" y2="640" stroke="#E0E0E0" stroke-width="1"/>
<text x="360" y="125" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#5D6D7E" text-anchor="middle">
<tspan>1月</tspan>
</text>
<line x1="440" y1="105" x2="440" y2="640" stroke="#E0E0E0" stroke-width="1"/>
<text x="520" y="125" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#5D6D7E" text-anchor="middle">
<tspan>2月</tspan>
</text>
<line x1="600" y1="105" x2="600" y2="640" stroke="#E0E0E0" stroke-width="1"/>
<text x="680" y="125" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#5D6D7E" text-anchor="middle">
<tspan>3月</tspan>
</text>
<line x1="760" y1="105" x2="760" y2="640" stroke="#E0E0E0" stroke-width="1"/>
<text x="840" y="125" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#5D6D7E" text-anchor="middle">
<tspan>4月</tspan>
</text>
<line x1="920" y1="105" x2="920" y2="640" stroke="#E0E0E0" stroke-width="1"/>
<text x="1000" y="125" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#5D6D7E" text-anchor="middle">
<tspan>5月</tspan>
</text>
<line x1="1080" y1="105" x2="1080" y2="640" stroke="#E0E0E0" stroke-width="1"/>
<text x="1160" y="125" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#5D6D7E" text-anchor="middle">
<tspan>6月</tspan>
</text>
<line x1="1240" y1="105" x2="1240" y2="640" stroke="#E0E0E0" stroke-width="1"/>
</g>
<!-- 周分隔线(虚线) -->
<g stroke="#F0F0F0" stroke-width="1" stroke-dasharray="2,2">
<line x1="320" y1="135" x2="320" y2="640"/>
<line x1="360" y1="135" x2="360" y2="640"/>
<line x1="400" y1="135" x2="400" y2="640"/>
<line x1="480" y1="135" x2="480" y2="640"/>
<line x1="520" y1="135" x2="520" y2="640"/>
<line x1="560" y1="135" x2="560" y2="640"/>
<line x1="640" y1="135" x2="640" y2="640"/>
<line x1="680" y1="135" x2="680" y2="640"/>
<line x1="720" y1="135" x2="720" y2="640"/>
<line x1="800" y1="135" x2="800" y2="640"/>
<line x1="840" y1="135" x2="840" y2="640"/>
<line x1="880" y1="135" x2="880" y2="640"/>
<line x1="960" y1="135" x2="960" y2="640"/>
<line x1="1000" y1="135" x2="1000" y2="640"/>
<line x1="1040" y1="135" x2="1040" y2="640"/>
<line x1="1120" y1="135" x2="1120" y2="640"/>
<line x1="1160" y1="135" x2="1160" y2="640"/>
<line x1="1200" y1="135" x2="1200" y2="640"/>
</g>
<!-- 任务列表区域 -->
<g id="taskList">
<!-- 任务行背景(交替色) -->
<rect x="40" y="135" width="240" height="60" fill="#FFFFFF"/>
<rect x="40" y="195" width="240" height="60" fill="#FAFAFA"/>
<rect x="40" y="255" width="240" height="60" fill="#FFFFFF"/>
<rect x="40" y="315" width="240" height="60" fill="#FAFAFA"/>
<rect x="40" y="375" width="240" height="60" fill="#FFFFFF"/>
<rect x="40" y="435" width="240" height="60" fill="#FAFAFA"/>
<rect x="40" y="495" width="240" height="60" fill="#FFFFFF"/>
<rect x="40" y="555" width="240" height="60" fill="#FAFAFA"/>
<!-- 任务名称 -->
<text x="55" y="172" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#2C3E50">
<tspan>1. 需求分析</tspan>
</text>
<text x="55" y="232" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#2C3E50">
<tspan>2. 产品设计</tspan>
</text>
<text x="55" y="292" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#2C3E50">
<tspan>3. UI/UX 设计</tspan>
</text>
<text x="55" y="352" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#2C3E50">
<tspan>4. 前端开发</tspan>
</text>
<text x="55" y="412" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#2C3E50">
<tspan>5. 后端开发</tspan>
</text>
<text x="55" y="472" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#2C3E50">
<tspan>6. 测试验收</tspan>
</text>
<text x="55" y="532" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#2C3E50">
<tspan>7. 部署上线</tspan>
</text>
<text x="55" y="592" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#2C3E50">
<tspan>8. 运营推广</tspan>
</text>
</g>
<!-- 甘特条 -->
<g id="ganttBars">
<!-- 任务1: 需求分析 (1月第1周 - 1月第3周) -->
<rect x="280" y="155" width="120" height="24" rx="4" fill="url(#taskGrad1)" filter="url(#ganttShadow)"/>
<text x="340" y="172" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#FFFFFF" text-anchor="middle">
<tspan>3周</tspan>
</text>
<!-- 任务2: 产品设计 (1月第2周 - 2月第2周) -->
<rect x="320" y="215" width="200" height="24" rx="4" fill="url(#taskGrad1)" filter="url(#ganttShadow)"/>
<text x="420" y="232" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#FFFFFF" text-anchor="middle">
<tspan>5周</tspan>
</text>
<!-- 任务3: UI/UX 设计 (2月第1周 - 3月第1周) -->
<rect x="440" y="275" width="200" height="24" rx="4" fill="url(#taskGrad2)" filter="url(#ganttShadow)"/>
<text x="540" y="292" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#FFFFFF" text-anchor="middle">
<tspan>5周</tspan>
</text>
<!-- 任务4: 前端开发 (2月第3周 - 4月第2周) -->
<rect x="520" y="335" width="320" height="24" rx="4" fill="url(#taskGrad3)" filter="url(#ganttShadow)"/>
<text x="680" y="352" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#FFFFFF" text-anchor="middle">
<tspan>8周</tspan>
</text>
<!-- 任务5: 后端开发 (2月第2周 - 4月第3周) -->
<rect x="480" y="395" width="400" height="24" rx="4" fill="url(#taskGrad3)" filter="url(#ganttShadow)"/>
<text x="680" y="412" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#FFFFFF" text-anchor="middle">
<tspan>10周</tspan>
</text>
<!-- 任务6: 测试验收 (4月第1周 - 5月第2周) -->
<rect x="760" y="455" width="240" height="24" rx="4" fill="url(#taskGrad4)" filter="url(#ganttShadow)"/>
<text x="880" y="472" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#FFFFFF" text-anchor="middle">
<tspan>6周</tspan>
</text>
<!-- 任务7: 部署上线 (5月第2周 - 5月第3周) -->
<rect x="1000" y="515" width="80" height="24" rx="4" fill="url(#taskGrad4)" filter="url(#ganttShadow)"/>
<text x="1040" y="532" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#FFFFFF" text-anchor="middle">
<tspan>2周</tspan>
</text>
<!-- 任务8: 运营推广 (5月第3周 - 6月第4周) -->
<rect x="1040" y="575" width="200" height="24" rx="4" fill="url(#taskGrad2)" filter="url(#ganttShadow)"/>
<text x="1140" y="592" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#FFFFFF" text-anchor="middle">
<tspan>5周</tspan>
</text>
</g>
<!-- 里程碑标记 -->
<g id="milestones">
<!-- 里程碑: 需求评审 -->
<polygon points="400,165 410,155 420,165 410,175" fill="#E74C3C"/>
<text x="410" y="145" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#E74C3C" text-anchor="middle">
<tspan>需求评审</tspan>
</text>
<!-- 里程碑: 设计评审 -->
<polygon points="640,285 650,275 660,285 650,295" fill="#E74C3C"/>
<text x="650" y="265" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#E74C3C" text-anchor="middle">
<tspan>设计评审</tspan>
</text>
<!-- 里程碑: 产品发布 -->
<polygon points="1070,525 1080,515 1090,525 1080,535" fill="#E74C3C"/>
<text x="1080" y="550" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#E74C3C" text-anchor="middle">
<tspan>产品发布</tspan>
</text>
</g>
<!-- 今日线 (假设在3月第2周) -->
<line x1="680" y1="105" x2="680" y2="640" stroke="#E74C3C" stroke-width="2" stroke-dasharray="6,3"/>
<rect x="660" y="95" width="40" height="20" rx="3" fill="#E74C3C"/>
<text x="680" y="109" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#FFFFFF" text-anchor="middle">
<tspan>今日</tspan>
</text>
<!-- 图例 -->
<g id="legend" transform="translate(60, 650)">
<rect x="0" y="0" width="16" height="16" rx="2" fill="url(#taskGrad1)"/>
<text x="25" y="13" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#5D6D7E">
<tspan>规划阶段</tspan>
</text>
<rect x="100" y="0" width="16" height="16" rx="2" fill="url(#taskGrad2)"/>
<text x="125" y="13" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#5D6D7E">
<tspan>设计阶段</tspan>
</text>
<rect x="200" y="0" width="16" height="16" rx="2" fill="url(#taskGrad3)"/>
<text x="225" y="13" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#5D6D7E">
<tspan>开发阶段</tspan>
</text>
<rect x="300" y="0" width="16" height="16" rx="2" fill="url(#taskGrad4)"/>
<text x="325" y="13" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#5D6D7E">
<tspan>测试发布</tspan>
</text>
<polygon points="420,8 430,-2 440,8 430,18" fill="#E74C3C"/>
<text x="455" y="13" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#5D6D7E">
<tspan>里程碑</tspan>
</text>
</g>
<!-- 项目信息 -->
<text x="1220" y="695" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#95A5A6" text-anchor="end">
<tspan>项目经理: 张三 | 更新日期: 2025-03-15</tspan>
</text>
</svg>
templates/charts/gauge_chart.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" width="1280" height="720">
<!--
仪表盘图模板
用途: 单一指标达成度展示
适用场景: KPI完成率、健康指数、性能评分、目标达成
支持: 3个仪表盘并排展示
-->
<defs>
<!-- 背景弧渐变 -->
<linearGradient id="bgArcGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#E0E0E0;stop-opacity:1" />
<stop offset="100%" style="stop-color:#EEEEEE;stop-opacity:1" />
</linearGradient>
<!-- 优秀区域渐变(绿色) -->
<linearGradient id="excellentGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#4CAF50;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8BC34A;stop-opacity:1" />
</linearGradient>
<!-- 良好区域渐变(蓝色) -->
<linearGradient id="goodGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:1" />
<stop offset="100%" style="stop-color:#03A9F4;stop-opacity:1" />
</linearGradient>
<!-- 警告区域渐变(橙色) -->
<linearGradient id="warningGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#FF9800;stop-opacity:1" />
<stop offset="100%" style="stop-color:#FFC107;stop-opacity:1" />
</linearGradient>
<!-- 危险区域渐变(红色) -->
<linearGradient id="dangerGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#f44336;stop-opacity:1" />
<stop offset="100%" style="stop-color:#E91E63;stop-opacity:1" />
</linearGradient>
<!-- 指针阴影 -->
<filter id="needleShadow">
<feGaussianBlur in="SourceAlpha" stdDeviation="3"/>
<feOffset dx="0" dy="2"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.4"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- 背景 -->
<rect width="1280" height="720" fill="#FFFFFF"/>
<!-- 标题 -->
<text x="640" y="60" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="32" font-weight="bold" fill="#2C3E50" text-anchor="middle">
<tspan>业务核心指标仪表盘</tspan>
</text>
<text x="640" y="95" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" fill="#7F8C8D" text-anchor="middle">
<tspan>2025年第四季度实时数据</tspan>
</text>
<!-- 仪表盘1: 销售完成率 85% -->
<g id="gauge1" transform="translate(220, 380)">
<!-- 背景弧 -->
<path d="M -150,0 A 150,150 0 0,1 150,0" fill="none" stroke="#EEEEEE" stroke-width="30" stroke-linecap="round"/>
<!-- 刻度区域 (0-60% 红色, 60-80% 橙色, 80-100% 绿色) -->
<!-- 红色区域 0-60% (0° to 108°) -->
<path d="M -150,0 A 150,150 0 0,1 -46.35,-142.65" fill="none" stroke="#FFCDD2" stroke-width="30" stroke-linecap="round"/>
<!-- 橙色区域 60-80% (108° to 144°) -->
<path d="M -46.35,-142.65 A 150,150 0 0,1 58.68,-138.05" fill="none" stroke="#FFE0B2" stroke-width="30" stroke-linecap="round"/>
<!-- 绿色区域 80-100% (144° to 180°) -->
<path d="M 58.68,-138.05 A 150,150 0 0,1 150,0" fill="none" stroke="#C8E6C9" stroke-width="30" stroke-linecap="round"/>
<!-- 当前值弧 85% (从左到85%位置,约153°) -->
<path d="M -150,0 A 150,150 0 0,1 103.95,-108.45" fill="none" stroke="url(#excellentGrad)" stroke-width="30" stroke-linecap="round"/>
<!-- 刻度线 -->
<line x1="-135" y1="0" x2="-120" y2="0" stroke="#7F8C8D" stroke-width="2"/>
<line x1="-95.5" y1="-95.5" x2="-84.85" y2="-84.85" stroke="#7F8C8D" stroke-width="2"/>
<line x1="0" y1="-135" x2="0" y2="-120" stroke="#7F8C8D" stroke-width="2"/>
<line x1="95.5" y1="-95.5" x2="84.85" y2="-84.85" stroke="#7F8C8D" stroke-width="2"/>
<line x1="135" y1="0" x2="120" y2="0" stroke="#7F8C8D" stroke-width="2"/>
<!-- 刻度标签 -->
<text x="-155" y="25" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle"><tspan>0%</tspan></text>
<text x="0" y="-155" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle"><tspan>50%</tspan></text>
<text x="155" y="25" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle"><tspan>100%</tspan></text>
<!-- 指针 (85% = 153°, 从-90°开始) -->
<g transform="rotate(63)">
<path d="M 0,-100 L -8,0 L 0,15 L 8,0 Z" fill="#2C3E50" filter="url(#needleShadow)"/>
</g>
<circle cx="0" cy="0" r="15" fill="#2C3E50"/>
<circle cx="0" cy="0" r="8" fill="#FFFFFF"/>
<!-- 中心数值 -->
<text x="0" y="60" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="48" font-weight="bold" fill="#4CAF50" text-anchor="middle">
<tspan>85%</tspan>
</text>
<!-- 标题 -->
<text x="0" y="100" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>销售完成率</tspan>
</text>
<!-- 状态标签 -->
<rect x="-35" y="115" width="70" height="26" rx="13" fill="#E8F5E9"/>
<text x="0" y="133" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#4CAF50" text-anchor="middle">
<tspan>优秀</tspan>
</text>
</g>
<!-- 仪表盘2: 客户满意度 72% -->
<g id="gauge2" transform="translate(640, 380)">
<!-- 背景弧 -->
<path d="M -150,0 A 150,150 0 0,1 150,0" fill="none" stroke="#EEEEEE" stroke-width="30" stroke-linecap="round"/>
<!-- 刻度区域 -->
<path d="M -150,0 A 150,150 0 0,1 -46.35,-142.65" fill="none" stroke="#FFCDD2" stroke-width="30" stroke-linecap="round"/>
<path d="M -46.35,-142.65 A 150,150 0 0,1 58.68,-138.05" fill="none" stroke="#FFE0B2" stroke-width="30" stroke-linecap="round"/>
<path d="M 58.68,-138.05 A 150,150 0 0,1 150,0" fill="none" stroke="#C8E6C9" stroke-width="30" stroke-linecap="round"/>
<!-- 当前值弧 72% (约129.6°) -->
<path d="M -150,0 A 150,150 0 0,1 24.93,-147.91" fill="none" stroke="url(#goodGrad)" stroke-width="30" stroke-linecap="round"/>
<!-- 刻度线 -->
<line x1="-135" y1="0" x2="-120" y2="0" stroke="#7F8C8D" stroke-width="2"/>
<line x1="-95.5" y1="-95.5" x2="-84.85" y2="-84.85" stroke="#7F8C8D" stroke-width="2"/>
<line x1="0" y1="-135" x2="0" y2="-120" stroke="#7F8C8D" stroke-width="2"/>
<line x1="95.5" y1="-95.5" x2="84.85" y2="-84.85" stroke="#7F8C8D" stroke-width="2"/>
<line x1="135" y1="0" x2="120" y2="0" stroke="#7F8C8D" stroke-width="2"/>
<!-- 刻度标签 -->
<text x="-155" y="25" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle"><tspan>0%</tspan></text>
<text x="0" y="-155" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle"><tspan>50%</tspan></text>
<text x="155" y="25" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle"><tspan>100%</tspan></text>
<!-- 指针 (72% = 129.6°, 从-90°开始,即39.6°) -->
<g transform="rotate(39.6)">
<path d="M 0,-100 L -8,0 L 0,15 L 8,0 Z" fill="#2C3E50" filter="url(#needleShadow)"/>
</g>
<circle cx="0" cy="0" r="15" fill="#2C3E50"/>
<circle cx="0" cy="0" r="8" fill="#FFFFFF"/>
<!-- 中心数值 -->
<text x="0" y="60" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="48" font-weight="bold" fill="#2196F3" text-anchor="middle">
<tspan>72%</tspan>
</text>
<!-- 标题 -->
<text x="0" y="100" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>客户满意度</tspan>
</text>
<!-- 状态标签 -->
<rect x="-35" y="115" width="70" height="26" rx="13" fill="#E3F2FD"/>
<text x="0" y="133" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#2196F3" text-anchor="middle">
<tspan>良好</tspan>
</text>
</g>
<!-- 仪表盘3: 库存周转 58% -->
<g id="gauge3" transform="translate(1060, 380)">
<!-- 背景弧 -->
<path d="M -150,0 A 150,150 0 0,1 150,0" fill="none" stroke="#EEEEEE" stroke-width="30" stroke-linecap="round"/>
<!-- 刻度区域 -->
<path d="M -150,0 A 150,150 0 0,1 -46.35,-142.65" fill="none" stroke="#FFCDD2" stroke-width="30" stroke-linecap="round"/>
<path d="M -46.35,-142.65 A 150,150 0 0,1 58.68,-138.05" fill="none" stroke="#FFE0B2" stroke-width="30" stroke-linecap="round"/>
<path d="M 58.68,-138.05 A 150,150 0 0,1 150,0" fill="none" stroke="#C8E6C9" stroke-width="30" stroke-linecap="round"/>
<!-- 当前值弧 58% (约104.4°) -->
<path d="M -150,0 A 150,150 0 0,1 -58.36,-138.17" fill="none" stroke="url(#warningGrad)" stroke-width="30" stroke-linecap="round"/>
<!-- 刻度线 -->
<line x1="-135" y1="0" x2="-120" y2="0" stroke="#7F8C8D" stroke-width="2"/>
<line x1="-95.5" y1="-95.5" x2="-84.85" y2="-84.85" stroke="#7F8C8D" stroke-width="2"/>
<line x1="0" y1="-135" x2="0" y2="-120" stroke="#7F8C8D" stroke-width="2"/>
<line x1="95.5" y1="-95.5" x2="84.85" y2="-84.85" stroke="#7F8C8D" stroke-width="2"/>
<line x1="135" y1="0" x2="120" y2="0" stroke="#7F8C8D" stroke-width="2"/>
<!-- 刻度标签 -->
<text x="-155" y="25" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle"><tspan>0%</tspan></text>
<text x="0" y="-155" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle"><tspan>50%</tspan></text>
<text x="155" y="25" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle"><tspan>100%</tspan></text>
<!-- 指针 (58% = 104.4°, 从-90°开始,即14.4°) -->
<g transform="rotate(14.4)">
<path d="M 0,-100 L -8,0 L 0,15 L 8,0 Z" fill="#2C3E50" filter="url(#needleShadow)"/>
</g>
<circle cx="0" cy="0" r="15" fill="#2C3E50"/>
<circle cx="0" cy="0" r="8" fill="#FFFFFF"/>
<!-- 中心数值 -->
<text x="0" y="60" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="48" font-weight="bold" fill="#FF9800" text-anchor="middle">
<tspan>58%</tspan>
</text>
<!-- 标题 -->
<text x="0" y="100" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>库存周转率</tspan>
</text>
<!-- 状态标签 -->
<rect x="-35" y="115" width="70" height="26" rx="13" fill="#FFF3E0"/>
<text x="0" y="133" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#FF9800" text-anchor="middle">
<tspan>需关注</tspan>
</text>
</g>
<!-- 图例说明 -->
<g id="legendBar" transform="translate(340, 600)">
<rect x="0" y="0" width="600" height="40" rx="8" fill="#F8F9FA" stroke="#E0E0E0" stroke-width="1"/>
<rect x="30" y="12" width="16" height="16" rx="3" fill="#FFCDD2"/>
<text x="55" y="24" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#5D6D7E"><tspan>危险 (0-60%)</tspan></text>
<rect x="170" y="12" width="16" height="16" rx="3" fill="#FFE0B2"/>
<text x="195" y="24" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#5D6D7E"><tspan>警告 (60-80%)</tspan></text>
<rect x="320" y="12" width="16" height="16" rx="3" fill="#C8E6C9"/>
<text x="345" y="24" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#5D6D7E"><tspan>优秀 (80-100%)</tspan></text>
<text x="500" y="24" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#95A5A6"><tspan>目标: 80%</tspan></text>
</g>
<!-- 数据来源 -->
<text x="640" y="695" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#95A5A6" text-anchor="middle">
<tspan>数据来源: 业务数据平台 · 实时更新</tspan>
</text>
</svg>
templates/charts/grouped_bar_chart.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" width="1280" height="720">
<!--
分组柱状图模板
用途: 多系列数据对比
适用场景: 多产品/时期/分类对比、同比环比分析
支持: 4组 × 3系列(可调整为3-6组,2-4系列)
与普通柱状图区别: 每个类别下有多个并排柱子,用于对比
-->
<defs>
<!-- 系列1渐变(蓝色) -->
<linearGradient id="groupGrad1" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1976D2;stop-opacity:1" />
</linearGradient>
<!-- 系列2渐变(绿色) -->
<linearGradient id="groupGrad2" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#4CAF50;stop-opacity:1" />
<stop offset="100%" style="stop-color:#388E3C;stop-opacity:1" />
</linearGradient>
<!-- 系列3渐变(橙色) -->
<linearGradient id="groupGrad3" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#FF9800;stop-opacity:1" />
<stop offset="100%" style="stop-color:#F57C00;stop-opacity:1" />
</linearGradient>
<!-- 阴影 -->
<filter id="groupShadow">
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
<feOffset dx="0" dy="1"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.25"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- 背景 -->
<rect width="1280" height="720" fill="#FFFFFF"/>
<!-- 标题 -->
<text x="60" y="70" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="32" font-weight="bold" fill="#2C3E50">
<tspan>各季度产品线销售对比</tspan>
</text>
<text x="60" y="105" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" fill="#7F8C8D">
<tspan>2025年度 · 单位:百万元</tspan>
</text>
<!-- 图例 -->
<g id="legend" transform="translate(900, 70)">
<rect x="0" y="0" width="20" height="20" rx="3" fill="url(#groupGrad1)"/>
<text x="30" y="15" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="15" fill="#5D6D7E">
<tspan>产品线A</tspan>
</text>
<rect x="120" y="0" width="20" height="20" rx="3" fill="url(#groupGrad2)"/>
<text x="150" y="15" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="15" fill="#5D6D7E">
<tspan>产品线B</tspan>
</text>
<rect x="240" y="0" width="20" height="20" rx="3" fill="url(#groupGrad3)"/>
<text x="270" y="15" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="15" fill="#5D6D7E">
<tspan>产品线C</tspan>
</text>
</g>
<!-- 图表区域 -->
<g id="chartArea">
<!-- Y轴网格线 -->
<line x1="140" y1="570" x2="1180" y2="570" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="140" y1="480" x2="1180" y2="480" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="140" y1="390" x2="1180" y2="390" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="140" y1="300" x2="1180" y2="300" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="140" y1="210" x2="1180" y2="210" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<!-- Y轴刻度标签 -->
<text x="125" y="575" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end">
<tspan>0</tspan>
</text>
<text x="125" y="485" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end">
<tspan>50</tspan>
</text>
<text x="125" y="395" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end">
<tspan>100</tspan>
</text>
<text x="125" y="305" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end">
<tspan>150</tspan>
</text>
<text x="125" y="215" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end">
<tspan>200</tspan>
</text>
<!-- 坐标轴 -->
<line x1="140" y1="165" x2="140" y2="570" stroke="#95A5A6" stroke-width="2"/>
<line x1="140" y1="570" x2="1180" y2="570" stroke="#95A5A6" stroke-width="2"/>
<!--
分组说明:
- 4个组(Q1-Q4),每组3个柱子
- 组宽度:240px,组间距:20px
- 柱宽:60px,柱间距:10px
- 每组起始:170, 430, 690, 950
- 每50单位=90px高度
-->
<!-- Q1 组 -->
<!-- Q1 产品线A: 120M (高度216px) -->
<rect x="170" y="354" width="60" height="216" rx="4" fill="url(#groupGrad1)" filter="url(#groupShadow)"/>
<text x="200" y="342" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>120</tspan>
</text>
<!-- Q1 产品线B: 85M (高度153px) -->
<rect x="240" y="417" width="60" height="153" rx="4" fill="url(#groupGrad2)" filter="url(#groupShadow)"/>
<text x="270" y="405" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>85</tspan>
</text>
<!-- Q1 产品线C: 65M (高度117px) -->
<rect x="310" y="453" width="60" height="117" rx="4" fill="url(#groupGrad3)" filter="url(#groupShadow)"/>
<text x="340" y="441" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>65</tspan>
</text>
<!-- Q1 标签 -->
<text x="255" y="600" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" fill="#5D6D7E" text-anchor="middle">
<tspan>Q1</tspan>
</text>
<!-- Q2 组 -->
<!-- Q2 产品线A: 145M (高度261px) -->
<rect x="430" y="309" width="60" height="261" rx="4" fill="url(#groupGrad1)" filter="url(#groupShadow)"/>
<text x="460" y="297" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>145</tspan>
</text>
<!-- Q2 产品线B: 98M (高度176px) -->
<rect x="500" y="394" width="60" height="176" rx="4" fill="url(#groupGrad2)" filter="url(#groupShadow)"/>
<text x="530" y="382" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>98</tspan>
</text>
<!-- Q2 产品线C: 78M (高度140px) -->
<rect x="570" y="430" width="60" height="140" rx="4" fill="url(#groupGrad3)" filter="url(#groupShadow)"/>
<text x="600" y="418" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>78</tspan>
</text>
<!-- Q2 标签 -->
<text x="515" y="600" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" fill="#5D6D7E" text-anchor="middle">
<tspan>Q2</tspan>
</text>
<!-- Q3 组 -->
<!-- Q3 产品线A: 168M (高度302px) -->
<rect x="690" y="268" width="60" height="302" rx="4" fill="url(#groupGrad1)" filter="url(#groupShadow)"/>
<text x="720" y="256" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>168</tspan>
</text>
<!-- Q3 产品线B: 112M (高度202px) -->
<rect x="760" y="368" width="60" height="202" rx="4" fill="url(#groupGrad2)" filter="url(#groupShadow)"/>
<text x="790" y="356" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>112</tspan>
</text>
<!-- Q3 产品线C: 92M (高度166px) -->
<rect x="830" y="404" width="60" height="166" rx="4" fill="url(#groupGrad3)" filter="url(#groupShadow)"/>
<text x="860" y="392" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>92</tspan>
</text>
<!-- Q3 标签 -->
<text x="775" y="600" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" fill="#5D6D7E" text-anchor="middle">
<tspan>Q3</tspan>
</text>
<!-- Q4 组 -->
<!-- Q4 产品线A: 185M (高度333px) -->
<rect x="950" y="237" width="60" height="333" rx="4" fill="url(#groupGrad1)" filter="url(#groupShadow)"/>
<text x="980" y="225" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>185</tspan>
</text>
<!-- Q4 产品线B: 125M (高度225px) -->
<rect x="1020" y="345" width="60" height="225" rx="4" fill="url(#groupGrad2)" filter="url(#groupShadow)"/>
<text x="1050" y="333" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>125</tspan>
</text>
<!-- Q4 产品线C: 108M (高度194px) -->
<rect x="1090" y="376" width="60" height="194" rx="4" fill="url(#groupGrad3)" filter="url(#groupShadow)"/>
<text x="1120" y="364" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>108</tspan>
</text>
<!-- Q4 标签 -->
<text x="1035" y="600" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" fill="#5D6D7E" text-anchor="middle">
<tspan>Q4</tspan>
</text>
</g>
<!-- 趋势总结卡片 -->
<g id="summaryCard">
<rect x="60" y="630" width="400" height="60" rx="8" fill="#F8F9FA" stroke="#E0E0E0" stroke-width="1"/>
<text x="80" y="655" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#5D6D7E">
<tspan>年度总计:</tspan>
</text>
<text x="160" y="655" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#2196F3">
<tspan>618M</tspan>
</text>
<text x="220" y="655" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#5D6D7E">
<tspan>|</tspan>
</text>
<text x="240" y="655" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#4CAF50">
<tspan>420M</tspan>
</text>
<text x="300" y="655" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#5D6D7E">
<tspan>|</tspan>
</text>
<text x="320" y="655" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#FF9800">
<tspan>343M</tspan>
</text>
<text x="80" y="675" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#95A5A6">
<tspan>同比增长: +15.2%</tspan>
</text>
</g>
<!-- 数据来源 -->
<text x="1220" y="695" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#95A5A6" text-anchor="end">
<tspan>数据来源: 销售部门</tspan>
</text>
</svg>
templates/charts/heatmap_chart.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" width="1280" height="720">
<!--
热力图模板
用途: 矩阵数据可视化、相关性分析
适用场景: 相关性矩阵、时间热力图、用户行为分析
支持: 7×7 矩阵格子
-->
<defs>
<!-- 热力图色阶(从蓝到红) -->
<linearGradient id="heatGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#1565C0"/>
<stop offset="25%" style="stop-color:#42A5F5"/>
<stop offset="50%" style="stop-color:#FFEB3B"/>
<stop offset="75%" style="stop-color:#FF9800"/>
<stop offset="100%" style="stop-color:#D32F2F"/>
</linearGradient>
</defs>
<!-- 背景 -->
<rect width="1280" height="720" fill="#FFFFFF"/>
<!-- 标题 -->
<text x="60" y="55" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="32" font-weight="bold" fill="#2C3E50">
<tspan>用户活跃度热力图</tspan>
</text>
<text x="60" y="90" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" fill="#7F8C8D">
<tspan>按星期和时段统计 · 数值代表平均在线用户数(千人)</tspan>
</text>
<!-- 热力图区域 -->
<g id="heatmapArea" transform="translate(180, 130)">
<!--
格子大小: 80×60
列: 7列(周一到周日)
行: 8行(时段)
颜色映射: 0-100 -> 蓝到红
-->
<!-- 列标题(星期) -->
<text x="40" y="-15" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" font-weight="600" fill="#5D6D7E" text-anchor="middle"><tspan>周一</tspan></text>
<text x="120" y="-15" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" font-weight="600" fill="#5D6D7E" text-anchor="middle"><tspan>周二</tspan></text>
<text x="200" y="-15" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" font-weight="600" fill="#5D6D7E" text-anchor="middle"><tspan>周三</tspan></text>
<text x="280" y="-15" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" font-weight="600" fill="#5D6D7E" text-anchor="middle"><tspan>周四</tspan></text>
<text x="360" y="-15" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" font-weight="600" fill="#5D6D7E" text-anchor="middle"><tspan>周五</tspan></text>
<text x="440" y="-15" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" font-weight="600" fill="#E91E63" text-anchor="middle"><tspan>周六</tspan></text>
<text x="520" y="-15" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" font-weight="600" fill="#E91E63" text-anchor="middle"><tspan>周日</tspan></text>
<!-- 行标题(时段) -->
<text x="-15" y="35" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="end"><tspan>6-9时</tspan></text>
<text x="-15" y="95" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="end"><tspan>9-12时</tspan></text>
<text x="-15" y="155" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="end"><tspan>12-14时</tspan></text>
<text x="-15" y="215" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="end"><tspan>14-17时</tspan></text>
<text x="-15" y="275" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="end"><tspan>17-19时</tspan></text>
<text x="-15" y="335" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="end"><tspan>19-21时</tspan></text>
<text x="-15" y="395" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="end"><tspan>21-23时</tspan></text>
<text x="-15" y="455" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="end"><tspan>23-6时</tspan></text>
<!-- 热力图格子 -->
<!-- 第1行: 6-9时 -->
<rect x="0" y="5" width="78" height="58" rx="4" fill="#64B5F6"/><text x="40" y="40" font-size="13" fill="#1565C0" text-anchor="middle"><tspan>35</tspan></text>
<rect x="80" y="5" width="78" height="58" rx="4" fill="#42A5F5"/><text x="120" y="40" font-size="13" fill="#1565C0" text-anchor="middle"><tspan>42</tspan></text>
<rect x="160" y="5" width="78" height="58" rx="4" fill="#42A5F5"/><text x="200" y="40" font-size="13" fill="#1565C0" text-anchor="middle"><tspan>40</tspan></text>
<rect x="240" y="5" width="78" height="58" rx="4" fill="#64B5F6"/><text x="280" y="40" font-size="13" fill="#1565C0" text-anchor="middle"><tspan>38</tspan></text>
<rect x="320" y="5" width="78" height="58" rx="4" fill="#90CAF9"/><text x="360" y="40" font-size="13" fill="#1565C0" text-anchor="middle"><tspan>28</tspan></text>
<rect x="400" y="5" width="78" height="58" rx="4" fill="#BBDEFB"/><text x="440" y="40" font-size="13" fill="#1565C0" text-anchor="middle"><tspan>15</tspan></text>
<rect x="480" y="5" width="78" height="58" rx="4" fill="#E3F2FD"/><text x="520" y="40" font-size="13" fill="#1565C0" text-anchor="middle"><tspan>10</tspan></text>
<!-- 第2行: 9-12时 -->
<rect x="0" y="65" width="78" height="58" rx="4" fill="#FFF176"/><text x="40" y="100" font-size="13" fill="#F57F17" text-anchor="middle"><tspan>58</tspan></text>
<rect x="80" y="65" width="78" height="58" rx="4" fill="#FFD54F"/><text x="120" y="100" font-size="13" fill="#F57F17" text-anchor="middle"><tspan>65</tspan></text>
<rect x="160" y="65" width="78" height="58" rx="4" fill="#FFC107"/><text x="200" y="100" font-size="13" fill="#FFFFFF" text-anchor="middle"><tspan>72</tspan></text>
<rect x="240" y="65" width="78" height="58" rx="4" fill="#FFD54F"/><text x="280" y="100" font-size="13" fill="#F57F17" text-anchor="middle"><tspan>68</tspan></text>
<rect x="320" y="65" width="78" height="58" rx="4" fill="#FFF176"/><text x="360" y="100" font-size="13" fill="#F57F17" text-anchor="middle"><tspan>55</tspan></text>
<rect x="400" y="65" width="78" height="58" rx="4" fill="#42A5F5"/><text x="440" y="100" font-size="13" fill="#1565C0" text-anchor="middle"><tspan>45</tspan></text>
<rect x="480" y="65" width="78" height="58" rx="4" fill="#64B5F6"/><text x="520" y="100" font-size="13" fill="#1565C0" text-anchor="middle"><tspan>38</tspan></text>
<!-- 第3行: 12-14时 -->
<rect x="0" y="125" width="78" height="58" rx="4" fill="#FFB74D"/><text x="40" y="160" font-size="13" fill="#E65100" text-anchor="middle"><tspan>75</tspan></text>
<rect x="80" y="125" width="78" height="58" rx="4" fill="#FFA726"/><text x="120" y="160" font-size="13" fill="#FFFFFF" text-anchor="middle"><tspan>82</tspan></text>
<rect x="160" y="125" width="78" height="58" rx="4" fill="#FF9800"/><text x="200" y="160" font-size="13" fill="#FFFFFF" text-anchor="middle"><tspan>88</tspan></text>
<rect x="240" y="125" width="78" height="58" rx="4" fill="#FFA726"/><text x="280" y="160" font-size="13" fill="#FFFFFF" text-anchor="middle"><tspan>80</tspan></text>
<rect x="320" y="125" width="78" height="58" rx="4" fill="#FFB74D"/><text x="360" y="160" font-size="13" fill="#E65100" text-anchor="middle"><tspan>70</tspan></text>
<rect x="400" y="125" width="78" height="58" rx="4" fill="#FFF176"/><text x="440" y="160" font-size="13" fill="#F57F17" text-anchor="middle"><tspan>60</tspan></text>
<rect x="480" y="125" width="78" height="58" rx="4" fill="#FFD54F"/><text x="520" y="160" font-size="13" fill="#F57F17" text-anchor="middle"><tspan>65</tspan></text>
<!-- 第4行: 14-17时 -->
<rect x="0" y="185" width="78" height="58" rx="4" fill="#FFF176"/><text x="40" y="220" font-size="13" fill="#F57F17" text-anchor="middle"><tspan>55</tspan></text>
<rect x="80" y="185" width="78" height="58" rx="4" fill="#FFD54F"/><text x="120" y="220" font-size="13" fill="#F57F17" text-anchor="middle"><tspan>62</tspan></text>
<rect x="160" y="185" width="78" height="58" rx="4" fill="#FFD54F"/><text x="200" y="220" font-size="13" fill="#F57F17" text-anchor="middle"><tspan>68</tspan></text>
<rect x="240" y="185" width="78" height="58" rx="4" fill="#FFF176"/><text x="280" y="220" font-size="13" fill="#F57F17" text-anchor="middle"><tspan>60</tspan></text>
<rect x="320" y="185" width="78" height="58" rx="4" fill="#42A5F5"/><text x="360" y="220" font-size="13" fill="#1565C0" text-anchor="middle"><tspan>48</tspan></text>
<rect x="400" y="185" width="78" height="58" rx="4" fill="#FFB74D"/><text x="440" y="220" font-size="13" fill="#E65100" text-anchor="middle"><tspan>72</tspan></text>
<rect x="480" y="185" width="78" height="58" rx="4" fill="#FFA726"/><text x="520" y="220" font-size="13" fill="#FFFFFF" text-anchor="middle"><tspan>78</tspan></text>
<!-- 第5行: 17-19时 -->
<rect x="0" y="245" width="78" height="58" rx="4" fill="#64B5F6"/><text x="40" y="280" font-size="13" fill="#1565C0" text-anchor="middle"><tspan>32</tspan></text>
<rect x="80" y="245" width="78" height="58" rx="4" fill="#64B5F6"/><text x="120" y="280" font-size="13" fill="#1565C0" text-anchor="middle"><tspan>35</tspan></text>
<rect x="160" y="245" width="78" height="58" rx="4" fill="#64B5F6"/><text x="200" y="280" font-size="13" fill="#1565C0" text-anchor="middle"><tspan>38</tspan></text>
<rect x="240" y="245" width="78" height="58" rx="4" fill="#64B5F6"/><text x="280" y="280" font-size="13" fill="#1565C0" text-anchor="middle"><tspan>35</tspan></text>
<rect x="320" y="245" width="78" height="58" rx="4" fill="#90CAF9"/><text x="360" y="280" font-size="13" fill="#1565C0" text-anchor="middle"><tspan>28</tspan></text>
<rect x="400" y="245" width="78" height="58" rx="4" fill="#FFD54F"/><text x="440" y="280" font-size="13" fill="#F57F17" text-anchor="middle"><tspan>65</tspan></text>
<rect x="480" y="245" width="78" height="58" rx="4" fill="#FFB74D"/><text x="520" y="280" font-size="13" fill="#E65100" text-anchor="middle"><tspan>70</tspan></text>
<!-- 第6行: 19-21时 (高峰) -->
<rect x="0" y="305" width="78" height="58" rx="4" fill="#FFA726"/><text x="40" y="340" font-size="13" fill="#FFFFFF" text-anchor="middle"><tspan>78</tspan></text>
<rect x="80" y="305" width="78" height="58" rx="4" fill="#FF9800"/><text x="120" y="340" font-size="13" fill="#FFFFFF" text-anchor="middle"><tspan>85</tspan></text>
<rect x="160" y="305" width="78" height="58" rx="4" fill="#FF9800"/><text x="200" y="340" font-size="13" fill="#FFFFFF" text-anchor="middle"><tspan>88</tspan></text>
<rect x="240" y="305" width="78" height="58" rx="4" fill="#FF9800"/><text x="280" y="340" font-size="13" fill="#FFFFFF" text-anchor="middle"><tspan>86</tspan></text>
<rect x="320" y="305" width="78" height="58" rx="4" fill="#FFA726"/><text x="360" y="340" font-size="13" fill="#FFFFFF" text-anchor="middle"><tspan>80</tspan></text>
<rect x="400" y="305" width="78" height="58" rx="4" fill="#EF5350"/><text x="440" y="340" font-size="13" fill="#FFFFFF" text-anchor="middle"><tspan>95</tspan></text>
<rect x="480" y="305" width="78" height="58" rx="4" fill="#D32F2F"/><text x="520" y="340" font-size="14" font-weight="bold" fill="#FFFFFF" text-anchor="middle"><tspan>98</tspan></text>
<!-- 第7行: 21-23时 -->
<rect x="0" y="365" width="78" height="58" rx="4" fill="#FFD54F"/><text x="40" y="400" font-size="13" fill="#F57F17" text-anchor="middle"><tspan>62</tspan></text>
<rect x="80" y="365" width="78" height="58" rx="4" fill="#FFB74D"/><text x="120" y="400" font-size="13" fill="#E65100" text-anchor="middle"><tspan>70</tspan></text>
<rect x="160" y="365" width="78" height="58" rx="4" fill="#FFB74D"/><text x="200" y="400" font-size="13" fill="#E65100" text-anchor="middle"><tspan>72</tspan></text>
<rect x="240" y="365" width="78" height="58" rx="4" fill="#FFB74D"/><text x="280" y="400" font-size="13" fill="#E65100" text-anchor="middle"><tspan>68</tspan></text>
<rect x="320" y="365" width="78" height="58" rx="4" fill="#FFD54F"/><text x="360" y="400" font-size="13" fill="#F57F17" text-anchor="middle"><tspan>65</tspan></text>
<rect x="400" y="365" width="78" height="58" rx="4" fill="#FF9800"/><text x="440" y="400" font-size="13" fill="#FFFFFF" text-anchor="middle"><tspan>85</tspan></text>
<rect x="480" y="365" width="78" height="58" rx="4" fill="#FFA726"/><text x="520" y="400" font-size="13" fill="#FFFFFF" text-anchor="middle"><tspan>82</tspan></text>
<!-- 第8行: 23-6时 -->
<rect x="0" y="425" width="78" height="58" rx="4" fill="#BBDEFB"/><text x="40" y="460" font-size="13" fill="#1565C0" text-anchor="middle"><tspan>18</tspan></text>
<rect x="80" y="425" width="78" height="58" rx="4" fill="#BBDEFB"/><text x="120" y="460" font-size="13" fill="#1565C0" text-anchor="middle"><tspan>15</tspan></text>
<rect x="160" y="425" width="78" height="58" rx="4" fill="#BBDEFB"/><text x="200" y="460" font-size="13" fill="#1565C0" text-anchor="middle"><tspan>12</tspan></text>
<rect x="240" y="425" width="78" height="58" rx="4" fill="#BBDEFB"/><text x="280" y="460" font-size="13" fill="#1565C0" text-anchor="middle"><tspan>15</tspan></text>
<rect x="320" y="425" width="78" height="58" rx="4" fill="#90CAF9"/><text x="360" y="460" font-size="13" fill="#1565C0" text-anchor="middle"><tspan>22</tspan></text>
<rect x="400" y="425" width="78" height="58" rx="4" fill="#64B5F6"/><text x="440" y="460" font-size="13" fill="#1565C0" text-anchor="middle"><tspan>35</tspan></text>
<rect x="480" y="425" width="78" height="58" rx="4" fill="#90CAF9"/><text x="520" y="460" font-size="13" fill="#1565C0" text-anchor="middle"><tspan>28</tspan></text>
</g>
<!-- 色阶图例 -->
<g id="colorLegend" transform="translate(780, 150)">
<text x="0" y="0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" font-weight="600" fill="#2C3E50"><tspan>活跃度色阶</tspan></text>
<rect x="0" y="15" width="200" height="20" rx="4" fill="url(#heatGradient)"/>
<text x="0" y="52" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#7F8C8D"><tspan>低 (0)</tspan></text>
<text x="90" y="52" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#7F8C8D" text-anchor="middle"><tspan>中 (50)</tspan></text>
<text x="200" y="52" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#7F8C8D" text-anchor="end"><tspan>高 (100)</tspan></text>
</g>
<!-- 统计面板 -->
<g id="statsPanel" transform="translate(780, 240)">
<rect x="0" y="0" width="200" height="180" rx="8" fill="#F8F9FA" stroke="#E0E0E0" stroke-width="1"/>
<text x="100" y="28" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" font-weight="600" fill="#2C3E50" text-anchor="middle"><tspan>统计摘要</tspan></text>
<line x1="15" y1="42" x2="185" y2="42" stroke="#E0E0E0" stroke-width="1"/>
<text x="20" y="68" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#5D6D7E"><tspan>🔥 峰值时段</tspan></text>
<text x="20" y="88" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#D32F2F"><tspan>周日 19-21时 (98K)</tspan></text>
<text x="20" y="115" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#5D6D7E"><tspan>❄️ 低谷时段</tspan></text>
<text x="20" y="135" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#1565C0"><tspan>周日 6-9时 (10K)</tspan></text>
<text x="20" y="162" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#5D6D7E"><tspan>📊 日均在线</tspan></text>
<text x="130" y="162" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="bold" fill="#2C3E50"><tspan>54.2K</tspan></text>
</g>
<!-- 洞察卡片 -->
<g id="insightCard" transform="translate(780, 440)">
<rect x="0" y="0" width="200" height="120" rx="8" fill="#E8F5E9" stroke="#4CAF50" stroke-width="1"/>
<text x="20" y="28" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" font-weight="600" fill="#2E7D32"><tspan>💡 关键洞察</tspan></text>
<text x="20" y="55" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="12" fill="#5D6D7E"><tspan>• 周末晚间活跃度最高</tspan></text>
<text x="20" y="75" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="12" fill="#5D6D7E"><tspan>• 工作日午间有小高峰</tspan></text>
<text x="20" y="95" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="12" fill="#5D6D7E"><tspan>• 凌晨时段流量最低</tspan></text>
<text x="20" y="115" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="11" fill="#7F8C8D"><tspan>建议: 周末晚间增加服务器</tspan></text>
</g>
<!-- 数据来源 -->
<text x="60" y="695" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#95A5A6">
<tspan>数据来源: 用户行为分析平台</tspan>
</text>
</svg>
templates/charts/horizontal_bar_chart.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" width="1280" height="720">
<!--
水平条形图模板
用途: 类别排名、数值对比(适合类别名称较长的场景)
适用场景: 销售排行榜、品牌认知度、满意度评分
支持: 8个条形(可调整为5-12个)
与柱状图区别: 水平展示,更适合排名和类别名较长的数据
-->
<defs>
<!-- 条形渐变 -->
<linearGradient id="hbarGrad1" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:1" />
<stop offset="100%" style="stop-color:#42A5F5;stop-opacity:1" />
</linearGradient>
<linearGradient id="hbarGrad2" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#4CAF50;stop-opacity:1" />
<stop offset="100%" style="stop-color:#66BB6A;stop-opacity:1" />
</linearGradient>
<linearGradient id="hbarGrad3" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#FF9800;stop-opacity:1" />
<stop offset="100%" style="stop-color:#FFB74D;stop-opacity:1" />
</linearGradient>
<!-- 阴影 -->
<filter id="hbarShadow">
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
<feOffset dx="1" dy="0"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.2"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- 背景 -->
<rect width="1280" height="720" fill="#FFFFFF"/>
<!-- 标题 -->
<text x="60" y="70" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="32" font-weight="bold" fill="#2C3E50">
<tspan>品牌认知度排行榜</tspan>
</text>
<text x="60" y="105" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" fill="#7F8C8D">
<tspan>2025年消费者调研报告 · 样本量 N=5,000</tspan>
</text>
<!-- 图表区域 -->
<g id="chartArea">
<!-- X轴网格线 -->
<line x1="300" y1="140" x2="300" y2="620" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="480" y1="140" x2="480" y2="620" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="660" y1="140" x2="660" y2="620" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="840" y1="140" x2="840" y2="620" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="1020" y1="140" x2="1020" y2="620" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="1200" y1="140" x2="1200" y2="620" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<!-- X轴刻度标签 -->
<text x="300" y="650" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle">
<tspan>0%</tspan>
</text>
<text x="480" y="650" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle">
<tspan>20%</tspan>
</text>
<text x="660" y="650" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle">
<tspan>40%</tspan>
</text>
<text x="840" y="650" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle">
<tspan>60%</tspan>
</text>
<text x="1020" y="650" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle">
<tspan>80%</tspan>
</text>
<text x="1200" y="650" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle">
<tspan>100%</tspan>
</text>
<!-- Y轴基线 -->
<line x1="300" y1="140" x2="300" y2="620" stroke="#95A5A6" stroke-width="2"/>
<line x1="300" y1="620" x2="1200" y2="620" stroke="#95A5A6" stroke-width="2"/>
<!-- 条形数据 (条高36px, 间距24px, 总间距60px) -->
<!-- 条形1: 品牌A - 92% (第1名,蓝色) -->
<text x="290" y="175" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" fill="#2C3E50" text-anchor="end">
<tspan>品牌A (市场领导者)</tspan>
</text>
<rect x="300" y="155" width="828" height="36" rx="4" fill="url(#hbarGrad1)" filter="url(#hbarShadow)"/>
<text x="1138" y="179" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="600" fill="#2C3E50">
<tspan>92%</tspan>
</text>
<!-- 条形2: 品牌B - 78% (第2名,蓝色) -->
<text x="290" y="235" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" fill="#2C3E50" text-anchor="end">
<tspan>品牌B (新锐品牌)</tspan>
</text>
<rect x="300" y="215" width="702" height="36" rx="4" fill="url(#hbarGrad1)" filter="url(#hbarShadow)"/>
<text x="1012" y="239" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="600" fill="#2C3E50">
<tspan>78%</tspan>
</text>
<!-- 条形3: 品牌C - 65% (第3名,蓝色) -->
<text x="290" y="295" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" fill="#2C3E50" text-anchor="end">
<tspan>品牌C (经典品牌)</tspan>
</text>
<rect x="300" y="275" width="585" height="36" rx="4" fill="url(#hbarGrad1)" filter="url(#hbarShadow)"/>
<text x="895" y="299" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="600" fill="#2C3E50">
<tspan>65%</tspan>
</text>
<!-- 条形4: 品牌D - 52% (第4名,绿色) -->
<text x="290" y="355" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" fill="#2C3E50" text-anchor="end">
<tspan>品牌D (区域品牌)</tspan>
</text>
<rect x="300" y="335" width="468" height="36" rx="4" fill="url(#hbarGrad2)" filter="url(#hbarShadow)"/>
<text x="778" y="359" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="600" fill="#2C3E50">
<tspan>52%</tspan>
</text>
<!-- 条形5: 品牌E - 45% (第5名,绿色) -->
<text x="290" y="415" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" fill="#2C3E50" text-anchor="end">
<tspan>品牌E (性价比品牌)</tspan>
</text>
<rect x="300" y="395" width="405" height="36" rx="4" fill="url(#hbarGrad2)" filter="url(#hbarShadow)"/>
<text x="715" y="419" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="600" fill="#2C3E50">
<tspan>45%</tspan>
</text>
<!-- 条形6: 品牌F - 38% (第6名,绿色) -->
<text x="290" y="475" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" fill="#2C3E50" text-anchor="end">
<tspan>品牌F (高端品牌)</tspan>
</text>
<rect x="300" y="455" width="342" height="36" rx="4" fill="url(#hbarGrad2)" filter="url(#hbarShadow)"/>
<text x="652" y="479" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="600" fill="#2C3E50">
<tspan>38%</tspan>
</text>
<!-- 条形7: 品牌G - 25% (第7名,橙色) -->
<text x="290" y="535" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" fill="#2C3E50" text-anchor="end">
<tspan>品牌G (新进入者)</tspan>
</text>
<rect x="300" y="515" width="225" height="36" rx="4" fill="url(#hbarGrad3)" filter="url(#hbarShadow)"/>
<text x="535" y="539" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="600" fill="#2C3E50">
<tspan>25%</tspan>
</text>
<!-- 条形8: 其他 - 12% (第8名,橙色) -->
<text x="290" y="595" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" fill="#2C3E50" text-anchor="end">
<tspan>其他品牌</tspan>
</text>
<rect x="300" y="575" width="108" height="36" rx="4" fill="url(#hbarGrad3)" filter="url(#hbarShadow)"/>
<text x="418" y="599" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="600" fill="#2C3E50">
<tspan>12%</tspan>
</text>
</g>
<!-- 图例 -->
<g id="legend">
<rect x="60" y="660" width="16" height="16" rx="2" fill="url(#hbarGrad1)"/>
<text x="85" y="673" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#5D6D7E">
<tspan>高认知度 (≥50%)</tspan>
</text>
<rect x="240" y="660" width="16" height="16" rx="2" fill="url(#hbarGrad2)"/>
<text x="265" y="673" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#5D6D7E">
<tspan>中等认知度 (30-50%)</tspan>
</text>
<rect x="450" y="660" width="16" height="16" rx="2" fill="url(#hbarGrad3)"/>
<text x="475" y="673" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#5D6D7E">
<tspan>低认知度 (<30%)</tspan>
</text>
</g>
<!-- 数据来源 -->
<text x="1220" y="695" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#95A5A6" text-anchor="end">
<tspan>数据来源: 市场研究部门</tspan>
</text>
</svg>
templates/charts/kpi_cards.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" width="1280" height="720">
<!--
KPI 卡片模板 - 2×2 布局(CRAP 优化版)
用途: 展示关键业绩指标
适用场景: 财务指标、业务数据看板、增长率展示
优化: 强化对齐、对比、重复、亲密性原则
-->
<defs>
<!-- 卡片阴影 - 更精致柔和 -->
<filter id="cardShadow" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur in="SourceAlpha" stdDeviation="6"/>
<feOffset dx="0" dy="3" result="offsetblur"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.15"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<!-- 渐变背景 - 增加层次感 -->
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#F8F9FA;stop-opacity:1" />
<stop offset="100%" style="stop-color:#ECF0F1;stop-opacity:1" />
</linearGradient>
</defs>
<!-- 背景 -->
<rect width="1280" height="720" fill="url(#bgGradient)"/>
<!-- 标题区域 - 增强对比 -->
<text x="60" y="75" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="36" font-weight="700" fill="#1A252F" letter-spacing="-0.5">
<tspan>关键业绩指标</tspan>
</text>
<text x="60" y="105" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" fill="#7F8C8D">
<tspan>Key Performance Indicators</tspan>
</text>
<!-- KPI 卡片布局 2×2 - 统一间距 40px -->
<!-- 卡片 1 - 左上 (收入增长) -->
<g filter="url(#cardShadow)">
<!-- 卡片背景 - 更圆润的圆角 -->
<rect x="60" y="150" width="560" height="250" rx="12" fill="#FFFFFF" stroke="#E8EAED" stroke-width="1"/>
<!-- 左侧色块指示器 - 更粗更醒目 -->
<rect x="76" y="170" width="8" height="70" rx="4" fill="#2196F3"/>
<!-- 指标名称 - 统一左边距 100px -->
<text x="100" y="190" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" font-weight="500" fill="#5F6368" letter-spacing="0.2">
<tspan>总收入</tspan>
</text>
<text x="100" y="213" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#9AA0A6">
<tspan>Total Revenue</tspan>
</text>
<!-- 主数值 - 增强视觉权重 -->
<text x="100" y="285" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="64" font-weight="700" fill="#1A252F" letter-spacing="-1">
<tspan>¥2.45</tspan>
</text>
<text x="310" y="285" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="28" font-weight="500" fill="#5F6368">
<tspan>亿</tspan>
</text>
<!-- 趋势指示 - 优化间距和对齐 -->
<circle cx="110" cy="340" r="18" fill="#E8F5E9"/>
<polygon points="110,333 116,347 104,347" fill="#27AE60"/>
<text x="140" y="349" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="700" fill="#27AE60">
<tspan>+18.5%</tspan>
</text>
<!-- 同比说明 - 更紧凑的分组 -->
<text x="245" y="349" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" fill="#9AA0A6">
<tspan>较去年同期</tspan>
</text>
</g>
<!-- 卡片 2 - 右上 (用户数量) -->
<g filter="url(#cardShadow)">
<!-- 卡片背景 -->
<rect x="660" y="150" width="560" height="250" rx="12" fill="#FFFFFF" stroke="#E8EAED" stroke-width="1"/>
<!-- 左侧色块指示器 -->
<rect x="676" y="170" width="8" height="70" rx="4" fill="#4CAF50"/>
<!-- 指标名称 -->
<text x="700" y="190" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" font-weight="500" fill="#5F6368" letter-spacing="0.2">
<tspan>活跃用户</tspan>
</text>
<text x="700" y="213" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#9AA0A6">
<tspan>Active Users</tspan>
</text>
<!-- 主数值 -->
<text x="700" y="285" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="64" font-weight="700" fill="#1A252F" letter-spacing="-1">
<tspan>128.3</tspan>
</text>
<text x="930" y="285" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="28" font-weight="500" fill="#5F6368">
<tspan>万</tspan>
</text>
<!-- 趋势指示 -->
<circle cx="710" cy="340" r="18" fill="#E8F5E9"/>
<polygon points="710,333 716,347 704,347" fill="#27AE60"/>
<text x="740" y="349" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="700" fill="#27AE60">
<tspan>+12.3%</tspan>
</text>
<!-- 同比说明 -->
<text x="845" y="349" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" fill="#9AA0A6">
<tspan>月同比增长</tspan>
</text>
</g>
<!-- 卡片 3 - 左下 (客单价) -->
<g filter="url(#cardShadow)">
<!-- 卡片背景 -->
<rect x="60" y="440" width="560" height="250" rx="12" fill="#FFFFFF" stroke="#E8EAED" stroke-width="1"/>
<!-- 左侧色块指示器 -->
<rect x="76" y="460" width="8" height="70" rx="4" fill="#FF9800"/>
<!-- 指标名称 -->
<text x="100" y="480" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" font-weight="500" fill="#5F6368" letter-spacing="0.2">
<tspan>平均客单价</tspan>
</text>
<text x="100" y="503" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#9AA0A6">
<tspan>Avg Order Value</tspan>
</text>
<!-- 主数值 -->
<text x="100" y="575" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="64" font-weight="700" fill="#1A252F" letter-spacing="-1">
<tspan>¥432</tspan>
</text>
<!-- 趋势指示 - 下降趋势 -->
<circle cx="110" cy="630" r="18" fill="#FFEBEE"/>
<polygon points="110,643 116,629 104,629" fill="#E74C3C"/>
<text x="140" y="639" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="700" fill="#E74C3C">
<tspan>-3.2%</tspan>
</text>
<!-- 同比说明 -->
<text x="235" y="639" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" fill="#9AA0A6">
<tspan>环比上月</tspan>
</text>
</g>
<!-- 卡片 4 - 右下 (转化率) -->
<g filter="url(#cardShadow)">
<!-- 卡片背景 -->
<rect x="660" y="440" width="560" height="250" rx="12" fill="#FFFFFF" stroke="#E8EAED" stroke-width="1"/>
<!-- 左侧色块指示器 -->
<rect x="676" y="460" width="8" height="70" rx="4" fill="#9C27B0"/>
<!-- 指标名称 -->
<text x="700" y="480" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" font-weight="500" fill="#5F6368" letter-spacing="0.2">
<tspan>转化率</tspan>
</text>
<text x="700" y="503" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#9AA0A6">
<tspan>Conversion Rate</tspan>
</text>
<!-- 主数值 -->
<text x="700" y="575" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="64" font-weight="700" fill="#1A252F" letter-spacing="-1">
<tspan>8.7%</tspan>
</text>
<!-- 趋势指示 -->
<circle cx="710" cy="630" r="18" fill="#E8F5E9"/>
<polygon points="710,623 716,637 704,637" fill="#27AE60"/>
<text x="740" y="639" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="700" fill="#27AE60">
<tspan>+0.8%</tspan>
</text>
<!-- 同比说明 -->
<text x="835" y="639" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" fill="#9AA0A6">
<tspan>百分点提升</tspan>
</text>
</g>
<!-- 数据来源标注 - 优化样式 -->
<text x="1160" y="705" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#9AA0A6" text-anchor="end">
<tspan>数据截至 2025年11月 | Data as of Nov 2025</tspan>
</text>
</svg>
templates/charts/line_chart.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" width="1280" height="720">
<!--
折线图模板
用途: 趋势和变化展示
适用场景: 时间序列分析、增长趋势、多指标对比
支持: 双折线对比
-->
<defs>
<!-- 线条渐变 -->
<linearGradient id="lineGrad1" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:1" />
<stop offset="100%" style="stop-color:#42A5F5;stop-opacity:1" />
</linearGradient>
<linearGradient id="lineGrad2" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#4CAF50;stop-opacity:1" />
<stop offset="100%" style="stop-color:#66BB6A;stop-opacity:1" />
</linearGradient>
<!-- 区域填充渐变 -->
<linearGradient id="areaGrad1" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:0.2" />
<stop offset="100%" style="stop-color:#2196F3;stop-opacity:0.05" />
</linearGradient>
<linearGradient id="areaGrad2" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#4CAF50;stop-opacity:0.2" />
<stop offset="100%" style="stop-color:#4CAF50;stop-opacity:0.05" />
</linearGradient>
<!-- 数据点阴影 -->
<filter id="dotShadow">
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
<feOffset dx="0" dy="1"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.4"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- 背景 -->
<rect width="1280" height="720" fill="#FFFFFF"/>
<!-- 标题 -->
<text x="60" y="80" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="32" font-weight="bold" fill="#2C3E50">
<tspan>营收与利润趋势</tspan>
</text>
<text x="60" y="115" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" fill="#7F8C8D">
<tspan>2024年 1月 - 12月 · 单位:百万元</tspan>
</text>
<!-- 图表区域 -->
<g id="chartArea">
<!-- 网格线 -->
<line x1="140" y1="550" x2="1160" y2="550" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="140" y1="470" x2="1160" y2="470" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="140" y1="390" x2="1160" y2="390" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="140" y1="310" x2="1160" y2="310" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="140" y1="230" x2="1160" y2="230" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="140" y1="150" x2="1160" y2="150" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<!-- Y轴刻度标签 -->
<text x="125" y="555" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end">
<tspan>0</tspan>
</text>
<text x="125" y="475" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end">
<tspan>20</tspan>
</text>
<text x="125" y="395" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end">
<tspan>40</tspan>
</text>
<text x="125" y="315" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end">
<tspan>60</tspan>
</text>
<text x="125" y="235" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end">
<tspan>80</tspan>
</text>
<text x="125" y="155" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end">
<tspan>100</tspan>
</text>
<!-- 坐标轴 -->
<line x1="140" y1="150" x2="140" y2="550" stroke="#95A5A6" stroke-width="2"/>
<line x1="140" y1="550" x2="1160" y2="550" stroke="#95A5A6" stroke-width="2"/>
<!-- X轴月份标签 (12个月, 间距85px) -->
<text x="225" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle">
<tspan>1月</tspan>
</text>
<text x="310" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle">
<tspan>2月</tspan>
</text>
<text x="395" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle">
<tspan>3月</tspan>
</text>
<text x="480" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle">
<tspan>4月</tspan>
</text>
<text x="565" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle">
<tspan>5月</tspan>
</text>
<text x="650" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle">
<tspan>6月</tspan>
</text>
<text x="735" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle">
<tspan>7月</tspan>
</text>
<text x="820" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle">
<tspan>8月</tspan>
</text>
<text x="905" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle">
<tspan>9月</tspan>
</text>
<text x="990" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle">
<tspan>10月</tspan>
</text>
<text x="1075" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle">
<tspan>11月</tspan>
</text>
<text x="1160" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle">
<tspan>12月</tspan>
</text>
<!-- 折线1区域填充 (营收) - 数据点: 32,38,45,52,58,62,68,75,78,82,88,92 -->
<path d="M 225,422 L 310,398 L 395,370 L 480,342 L 565,318 L 650,302 L 735,278 L 820,250 L 905,238 L 990,222 L 1075,198 L 1160,182 L 1160,550 L 225,550 Z"
fill="url(#areaGrad1)" opacity="0.3"/>
<!-- 折线1 (营收) -->
<polyline points="225,422 310,398 395,370 480,342 565,318 650,302 735,278 820,250 905,238 990,222 1075,198 1160,182"
fill="none" stroke="url(#lineGrad1)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<!-- 折线1数据点 -->
<circle cx="225" cy="422" r="5" fill="#2196F3" filter="url(#dotShadow)"/>
<circle cx="310" cy="398" r="5" fill="#2196F3" filter="url(#dotShadow)"/>
<circle cx="395" cy="370" r="5" fill="#2196F3" filter="url(#dotShadow)"/>
<circle cx="480" cy="342" r="5" fill="#2196F3" filter="url(#dotShadow)"/>
<circle cx="565" cy="318" r="5" fill="#2196F3" filter="url(#dotShadow)"/>
<circle cx="650" cy="302" r="5" fill="#2196F3" filter="url(#dotShadow)"/>
<circle cx="735" cy="278" r="5" fill="#2196F3" filter="url(#dotShadow)"/>
<circle cx="820" cy="250" r="5" fill="#2196F3" filter="url(#dotShadow)"/>
<circle cx="905" cy="238" r="5" fill="#2196F3" filter="url(#dotShadow)"/>
<circle cx="990" cy="222" r="5" fill="#2196F3" filter="url(#dotShadow)"/>
<circle cx="1075" cy="198" r="5" fill="#2196F3" filter="url(#dotShadow)"/>
<circle cx="1160" cy="182" r="5" fill="#2196F3" filter="url(#dotShadow)"/>
<!-- 折线2区域填充 (利润) - 数据点: 12,15,18,22,25,28,32,36,38,42,48,52 -->
<path d="M 225,502 L 310,490 L 395,478 L 480,462 L 565,450 L 650,438 L 735,422 L 820,406 L 905,398 L 990,382 L 1075,358 L 1160,342 L 1160,550 L 225,550 Z"
fill="url(#areaGrad2)" opacity="0.3"/>
<!-- 折线2 (利润) -->
<polyline points="225,502 310,490 395,478 480,462 565,450 650,438 735,422 820,406 905,398 990,382 1075,358 1160,342"
fill="none" stroke="url(#lineGrad2)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<!-- 折线2数据点 -->
<circle cx="225" cy="502" r="5" fill="#4CAF50" filter="url(#dotShadow)"/>
<circle cx="310" cy="490" r="5" fill="#4CAF50" filter="url(#dotShadow)"/>
<circle cx="395" cy="478" r="5" fill="#4CAF50" filter="url(#dotShadow)"/>
<circle cx="480" cy="462" r="5" fill="#4CAF50" filter="url(#dotShadow)"/>
<circle cx="565" cy="450" r="5" fill="#4CAF50" filter="url(#dotShadow)"/>
<circle cx="650" cy="438" r="5" fill="#4CAF50" filter="url(#dotShadow)"/>
<circle cx="735" cy="422" r="5" fill="#4CAF50" filter="url(#dotShadow)"/>
<circle cx="820" cy="406" r="5" fill="#4CAF50" filter="url(#dotShadow)"/>
<circle cx="905" cy="398" r="5" fill="#4CAF50" filter="url(#dotShadow)"/>
<circle cx="990" cy="382" r="5" fill="#4CAF50" filter="url(#dotShadow)"/>
<circle cx="1075" cy="358" r="5" fill="#4CAF50" filter="url(#dotShadow)"/>
<circle cx="1160" cy="342" r="5" fill="#4CAF50" filter="url(#dotShadow)"/>
</g>
<!-- 图例 -->
<g id="legend">
<line x1="920" y1="630" x2="950" y2="630" stroke="#2196F3" stroke-width="3"/>
<circle cx="935" cy="630" r="5" fill="#2196F3"/>
<text x="960" y="635" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="15" fill="#5D6D7E">
<tspan>营收</tspan>
</text>
<line x1="1040" y1="630" x2="1070" y2="630" stroke="#4CAF50" stroke-width="3"/>
<circle cx="1055" cy="630" r="5" fill="#4CAF50"/>
<text x="1080" y="635" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="15" fill="#5D6D7E">
<tspan>净利润</tspan>
</text>
</g>
<!-- 数据来源 -->
<text x="60" y="695" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#95A5A6">
<tspan>数据来源: 财务部门月度报告</tspan>
</text>
</svg>
templates/charts/matrix_2x2.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" width="1280" height="720">
<!--
矩阵图模板 (2×2) - CRAP 深度优化版
用途: 四象限分析
适用场景: 波士顿矩阵、风险评估、优先级分析
优化重点:
- 对齐: 网格化布局,统一边距
- 对比: 强化色彩层次和视觉重量
- 重复: 统一字体大小、间距、样式
- 亲密性: 优化元素分组和留白
-->
<defs>
<!-- 象限渐变背景 - 优化对比度和饱和度 -->
<linearGradient id="quadrant1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#E8F5E9;stop-opacity:1" />
<stop offset="100%" style="stop-color:#C8E6C9;stop-opacity:1" />
</linearGradient>
<linearGradient id="quadrant2" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#FFF8E1;stop-opacity:1" />
<stop offset="100%" style="stop-color:#FFE082;stop-opacity:1" />
</linearGradient>
<linearGradient id="quadrant3" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#FFEBEE;stop-opacity:1" />
<stop offset="100%" style="stop-color:#FFCDD2;stop-opacity:1" />
</linearGradient>
<linearGradient id="quadrant4" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#E3F2FD;stop-opacity:1" />
<stop offset="100%" style="stop-color:#BBDEFB;stop-opacity:1" />
</linearGradient>
<!-- 整体背景渐变 -->
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#FAFBFC;stop-opacity:1" />
<stop offset="100%" style="stop-color:#F5F6F8;stop-opacity:1" />
</linearGradient>
<!-- 数据点阴影 - 柔和深度感 -->
<filter id="bubbleShadow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceAlpha" stdDeviation="6"/>
<feOffset dx="0" dy="4"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.25"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<!-- 坐标轴箭头标记 -->
<marker id="arrowX" markerWidth="14" markerHeight="14" refX="12" refY="7" orient="auto">
<path d="M 2,2 L 12,7 L 2,12 Z" fill="#1A252F"/>
</marker>
<marker id="arrowY" markerWidth="14" markerHeight="14" refX="7" refY="2" orient="auto">
<path d="M 2,12 L 7,2 L 12,12 Z" fill="#1A252F"/>
</marker>
</defs>
<!-- 背景 -->
<rect width="1280" height="720" fill="url(#bgGradient)"/>
<!-- 标题区域 - 增强层级 -->
<text x="60" y="70" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="36" font-weight="700" fill="#1A252F" letter-spacing="-0.5">
<tspan>产品组合矩阵分析</tspan>
</text>
<text x="60" y="95" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="15" fill="#9AA0A6">
<tspan>Boston Matrix · Product Portfolio Analysis</tspan>
</text>
<text x="60" y="120" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="500" fill="#5F6368">
<tspan>市场增长率 vs 市场占有率 · 气泡大小代表营收规模</tspan>
</text>
<!-- 矩阵主区域 - 优化布局,中心点 (565, 410) -->
<g id="matrixArea">
<!-- 象限背景 - 统一尺寸 480×240,增强边框对比 -->
<!-- 象限1: 右上 (高增长, 高份额) - 明星 -->
<rect x="565" y="170" width="480" height="240" fill="url(#quadrant1)"
stroke="#FFFFFF" stroke-width="3" rx="2"/>
<!-- 象限2: 左上 (高增长, 低份额) - 问题 -->
<rect x="85" y="170" width="480" height="240" fill="url(#quadrant2)"
stroke="#FFFFFF" stroke-width="3" rx="2"/>
<!-- 象限3: 左下 (低增长, 低份额) - 瘦狗 -->
<rect x="85" y="410" width="480" height="240" fill="url(#quadrant3)"
stroke="#FFFFFF" stroke-width="3" rx="2"/>
<!-- 象限4: 右下 (低增长, 高份额) - 现金牛 -->
<rect x="565" y="410" width="480" height="240" fill="url(#quadrant4)"
stroke="#FFFFFF" stroke-width="3" rx="2"/>
<!-- 坐标轴 - 清晰有力 -->
<line x1="85" y1="410" x2="1045" y2="410" stroke="#1A252F" stroke-width="4"
marker-end="url(#arrowX)"/>
<line x1="565" y1="170" x2="565" y2="650" stroke="#1A252F" stroke-width="4"
marker-start="url(#arrowY)"/>
<!-- X轴标签 - 统一样式层级 -->
<text x="565" y="680" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="600" fill="#1A252F" text-anchor="middle">
<tspan>市场占有率 Market Share</tspan>
</text>
<text x="220" y="680" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="500" fill="#5F6368" text-anchor="middle">
<tspan>低 Low</tspan>
</text>
<text x="910" y="680" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="500" fill="#5F6368" text-anchor="middle">
<tspan>高 High</tspan>
</text>
<!-- Y轴标签 - 统一样式层级 -->
<text x="40" y="410" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="600" fill="#1A252F" text-anchor="middle"
transform="rotate(-90, 40, 410)">
<tspan>市场增长率</tspan>
</text>
<text x="25" y="410" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#9AA0A6" text-anchor="middle"
transform="rotate(-90, 25, 410)">
<tspan>Growth Rate</tspan>
</text>
<!-- Y轴端点标记 - 优化对齐 -->
<text x="50" y="655" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="500" fill="#5F6368">
<tspan>低</tspan>
</text>
<text x="50" y="668" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#9AA0A6">
<tspan>Low</tspan>
</text>
<text x="50" y="180" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="500" fill="#5F6368">
<tspan>高</tspan>
</text>
<text x="50" y="193" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#9AA0A6">
<tspan>High</tspan>
</text>
<!-- 象限标签 - 对齐优化,统一间距 -->
<!-- 左上:问题产品 -->
<text x="105" y="200" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="700" fill="#F57C00">
<tspan>问题产品</tspan>
</text>
<text x="105" y="220" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#E65100">
<tspan>Question Marks</tspan>
</text>
<text x="105" y="238" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="500" fill="#D84315">
<tspan>高投入 · 低回报</tspan>
</text>
<!-- 右上:明星产品 -->
<text x="585" y="200" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="700" fill="#388E3C">
<tspan>明星产品</tspan>
</text>
<text x="585" y="220" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#2E7D32">
<tspan>Stars</tspan>
</text>
<text x="585" y="238" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="500" fill="#1B5E20">
<tspan>高投入 · 高回报</tspan>
</text>
<!-- 左下:瘦狗产品 -->
<text x="105" y="610" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="700" fill="#D32F2F">
<tspan>瘦狗产品</tspan>
</text>
<text x="105" y="630" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#C62828">
<tspan>Dogs</tspan>
</text>
<!-- 右下:现金牛产品 -->
<text x="585" y="610" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="700" fill="#1976D2">
<tspan>现金牛产品</tspan>
</text>
<text x="585" y="630" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#1565C0">
<tspan>Cash Cows</tspan>
</text>
<!-- 数据点 (气泡图) - 重新定位,统一样式 -->
<!-- 产品A: 明星 (市场份额: 高75%, 增长率: 高35%, 营收: 大) -->
<circle cx="879" cy="247" r="55" fill="#4CAF50" opacity="0.9" filter="url(#bubbleShadow)"/>
<text x="879" y="244" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="17" font-weight="700" fill="#FFFFFF" text-anchor="middle">
<tspan>产品A</tspan>
</text>
<text x="879" y="263" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#FFFFFF" text-anchor="middle">
<tspan>¥3.2亿</tspan>
</text>
<!-- 产品B: 现金牛 (市场份额: 高80%, 增长率: 低8%, 营收: 最大) -->
<circle cx="935" cy="544" r="70" fill="#2196F3" opacity="0.9" filter="url(#bubbleShadow)"/>
<text x="935" y="540" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" font-weight="700" fill="#FFFFFF" text-anchor="middle">
<tspan>产品B</tspan>
</text>
<text x="935" y="560" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="15" font-weight="600" fill="#FFFFFF" text-anchor="middle">
<tspan>¥4.8亿</tspan>
</text>
<!-- 产品C: 问题 (市场份额: 低25%, 增长率: 高40%, 营收: 中) -->
<circle cx="269" cy="228" r="42" fill="#FF9800" opacity="0.9" filter="url(#bubbleShadow)"/>
<text x="269" y="225" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="700" fill="#FFFFFF" text-anchor="middle">
<tspan>产品C</tspan>
</text>
<text x="269" y="243" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" font-weight="600" fill="#FFFFFF" text-anchor="middle">
<tspan>¥1.6亿</tspan>
</text>
<!-- 产品D: 问题 (市场份额: 低30%, 增长率: 高28%, 营收: 中) -->
<circle cx="325" cy="309" r="38" fill="#FF9800" opacity="0.9" filter="url(#bubbleShadow)"/>
<text x="325" y="306" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="15" font-weight="700" fill="#FFFFFF" text-anchor="middle">
<tspan>产品D</tspan>
</text>
<text x="325" y="323" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" font-weight="600" fill="#FFFFFF" text-anchor="middle">
<tspan>¥1.2亿</tspan>
</text>
<!-- 产品E: 瘦狗 (市场份额: 低15%, 增长率: 低5%, 营收: 小) -->
<circle cx="195" cy="573" r="30" fill="#E74C3C" opacity="0.9" filter="url(#bubbleShadow)"/>
<text x="195" y="571" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="700" fill="#FFFFFF" text-anchor="middle">
<tspan>产品E</tspan>
</text>
<text x="195" y="587" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" font-weight="600" fill="#FFFFFF" text-anchor="middle">
<tspan>¥0.5亿</tspan>
</text>
<!-- 产品F: 现金牛 (市场份额: 中60%, 增长率: 低12%, 营收: 中) -->
<circle cx="741" cy="506" r="45" fill="#2196F3" opacity="0.9" filter="url(#bubbleShadow)"/>
<text x="741" y="503" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="700" fill="#FFFFFF" text-anchor="middle">
<tspan>产品F</tspan>
</text>
<text x="741" y="521" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" font-weight="600" fill="#FFFFFF" text-anchor="middle">
<tspan>¥1.8亿</tspan>
</text>
<!-- 产品G: 明星 (市场份额: 中55%, 增长率: 高30%, 营收: 中) -->
<circle cx="695" cy="285" r="40" fill="#4CAF50" opacity="0.9" filter="url(#bubbleShadow)"/>
<text x="695" y="282" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="15" font-weight="700" fill="#FFFFFF" text-anchor="middle">
<tspan>产品G</tspan>
</text>
<text x="695" y="299" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" font-weight="600" fill="#FFFFFF" text-anchor="middle">
<tspan>¥1.4亿</tspan>
</text>
</g>
<!-- 战略建议框 - 重新设计,优化亲密性与留白 -->
<g id="strategicRecommendations">
<!-- 背景卡片 -->
<rect x="1070" y="180" width="170" height="240" fill="#FFFFFF"
stroke="#E0E0E0" stroke-width="1.5" rx="8" opacity="0.95"/>
<!-- 标题 -->
<text x="1155" y="210" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="15" font-weight="700" fill="#1A252F" text-anchor="middle">
<tspan>战略建议</tspan>
</text>
<!-- 分隔线 -->
<line x1="1085" y1="225" x2="1225" y2="225" stroke="#E0E0E0" stroke-width="1"/>
<!-- 明星产品 -->
<circle cx="1090" cy="250" r="6" fill="#4CAF50" opacity="0.9"/>
<text x="1105" y="254" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" font-weight="600" fill="#388E3C">
<tspan>明星: 持续投资</tspan>
</text>
<!-- 问题产品 -->
<circle cx="1090" cy="285" r="6" fill="#FF9800" opacity="0.9"/>
<text x="1105" y="289" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" font-weight="600" fill="#F57C00">
<tspan>问题: 选择性投资</tspan>
</text>
<!-- 现金牛产品 -->
<circle cx="1090" cy="320" r="6" fill="#2196F3" opacity="0.9"/>
<text x="1105" y="324" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" font-weight="600" fill="#1976D2">
<tspan>现金牛: 收割</tspan>
</text>
<!-- 瘦狗产品 -->
<circle cx="1090" cy="355" r="6" fill="#E74C3C" opacity="0.9"/>
<text x="1105" y="359" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" font-weight="600" fill="#D32F2F">
<tspan>瘦狗: 撤资</tspan>
</text>
<!-- 底部说明 -->
<text x="1155" y="395" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="10" fill="#9AA0A6" text-anchor="middle">
<tspan>气泡大小 = 营收规模</tspan>
</text>
</g>
<!-- 数据来源 - 优化样式 -->
<text x="60" y="705" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#9AA0A6">
<tspan>数据来源: 市场研究部门分析 | Source: Market Research Department</tspan>
</text>
</svg>
templates/charts/org_chart.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" width="1280" height="720">
<!--
组织结构图模板
用途: 层级关系展示、组织架构、汇报关系
适用场景: 公司架构、团队结构、项目组织
支持: 3层结构(可扩展)
-->
<defs>
<!-- 卡片阴影 -->
<filter id="orgShadow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur in="SourceAlpha" stdDeviation="4"/>
<feOffset dx="0" dy="2"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.15"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<!-- CEO卡片渐变 -->
<linearGradient id="ceoGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#1976D2;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1565C0;stop-opacity:1" />
</linearGradient>
<!-- 副总裁卡片渐变 -->
<linearGradient id="vpGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#388E3C;stop-opacity:1" />
<stop offset="100%" style="stop-color:#2E7D32;stop-opacity:1" />
</linearGradient>
</defs>
<!-- 背景 -->
<rect width="1280" height="720" fill="#F8F9FA"/>
<!-- 标题 -->
<text x="640" y="50" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="32" font-weight="bold" fill="#2C3E50" text-anchor="middle">
<tspan>公司组织架构</tspan>
</text>
<!-- 连接线 - 第1层到第2层 -->
<line x1="640" y1="175" x2="640" y2="210" stroke="#BDC3C7" stroke-width="2"/>
<line x1="150" y1="210" x2="1130" y2="210" stroke="#BDC3C7" stroke-width="2"/>
<line x1="150" y1="210" x2="150" y2="245" stroke="#BDC3C7" stroke-width="2"/>
<line x1="395" y1="210" x2="395" y2="245" stroke="#BDC3C7" stroke-width="2"/>
<line x1="640" y1="210" x2="640" y2="245" stroke="#BDC3C7" stroke-width="2"/>
<line x1="885" y1="210" x2="885" y2="245" stroke="#BDC3C7" stroke-width="2"/>
<line x1="1130" y1="210" x2="1130" y2="245" stroke="#BDC3C7" stroke-width="2"/>
<!-- 连接线 - 第2层到第3层 -->
<!-- 技术VP → 前端、后端 -->
<line x1="150" y1="355" x2="150" y2="390" stroke="#BDC3C7" stroke-width="2"/>
<line x1="100" y1="390" x2="200" y2="390" stroke="#BDC3C7" stroke-width="2"/>
<line x1="100" y1="390" x2="100" y2="425" stroke="#BDC3C7" stroke-width="2"/>
<line x1="200" y1="390" x2="200" y2="425" stroke="#BDC3C7" stroke-width="2"/>
<!-- 产品VP → 产品设计、用户研究 -->
<line x1="395" y1="355" x2="395" y2="390" stroke="#BDC3C7" stroke-width="2"/>
<line x1="345" y1="390" x2="445" y2="390" stroke="#BDC3C7" stroke-width="2"/>
<line x1="345" y1="390" x2="345" y2="425" stroke="#BDC3C7" stroke-width="2"/>
<line x1="445" y1="390" x2="445" y2="425" stroke="#BDC3C7" stroke-width="2"/>
<!-- 销售VP → 华东、华南 -->
<line x1="640" y1="355" x2="640" y2="390" stroke="#BDC3C7" stroke-width="2"/>
<line x1="590" y1="390" x2="690" y2="390" stroke="#BDC3C7" stroke-width="2"/>
<line x1="590" y1="390" x2="590" y2="425" stroke="#BDC3C7" stroke-width="2"/>
<line x1="690" y1="390" x2="690" y2="425" stroke="#BDC3C7" stroke-width="2"/>
<!-- 财务VP → 财务核算、预算管理 -->
<line x1="885" y1="355" x2="885" y2="390" stroke="#BDC3C7" stroke-width="2"/>
<line x1="835" y1="390" x2="935" y2="390" stroke="#BDC3C7" stroke-width="2"/>
<line x1="835" y1="390" x2="835" y2="425" stroke="#BDC3C7" stroke-width="2"/>
<line x1="935" y1="390" x2="935" y2="425" stroke="#BDC3C7" stroke-width="2"/>
<!-- 人力VP → 招聘培训、薪酬福利 -->
<line x1="1130" y1="355" x2="1130" y2="390" stroke="#BDC3C7" stroke-width="2"/>
<line x1="1080" y1="390" x2="1180" y2="390" stroke="#BDC3C7" stroke-width="2"/>
<line x1="1080" y1="390" x2="1080" y2="425" stroke="#BDC3C7" stroke-width="2"/>
<line x1="1180" y1="390" x2="1180" y2="425" stroke="#BDC3C7" stroke-width="2"/>
<!-- 第1层 - CEO -->
<g filter="url(#orgShadow)">
<rect x="540" y="85" width="200" height="90" rx="8" fill="url(#ceoGrad)"/>
<text x="640" y="125" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>张三</tspan>
</text>
<text x="640" y="150" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#E3F2FD" text-anchor="middle">
<tspan>首席执行官 CEO</tspan>
</text>
</g>
<!-- 第2层 - 副总裁们 (宽度180px, 间距调整) -->
<g filter="url(#orgShadow)">
<rect x="60" y="245" width="180" height="110" rx="8" fill="url(#vpGrad)"/>
<text x="150" y="285" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>李四</tspan>
</text>
<text x="150" y="310" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#E8F5E9" text-anchor="middle">
<tspan>技术副总裁</tspan>
</text>
<text x="150" y="335" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#C8E6C9" text-anchor="middle">
<tspan>VP of Engineering</tspan>
</text>
</g>
<g filter="url(#orgShadow)">
<rect x="305" y="245" width="180" height="110" rx="8" fill="url(#vpGrad)"/>
<text x="395" y="285" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>王五</tspan>
</text>
<text x="395" y="310" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#E8F5E9" text-anchor="middle">
<tspan>产品副总裁</tspan>
</text>
<text x="395" y="335" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#C8E6C9" text-anchor="middle">
<tspan>VP of Product</tspan>
</text>
</g>
<g filter="url(#orgShadow)">
<rect x="550" y="245" width="180" height="110" rx="8" fill="url(#vpGrad)"/>
<text x="640" y="285" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>赵六</tspan>
</text>
<text x="640" y="310" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#E8F5E9" text-anchor="middle">
<tspan>销售副总裁</tspan>
</text>
<text x="640" y="335" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#C8E6C9" text-anchor="middle">
<tspan>VP of Sales</tspan>
</text>
</g>
<g filter="url(#orgShadow)">
<rect x="795" y="245" width="180" height="110" rx="8" fill="url(#vpGrad)"/>
<text x="885" y="285" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>钱七</tspan>
</text>
<text x="885" y="310" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#E8F5E9" text-anchor="middle">
<tspan>财务副总裁</tspan>
</text>
<text x="885" y="335" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#C8E6C9" text-anchor="middle">
<tspan>VP of Finance</tspan>
</text>
</g>
<g filter="url(#orgShadow)">
<rect x="1040" y="245" width="180" height="110" rx="8" fill="url(#vpGrad)"/>
<text x="1130" y="285" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>孙八</tspan>
</text>
<text x="1130" y="310" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#E8F5E9" text-anchor="middle">
<tspan>人力副总裁</tspan>
</text>
<text x="1130" y="335" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#C8E6C9" text-anchor="middle">
<tspan>VP of HR</tspan>
</text>
</g>
<!-- 第3层 - 部门负责人 (宽度90px, 避免重叠) -->
<!-- 技术VP下属 -->
<g filter="url(#orgShadow)">
<rect x="55" y="425" width="90" height="70" rx="6" fill="#FFFFFF" stroke="#2196F3" stroke-width="2"/>
<text x="100" y="455" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>前端团队</tspan>
</text>
<text x="100" y="475" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#7F8C8D" text-anchor="middle">
<tspan>12人</tspan>
</text>
</g>
<g filter="url(#orgShadow)">
<rect x="155" y="425" width="90" height="70" rx="6" fill="#FFFFFF" stroke="#2196F3" stroke-width="2"/>
<text x="200" y="455" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>后端团队</tspan>
</text>
<text x="200" y="475" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#7F8C8D" text-anchor="middle">
<tspan>15人</tspan>
</text>
</g>
<!-- 产品VP下属 -->
<g filter="url(#orgShadow)">
<rect x="300" y="425" width="90" height="70" rx="6" fill="#FFFFFF" stroke="#4CAF50" stroke-width="2"/>
<text x="345" y="455" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>产品设计</tspan>
</text>
<text x="345" y="475" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#7F8C8D" text-anchor="middle">
<tspan>8人</tspan>
</text>
</g>
<g filter="url(#orgShadow)">
<rect x="400" y="425" width="90" height="70" rx="6" fill="#FFFFFF" stroke="#4CAF50" stroke-width="2"/>
<text x="445" y="455" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>用户研究</tspan>
</text>
<text x="445" y="475" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#7F8C8D" text-anchor="middle">
<tspan>5人</tspan>
</text>
</g>
<!-- 销售VP下属 -->
<g filter="url(#orgShadow)">
<rect x="545" y="425" width="90" height="70" rx="6" fill="#FFFFFF" stroke="#FF9800" stroke-width="2"/>
<text x="590" y="455" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>华东区</tspan>
</text>
<text x="590" y="475" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#7F8C8D" text-anchor="middle">
<tspan>20人</tspan>
</text>
</g>
<g filter="url(#orgShadow)">
<rect x="645" y="425" width="90" height="70" rx="6" fill="#FFFFFF" stroke="#FF9800" stroke-width="2"/>
<text x="690" y="455" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>华南区</tspan>
</text>
<text x="690" y="475" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#7F8C8D" text-anchor="middle">
<tspan>18人</tspan>
</text>
</g>
<!-- 财务VP下属 -->
<g filter="url(#orgShadow)">
<rect x="790" y="425" width="90" height="70" rx="6" fill="#FFFFFF" stroke="#9C27B0" stroke-width="2"/>
<text x="835" y="455" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>财务核算</tspan>
</text>
<text x="835" y="475" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#7F8C8D" text-anchor="middle">
<tspan>6人</tspan>
</text>
</g>
<g filter="url(#orgShadow)">
<rect x="890" y="425" width="90" height="70" rx="6" fill="#FFFFFF" stroke="#9C27B0" stroke-width="2"/>
<text x="935" y="455" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>预算管理</tspan>
</text>
<text x="935" y="475" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#7F8C8D" text-anchor="middle">
<tspan>4人</tspan>
</text>
</g>
<!-- 人力VP下属 -->
<g filter="url(#orgShadow)">
<rect x="1035" y="425" width="90" height="70" rx="6" fill="#FFFFFF" stroke="#E91E63" stroke-width="2"/>
<text x="1080" y="455" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>招聘培训</tspan>
</text>
<text x="1080" y="475" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#7F8C8D" text-anchor="middle">
<tspan>5人</tspan>
</text>
</g>
<g filter="url(#orgShadow)">
<rect x="1135" y="425" width="90" height="70" rx="6" fill="#FFFFFF" stroke="#E91E63" stroke-width="2"/>
<text x="1180" y="455" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>薪酬福利</tspan>
</text>
<text x="1180" y="475" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#7F8C8D" text-anchor="middle">
<tspan>3人</tspan>
</text>
</g>
<!-- 统计信息 -->
<text x="640" y="560" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" fill="#7F8C8D" text-anchor="middle">
<tspan>总员工数: 96人 | 管理层: 6人 | 部门: 10个</tspan>
</text>
<!-- 更新时间 -->
<text x="60" y="695" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#95A5A6">
<tspan>更新时间: 2025年12月</tspan>
</text>
</svg>
templates/charts/pareto_chart.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" width="1280" height="720">
<!--
Pareto Chart Template
Purpose: 80/20 analysis, identify key factors
Use cases: Quality issue analysis, sales contribution, cost structure, defect attribution
Supports: Bar chart + cumulative line chart combination
-->
<defs>
<linearGradient id="paretoBarGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1565C0;stop-opacity:1" />
</linearGradient>
<linearGradient id="paretoBarHighlight" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#FF9800;stop-opacity:1" />
<stop offset="100%" style="stop-color:#F57C00;stop-opacity:1" />
</linearGradient>
<linearGradient id="cumulativeLineGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#E74C3C;stop-opacity:1" />
<stop offset="100%" style="stop-color:#C0392B;stop-opacity:1" />
</linearGradient>
<linearGradient id="areaFill80" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#FFF3E0;stop-opacity:0.8" />
<stop offset="100%" style="stop-color:#FFE0B2;stop-opacity:0.3" />
</linearGradient>
</defs>
<rect width="1280" height="720" fill="#FFFFFF"/>
<text x="640" y="45" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="32" font-weight="bold" fill="#2C3E50" text-anchor="middle">
<tspan>Product Quality Issue Pareto Analysis</tspan>
</text>
<text x="640" y="80" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" fill="#7F8C8D" text-anchor="middle">
<tspan>2025 Q4 - Customer Complaint Attribution (Total: 1,000 cases)</tspan>
</text>
<!-- Legend -->
<g id="legend" transform="translate(150, 100)">
<rect x="0" y="0" width="20" height="14" fill="url(#paretoBarHighlight)" rx="2"/>
<text x="28" y="11" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#5D6D7E"><tspan>Key Factors (80%)</tspan></text>
<rect x="170" y="0" width="20" height="14" fill="url(#paretoBarGrad)" rx="2"/>
<text x="198" y="11" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#5D6D7E"><tspan>Minor Factors</tspan></text>
<line x1="320" y1="7" x2="340" y2="7" stroke="#E74C3C" stroke-width="3"/>
<circle cx="330" cy="7" r="5" fill="#E74C3C"/>
<text x="348" y="11" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#5D6D7E"><tspan>Cumulative %</tspan></text>
</g>
<!-- Y-axis (Left - Count) -->
<line x1="130" y1="130" x2="130" y2="580" stroke="#E0E0E0" stroke-width="2"/>
<text x="70" y="360" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" font-weight="600" fill="#2C3E50" text-anchor="middle" transform="rotate(-90, 70, 360)">
<tspan>Complaint Count</tspan>
</text>
<!-- Y-axis ticks (Left) -->
<text x="120" y="585" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="end"><tspan>0</tspan></text>
<text x="120" y="490" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="end"><tspan>100</tspan></text>
<text x="120" y="395" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="end"><tspan>200</tspan></text>
<text x="120" y="300" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="end"><tspan>300</tspan></text>
<text x="120" y="205" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="end"><tspan>400</tspan></text>
<text x="120" y="135" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="end"><tspan>450</tspan></text>
<!-- Y-axis (Right - Percentage) -->
<line x1="1150" y1="130" x2="1150" y2="580" stroke="#E0E0E0" stroke-width="2"/>
<text x="1210" y="360" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" font-weight="600" fill="#E74C3C" text-anchor="middle" transform="rotate(90, 1210, 360)">
<tspan>Cumulative %</tspan>
</text>
<!-- Y-axis ticks (Right) -->
<text x="1160" y="585" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#E74C3C"><tspan>0%</tspan></text>
<text x="1160" y="470" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#E74C3C"><tspan>25%</tspan></text>
<text x="1160" y="355" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#E74C3C"><tspan>50%</tspan></text>
<text x="1160" y="240" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#E74C3C"><tspan>75%</tspan></text>
<text x="1160" y="135" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#E74C3C"><tspan>100%</tspan></text>
<!-- X-axis -->
<line x1="130" y1="580" x2="1150" y2="580" stroke="#E0E0E0" stroke-width="2"/>
<!-- Grid lines -->
<line x1="130" y1="490" x2="1150" y2="490" stroke="#F0F0F0" stroke-width="1" stroke-dasharray="5,5"/>
<line x1="130" y1="395" x2="1150" y2="395" stroke="#F0F0F0" stroke-width="1" stroke-dasharray="5,5"/>
<line x1="130" y1="300" x2="1150" y2="300" stroke="#F0F0F0" stroke-width="1" stroke-dasharray="5,5"/>
<line x1="130" y1="205" x2="1150" y2="205" stroke="#F0F0F0" stroke-width="1" stroke-dasharray="5,5"/>
<!-- 80% reference line -->
<line x1="130" y1="220" x2="1150" y2="220" stroke="#FF9800" stroke-width="2" stroke-dasharray="10,5"/>
<text x="145" y="215" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#FF9800"><tspan>80% Key Factor Line</tspan></text>
<!-- 80% area fill -->
<rect x="130" y="220" width="640" height="360" fill="url(#areaFill80)" opacity="0.3"/>
<!-- Bar chart data (sorted by frequency high to low) -->
<!-- 1. Function Error: 350 (35%) - Cumulative 35% -->
<rect x="170" y="230" width="100" height="350" fill="url(#paretoBarHighlight)" rx="4"/>
<text x="220" y="220" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#F57C00" text-anchor="middle"><tspan>350</tspan></text>
<text x="220" y="610" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#2C3E50" text-anchor="middle"><tspan>Function Error</tspan></text>
<text x="220" y="628" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#7F8C8D" text-anchor="middle"><tspan>(35%)</tspan></text>
<!-- 2. Appearance Defect: 220 (22%) - Cumulative 57% -->
<rect x="300" y="360" width="100" height="220" fill="url(#paretoBarHighlight)" rx="4"/>
<text x="350" y="350" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#F57C00" text-anchor="middle"><tspan>220</tspan></text>
<text x="350" y="610" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#2C3E50" text-anchor="middle"><tspan>Appearance</tspan></text>
<text x="350" y="628" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#7F8C8D" text-anchor="middle"><tspan>(22%)</tspan></text>
<!-- 3. Missing Parts: 150 (15%) - Cumulative 72% -->
<rect x="430" y="430" width="100" height="150" fill="url(#paretoBarHighlight)" rx="4"/>
<text x="480" y="420" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#F57C00" text-anchor="middle"><tspan>150</tspan></text>
<text x="480" y="610" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#2C3E50" text-anchor="middle"><tspan>Missing Parts</tspan></text>
<text x="480" y="628" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#7F8C8D" text-anchor="middle"><tspan>(15%)</tspan></text>
<!-- 4. Shipping Damage: 120 (12%) - Cumulative 84% -->
<rect x="560" y="460" width="100" height="120" fill="url(#paretoBarHighlight)" rx="4"/>
<text x="610" y="450" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#F57C00" text-anchor="middle"><tspan>120</tspan></text>
<text x="610" y="610" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#2C3E50" text-anchor="middle"><tspan>Ship Damage</tspan></text>
<text x="610" y="628" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#7F8C8D" text-anchor="middle"><tspan>(12%)</tspan></text>
<!-- 5. Manual Error: 70 (7%) - Cumulative 91% -->
<rect x="690" y="510" width="100" height="70" fill="url(#paretoBarGrad)" rx="4"/>
<text x="740" y="500" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="bold" fill="#1565C0" text-anchor="middle"><tspan>70</tspan></text>
<text x="740" y="610" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#2C3E50" text-anchor="middle"><tspan>Manual Error</tspan></text>
<text x="740" y="628" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#7F8C8D" text-anchor="middle"><tspan>(7%)</tspan></text>
<!-- 6. Packaging Issue: 50 (5%) - Cumulative 96% -->
<rect x="820" y="530" width="100" height="50" fill="url(#paretoBarGrad)" rx="4"/>
<text x="870" y="520" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="bold" fill="#1565C0" text-anchor="middle"><tspan>50</tspan></text>
<text x="870" y="610" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#2C3E50" text-anchor="middle"><tspan>Packaging</tspan></text>
<text x="870" y="628" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#7F8C8D" text-anchor="middle"><tspan>(5%)</tspan></text>
<!-- 7. Other Issues: 40 (4%) - Cumulative 100% -->
<rect x="950" y="540" width="100" height="40" fill="url(#paretoBarGrad)" rx="4"/>
<text x="1000" y="530" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="bold" fill="#1565C0" text-anchor="middle"><tspan>40</tspan></text>
<text x="1000" y="610" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#2C3E50" text-anchor="middle"><tspan>Other</tspan></text>
<text x="1000" y="628" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#7F8C8D" text-anchor="middle"><tspan>(4%)</tspan></text>
<!-- Cumulative line chart -->
<polyline points="220,420 350,322 480,254 610,205 740,170 870,151 1000,130"
fill="none" stroke="#E74C3C" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<!-- Cumulative line data points -->
<circle cx="220" cy="420" r="8" fill="#E74C3C"/>
<text x="220" y="445" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" font-weight="bold" fill="#E74C3C" text-anchor="middle"><tspan>35%</tspan></text>
<circle cx="350" cy="322" r="8" fill="#E74C3C"/>
<text x="350" y="310" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" font-weight="bold" fill="#E74C3C" text-anchor="middle"><tspan>57%</tspan></text>
<circle cx="480" cy="254" r="8" fill="#E74C3C"/>
<text x="480" y="242" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" font-weight="bold" fill="#E74C3C" text-anchor="middle"><tspan>72%</tspan></text>
<circle cx="610" cy="205" r="8" fill="#FF9800"/>
<text x="610" y="193" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" font-weight="bold" fill="#FF9800" text-anchor="middle"><tspan>84%</tspan></text>
<circle cx="740" cy="170" r="6" fill="#E74C3C"/>
<text x="740" y="158" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" font-weight="bold" fill="#E74C3C" text-anchor="middle"><tspan>91%</tspan></text>
<circle cx="870" cy="151" r="6" fill="#E74C3C"/>
<text x="870" y="139" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" font-weight="bold" fill="#E74C3C" text-anchor="middle"><tspan>96%</tspan></text>
<circle cx="1000" cy="130" r="6" fill="#E74C3C"/>
<text x="1000" y="118" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" font-weight="bold" fill="#E74C3C" text-anchor="middle"><tspan>100%</tspan></text>
<!-- Insight box -->
<rect x="130" y="655" width="1020" height="50" rx="6" fill="#FFF3E0" stroke="#FF9800" stroke-width="2"/>
<text x="150" y="678" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" font-weight="600" fill="#E65100">
<tspan>Key Finding:</tspan>
</text>
<text x="270" y="678" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="15" fill="#5D6D7E">
<tspan>Top 4 issues account for 84% of complaints - prioritize resolution</tspan>
</text>
<text x="150" y="695" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#7F8C8D">
<tspan>Action: Enhance QC process, optimize logistics packaging, improve parts checklist verification</tspan>
</text>
</svg>
templates/charts/pie_chart.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" width="1280" height="720">
<!--
饼图模板
用途: 占比和构成分析(基础版)
适用场景: 市场份额、预算分配、结构分析
支持: 5个分块 (推荐3-6个)
与环形图区别: 无中心孔,更适合简单占比展示
-->
<defs>
<!-- 扇区阴影 -->
<filter id="pieShadow">
<feGaussianBlur in="SourceAlpha" stdDeviation="3"/>
<feOffset dx="0" dy="2"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.25"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<!-- 颜色定义 -->
<linearGradient id="pieGrad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1976D2;stop-opacity:1" />
</linearGradient>
<linearGradient id="pieGrad2" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#4CAF50;stop-opacity:1" />
<stop offset="100%" style="stop-color:#388E3C;stop-opacity:1" />
</linearGradient>
<linearGradient id="pieGrad3" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#FF9800;stop-opacity:1" />
<stop offset="100%" style="stop-color:#F57C00;stop-opacity:1" />
</linearGradient>
<linearGradient id="pieGrad4" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#9C27B0;stop-opacity:1" />
<stop offset="100%" style="stop-color:#7B1FA2;stop-opacity:1" />
</linearGradient>
<linearGradient id="pieGrad5" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#607D8B;stop-opacity:1" />
<stop offset="100%" style="stop-color:#455A64;stop-opacity:1" />
</linearGradient>
</defs>
<!-- 背景 -->
<rect width="1280" height="720" fill="#FFFFFF"/>
<!-- 标题 -->
<text x="60" y="80" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="32" font-weight="bold" fill="#2C3E50">
<tspan>市场份额分布</tspan>
</text>
<text x="60" y="115" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" fill="#7F8C8D">
<tspan>2025年第三季度 · 行业整体市场规模 120亿</tspan>
</text>
<!-- 饼图中心 -->
<g id="pieChart" transform="translate(420, 400)">
<!--
圆心: (420, 400)
半径: 200px
起始角度: -90° (12点方向)
数据:
1. 公司A: 35% (蓝色) - 角度: 126°
2. 公司B: 25% (绿色) - 角度: 90°
3. 公司C: 20% (橙色) - 角度: 72°
4. 公司D: 12% (紫色) - 角度: 43.2°
5. 其他: 8% (灰色) - 角度: 28.8°
-->
<!-- 扇区1: 公司A 35% (从-90°到36°, 即126°) -->
<path d="M 0,0 L 0,-200 A 200,200 0 0,1 173.2,100 Z"
fill="url(#pieGrad1)" filter="url(#pieShadow)"/>
<!-- 扇区2: 公司B 25% (从36°到126°, 即90°) -->
<path d="M 0,0 L 173.2,100 A 200,200 0 0,1 -117.56,162.34 Z"
fill="url(#pieGrad2)" filter="url(#pieShadow)"/>
<!-- 扇区3: 公司C 20% (从126°到198°, 即72°) -->
<path d="M 0,0 L -117.56,162.34 A 200,200 0 0,1 -195.63,-62.93 Z"
fill="url(#pieGrad3)" filter="url(#pieShadow)"/>
<!-- 扇区4: 公司D 12% (从198°到241.2°, 即43.2°) -->
<path d="M 0,0 L -195.63,-62.93 A 200,200 0 0,1 -62.93,-189.87 Z"
fill="url(#pieGrad4)" filter="url(#pieShadow)"/>
<!-- 扇区5: 其他 8% (从241.2°到270°, 即28.8°) -->
<path d="M 0,0 L -62.93,-189.87 A 200,200 0 0,1 0,-200 Z"
fill="url(#pieGrad5)" filter="url(#pieShadow)"/>
</g>
<!-- 百分比标签(直接在扇区上显示) -->
<!-- 标签1: 公司A 35% -->
<text x="500" y="320" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="24" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>35%</tspan>
</text>
<!-- 标签2: 公司B 25% -->
<text x="450" y="520" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="22" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>25%</tspan>
</text>
<!-- 标签3: 公司C 20% -->
<text x="280" y="480" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>20%</tspan>
</text>
<!-- 标签4: 公司D 12% -->
<text x="280" y="320" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>12%</tspan>
</text>
<!-- 标签5: 其他 8% (扇区较小,使用引线标签) -->
<g>
<line x1="380" y1="220" x2="340" y2="170" stroke="#607D8B" stroke-width="1.5"/>
<circle cx="380" cy="220" r="3" fill="#607D8B"/>
<text x="330" y="165" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="600" fill="#607D8B" text-anchor="end">
<tspan>8%</tspan>
</text>
</g>
<!-- 右侧图例 -->
<g id="legend" transform="translate(720, 200)">
<!-- 图例标题 -->
<text x="0" y="0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="20" font-weight="600" fill="#2C3E50">
<tspan>市场份额</tspan>
</text>
<!-- 图例项1: 公司A -->
<rect x="0" y="30" width="24" height="24" rx="4" fill="url(#pieGrad1)"/>
<text x="40" y="48" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" fill="#5D6D7E">
<tspan>公司A</tspan>
</text>
<text x="460" y="48" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" font-weight="600" fill="#2C3E50" text-anchor="end">
<tspan>42亿 (35%)</tspan>
</text>
<!-- 图例项2: 公司B -->
<rect x="0" y="70" width="24" height="24" rx="4" fill="url(#pieGrad2)"/>
<text x="40" y="88" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" fill="#5D6D7E">
<tspan>公司B</tspan>
</text>
<text x="460" y="88" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" font-weight="600" fill="#2C3E50" text-anchor="end">
<tspan>30亿 (25%)</tspan>
</text>
<!-- 图例项3: 公司C -->
<rect x="0" y="110" width="24" height="24" rx="4" fill="url(#pieGrad3)"/>
<text x="40" y="128" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" fill="#5D6D7E">
<tspan>公司C</tspan>
</text>
<text x="460" y="128" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" font-weight="600" fill="#2C3E50" text-anchor="end">
<tspan>24亿 (20%)</tspan>
</text>
<!-- 图例项4: 公司D -->
<rect x="0" y="150" width="24" height="24" rx="4" fill="url(#pieGrad4)"/>
<text x="40" y="168" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" fill="#5D6D7E">
<tspan>公司D</tspan>
</text>
<text x="460" y="168" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" font-weight="600" fill="#2C3E50" text-anchor="end">
<tspan>14.4亿 (12%)</tspan>
</text>
<!-- 图例项5: 其他 -->
<rect x="0" y="190" width="24" height="24" rx="4" fill="url(#pieGrad5)"/>
<text x="40" y="208" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" fill="#5D6D7E">
<tspan>其他</tspan>
</text>
<text x="460" y="208" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" font-weight="600" fill="#2C3E50" text-anchor="end">
<tspan>9.6亿 (8%)</tspan>
</text>
<!-- 分隔线 -->
<line x1="0" y1="240" x2="460" y2="240" stroke="#E0E0E0" stroke-width="2"/>
<!-- 合计 -->
<text x="0" y="270" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" font-weight="600" fill="#2C3E50">
<tspan>市场总规模</tspan>
</text>
<text x="460" y="270" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="bold" fill="#2C3E50" text-anchor="end">
<tspan>120亿 (100%)</tspan>
</text>
</g>
<!-- 数据来源 -->
<text x="60" y="695" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#95A5A6">
<tspan>数据来源: 行业研究报告</tspan>
</text>
</svg>
templates/charts/porter_five_forces.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" width="1280" height="720">
<!--
Porter's Five Forces Chart Template
Purpose: Industry competitive analysis, strategic planning
Use cases: Industry reports, market entry evaluation, competitive strategy, investment analysis
Supports: Five forces radial layout
-->
<defs>
<linearGradient id="centerGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2C3E50;stop-opacity:1" />
<stop offset="100%" style="stop-color:#34495E;stop-opacity:1" />
</linearGradient>
<linearGradient id="forceGrad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#E74C3C;stop-opacity:1" />
<stop offset="100%" style="stop-color:#C0392B;stop-opacity:1" />
</linearGradient>
<linearGradient id="forceGrad2" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#3498DB;stop-opacity:1" />
<stop offset="100%" style="stop-color:#2980B9;stop-opacity:1" />
</linearGradient>
<linearGradient id="forceGrad3" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2ECC71;stop-opacity:1" />
<stop offset="100%" style="stop-color:#27AE60;stop-opacity:1" />
</linearGradient>
<linearGradient id="forceGrad4" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#9B59B6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8E44AD;stop-opacity:1" />
</linearGradient>
<linearGradient id="forceGrad5" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#F39C12;stop-opacity:1" />
<stop offset="100%" style="stop-color:#E67E22;stop-opacity:1" />
</linearGradient>
<filter id="forceShadow">
<feGaussianBlur in="SourceAlpha" stdDeviation="4"/>
<feOffset dx="0" dy="3"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.25"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<marker id="arrowHead" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#7F8C8D"/>
</marker>
</defs>
<rect width="1280" height="720" fill="#FAFBFC"/>
<text x="640" y="45" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="32" font-weight="bold" fill="#2C3E50" text-anchor="middle">
<tspan>Porter's Five Forces - Industry Analysis</tspan>
</text>
<text x="640" y="80" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" fill="#7F8C8D" text-anchor="middle">
<tspan>Enterprise SaaS Market - Competitive Landscape Assessment 2025</tspan>
</text>
<!-- Center circle - Industry Competition -->
<circle cx="640" cy="380" r="90" fill="url(#centerGrad)" filter="url(#forceShadow)"/>
<text x="640" y="365" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>Industry</tspan>
</text>
<text x="640" y="390" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>Rivalry</tspan>
</text>
<text x="640" y="415" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#BDC3C7" text-anchor="middle">
<tspan>High</tspan>
</text>
<!-- Force 1: Top - Threat of New Entrants -->
<g id="force1" transform="translate(640, 150)">
<rect x="-150" y="-55" width="300" height="90" rx="12" fill="url(#forceGrad1)" filter="url(#forceShadow)"/>
<text x="0" y="-25" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>Threat of New Entrants</tspan>
</text>
<rect x="-120" y="-5" width="180" height="10" rx="5" fill="rgba(255,255,255,0.3)"/>
<rect x="-120" y="-5" width="90" height="10" rx="5" fill="#FFFFFF"/>
<text x="70" y="3" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" font-weight="bold" fill="#FFFFFF">
<tspan>Medium</tspan>
</text>
<text x="0" y="22" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="11" fill="rgba(255,255,255,0.9)" text-anchor="middle">
<tspan>Capital barriers, Tech accumulation needed</tspan>
</text>
</g>
<line x1="640" y1="210" x2="640" y2="290" stroke="#7F8C8D" stroke-width="2" marker-end="url(#arrowHead)"/>
<!-- Force 2: Right - Buyer Power -->
<g id="force2" transform="translate(950, 380)">
<rect x="-150" y="-55" width="300" height="90" rx="12" fill="url(#forceGrad2)" filter="url(#forceShadow)"/>
<text x="0" y="-25" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>Bargaining Power of Buyers</tspan>
</text>
<rect x="-120" y="-5" width="180" height="10" rx="5" fill="rgba(255,255,255,0.3)"/>
<rect x="-120" y="-5" width="135" height="10" rx="5" fill="#FFFFFF"/>
<text x="70" y="3" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" font-weight="bold" fill="#FFFFFF">
<tspan>High</tspan>
</text>
<text x="0" y="22" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="11" fill="rgba(255,255,255,0.9)" text-anchor="middle">
<tspan>Many choices, Low switching costs</tspan>
</text>
</g>
<line x1="730" y1="380" x2="785" y2="380" stroke="#7F8C8D" stroke-width="2" marker-end="url(#arrowHead)"/>
<!-- Force 3: Bottom - Threat of Substitutes -->
<g id="force3" transform="translate(640, 610)">
<rect x="-150" y="-55" width="300" height="90" rx="12" fill="url(#forceGrad3)" filter="url(#forceShadow)"/>
<text x="0" y="-25" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>Threat of Substitutes</tspan>
</text>
<rect x="-120" y="-5" width="180" height="10" rx="5" fill="rgba(255,255,255,0.3)"/>
<rect x="-120" y="-5" width="54" height="10" rx="5" fill="#FFFFFF"/>
<text x="70" y="3" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" font-weight="bold" fill="#FFFFFF">
<tspan>Low</tspan>
</text>
<text x="0" y="22" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="11" fill="rgba(255,255,255,0.9)" text-anchor="middle">
<tspan>Few effective alternatives</tspan>
</text>
</g>
<line x1="640" y1="470" x2="640" y2="540" stroke="#7F8C8D" stroke-width="2" marker-end="url(#arrowHead)"/>
<!-- Force 4: Left - Supplier Power -->
<g id="force4" transform="translate(330, 380)">
<rect x="-150" y="-55" width="300" height="90" rx="12" fill="url(#forceGrad4)" filter="url(#forceShadow)"/>
<text x="0" y="-25" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>Bargaining Power of Suppliers</tspan>
</text>
<rect x="-120" y="-5" width="180" height="10" rx="5" fill="rgba(255,255,255,0.3)"/>
<rect x="-120" y="-5" width="108" height="10" rx="5" fill="#FFFFFF"/>
<text x="70" y="3" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" font-weight="bold" fill="#FFFFFF">
<tspan>Medium-High</tspan>
</text>
<text x="0" y="22" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="11" fill="rgba(255,255,255,0.9)" text-anchor="middle">
<tspan>Cloud vendors concentrated</tspan>
</text>
</g>
<line x1="550" y1="380" x2="495" y2="380" stroke="#7F8C8D" stroke-width="2" marker-end="url(#arrowHead)"/>
<!-- Force 5: Top Right Corner - Competitive Rivalry Detail -->
<g id="force5" transform="translate(1050, 150)">
<rect x="-150" y="-55" width="300" height="90" rx="12" fill="url(#forceGrad5)" filter="url(#forceShadow)"/>
<text x="0" y="-25" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>Competitive Rivalry</tspan>
</text>
<rect x="-120" y="-5" width="180" height="10" rx="5" fill="rgba(255,255,255,0.3)"/>
<rect x="-120" y="-5" width="162" height="10" rx="5" fill="#FFFFFF"/>
<text x="70" y="3" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" font-weight="bold" fill="#FFFFFF">
<tspan>Very High</tspan>
</text>
<text x="0" y="22" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="11" fill="rgba(255,255,255,0.9)" text-anchor="middle">
<tspan>Many competitors, Price wars common</tspan>
</text>
</g>
<!-- Info panels -->
<g id="infoPanel">
<rect x="60" y="520" width="200" height="100" rx="8" fill="#FFFFFF" stroke="#E0E0E0" stroke-width="1" filter="url(#forceShadow)"/>
<text x="160" y="545" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" font-weight="bold" fill="#2C3E50" text-anchor="middle">
<tspan>Overall Assessment</tspan>
</text>
<text x="160" y="575" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="28" font-weight="bold" fill="#E74C3C" text-anchor="middle">
<tspan>3.4 / 5</tspan>
</text>
<text x="160" y="600" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="12" fill="#7F8C8D" text-anchor="middle">
<tspan>High Competition Intensity</tspan>
</text>
</g>
<!-- Strategy recommendations -->
<g id="recommendations">
<rect x="60" y="110" width="200" height="120" rx="8" fill="#E8F5E9" stroke="#4CAF50" stroke-width="2"/>
<text x="160" y="135" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" font-weight="bold" fill="#2E7D32" text-anchor="middle">
<tspan>Strategic Advice</tspan>
</text>
<text x="75" y="160" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="12" fill="#5D6D7E">
<tspan>1. Build technical moats</tspan>
</text>
<text x="75" y="180" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="12" fill="#5D6D7E">
<tspan>2. Improve customer stickiness</tspan>
</text>
<text x="75" y="200" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="12" fill="#5D6D7E">
<tspan>3. Diversify supply chain</tspan>
</text>
<text x="75" y="220" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="12" fill="#5D6D7E">
<tspan>4. Focus on differentiation</tspan>
</text>
</g>
<!-- Legend -->
<g id="legend" transform="translate(60, 635)">
<text x="0" y="15" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" font-weight="600" fill="#5D6D7E">
<tspan>Threat Level:</tspan>
</text>
<rect x="90" y="5" width="40" height="14" rx="7" fill="#4CAF50"/>
<text x="138" y="16" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#5D6D7E"><tspan>Low</tspan></text>
<rect x="165" y="5" width="60" height="14" rx="7" fill="#F39C12"/>
<text x="233" y="16" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#5D6D7E"><tspan>Medium</tspan></text>
<rect x="280" y="5" width="80" height="14" rx="7" fill="#E74C3C"/>
<text x="368" y="16" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#5D6D7E"><tspan>High</tspan></text>
</g>
<text x="640" y="695" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="12" fill="#95A5A6" text-anchor="middle">
<tspan>Data Source: Industry Research Department - 2025 Market Competition Analysis Report</tspan>
</text>
</svg>
templates/charts/process_flow.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" width="1280" height="720">
<!--
流程图模板 - CRAP 优化版
用途: 业务流程、决策步骤、操作流程展示
适用场景: 业务流程图、操作指南、决策树
优化重点:
- 对齐: 步骤卡片网格化布局
- 对比: 不同类型节点视觉区分
- 重复: 统一的卡片样式和连接线
- 亲密性: 相关步骤分组,流程线清晰
-->
<defs>
<!-- 整体背景渐变 -->
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#FAFBFC;stop-opacity:1" />
<stop offset="100%" style="stop-color:#F5F6F8;stop-opacity:1" />
</linearGradient>
<!-- 主流程卡片渐变 -->
<linearGradient id="cardGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1976D2;stop-opacity:1" />
</linearGradient>
<!-- 决策节点渐变 -->
<linearGradient id="decisionGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#FF9800;stop-opacity:1" />
<stop offset="100%" style="stop-color:#F57C00;stop-opacity:1" />
</linearGradient>
<!-- 结束节点渐变 -->
<linearGradient id="endGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#4CAF50;stop-opacity:1" />
<stop offset="100%" style="stop-color:#388E3C;stop-opacity:1" />
</linearGradient>
<!-- 卡片阴影 -->
<filter id="cardShadow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceAlpha" stdDeviation="6" />
<feOffset dx="0" dy="4" />
<feComponentTransfer>
<feFuncA type="linear" slope="0.2" />
</feComponentTransfer>
<feMerge>
<feMergeNode />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<!-- 箭头标记 -->
<marker id="arrowBlue" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto">
<path d="M 0,0 L 10,5 L 0,10 Z" fill="#1976D2" />
</marker>
<marker id="arrowOrange" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto">
<path d="M 0,0 L 10,5 L 0,10 Z" fill="#F57C00" />
</marker>
<marker id="arrowGreen" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto">
<path d="M 0,0 L 10,5 L 0,10 Z" fill="#388E3C" />
</marker>
</defs>
<!-- 背景 -->
<rect width="1280" height="720" fill="url(#bgGradient)" />
<!-- 标题区域 -->
<text x="60" y="70" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="36"
font-weight="700" fill="#1A252F" letter-spacing="-0.5">
<tspan>业务流程图</tspan>
</text>
<text x="60" y="95" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="15"
fill="#9AA0A6">
<tspan>Business Process Flow · 标准化操作流程</tspan>
</text>
<!-- 流程主区域 -->
<g id="processFlow">
<!-- 第一行:开始 → 步骤1 → 决策 -->
<!-- 开始节点 -->
<g id="start">
<rect x="60" y="160" width="180" height="80" rx="40" fill="url(#cardGradient)" filter="url(#cardShadow)" />
<text x="150" y="192" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="18"
font-weight="600" fill="#FFFFFF" text-anchor="middle">
<tspan>开始</tspan>
</text>
<text x="150" y="210" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="13"
fill="#FFFFFF" text-anchor="middle" opacity="0.9">
<tspan>Start</tspan>
</text>
</g>
<!-- 箭头: 开始 → 步骤1 -->
<line x1="240" y1="200" x2="300" y2="200" stroke="#1976D2" stroke-width="3" marker-end="url(#arrowBlue)" />
<!-- 步骤1:需求收集 -->
<g id="step1">
<rect x="300" y="160" width="200" height="80" rx="8" fill="url(#cardGradient)" filter="url(#cardShadow)" />
<text x="400" y="192" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="17"
font-weight="700" fill="#FFFFFF" text-anchor="middle">
<tspan>需求收集</tspan>
</text>
<text x="400" y="210" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="13"
fill="#FFFFFF" text-anchor="middle" opacity="0.9">
<tspan>Requirement Gathering</tspan>
</text>
<text x="400" y="227" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="12"
fill="#FFFFFF" text-anchor="middle" opacity="0.8">
<tspan>1-2 天</tspan>
</text>
</g>
<!-- 箭头: 步骤1 → 决策 -->
<line x1="500" y1="200" x2="560" y2="200" stroke="#1976D2" stroke-width="3" marker-end="url(#arrowBlue)" />
<!-- 决策节点:是否可行 -->
<g id="decision1">
<path d="M 660,160 L 740,200 L 660,240 L 580,200 Z" fill="url(#decisionGradient)"
filter="url(#cardShadow)" />
<text x="660" y="197" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="16"
font-weight="700" fill="#FFFFFF" text-anchor="middle">
<tspan>是否可行?</tspan>
</text>
<text x="660" y="214" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="12"
fill="#FFFFFF" text-anchor="middle" opacity="0.9">
<tspan>Feasible?</tspan>
</text>
</g>
<!-- 决策箭头: 是 → 步骤2 -->
<line x1="740" y1="200" x2="800" y2="200" stroke="#1976D2" stroke-width="3" marker-end="url(#arrowBlue)" />
<text x="740" y="178" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="13"
font-weight="600" fill="#388E3C">
<tspan>是 Yes</tspan>
</text>
<!-- 决策箭头: 否 → 重新评估 (向下) -->
<path d="M 660,240 L 660,280" stroke="#F57C00" stroke-width="3" marker-end="url(#arrowOrange)" />
<text x="680" y="265" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="13"
font-weight="600" fill="#E74C3C">
<tspan>否 No</tspan>
</text>
<!-- 第二行:步骤2 → 步骤3 → 步骤4 -->
<!-- 步骤2:方案设计 -->
<g id="step2">
<rect x="800" y="160" width="200" height="80" rx="8" fill="url(#cardGradient)" filter="url(#cardShadow)" />
<text x="900" y="192" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="17"
font-weight="700" fill="#FFFFFF" text-anchor="middle">
<tspan>方案设计</tspan>
</text>
<text x="900" y="210" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="13"
fill="#FFFFFF" text-anchor="middle" opacity="0.9">
<tspan>Solution Design</tspan>
</text>
<text x="900" y="227" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="12"
fill="#FFFFFF" text-anchor="middle" opacity="0.8">
<tspan>3-5 天</tspan>
</text>
</g>
<!-- 箭头: 步骤2 → 下方 -->
<path d="M 900,240 L 900,320" stroke="#1976D2" stroke-width="3" marker-end="url(#arrowBlue)" />
<!-- 重新评估节点 (第二行左侧) -->
<g id="reevaluate">
<rect x="560" y="280" width="200" height="80" rx="8" fill="#FFFFFF" stroke="#F57C00" stroke-width="3"
filter="url(#cardShadow)" />
<text x="660" y="312" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="17"
font-weight="700" fill="#F57C00" text-anchor="middle">
<tspan>重新评估</tspan>
</text>
<text x="660" y="330" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="13"
fill="#F57C00" text-anchor="middle">
<tspan>Re-evaluation</tspan>
</text>
<text x="660" y="347" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="12"
fill="#9AA0A6" text-anchor="middle">
<tspan>调整需求</tspan>
</text>
</g>
<!-- 箭头: 重新评估 → 步骤1 (回环) - 从左侧折线上升 -->
<path d="M 560,320 L 280,320 L 280,240 L 300,240" stroke="#F57C00" stroke-width="3" stroke-dasharray="5,5"
marker-end="url(#arrowOrange)" fill="none" />
<text x="255" y="280" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="12"
font-weight="500" fill="#F57C00">
<tspan>重新收集</tspan>
</text>
<!-- 第三行:步骤3 → 步骤4 → 结束 -->
<!-- 步骤3:开发实施 -->
<g id="step3">
<rect x="800" y="320" width="200" height="80" rx="8" fill="url(#cardGradient)" filter="url(#cardShadow)" />
<text x="900" y="352" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="17"
font-weight="700" fill="#FFFFFF" text-anchor="middle">
<tspan>开发实施</tspan>
</text>
<text x="900" y="370" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="13"
fill="#FFFFFF" text-anchor="middle" opacity="0.9">
<tspan>Development</tspan>
</text>
<text x="900" y="387" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="12"
fill="#FFFFFF" text-anchor="middle" opacity="0.8">
<tspan>7-14 天</tspan>
</text>
</g>
<!-- 箭头: 步骤3 → 步骤4 -->
<path d="M 900,400 L 900,480" stroke="#1976D2" stroke-width="3" marker-end="url(#arrowBlue)" />
<!-- 步骤4:测试验收 -->
<g id="step4">
<rect x="800" y="480" width="200" height="80" rx="8" fill="url(#cardGradient)" filter="url(#cardShadow)" />
<text x="900" y="512" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="17"
font-weight="700" fill="#FFFFFF" text-anchor="middle">
<tspan>测试验收</tspan>
</text>
<text x="900" y="530" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="13"
fill="#FFFFFF" text-anchor="middle" opacity="0.9">
<tspan>Testing & Acceptance</tspan>
</text>
<text x="900" y="547" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="12"
fill="#FFFFFF" text-anchor="middle" opacity="0.8">
<tspan>2-3 天</tspan>
</text>
</g>
<!-- 箭头: 步骤4 → 结束 -->
<line x1="1000" y1="520" x2="1060" y2="520" stroke="#388E3C" stroke-width="3" marker-end="url(#arrowGreen)" />
<!-- 结束节点 -->
<g id="end">
<rect x="1060" y="480" width="180" height="80" rx="40" fill="url(#endGradient)" filter="url(#cardShadow)" />
<text x="1150" y="527" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" font-weight="600" fill="#FFFFFF" text-anchor="middle">
<tspan>完成</tspan>
</text>
<text x="1150" y="545" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#FFFFFF" text-anchor="middle" opacity="0.9">
<tspan>Complete</tspan>
</text>
</g>
</g>
<!-- 图例说明 -->
<g id="legend">
<text x="60" y="480" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="16"
font-weight="700" fill="#1A252F">
<tspan>图例说明</tspan>
</text>
<!-- 流程步骤 -->
<rect x="60" y="500" width="80" height="40" rx="6" fill="url(#cardGradient)" />
<text x="150" y="525" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="14"
font-weight="500" fill="#5F6368">
<tspan>流程步骤</tspan>
</text>
<!-- 决策节点 -->
<path d="M 100,570 L 130,590 L 100,610 L 70,590 Z" fill="url(#decisionGradient)" />
<text x="150" y="595" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="14"
font-weight="500" fill="#5F6368">
<tspan>决策节点</tspan>
</text>
<!-- 异常处理 -->
<rect x="60" y="630" width="80" height="40" rx="6" fill="#FFFFFF" stroke="#F57C00" stroke-width="2" />
<text x="150" y="655" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="14"
font-weight="500" fill="#5F6368">
<tspan>异常处理</tspan>
</text>
</g>
<!-- 时间统计 -->
<g id="timeInfo">
<rect x="280" y="500" width="200" height="170" rx="8" fill="#FFFFFF" stroke="#E0E0E0" stroke-width="1.5"
opacity="0.95" />
<text x="380" y="530" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="15"
font-weight="700" fill="#1A252F" text-anchor="middle">
<tspan>流程时间统计</tspan>
</text>
<line x1="295" y1="545" x2="465" y2="545" stroke="#E0E0E0" stroke-width="1" />
<text x="295" y="570" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="13"
fill="#5F6368">
<tspan>总周期:</tspan>
</text>
<text x="440" y="570" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="13"
font-weight="600" fill="#2196F3" text-anchor="end">
<tspan>13-24 天</tspan>
</text>
<text x="295" y="595" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="13"
fill="#5F6368">
<tspan>关键步骤:</tspan>
</text>
<text x="440" y="595" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="13"
font-weight="600" fill="#2196F3" text-anchor="end">
<tspan>4 个</tspan>
</text>
<text x="295" y="620" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="13"
fill="#5F6368">
<tspan>决策节点:</tspan>
</text>
<text x="440" y="620" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="13"
font-weight="600" fill="#FF9800" text-anchor="end">
<tspan>1 个</tspan>
</text>
<text x="295" y="645" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="13"
fill="#5F6368">
<tspan>风险等级:</tspan>
</text>
<text x="440" y="645" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="13"
font-weight="600" fill="#388E3C" text-anchor="end">
<tspan>低</tspan>
</text>
</g>
<!-- 数据来源 -->
<text x="60" y="705" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="13"
fill="#9AA0A6">
<tspan>流程版本: v2.1 | 最后更新: 2025-11-09 | Process Management System</tspan>
</text>
</svg>templates/charts/progress_bar_chart.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" width="1280" height="720">
<!--
进度条图模板
用途: 目标完成进度、多项指标对比
适用场景: OKR进度、项目完成度、KPI达成、预算执行
支持: 6个进度条,带目标线和状态标签
-->
<defs>
<!-- 进度条渐变(蓝色-默认) -->
<linearGradient id="progressBlue" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:1" />
<stop offset="100%" style="stop-color:#42A5F5;stop-opacity:1" />
</linearGradient>
<!-- 进度条渐变(绿色-完成/超额) -->
<linearGradient id="progressGreen" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#4CAF50;stop-opacity:1" />
<stop offset="100%" style="stop-color:#66BB6A;stop-opacity:1" />
</linearGradient>
<!-- 进度条渐变(橙色-进行中) -->
<linearGradient id="progressOrange" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#FF9800;stop-opacity:1" />
<stop offset="100%" style="stop-color:#FFB74D;stop-opacity:1" />
</linearGradient>
<!-- 进度条渐变(红色-落后) -->
<linearGradient id="progressRed" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#f44336;stop-opacity:1" />
<stop offset="100%" style="stop-color:#EF5350;stop-opacity:1" />
</linearGradient>
<!-- 进度条阴影 -->
<filter id="progressShadow">
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
<feOffset dx="0" dy="1"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.2"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- 背景 -->
<rect width="1280" height="720" fill="#FFFFFF"/>
<!-- 标题 -->
<text x="60" y="60" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="32" font-weight="bold" fill="#2C3E50">
<tspan>2025年Q4 OKR进度追踪</tspan>
</text>
<text x="60" y="95" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" fill="#7F8C8D">
<tspan>截止日期: 2025年12月31日 · 剩余 20 天</tspan>
</text>
<!-- 总体进度概览 -->
<g id="overallProgress" transform="translate(900, 50)">
<rect x="0" y="0" width="320" height="60" rx="8" fill="#F8F9FA" stroke="#E0E0E0" stroke-width="1"/>
<text x="20" y="25" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#7F8C8D">
<tspan>整体完成度</tspan>
</text>
<text x="20" y="48" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="24" font-weight="bold" fill="#2196F3">
<tspan>73.5%</tspan>
</text>
<text x="110" y="48" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#7F8C8D">
<tspan>/ 目标 100%</tspan>
</text>
<!-- 迷你进度条 -->
<rect x="200" y="20" width="100" height="24" rx="12" fill="#E0E0E0"/>
<rect x="200" y="20" width="73.5" height="24" rx="12" fill="url(#progressBlue)"/>
</g>
<!-- 进度条区域 -->
<g id="progressBars">
<!--
每个进度条:
- 总宽度: 700px (从x=360到x=1060)
- 高度: 32px
- 垂直间距: 85px
-->
<!-- 进度条1: 营收目标 92% (超额完成) -->
<g transform="translate(60, 150)">
<!-- 标签区 -->
<text x="0" y="20" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" font-weight="600" fill="#2C3E50">
<tspan>营收目标</tspan>
</text>
<text x="0" y="42" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#7F8C8D">
<tspan>目标: 5000万 | 实际: 4600万</tspan>
</text>
<!-- 背景条 -->
<rect x="300" y="8" width="700" height="32" rx="16" fill="#EEEEEE"/>
<!-- 进度条 92% -->
<rect x="300" y="8" width="644" height="32" rx="16" fill="url(#progressGreen)" filter="url(#progressShadow)"/>
<!-- 目标线 (100% = 700px) -->
<line x1="1000" y1="2" x2="1000" y2="46" stroke="#2C3E50" stroke-width="2" stroke-dasharray="4,2"/>
<text x="1000" y="60" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#7F8C8D" text-anchor="middle"><tspan>目标</tspan></text>
<!-- 百分比 -->
<text x="1050" y="30" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="bold" fill="#4CAF50">
<tspan>92%</tspan>
</text>
<!-- 状态标签 -->
<rect x="1110" y="8" width="70" height="32" rx="16" fill="#E8F5E9"/>
<text x="1145" y="30" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#4CAF50" text-anchor="middle">
<tspan>超前</tspan>
</text>
</g>
<!-- 进度条2: 新客户获取 85% -->
<g transform="translate(60, 235)">
<text x="0" y="20" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" font-weight="600" fill="#2C3E50">
<tspan>新客户获取</tspan>
</text>
<text x="0" y="42" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#7F8C8D">
<tspan>目标: 200家 | 实际: 170家</tspan>
</text>
<rect x="300" y="8" width="700" height="32" rx="16" fill="#EEEEEE"/>
<rect x="300" y="8" width="595" height="32" rx="16" fill="url(#progressGreen)" filter="url(#progressShadow)"/>
<line x1="1000" y1="2" x2="1000" y2="46" stroke="#2C3E50" stroke-width="2" stroke-dasharray="4,2"/>
<text x="1050" y="30" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="bold" fill="#4CAF50">
<tspan>85%</tspan>
</text>
<rect x="1110" y="8" width="70" height="32" rx="16" fill="#E8F5E9"/>
<text x="1145" y="30" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#4CAF50" text-anchor="middle">
<tspan>达标</tspan>
</text>
</g>
<!-- 进度条3: 产品发布 75% -->
<g transform="translate(60, 320)">
<text x="0" y="20" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" font-weight="600" fill="#2C3E50">
<tspan>产品发布</tspan>
</text>
<text x="0" y="42" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#7F8C8D">
<tspan>目标: 4个功能 | 实际: 3个功能</tspan>
</text>
<rect x="300" y="8" width="700" height="32" rx="16" fill="#EEEEEE"/>
<rect x="300" y="8" width="525" height="32" rx="16" fill="url(#progressBlue)" filter="url(#progressShadow)"/>
<line x1="1000" y1="2" x2="1000" y2="46" stroke="#2C3E50" stroke-width="2" stroke-dasharray="4,2"/>
<text x="1050" y="30" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="bold" fill="#2196F3">
<tspan>75%</tspan>
</text>
<rect x="1110" y="8" width="70" height="32" rx="16" fill="#E3F2FD"/>
<text x="1145" y="30" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#2196F3" text-anchor="middle">
<tspan>进行中</tspan>
</text>
</g>
<!-- 进度条4: 团队扩张 68% -->
<g transform="translate(60, 405)">
<text x="0" y="20" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" font-weight="600" fill="#2C3E50">
<tspan>团队扩张</tspan>
</text>
<text x="0" y="42" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#7F8C8D">
<tspan>目标: 25人 | 实际: 17人</tspan>
</text>
<rect x="300" y="8" width="700" height="32" rx="16" fill="#EEEEEE"/>
<rect x="300" y="8" width="476" height="32" rx="16" fill="url(#progressOrange)" filter="url(#progressShadow)"/>
<line x1="1000" y1="2" x2="1000" y2="46" stroke="#2C3E50" stroke-width="2" stroke-dasharray="4,2"/>
<text x="1050" y="30" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="bold" fill="#FF9800">
<tspan>68%</tspan>
</text>
<rect x="1110" y="8" width="70" height="32" rx="16" fill="#FFF3E0"/>
<text x="1145" y="30" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#FF9800" text-anchor="middle">
<tspan>滞后</tspan>
</text>
</g>
<!-- 进度条5: 成本控制 55% -->
<g transform="translate(60, 490)">
<text x="0" y="20" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" font-weight="600" fill="#2C3E50">
<tspan>成本控制</tspan>
</text>
<text x="0" y="42" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#7F8C8D">
<tspan>目标: 降低20% | 实际: 降低11%</tspan>
</text>
<rect x="300" y="8" width="700" height="32" rx="16" fill="#EEEEEE"/>
<rect x="300" y="8" width="385" height="32" rx="16" fill="url(#progressRed)" filter="url(#progressShadow)"/>
<line x1="1000" y1="2" x2="1000" y2="46" stroke="#2C3E50" stroke-width="2" stroke-dasharray="4,2"/>
<text x="1050" y="30" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="bold" fill="#f44336">
<tspan>55%</tspan>
</text>
<rect x="1110" y="8" width="70" height="32" rx="16" fill="#FFEBEE"/>
<text x="1145" y="30" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#f44336" text-anchor="middle">
<tspan>风险</tspan>
</text>
</g>
<!-- 进度条6: 品牌建设 66% -->
<g transform="translate(60, 575)">
<text x="0" y="20" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" font-weight="600" fill="#2C3E50">
<tspan>品牌建设</tspan>
</text>
<text x="0" y="42" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#7F8C8D">
<tspan>目标: 知名度+30% | 实际: +20%</tspan>
</text>
<rect x="300" y="8" width="700" height="32" rx="16" fill="#EEEEEE"/>
<rect x="300" y="8" width="462" height="32" rx="16" fill="url(#progressOrange)" filter="url(#progressShadow)"/>
<line x1="1000" y1="2" x2="1000" y2="46" stroke="#2C3E50" stroke-width="2" stroke-dasharray="4,2"/>
<text x="1050" y="30" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="bold" fill="#FF9800">
<tspan>66%</tspan>
</text>
<rect x="1110" y="8" width="70" height="32" rx="16" fill="#FFF3E0"/>
<text x="1145" y="30" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#FF9800" text-anchor="middle">
<tspan>滞后</tspan>
</text>
</g>
</g>
<!-- 图例 -->
<g id="legend" transform="translate(60, 660)">
<rect x="0" y="0" width="16" height="16" rx="4" fill="url(#progressGreen)"/>
<text x="24" y="13" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#5D6D7E"><tspan>达标/超前 (≥80%)</tspan></text>
<rect x="160" y="0" width="16" height="16" rx="4" fill="url(#progressBlue)"/>
<text x="184" y="13" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#5D6D7E"><tspan>进行中 (70-80%)</tspan></text>
<rect x="320" y="0" width="16" height="16" rx="4" fill="url(#progressOrange)"/>
<text x="344" y="13" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#5D6D7E"><tspan>滞后 (60-70%)</tspan></text>
<rect x="470" y="0" width="16" height="16" rx="4" fill="url(#progressRed)"/>
<text x="494" y="13" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#5D6D7E"><tspan>风险 (<60%)</tspan></text>
</g>
<!-- 数据来源 -->
<text x="1220" y="695" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#95A5A6" text-anchor="end">
<tspan>数据来源: OKR管理系统</tspan>
</text>
</svg>
templates/charts/radar_chart.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" width="1280" height="720">
<!--
雷达图模板
用途: 多维度能力评估、综合对比分析
适用场景: 能力评估、竞品对比、绩效分析
支持: 6个维度(可调整为4-8个)
-->
<defs>
<!-- 数据区域渐变 -->
<linearGradient id="radarGrad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:0.6" />
<stop offset="100%" style="stop-color:#1976D2;stop-opacity:0.4" />
</linearGradient>
<linearGradient id="radarGrad2" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#4CAF50;stop-opacity:0.5" />
<stop offset="100%" style="stop-color:#388E3C;stop-opacity:0.3" />
</linearGradient>
<!-- 阴影 -->
<filter id="radarShadow">
<feGaussianBlur in="SourceAlpha" stdDeviation="3"/>
<feOffset dx="0" dy="2"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.2"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- 背景 -->
<rect width="1280" height="720" fill="#FFFFFF"/>
<!-- 标题 -->
<text x="60" y="70" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="32" font-weight="bold" fill="#2C3E50">
<tspan>综合能力评估对比</tspan>
</text>
<text x="60" y="105" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" fill="#7F8C8D">
<tspan>产品 A vs 产品 B · 六维度分析</tspan>
</text>
<!-- 雷达图区域 - 中心点 (640, 400), 半径 220 -->
<g id="radarChart" transform="translate(640, 400)">
<!-- 背景网格 - 5层同心六边形 -->
<!-- 第5层 (100%) -->
<polygon points="0,-220 190.5,-110 190.5,110 0,220 -190.5,110 -190.5,-110"
fill="none" stroke="#E0E0E0" stroke-width="1"/>
<!-- 第4层 (80%) -->
<polygon points="0,-176 152.4,-88 152.4,88 0,176 -152.4,88 -152.4,-88"
fill="none" stroke="#E0E0E0" stroke-width="1"/>
<!-- 第3层 (60%) -->
<polygon points="0,-132 114.3,-66 114.3,66 0,132 -114.3,66 -114.3,-66"
fill="none" stroke="#E0E0E0" stroke-width="1"/>
<!-- 第2层 (40%) -->
<polygon points="0,-88 76.2,-44 76.2,44 0,88 -76.2,44 -76.2,-44"
fill="none" stroke="#E0E0E0" stroke-width="1"/>
<!-- 第1层 (20%) -->
<polygon points="0,-44 38.1,-22 38.1,22 0,44 -38.1,22 -38.1,-22"
fill="none" stroke="#E0E0E0" stroke-width="1"/>
<!-- 轴线 -->
<line x1="0" y1="0" x2="0" y2="-220" stroke="#BDC3C7" stroke-width="1"/>
<line x1="0" y1="0" x2="190.5" y2="-110" stroke="#BDC3C7" stroke-width="1"/>
<line x1="0" y1="0" x2="190.5" y2="110" stroke="#BDC3C7" stroke-width="1"/>
<line x1="0" y1="0" x2="0" y2="220" stroke="#BDC3C7" stroke-width="1"/>
<line x1="0" y1="0" x2="-190.5" y2="110" stroke="#BDC3C7" stroke-width="1"/>
<line x1="0" y1="0" x2="-190.5" y2="-110" stroke="#BDC3C7" stroke-width="1"/>
<!-- 数据区域 1 - 产品 A (蓝色) -->
<!-- 数值: 性能90%, 安全85%, 易用75%, 价格70%, 服务80%, 创新88% -->
<polygon points="0,-198 162,-93.5 143,77 0,154 -152.4,88 -167.2,-96.8"
fill="url(#radarGrad1)" stroke="#2196F3" stroke-width="2.5" filter="url(#radarShadow)"/>
<!-- 数据点 - 产品 A -->
<circle cx="0" cy="-198" r="6" fill="#2196F3" stroke="#FFFFFF" stroke-width="2"/>
<circle cx="162" cy="-93.5" r="6" fill="#2196F3" stroke="#FFFFFF" stroke-width="2"/>
<circle cx="143" cy="77" r="6" fill="#2196F3" stroke="#FFFFFF" stroke-width="2"/>
<circle cx="0" cy="154" r="6" fill="#2196F3" stroke="#FFFFFF" stroke-width="2"/>
<circle cx="-152.4" cy="88" r="6" fill="#2196F3" stroke="#FFFFFF" stroke-width="2"/>
<circle cx="-167.2" cy="-96.8" r="6" fill="#2196F3" stroke="#FFFFFF" stroke-width="2"/>
<!-- 数据区域 2 - 产品 B (绿色) -->
<!-- 数值: 性能75%, 安全90%, 易用85%, 价格80%, 服务70%, 创新65% -->
<polygon points="0,-165 171.5,-99 162,93.5 0,176 -133.4,77 -124,-71.5"
fill="url(#radarGrad2)" stroke="#4CAF50" stroke-width="2.5"/>
<!-- 数据点 - 产品 B -->
<circle cx="0" cy="-165" r="6" fill="#4CAF50" stroke="#FFFFFF" stroke-width="2"/>
<circle cx="171.5" cy="-99" r="6" fill="#4CAF50" stroke="#FFFFFF" stroke-width="2"/>
<circle cx="162" cy="93.5" r="6" fill="#4CAF50" stroke="#FFFFFF" stroke-width="2"/>
<circle cx="0" cy="176" r="6" fill="#4CAF50" stroke="#FFFFFF" stroke-width="2"/>
<circle cx="-133.4" cy="77" r="6" fill="#4CAF50" stroke="#FFFFFF" stroke-width="2"/>
<circle cx="-124" cy="-71.5" r="6" fill="#4CAF50" stroke="#FFFFFF" stroke-width="2"/>
</g>
<!-- 维度标签 -->
<text x="640" y="145" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>性能</tspan>
</text>
<text x="870" y="270" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="600" fill="#2C3E50" text-anchor="start">
<tspan>安全性</tspan>
</text>
<text x="870" y="540" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="600" fill="#2C3E50" text-anchor="start">
<tspan>易用性</tspan>
</text>
<text x="640" y="660" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>价格竞争力</tspan>
</text>
<text x="410" y="540" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="600" fill="#2C3E50" text-anchor="end">
<tspan>售后服务</tspan>
</text>
<text x="410" y="270" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="600" fill="#2C3E50" text-anchor="end">
<tspan>创新能力</tspan>
</text>
<!-- 图例 -->
<g id="legend" transform="translate(1020, 150)">
<rect x="0" y="0" width="200" height="120" rx="8" fill="#F8F9FA" stroke="#E0E0E0" stroke-width="1"/>
<text x="100" y="30" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>图例</tspan>
</text>
<rect x="20" y="50" width="24" height="12" rx="2" fill="#2196F3" opacity="0.7"/>
<text x="55" y="60" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#5D6D7E">
<tspan>产品 A</tspan>
</text>
<rect x="20" y="80" width="24" height="12" rx="2" fill="#4CAF50" opacity="0.6"/>
<text x="55" y="90" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#5D6D7E">
<tspan>产品 B</tspan>
</text>
</g>
<!-- 数据来源 -->
<text x="60" y="695" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#95A5A6">
<tspan>数据来源: 产品评估报告 2025</tspan>
</text>
</svg>
templates/charts/README.md
# SVG Chart Template Library (33 Types)
This directory provides **33 standardized SVG chart templates** for use as chart selection references in PPT Master.
- **Full Index**: [README.md](./README.md) (human browsing)
- **JSON Index**: [charts_index.json](./charts_index.json) (AI / programmatic lookup — preferred)
Before generating chart pages, review the corresponding template file to understand its structure and layout.
## Quick Selection
> **AI / Programmatic recommendation**: Prefer reading `charts_index.json`; use this README for human browsing and quick comparison.
| I want to show... | Recommended Template | Filename |
|-------------------|---------------------|----------|
| Key numeric metrics | KPI Cards | `kpi_cards.svg` |
| Category value comparison | Bar Chart | `bar_chart.svg` |
| Long-label rankings | Horizontal Bar Chart | `horizontal_bar_chart.svg` |
| Multi-series comparison | Grouped Bar Chart | `grouped_bar_chart.svg` |
| Time trends | Line Chart | `line_chart.svg` |
| Cumulative trends | Area Chart | `area_chart.svg` |
| Proportional composition | Pie / Donut Chart | `pie_chart.svg` / `donut_chart.svg` |
| Goal completion rate | Progress Bar / Gauge | `progress_bar_chart.svg` / `gauge_chart.svg` |
| Conversion funnel | Funnel Chart | `funnel_chart.svg` |
| Project scheduling | Gantt Chart | `gantt_chart.svg` |
| Milestone events | Timeline | `timeline.svg` |
| Multi-dimensional assessment | Radar Chart | `radar_chart.svg` |
| Bidirectional comparison | Butterfly Chart | `butterfly_chart.svg` |
| Incremental breakdown | Waterfall Chart | `waterfall_chart.svg` |
| Flow / fund allocation | Sankey Chart | `sankey_chart.svg` |
| Strategic analysis | SWOT / Porter's Five Forces | `swot_analysis.svg` / `porter_five_forces.svg` |
| Quadrant analysis | Matrix (2x2) | `matrix_2x2.svg` |
## Full Chart Index
### Comparison
| Filename | Purpose | Use Cases |
|----------|---------|-----------|
| `bar_chart.svg` | Vertical bar comparison (3-8 bars) | Sales comparison, regional ranking |
| `horizontal_bar_chart.svg` | Horizontal bar ranking (5-12 bars) | Brand ranking, satisfaction scores |
| `grouped_bar_chart.svg` | Multi-series grouped comparison | Quarterly product line comparison, YoY/QoQ |
| `stacked_bar_chart.svg` | Stacked composition comparison | Revenue composition, market share changes |
| `butterfly_chart.svg` | Bidirectional comparison | Population pyramid, A/B testing, income vs expense |
| `bullet_chart.svg` | Target vs actual | KPI achievement, performance evaluation |
| `dumbbell_chart.svg` | Multi-dimension score comparison | Competitive analysis, composite index |
| `waterfall_chart.svg` | Incremental change breakdown | Profit decomposition, budget variance |
### Trends
| Filename | Purpose | Use Cases |
|----------|---------|-----------|
| `line_chart.svg` | Line trend (multi-line support) | Time series, growth trends |
| `area_chart.svg` | Area cumulative trend | Traffic trends, user growth |
| `stacked_area_chart.svg` | Multi-series stacked trend | Revenue sources, traffic source changes |
| `dual_axis_line_chart.svg` | Dual Y-axis different-unit comparison | Sales vs profit margin, traffic vs conversion rate |
### Proportions
| Filename | Purpose | Use Cases |
|----------|---------|-----------|
| `pie_chart.svg` | Basic proportion (3-6 slices) | Market share, budget allocation |
| `donut_chart.svg` | Ring proportion (with center data) | Structural proportion, category composition |
| `treemap_chart.svg` | Hierarchical area proportion | Budget allocation, market share composition |
### Metrics
| Filename | Purpose | Use Cases |
|----------|---------|-----------|
| `kpi_cards.svg` | Key metric cards (2x2 / 1x4) | Financial overview, data dashboard |
| `gauge_chart.svg` | Gauge completion rate | KPI completion, performance monitoring |
| `progress_bar_chart.svg` | Multi-item progress bars | OKR progress, project completion |
### Analysis
| Filename | Purpose | Use Cases |
|----------|---------|-----------|
| `radar_chart.svg` | Multi-dimensional assessment (4-8 axes) | Capability assessment, competitive comparison |
| `scatter_chart.svg` | Correlation / distribution | Input-output analysis, price-demand |
| `funnel_chart.svg` | Conversion funnel (3-5 stages) | Sales funnel, user conversion |
| `matrix_2x2.svg` | Four-quadrant analysis | BCG matrix, priority analysis |
| `bubble_chart.svg` | 3D bubble (X/Y/Size) | Market size vs growth rate vs share |
| `heatmap_chart.svg` | Matrix heatmap | User activity periods, correlation matrix |
| `pareto_chart.svg` | 80/20 Pareto analysis | Quality attribution, sales contribution |
| `box_plot_chart.svg` | Box plot distribution | Salary distribution, quality control |
### Project Management / Relationships
| Filename | Purpose | Use Cases |
|----------|---------|-----------|
| `gantt_chart.svg` | Gantt schedule (6-12 tasks) | Project management, product roadmap |
| `timeline.svg` | Timeline events (3-8 nodes) | Milestones, historical evolution |
| `process_flow.svg` | Process flow steps | Business processes, operational guides |
| `org_chart.svg` | Org structure (2-4 levels) | Company structure, reporting relationships |
| `sankey_chart.svg` | Data flow (three-tier) | Budget flow, user conversion paths |
### Strategic Frameworks
| Filename | Purpose | Use Cases |
|----------|---------|-----------|
| `swot_analysis.svg` | SWOT four-quadrant analysis | Strategic planning, competitive analysis |
| `porter_five_forces.svg` | Porter's Five Forces model | Industry analysis, market entry assessment |
templates/charts/sankey_chart.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" width="1280" height="720">
<!--
Sankey Chart Template
Purpose: Data flow visualization, budget allocation, energy flow
Use cases: Budget distribution, energy consumption, resource allocation
Feature: All flows are complete - input total = output total
-->
<defs>
<!-- Flow gradients: Source to Department -->
<linearGradient id="flowA1" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:0.7" />
<stop offset="100%" style="stop-color:#4CAF50;stop-opacity:0.7" />
</linearGradient>
<linearGradient id="flowA2" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:0.7" />
<stop offset="100%" style="stop-color:#FF9800;stop-opacity:0.7" />
</linearGradient>
<linearGradient id="flowA3" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:0.7" />
<stop offset="100%" style="stop-color:#9C27B0;stop-opacity:0.7" />
</linearGradient>
<linearGradient id="flowA4" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:0.7" />
<stop offset="100%" style="stop-color:#607D8B;stop-opacity:0.7" />
</linearGradient>
<linearGradient id="flowB1" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#00BCD4;stop-opacity:0.7" />
<stop offset="100%" style="stop-color:#4CAF50;stop-opacity:0.7" />
</linearGradient>
<linearGradient id="flowB2" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#00BCD4;stop-opacity:0.7" />
<stop offset="100%" style="stop-color:#FF9800;stop-opacity:0.7" />
</linearGradient>
<linearGradient id="flowB3" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#00BCD4;stop-opacity:0.7" />
<stop offset="100%" style="stop-color:#9C27B0;stop-opacity:0.7" />
</linearGradient>
<linearGradient id="flowC1" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#E91E63;stop-opacity:0.7" />
<stop offset="100%" style="stop-color:#FF9800;stop-opacity:0.7" />
</linearGradient>
<linearGradient id="flowC2" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#E91E63;stop-opacity:0.7" />
<stop offset="100%" style="stop-color:#607D8B;stop-opacity:0.7" />
</linearGradient>
<!-- Flow gradients: Department to Expense -->
<linearGradient id="flowD1" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#4CAF50;stop-opacity:0.7" />
<stop offset="100%" style="stop-color:#3F51B5;stop-opacity:0.7" />
</linearGradient>
<linearGradient id="flowD2" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#4CAF50;stop-opacity:0.7" />
<stop offset="100%" style="stop-color:#009688;stop-opacity:0.7" />
</linearGradient>
<linearGradient id="flowE1" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#FF9800;stop-opacity:0.7" />
<stop offset="100%" style="stop-color:#3F51B5;stop-opacity:0.7" />
</linearGradient>
<linearGradient id="flowE2" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#FF9800;stop-opacity:0.7" />
<stop offset="100%" style="stop-color:#F44336;stop-opacity:0.7" />
</linearGradient>
<linearGradient id="flowF1" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#9C27B0;stop-opacity:0.7" />
<stop offset="100%" style="stop-color:#3F51B5;stop-opacity:0.7" />
</linearGradient>
<linearGradient id="flowF2" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#9C27B0;stop-opacity:0.7" />
<stop offset="100%" style="stop-color:#009688;stop-opacity:0.7" />
</linearGradient>
<linearGradient id="flowG1" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#607D8B;stop-opacity:0.7" />
<stop offset="100%" style="stop-color:#3F51B5;stop-opacity:0.7" />
</linearGradient>
<linearGradient id="flowG2" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#607D8B;stop-opacity:0.7" />
<stop offset="100%" style="stop-color:#795548;stop-opacity:0.7" />
</linearGradient>
</defs>
<rect width="1280" height="720" fill="#FFFFFF"/>
<!-- Title -->
<text x="640" y="45" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="28" font-weight="bold" fill="#2C3E50" text-anchor="middle">
<tspan>Annual Budget Allocation Flow</tspan>
</text>
<text x="640" y="75" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" fill="#7F8C8D" text-anchor="middle">
<tspan>2025 FY - Total Budget: $100M (All flows complete)</tspan>
</text>
<!-- Column Labels -->
<text x="120" y="105" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#5D6D7E" text-anchor="middle">
<tspan>Revenue Source</tspan>
</text>
<text x="640" y="105" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#5D6D7E" text-anchor="middle">
<tspan>Department</tspan>
</text>
<text x="1160" y="105" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#5D6D7E" text-anchor="middle">
<tspan>Expense Type</tspan>
</text>
<!-- ==================== LAYER 1: Revenue Sources ==================== -->
<!-- Product Sales: $60M (height=270) -->
<rect x="40" y="130" width="160" height="270" rx="4" fill="#2196F3"/>
<text x="120" y="250" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="15" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>Product Sales</tspan>
</text>
<text x="120" y="275" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>$60M</tspan>
</text>
<!-- Service Revenue: $30M (height=135) -->
<rect x="40" y="420" width="160" height="135" rx="4" fill="#00BCD4"/>
<text x="120" y="480" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="15" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>Service Revenue</tspan>
</text>
<text x="120" y="505" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>$30M</tspan>
</text>
<!-- Investment Income: $10M (height=45) -->
<rect x="40" y="575" width="160" height="45" rx="4" fill="#E91E63"/>
<text x="120" y="603" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>Investment $10M</tspan>
</text>
<!-- ==================== LAYER 2: Departments ==================== -->
<!-- R&D: $35M (height=157.5) -->
<rect x="560" y="130" width="160" height="157" rx="4" fill="#4CAF50"/>
<text x="640" y="200" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>R&D</tspan>
</text>
<text x="640" y="225" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>$35M</tspan>
</text>
<!-- Sales & Marketing: $30M (height=135) -->
<rect x="560" y="307" width="160" height="135" rx="4" fill="#FF9800"/>
<text x="640" y="365" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>Sales & Marketing</tspan>
</text>
<text x="640" y="390" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>$30M</tspan>
</text>
<!-- Operations: $25M (height=112.5) -->
<rect x="560" y="462" width="160" height="112" rx="4" fill="#9C27B0"/>
<text x="640" y="510" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>Operations</tspan>
</text>
<text x="640" y="535" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>$25M</tspan>
</text>
<!-- Admin: $10M (height=45) -->
<rect x="560" y="594" width="160" height="45" rx="4" fill="#607D8B"/>
<text x="640" y="622" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>Admin $10M</tspan>
</text>
<!-- ==================== LAYER 3: Expense Types ==================== -->
<!-- Personnel: $50M (height=225) -->
<rect x="1080" y="130" width="160" height="225" rx="4" fill="#3F51B5"/>
<text x="1160" y="230" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>Personnel</tspan>
</text>
<text x="1160" y="255" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>$50M</tspan>
</text>
<!-- Marketing Expense: $20M (height=90) -->
<rect x="1080" y="375" width="160" height="90" rx="4" fill="#F44336"/>
<text x="1160" y="415" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>Marketing</tspan>
</text>
<text x="1160" y="440" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>$20M</tspan>
</text>
<!-- Infrastructure: $20M (height=90) -->
<rect x="1080" y="485" width="160" height="90" rx="4" fill="#009688"/>
<text x="1160" y="525" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>Infrastructure</tspan>
</text>
<text x="1160" y="550" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>$20M</tspan>
</text>
<!-- Other: $10M (height=45) -->
<rect x="1080" y="595" width="160" height="45" rx="4" fill="#795548"/>
<text x="1160" y="623" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>Other $10M</tspan>
</text>
<!-- ==================== FLOW PATHS: Source to Department ==================== -->
<!-- Product Sales ($60M) flows to: R&D $25M, Sales $18M, Operations $12M, Admin $5M -->
<path d="M 200,175 C 380,175 380,175 560,175" fill="none" stroke="url(#flowA1)" stroke-width="56" opacity="0.6"/>
<path d="M 200,255 C 380,255 380,340 560,340" fill="none" stroke="url(#flowA2)" stroke-width="40" opacity="0.6"/>
<path d="M 200,320 C 380,320 380,490 560,490" fill="none" stroke="url(#flowA3)" stroke-width="27" opacity="0.6"/>
<path d="M 200,375 C 380,375 380,610 560,610" fill="none" stroke="url(#flowA4)" stroke-width="11" opacity="0.6"/>
<!-- Service Revenue ($30M) flows to: R&D $10M, Sales $10M, Operations $10M -->
<path d="M 200,450 C 380,450 380,255 560,255" fill="none" stroke="url(#flowB1)" stroke-width="23" opacity="0.6"/>
<path d="M 200,490 C 380,490 380,400 560,400" fill="none" stroke="url(#flowB2)" stroke-width="23" opacity="0.6"/>
<path d="M 200,530 C 380,530 380,545 560,545" fill="none" stroke="url(#flowB3)" stroke-width="23" opacity="0.6"/>
<!-- Investment Income ($10M) flows to: Sales $2M, Admin $8M -->
<path d="M 200,590 C 380,590 380,430 560,430" fill="none" stroke="url(#flowC1)" stroke-width="5" opacity="0.6"/>
<path d="M 200,605 C 380,605 380,625 560,625" fill="none" stroke="url(#flowC2)" stroke-width="18" opacity="0.6"/>
<!-- ==================== FLOW PATHS: Department to Expense ==================== -->
<!-- R&D ($35M) flows to: Personnel $28M, Infrastructure $7M -->
<path d="M 720,175 C 900,175 900,200 1080,200" fill="none" stroke="url(#flowD1)" stroke-width="63" opacity="0.6"/>
<path d="M 720,250 C 900,250 900,515 1080,515" fill="none" stroke="url(#flowD2)" stroke-width="16" opacity="0.6"/>
<!-- Sales & Marketing ($30M) flows to: Personnel $10M, Marketing $20M -->
<path d="M 720,340 C 900,340 900,290 1080,290" fill="none" stroke="url(#flowE1)" stroke-width="23" opacity="0.6"/>
<path d="M 720,400 C 900,400 900,415 1080,415" fill="none" stroke="url(#flowE2)" stroke-width="45" opacity="0.6"/>
<!-- Operations ($25M) flows to: Personnel $10M, Infrastructure $15M -->
<path d="M 720,490 C 900,490 900,330 1080,330" fill="none" stroke="url(#flowF1)" stroke-width="23" opacity="0.6"/>
<path d="M 720,545 C 900,545 900,550 1080,550" fill="none" stroke="url(#flowF2)" stroke-width="34" opacity="0.6"/>
<!-- Admin ($10M) flows to: Personnel $2M, Other $8M -->
<path d="M 720,605 C 900,605 900,350 1080,350" fill="none" stroke="url(#flowG1)" stroke-width="5" opacity="0.6"/>
<path d="M 720,625 C 900,625 900,615 1080,615" fill="none" stroke="url(#flowG2)" stroke-width="18" opacity="0.6"/>
<!-- Summary Stats -->
<g id="stats">
<rect x="40" y="655" width="1200" height="50" rx="6" fill="#F5F7FA" stroke="#E0E0E0" stroke-width="1"/>
<text x="160" y="680" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#5D6D7E" text-anchor="middle">
<tspan>Total Revenue</tspan>
</text>
<text x="160" y="697" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#2196F3" text-anchor="middle">
<tspan>$100M</tspan>
</text>
<text x="400" y="680" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#5D6D7E" text-anchor="middle">
<tspan>Largest Dept</tspan>
</text>
<text x="400" y="697" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#4CAF50" text-anchor="middle">
<tspan>R&D 35%</tspan>
</text>
<text x="640" y="680" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#5D6D7E" text-anchor="middle">
<tspan>Personnel Cost</tspan>
</text>
<text x="640" y="697" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#3F51B5" text-anchor="middle">
<tspan>50%</tspan>
</text>
<text x="880" y="680" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#5D6D7E" text-anchor="middle">
<tspan>Marketing Spend</tspan>
</text>
<text x="880" y="697" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#F44336" text-anchor="middle">
<tspan>20%</tspan>
</text>
<text x="1120" y="680" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#5D6D7E" text-anchor="middle">
<tspan>Total Expense</tspan>
</text>
<text x="1120" y="697" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#2C3E50" text-anchor="middle">
<tspan>$100M</tspan>
</text>
</g>
</svg>
templates/charts/scatter_chart.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" width="1280" height="720">
<!--
散点图模板
用途: 相关性分析、数据分布展示
适用场景: 价格vs销量、投入vs产出、身高vs体重
支持: 2个系列,每系列多个数据点
-->
<defs>
<!-- 数据点阴影 -->
<filter id="scatterShadow">
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
<feOffset dx="0" dy="1"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.3"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<!-- 系列1渐变 -->
<radialGradient id="dotGrad1" cx="30%" cy="30%">
<stop offset="0%" style="stop-color:#42A5F5;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1976D2;stop-opacity:1" />
</radialGradient>
<!-- 系列2渐变 -->
<radialGradient id="dotGrad2" cx="30%" cy="30%">
<stop offset="0%" style="stop-color:#66BB6A;stop-opacity:1" />
<stop offset="100%" style="stop-color:#388E3C;stop-opacity:1" />
</radialGradient>
</defs>
<!-- 背景 -->
<rect width="1280" height="720" fill="#FFFFFF"/>
<!-- 标题 -->
<text x="60" y="70" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="32" font-weight="bold" fill="#2C3E50">
<tspan>广告投入与销售额关系分析</tspan>
</text>
<text x="60" y="105" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" fill="#7F8C8D">
<tspan>2025年各区域数据 · 样本量 N=24</tspan>
</text>
<!-- 图例 -->
<g id="legend" transform="translate(950, 70)">
<circle cx="10" cy="10" r="8" fill="url(#dotGrad1)"/>
<text x="28" y="15" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="15" fill="#5D6D7E">
<tspan>线上渠道</tspan>
</text>
<circle cx="130" cy="10" r="8" fill="url(#dotGrad2)"/>
<text x="148" y="15" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="15" fill="#5D6D7E">
<tspan>线下渠道</tspan>
</text>
</g>
<!-- 图表区域 -->
<g id="chartArea">
<!-- 网格线 -->
<!-- 水平网格线 -->
<line x1="140" y1="560" x2="1100" y2="560" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="140" y1="470" x2="1100" y2="470" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="140" y1="380" x2="1100" y2="380" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="140" y1="290" x2="1100" y2="290" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="140" y1="200" x2="1100" y2="200" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<!-- 垂直网格线 -->
<line x1="300" y1="160" x2="300" y2="560" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="460" y1="160" x2="460" y2="560" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="620" y1="160" x2="620" y2="560" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="780" y1="160" x2="780" y2="560" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="940" y1="160" x2="940" y2="560" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="1100" y1="160" x2="1100" y2="560" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<!-- Y轴刻度标签 -->
<text x="125" y="565" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end"><tspan>0</tspan></text>
<text x="125" y="475" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end"><tspan>200</tspan></text>
<text x="125" y="385" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end"><tspan>400</tspan></text>
<text x="125" y="295" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end"><tspan>600</tspan></text>
<text x="125" y="205" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end"><tspan>800</tspan></text>
<!-- Y轴标题 -->
<text x="45" y="360" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" fill="#5D6D7E" text-anchor="middle" transform="rotate(-90, 45, 360)">
<tspan>销售额(万元)</tspan>
</text>
<!-- X轴刻度标签 -->
<text x="140" y="590" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle"><tspan>0</tspan></text>
<text x="300" y="590" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle"><tspan>20</tspan></text>
<text x="460" y="590" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle"><tspan>40</tspan></text>
<text x="620" y="590" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle"><tspan>60</tspan></text>
<text x="780" y="590" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle"><tspan>80</tspan></text>
<text x="940" y="590" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="middle"><tspan>100</tspan></text>
<!-- X轴标题 -->
<text x="620" y="625" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" fill="#5D6D7E" text-anchor="middle">
<tspan>广告投入(万元)</tspan>
</text>
<!-- 坐标轴 -->
<line x1="140" y1="160" x2="140" y2="560" stroke="#95A5A6" stroke-width="2"/>
<line x1="140" y1="560" x2="1100" y2="560" stroke="#95A5A6" stroke-width="2"/>
<!--
数据点计算:
X: 140 + (value/100) * 960
Y: 560 - (value/800) * 400
-->
<!-- 趋势线(线上渠道) -->
<line x1="180" y1="505" x2="1020" y2="215" stroke="#2196F3" stroke-width="2" stroke-dasharray="8,4" opacity="0.5"/>
<!-- 趋势线(线下渠道) -->
<line x1="220" y1="515" x2="900" y2="295" stroke="#4CAF50" stroke-width="2" stroke-dasharray="8,4" opacity="0.5"/>
<!-- 系列1: 线上渠道数据点(蓝色) -->
<circle cx="220" cy="470" r="10" fill="url(#dotGrad1)" filter="url(#scatterShadow)"/>
<circle cx="300" cy="425" r="10" fill="url(#dotGrad1)" filter="url(#scatterShadow)"/>
<circle cx="340" cy="445" r="10" fill="url(#dotGrad1)" filter="url(#scatterShadow)"/>
<circle cx="420" cy="380" r="10" fill="url(#dotGrad1)" filter="url(#scatterShadow)"/>
<circle cx="500" cy="350" r="10" fill="url(#dotGrad1)" filter="url(#scatterShadow)"/>
<circle cx="540" cy="320" r="10" fill="url(#dotGrad1)" filter="url(#scatterShadow)"/>
<circle cx="620" cy="310" r="10" fill="url(#dotGrad1)" filter="url(#scatterShadow)"/>
<circle cx="700" cy="270" r="10" fill="url(#dotGrad1)" filter="url(#scatterShadow)"/>
<circle cx="780" cy="250" r="10" fill="url(#dotGrad1)" filter="url(#scatterShadow)"/>
<circle cx="860" cy="230" r="10" fill="url(#dotGrad1)" filter="url(#scatterShadow)"/>
<circle cx="940" cy="210" r="10" fill="url(#dotGrad1)" filter="url(#scatterShadow)"/>
<circle cx="1000" cy="195" r="10" fill="url(#dotGrad1)" filter="url(#scatterShadow)"/>
<!-- 系列2: 线下渠道数据点(绿色) -->
<circle cx="260" cy="490" r="10" fill="url(#dotGrad2)" filter="url(#scatterShadow)"/>
<circle cx="320" cy="460" r="10" fill="url(#dotGrad2)" filter="url(#scatterShadow)"/>
<circle cx="400" cy="430" r="10" fill="url(#dotGrad2)" filter="url(#scatterShadow)"/>
<circle cx="460" cy="400" r="10" fill="url(#dotGrad2)" filter="url(#scatterShadow)"/>
<circle cx="540" cy="380" r="10" fill="url(#dotGrad2)" filter="url(#scatterShadow)"/>
<circle cx="600" cy="365" r="10" fill="url(#dotGrad2)" filter="url(#scatterShadow)"/>
<circle cx="680" cy="340" r="10" fill="url(#dotGrad2)" filter="url(#scatterShadow)"/>
<circle cx="740" cy="325" r="10" fill="url(#dotGrad2)" filter="url(#scatterShadow)"/>
<circle cx="820" cy="305" r="10" fill="url(#dotGrad2)" filter="url(#scatterShadow)"/>
<circle cx="880" cy="290" r="10" fill="url(#dotGrad2)" filter="url(#scatterShadow)"/>
<circle cx="920" cy="280" r="10" fill="url(#dotGrad2)" filter="url(#scatterShadow)"/>
<circle cx="980" cy="265" r="10" fill="url(#dotGrad2)" filter="url(#scatterShadow)"/>
</g>
<!-- 统计信息卡片 -->
<g id="statsPanel" transform="translate(1120, 160)">
<rect x="0" y="0" width="140" height="200" rx="8" fill="#F8F9FA" stroke="#E0E0E0" stroke-width="1"/>
<text x="70" y="30" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>统计摘要</tspan>
</text>
<line x1="15" y1="45" x2="125" y2="45" stroke="#E0E0E0" stroke-width="1"/>
<!-- 线上渠道 -->
<circle cx="25" cy="70" r="6" fill="url(#dotGrad1)"/>
<text x="40" y="75" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#5D6D7E">
<tspan>线上渠道</tspan>
</text>
<text x="25" y="95" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#7F8C8D">
<tspan>R² = 0.94</tspan>
</text>
<text x="25" y="112" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#7F8C8D">
<tspan>斜率 = 6.8</tspan>
</text>
<!-- 线下渠道 -->
<circle cx="25" cy="140" r="6" fill="url(#dotGrad2)"/>
<text x="40" y="145" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#5D6D7E">
<tspan>线下渠道</tspan>
</text>
<text x="25" y="165" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#7F8C8D">
<tspan>R² = 0.91</tspan>
</text>
<text x="25" y="182" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#7F8C8D">
<tspan>斜率 = 5.2</tspan>
</text>
</g>
<!-- 洞察卡片 -->
<g id="insightCard" transform="translate(1120, 380)">
<rect x="0" y="0" width="140" height="100" rx="8" fill="#E3F2FD" stroke="#2196F3" stroke-width="1"/>
<text x="15" y="25" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" font-weight="600" fill="#1976D2">
<tspan>💡 洞察</tspan>
</text>
<text x="15" y="50" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="12" fill="#5D6D7E">
<tspan>线上渠道ROI</tspan>
</text>
<text x="15" y="68" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="12" fill="#5D6D7E">
<tspan>高于线下 31%</tspan>
</text>
<text x="15" y="88" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="11" fill="#7F8C8D">
<tspan>建议增加线上投入</tspan>
</text>
</g>
<!-- 数据来源 -->
<text x="60" y="695" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#95A5A6">
<tspan>数据来源: 市场营销部门</tspan>
</text>
</svg>
templates/charts/stacked_area_chart.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" width="1280" height="720">
<!--
堆叠面积图模板
用途: 多系列累积趋势、构成变化
适用场景: 收入构成变化、流量来源趋势、市场份额演进
支持: 4个系列 × 12个时间点
-->
<defs>
<!-- 系列1渐变(蓝色) -->
<linearGradient id="stackGrad1" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:0.85" />
<stop offset="100%" style="stop-color:#2196F3;stop-opacity:0.6" />
</linearGradient>
<!-- 系列2渐变(绿色) -->
<linearGradient id="stackGrad2" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#4CAF50;stop-opacity:0.85" />
<stop offset="100%" style="stop-color:#4CAF50;stop-opacity:0.6" />
</linearGradient>
<!-- 系列3渐变(橙色) -->
<linearGradient id="stackGrad3" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#FF9800;stop-opacity:0.85" />
<stop offset="100%" style="stop-color:#FF9800;stop-opacity:0.6" />
</linearGradient>
<!-- 系列4渐变(紫色) -->
<linearGradient id="stackGrad4" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#9C27B0;stop-opacity:0.85" />
<stop offset="100%" style="stop-color:#9C27B0;stop-opacity:0.6" />
</linearGradient>
</defs>
<!-- 背景 -->
<rect width="1280" height="720" fill="#FFFFFF"/>
<!-- 标题 -->
<text x="60" y="60" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="32" font-weight="bold" fill="#2C3E50">
<tspan>营收来源构成趋势</tspan>
</text>
<text x="60" y="95" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" fill="#7F8C8D">
<tspan>2025年度月度数据 · 单位:百万元</tspan>
</text>
<!-- 图例 -->
<g id="legend" transform="translate(800, 60)">
<rect x="0" y="0" width="16" height="16" rx="3" fill="#2196F3"/>
<text x="24" y="13" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#5D6D7E"><tspan>订阅收入</tspan></text>
<rect x="110" y="0" width="16" height="16" rx="3" fill="#4CAF50"/>
<text x="134" y="13" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#5D6D7E"><tspan>广告收入</tspan></text>
<rect x="220" y="0" width="16" height="16" rx="3" fill="#FF9800"/>
<text x="244" y="13" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#5D6D7E"><tspan>增值服务</tspan></text>
<rect x="330" y="0" width="16" height="16" rx="3" fill="#9C27B0"/>
<text x="354" y="13" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#5D6D7E"><tspan>其他</tspan></text>
</g>
<!-- 图表区域 -->
<g id="chartArea">
<!-- Y轴网格线 -->
<line x1="100" y1="550" x2="1180" y2="550" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="100" y1="460" x2="1180" y2="460" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="100" y1="370" x2="1180" y2="370" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="100" y1="280" x2="1180" y2="280" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="100" y1="190" x2="1180" y2="190" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<!-- Y轴刻度标签 -->
<text x="85" y="555" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="end"><tspan>0</tspan></text>
<text x="85" y="465" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="end"><tspan>50</tspan></text>
<text x="85" y="375" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="end"><tspan>100</tspan></text>
<text x="85" y="285" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="end"><tspan>150</tspan></text>
<text x="85" y="195" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="end"><tspan>200</tspan></text>
<!-- 坐标轴 -->
<line x1="100" y1="150" x2="100" y2="550" stroke="#95A5A6" stroke-width="2"/>
<line x1="100" y1="550" x2="1180" y2="550" stroke="#95A5A6" stroke-width="2"/>
<!-- X轴标签(12个月) -->
<!-- 每月间距: 90px -->
<text x="145" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="middle"><tspan>1月</tspan></text>
<text x="235" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="middle"><tspan>2月</tspan></text>
<text x="325" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="middle"><tspan>3月</tspan></text>
<text x="415" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="middle"><tspan>4月</tspan></text>
<text x="505" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="middle"><tspan>5月</tspan></text>
<text x="595" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="middle"><tspan>6月</tspan></text>
<text x="685" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="middle"><tspan>7月</tspan></text>
<text x="775" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="middle"><tspan>8月</tspan></text>
<text x="865" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="middle"><tspan>9月</tspan></text>
<text x="955" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="middle"><tspan>10月</tspan></text>
<text x="1045" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="middle"><tspan>11月</tspan></text>
<text x="1135" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="middle"><tspan>12月</tspan></text>
<!--
数据说明(从下到上堆叠):
X坐标: 145, 235, 325, 415, 505, 595, 685, 775, 865, 955, 1045, 1135
Y: 550 - value * 1.8
系列1 订阅收入: 45, 48, 52, 55, 58, 62, 68, 72, 70, 68, 75, 82
系列2 广告收入: 25, 28, 30, 32, 35, 38, 42, 45, 43, 40, 45, 50
系列3 增值服务: 15, 16, 18, 20, 22, 25, 28, 30, 28, 26, 30, 35
系列4 其他: 8, 8, 10, 10, 12, 12, 15, 15, 14, 14, 15, 18
累计:
L1底: 0
L1顶: 45, 48, 52, 55, 58, 62, 68, 72, 70, 68, 75, 82
L2顶: 70, 76, 82, 87, 93, 100, 110, 117, 113, 108, 120, 132
L3顶: 85, 92, 100, 107, 115, 125, 138, 147, 141, 134, 150, 167
L4顶: 93, 100, 110, 117, 127, 137, 153, 162, 155, 148, 165, 185
-->
<!-- 系列4: 其他(最上层,紫色) -->
<path d="M 145,550
L 145,382.6 L 235,370 L 325,352 L 415,339.4 L 505,321.4 L 595,303.4 L 685,274.6 L 775,258.4 L 865,271 L 955,283.6 L 1045,253 L 1135,217
L 1135,249.4 L 1045,280 L 955,308.8 L 865,296.2 L 775,285.4 L 685,301.6 L 595,325 L 505,343 L 415,357.4 L 325,370 L 235,384.4 L 145,397
Z" fill="url(#stackGrad4)"/>
<!-- 系列3: 增值服务(橙色) -->
<path d="M 145,550
L 145,397 L 235,384.4 L 325,370 L 415,357.4 L 505,343 L 595,325 L 685,301.6 L 775,285.4 L 865,296.2 L 955,308.8 L 1045,280 L 1135,249.4
L 1135,312.4 L 1045,334 L 955,355.6 L 865,342.6 L 775,337 L 685,352.4 L 595,374 L 505,387 L 415,398.6 L 325,406 L 235,413.2 L 145,424
Z" fill="url(#stackGrad3)"/>
<!-- 系列2: 广告收入(绿色) -->
<path d="M 145,550
L 145,424 L 235,413.2 L 325,406 L 415,398.6 L 505,387 L 595,374 L 685,352.4 L 775,337 L 865,342.6 L 955,355.6 L 1045,334 L 1135,312.4
L 1135,402.4 L 1045,415 L 955,427.6 L 865,424 L 775,418.6 L 685,424 L 595,436.6 L 505,445 L 415,451 L 325,456.4 L 235,463.6 L 145,469
Z" fill="url(#stackGrad2)"/>
<!-- 系列1: 订阅收入(蓝色,最底层) -->
<path d="M 145,550
L 145,469 L 235,463.6 L 325,456.4 L 415,451 L 505,445 L 595,436.6 L 685,424 L 775,418.6 L 865,424 L 955,427.6 L 1045,415 L 1135,402.4
L 1135,550 Z" fill="url(#stackGrad1)"/>
<!-- 顶部边线 -->
<path d="M 145,382.6 L 235,370 L 325,352 L 415,339.4 L 505,321.4 L 595,303.4 L 685,274.6 L 775,258.4 L 865,271 L 955,283.6 L 1045,253 L 1135,217"
fill="none" stroke="#9C27B0" stroke-width="2"/>
</g>
<!-- 标注(8月峰值) -->
<g id="annotation">
<rect x="730" y="195" width="90" height="50" rx="6" fill="#9C27B0"/>
<text x="775" y="218" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" font-weight="600" fill="#FFFFFF" text-anchor="middle"><tspan>峰值月份</tspan></text>
<text x="775" y="236" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#FFFFFF" text-anchor="middle"><tspan>162M</tspan></text>
<path d="M 775,245 L 770,258.4 L 780,258.4 Z" fill="#9C27B0"/>
</g>
<!-- 统计卡片 -->
<g id="statsCards" transform="translate(60, 610)">
<!-- 卡片1 -->
<rect x="0" y="0" width="200" height="80" rx="8" fill="#E3F2FD" stroke="#2196F3" stroke-width="1"/>
<text x="20" y="25" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#1976D2"><tspan>订阅收入</tspan></text>
<text x="20" y="50" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="22" font-weight="bold" fill="#2196F3"><tspan>755M</tspan></text>
<text x="20" y="70" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#7F8C8D"><tspan>占比 48.5% ↑12%</tspan></text>
<!-- 卡片2 -->
<rect x="220" y="0" width="200" height="80" rx="8" fill="#E8F5E9" stroke="#4CAF50" stroke-width="1"/>
<text x="240" y="25" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#388E3C"><tspan>广告收入</tspan></text>
<text x="240" y="50" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="22" font-weight="bold" fill="#4CAF50"><tspan>453M</tspan></text>
<text x="240" y="70" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#7F8C8D"><tspan>占比 29.1% ↑8%</tspan></text>
<!-- 卡片3 -->
<rect x="440" y="0" width="200" height="80" rx="8" fill="#FFF3E0" stroke="#FF9800" stroke-width="1"/>
<text x="460" y="25" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#F57C00"><tspan>增值服务</tspan></text>
<text x="460" y="50" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="22" font-weight="bold" fill="#FF9800"><tspan>293M</tspan></text>
<text x="460" y="70" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#7F8C8D"><tspan>占比 18.8% ↑15%</tspan></text>
<!-- 卡片4 -->
<rect x="660" y="0" width="200" height="80" rx="8" fill="#F3E5F5" stroke="#9C27B0" stroke-width="1"/>
<text x="680" y="25" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#7B1FA2"><tspan>其他收入</tspan></text>
<text x="680" y="50" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="22" font-weight="bold" fill="#9C27B0"><tspan>56M</tspan></text>
<text x="680" y="70" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#7F8C8D"><tspan>占比 3.6% ↑5%</tspan></text>
<!-- 总计 -->
<rect x="880" y="0" width="200" height="80" rx="8" fill="#F8F9FA" stroke="#2C3E50" stroke-width="2"/>
<text x="900" y="25" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#5D6D7E"><tspan>年度总营收</tspan></text>
<text x="900" y="50" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="22" font-weight="bold" fill="#2C3E50"><tspan>1,557M</tspan></text>
<text x="900" y="70" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#27AE60"><tspan>同比增长 +11.2%</tspan></text>
</g>
<!-- 数据来源 -->
<text x="1220" y="700" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#95A5A6" text-anchor="end">
<tspan>数据来源: 财务部门</tspan>
</text>
</svg>
templates/charts/stacked_bar_chart.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" width="1280" height="720">
<!--
堆叠柱状图模板
用途: 多类别构成对比、占比趋势分析
适用场景: 收入构成、成本分解、市场份额变化
支持: 5组数据 × 4个类别(可调整)
-->
<defs>
<!-- 阴影 -->
<filter id="stackShadow">
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
<feOffset dx="0" dy="1"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.2"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- 背景 -->
<rect width="1280" height="720" fill="#FFFFFF"/>
<!-- 标题 -->
<text x="60" y="70" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="32" font-weight="bold" fill="#2C3E50">
<tspan>各季度收入构成分析</tspan>
</text>
<text x="60" y="105" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" fill="#7F8C8D">
<tspan>2024-2025年度 · 单位:百万元</tspan>
</text>
<!-- 图表区域 -->
<g id="chartArea">
<!-- Y轴网格线 -->
<line x1="140" y1="550" x2="1100" y2="550" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="140" y1="470" x2="1100" y2="470" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="140" y1="390" x2="1100" y2="390" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="140" y1="310" x2="1100" y2="310" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="140" y1="230" x2="1100" y2="230" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="140" y1="150" x2="1100" y2="150" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<!-- Y轴刻度标签 -->
<text x="125" y="555" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end">
<tspan>0</tspan>
</text>
<text x="125" y="475" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end">
<tspan>100</tspan>
</text>
<text x="125" y="395" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end">
<tspan>200</tspan>
</text>
<text x="125" y="315" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end">
<tspan>300</tspan>
</text>
<text x="125" y="235" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end">
<tspan>400</tspan>
</text>
<text x="125" y="155" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end">
<tspan>500</tspan>
</text>
<!-- X轴和Y轴 -->
<line x1="140" y1="150" x2="140" y2="550" stroke="#95A5A6" stroke-width="2"/>
<line x1="140" y1="550" x2="1100" y2="550" stroke="#95A5A6" stroke-width="2"/>
<!-- 堆叠柱子 - Q1 2024 (总高320, 按比例: 产品A 120, 产品B 100, 产品C 60, 产品D 40) -->
<g filter="url(#stackShadow)">
<rect x="200" y="422" width="80" height="128" fill="#2196F3"/>
<rect x="200" y="342" width="80" height="80" fill="#4CAF50"/>
<rect x="200" y="294" width="80" height="48" fill="#FF9800"/>
<rect x="200" y="262" width="80" height="32" fill="#9C27B0"/>
</g>
<text x="240" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#5D6D7E" text-anchor="middle">
<tspan>Q1'24</tspan>
</text>
<text x="240" y="247" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>320</tspan>
</text>
<!-- 堆叠柱子 - Q2 2024 (总高360) -->
<g filter="url(#stackShadow)">
<rect x="380" y="406" width="80" height="144" fill="#2196F3"/>
<rect x="380" y="318" width="80" height="88" fill="#4CAF50"/>
<rect x="380" y="262" width="80" height="56" fill="#FF9800"/>
<rect x="380" y="222" width="80" height="40" fill="#9C27B0"/>
</g>
<text x="420" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#5D6D7E" text-anchor="middle">
<tspan>Q2'24</tspan>
</text>
<text x="420" y="207" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>360</tspan>
</text>
<!-- 堆叠柱子 - Q3 2024 (总高400) -->
<g filter="url(#stackShadow)">
<rect x="560" y="390" width="80" height="160" fill="#2196F3"/>
<rect x="560" y="294" width="80" height="96" fill="#4CAF50"/>
<rect x="560" y="230" width="80" height="64" fill="#FF9800"/>
<rect x="560" y="182" width="80" height="48" fill="#9C27B0"/>
</g>
<text x="600" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#5D6D7E" text-anchor="middle">
<tspan>Q3'24</tspan>
</text>
<text x="600" y="167" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>400</tspan>
</text>
<!-- 堆叠柱子 - Q4 2024 (总高420) -->
<g filter="url(#stackShadow)">
<rect x="740" y="382" width="80" height="168" fill="#2196F3"/>
<rect x="740" y="278" width="80" height="104" fill="#4CAF50"/>
<rect x="740" y="206" width="80" height="72" fill="#FF9800"/>
<rect x="740" y="158" width="80" height="48" fill="#9C27B0"/>
</g>
<text x="780" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#5D6D7E" text-anchor="middle">
<tspan>Q4'24</tspan>
</text>
<text x="780" y="143" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>420</tspan>
</text>
<!-- 堆叠柱子 - Q1 2025 (总高480) -->
<g filter="url(#stackShadow)">
<rect x="920" y="358" width="80" height="192" fill="#2196F3"/>
<rect x="920" y="246" width="80" height="112" fill="#4CAF50"/>
<rect x="920" y="166" width="80" height="80" fill="#FF9800"/>
<rect x="920" y="110" width="80" height="56" fill="#9C27B0"/>
</g>
<text x="960" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#5D6D7E" text-anchor="middle">
<tspan>Q1'25</tspan>
</text>
<text x="960" y="95" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>480</tspan>
</text>
</g>
<!-- 图例 -->
<g id="legend" transform="translate(1120, 200)">
<rect x="0" y="0" width="140" height="180" rx="8" fill="#F8F9FA" stroke="#E0E0E0" stroke-width="1"/>
<text x="70" y="30" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>收入类别</tspan>
</text>
<rect x="15" y="50" width="20" height="16" rx="2" fill="#2196F3"/>
<text x="45" y="63" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#5D6D7E">
<tspan>产品销售</tspan>
</text>
<rect x="15" y="80" width="20" height="16" rx="2" fill="#4CAF50"/>
<text x="45" y="93" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#5D6D7E">
<tspan>服务收入</tspan>
</text>
<rect x="15" y="110" width="20" height="16" rx="2" fill="#FF9800"/>
<text x="45" y="123" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#5D6D7E">
<tspan>订阅收入</tspan>
</text>
<rect x="15" y="140" width="20" height="16" rx="2" fill="#9C27B0"/>
<text x="45" y="153" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#5D6D7E">
<tspan>其他收入</tspan>
</text>
</g>
<!-- 数据来源 -->
<text x="60" y="695" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#95A5A6">
<tspan>数据来源: 财务部门报表</tspan>
</text>
</svg>
templates/charts/swot_analysis.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" width="1280" height="720">
<!--
SWOT Analysis Chart Template
Purpose: Strategic analysis, competitive assessment
Use cases: Business plans, strategic planning, competitor analysis, project evaluation
Supports: Four quadrant display (Strengths, Weaknesses, Opportunities, Threats)
-->
<defs>
<linearGradient id="strengthsGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#E8F5E9;stop-opacity:1" />
<stop offset="100%" style="stop-color:#C8E6C9;stop-opacity:1" />
</linearGradient>
<linearGradient id="weaknessesGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#FFEBEE;stop-opacity:1" />
<stop offset="100%" style="stop-color:#FFCDD2;stop-opacity:1" />
</linearGradient>
<linearGradient id="opportunitiesGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#E3F2FD;stop-opacity:1" />
<stop offset="100%" style="stop-color:#BBDEFB;stop-opacity:1" />
</linearGradient>
<linearGradient id="threatsGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#FFF3E0;stop-opacity:1" />
<stop offset="100%" style="stop-color:#FFE0B2;stop-opacity:1" />
</linearGradient>
<filter id="cardShadow">
<feGaussianBlur in="SourceAlpha" stdDeviation="4"/>
<feOffset dx="0" dy="2"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.2"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<rect width="1280" height="720" fill="#FAFBFC"/>
<text x="640" y="50" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="32" font-weight="bold" fill="#2C3E50" text-anchor="middle">
<tspan>SWOT Strategic Analysis</tspan>
</text>
<text x="640" y="85" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" fill="#7F8C8D" text-anchor="middle">
<tspan>ABC Tech Company - 2025 Market Competition Assessment</tspan>
</text>
<!-- Axis labels -->
<text x="345" y="115" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#4CAF50" text-anchor="middle">
<tspan>Helpful</tspan>
</text>
<text x="935" y="115" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#E74C3C" text-anchor="middle">
<tspan>Harmful</tspan>
</text>
<text x="30" y="290" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#5D6D7E" text-anchor="middle" transform="rotate(-90, 30, 290)">
<tspan>Internal</tspan>
</text>
<text x="30" y="530" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#5D6D7E" text-anchor="middle" transform="rotate(-90, 30, 530)">
<tspan>External</tspan>
</text>
<!-- S - Strengths (Top Left) -->
<g id="strengths">
<rect x="60" y="130" width="570" height="240" rx="12" fill="url(#strengthsGrad)"
stroke="#4CAF50" stroke-width="3" filter="url(#cardShadow)"/>
<rect x="60" y="130" width="570" height="50" rx="12" fill="#4CAF50"/>
<rect x="60" y="168" width="570" height="12" fill="#4CAF50"/>
<text x="90" y="163" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="28" font-weight="bold" fill="#FFFFFF">
<tspan>S</tspan>
</text>
<text x="125" y="163" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="22" font-weight="bold" fill="#FFFFFF">
<tspan>Strengths</tspan>
</text>
<circle cx="85" cy="210" r="6" fill="#4CAF50"/>
<text x="100" y="215" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" fill="#2C3E50">
<tspan>Strong R&D team, 200+ tech patents</tspan>
</text>
<circle cx="85" cy="245" r="6" fill="#4CAF50"/>
<text x="100" y="250" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" fill="#2C3E50">
<tspan>High brand recognition, 85% customer loyalty</tspan>
</text>
<circle cx="85" cy="280" r="6" fill="#4CAF50"/>
<text x="100" y="285" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" fill="#2C3E50">
<tspan>Complete supply chain, 15% cost advantage</tspan>
</text>
<circle cx="85" cy="315" r="6" fill="#4CAF50"/>
<text x="100" y="320" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" fill="#2C3E50">
<tspan>Strong cash flow, stable financials</tspan>
</text>
<circle cx="85" cy="350" r="6" fill="#4CAF50"/>
<text x="100" y="355" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" fill="#2C3E50">
<tspan>Efficient management team, strong execution</tspan>
</text>
</g>
<!-- W - Weaknesses (Top Right) -->
<g id="weaknesses">
<rect x="650" y="130" width="570" height="240" rx="12" fill="url(#weaknessesGrad)"
stroke="#E74C3C" stroke-width="3" filter="url(#cardShadow)"/>
<rect x="650" y="130" width="570" height="50" rx="12" fill="#E74C3C"/>
<rect x="650" y="168" width="570" height="12" fill="#E74C3C"/>
<text x="680" y="163" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="28" font-weight="bold" fill="#FFFFFF">
<tspan>W</tspan>
</text>
<text x="720" y="163" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="22" font-weight="bold" fill="#FFFFFF">
<tspan>Weaknesses</tspan>
</text>
<circle cx="675" cy="210" r="6" fill="#E74C3C"/>
<text x="690" y="215" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" fill="#2C3E50">
<tspan>Insufficient marketing investment</tspan>
</text>
<circle cx="675" cy="245" r="6" fill="#E74C3C"/>
<text x="690" y="250" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" fill="#2C3E50">
<tspan>Single product line, core product dependency</tspan>
</text>
<circle cx="675" cy="280" r="6" fill="#E74C3C"/>
<text x="690" y="285" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" fill="#2C3E50">
<tspan>Lack of overseas market experience</tspan>
</text>
<circle cx="675" cy="315" r="6" fill="#E74C3C"/>
<text x="690" y="320" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" fill="#2C3E50">
<tspan>High talent turnover rate (12%)</tspan>
</text>
<circle cx="675" cy="350" r="6" fill="#E74C3C"/>
<text x="690" y="355" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" fill="#2C3E50">
<tspan>After-sales response needs improvement</tspan>
</text>
</g>
<!-- O - Opportunities (Bottom Left) -->
<g id="opportunities">
<rect x="60" y="390" width="570" height="240" rx="12" fill="url(#opportunitiesGrad)"
stroke="#2196F3" stroke-width="3" filter="url(#cardShadow)"/>
<rect x="60" y="390" width="570" height="50" rx="12" fill="#2196F3"/>
<rect x="60" y="428" width="570" height="12" fill="#2196F3"/>
<text x="90" y="423" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="28" font-weight="bold" fill="#FFFFFF">
<tspan>O</tspan>
</text>
<text x="125" y="423" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="22" font-weight="bold" fill="#FFFFFF">
<tspan>Opportunities</tspan>
</text>
<circle cx="85" cy="470" r="6" fill="#2196F3"/>
<text x="100" y="475" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" fill="#2C3E50">
<tspan>AI industry growing at 25% CAGR</tspan>
</text>
<circle cx="85" cy="505" r="6" fill="#2196F3"/>
<text x="100" y="510" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" fill="#2C3E50">
<tspan>Increased policy support and subsidies</tspan>
</text>
<circle cx="85" cy="540" r="6" fill="#2196F3"/>
<text x="100" y="545" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" fill="#2C3E50">
<tspan>Strong demand in emerging markets (SEA)</tspan>
</text>
<circle cx="85" cy="575" r="6" fill="#2196F3"/>
<text x="100" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" fill="#2C3E50">
<tspan>Competitors repositioning, market share available</tspan>
</text>
<circle cx="85" cy="610" r="6" fill="#2196F3"/>
<text x="100" y="615" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" fill="#2C3E50">
<tspan>Digital transformation driving enterprise demand</tspan>
</text>
</g>
<!-- T - Threats (Bottom Right) -->
<g id="threats">
<rect x="650" y="390" width="570" height="240" rx="12" fill="url(#threatsGrad)"
stroke="#FF9800" stroke-width="3" filter="url(#cardShadow)"/>
<rect x="650" y="390" width="570" height="50" rx="12" fill="#FF9800"/>
<rect x="650" y="428" width="570" height="12" fill="#FF9800"/>
<text x="680" y="423" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="28" font-weight="bold" fill="#FFFFFF">
<tspan>T</tspan>
</text>
<text x="715" y="423" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="22" font-weight="bold" fill="#FFFFFF">
<tspan>Threats</tspan>
</text>
<circle cx="675" cy="470" r="6" fill="#FF9800"/>
<text x="690" y="475" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" fill="#2C3E50">
<tspan>Intensified competition, giants entering market</tspan>
</text>
<circle cx="675" cy="505" r="6" fill="#FF9800"/>
<text x="690" y="510" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" fill="#2C3E50">
<tspan>Rapid technology iterations, R&D pressure</tspan>
</text>
<circle cx="675" cy="540" r="6" fill="#FF9800"/>
<text x="690" y="545" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" fill="#2C3E50">
<tspan>Rising raw material costs, margin squeeze</tspan>
</text>
<circle cx="675" cy="575" r="6" fill="#FF9800"/>
<text x="690" y="580" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" fill="#2C3E50">
<tspan>Stricter regulations, compliance costs rising</tspan>
</text>
<circle cx="675" cy="610" r="6" fill="#FF9800"/>
<text x="690" y="615" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" fill="#2C3E50">
<tspan>Economic uncertainty, client budgets tightening</tspan>
</text>
</g>
<!-- Center logo -->
<circle cx="640" cy="370" r="40" fill="#2C3E50" filter="url(#cardShadow)"/>
<text x="640" y="365" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" font-weight="bold" fill="#FFFFFF" text-anchor="middle">
<tspan>SWOT</tspan>
</text>
<text x="640" y="385" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#FFFFFF" text-anchor="middle">
<tspan>Analysis</tspan>
</text>
<text x="640" y="695" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#95A5A6" text-anchor="middle">
<tspan>Data Source: Strategic Planning Department - 2025 Annual Strategic Analysis Report</tspan>
</text>
</svg>
templates/charts/timeline.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" width="1280" height="720">
<!--
时间轴模板
用途: 时序事件展示
适用场景: 项目里程碑、历史演进、产品路线图
支持: 6个节点 (推荐3-8个)
-->
<defs>
<!-- 节点渐变 -->
<linearGradient id="nodeGrad1" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1976D2;stop-opacity:1" />
</linearGradient>
<linearGradient id="nodeGrad2" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#4CAF50;stop-opacity:1" />
<stop offset="100%" style="stop-color:#388E3C;stop-opacity:1" />
</linearGradient>
<linearGradient id="nodeGrad3" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#9C27B0;stop-opacity:1" />
<stop offset="100%" style="stop-color:#7B1FA2;stop-opacity:1" />
</linearGradient>
<!-- 节点阴影 -->
<filter id="nodeShadow">
<feGaussianBlur in="SourceAlpha" stdDeviation="3"/>
<feOffset dx="0" dy="2"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.4"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<!-- 卡片阴影 -->
<filter id="cardShadow">
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
<feOffset dx="0" dy="1"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.2"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- 背景 -->
<rect width="1280" height="720" fill="#FFFFFF"/>
<!-- 标题 -->
<text x="60" y="80" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="32" font-weight="bold" fill="#2C3E50">
<tspan>产品发展路线图</tspan>
</text>
<text x="60" y="115" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" fill="#7F8C8D">
<tspan>2024年 - 2026年 · 关键里程碑</tspan>
</text>
<!-- 时间轴 -->
<g id="timeline">
<!-- 主时间线 (y: 360) -->
<line x1="120" y1="360" x2="1160" y2="360" stroke="#E0E0E0" stroke-width="6" stroke-linecap="round"/>
<!-- 渐变进度条 (已完成部分) -->
<defs>
<linearGradient id="progressGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:1" />
<stop offset="100%" style="stop-color:#4CAF50;stop-opacity:1" />
</linearGradient>
</defs>
<line x1="120" y1="360" x2="640" y2="360" stroke="url(#progressGrad)" stroke-width="6" stroke-linecap="round"/>
<!-- 节点位置 (6个节点, 间距 208px) -->
<!-- x坐标: 120, 328, 536, 744, 952, 1160 -->
<!-- 节点1: 2024 Q1 - 产品立项 (已完成) -->
<g id="milestone1">
<circle cx="120" cy="360" r="18" fill="url(#nodeGrad2)" stroke="#FFFFFF" stroke-width="3" filter="url(#nodeShadow)"/>
<path d="M 115,360 L 118,365 L 125,355" stroke="#FFFFFF" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
<!-- 卡片 -->
<rect x="40" y="190" width="160" height="120" rx="8" fill="#FFFFFF" stroke="#4CAF50" stroke-width="2" filter="url(#cardShadow)"/>
<text x="120" y="220" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#4CAF50" text-anchor="middle">
<tspan>2024 Q1</tspan>
</text>
<text x="120" y="245" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#2C3E50" text-anchor="middle">
<tspan>产品立项</tspan>
</text>
<text x="120" y="270" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#5D6D7E" text-anchor="middle">
<tspan>完成需求分析</tspan>
</text>
<text x="120" y="290" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#5D6D7E" text-anchor="middle">
<tspan>制定技术方案</tspan>
</text>
<line x1="120" y1="310" x2="120" y2="340" stroke="#4CAF50" stroke-width="2" stroke-dasharray="4,4"/>
</g>
<!-- 节点2: 2024 Q2 - MVP开发 (已完成) -->
<g id="milestone2">
<circle cx="328" cy="360" r="18" fill="url(#nodeGrad2)" stroke="#FFFFFF" stroke-width="3" filter="url(#nodeShadow)"/>
<path d="M 323,360 L 326,365 L 333,355" stroke="#FFFFFF" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
<!-- 卡片 -->
<rect x="248" y="430" width="160" height="120" rx="8" fill="#FFFFFF" stroke="#4CAF50" stroke-width="2" filter="url(#cardShadow)"/>
<text x="328" y="460" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#4CAF50" text-anchor="middle">
<tspan>2024 Q2</tspan>
</text>
<text x="328" y="485" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#2C3E50" text-anchor="middle">
<tspan>MVP开发</tspan>
</text>
<text x="328" y="510" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#5D6D7E" text-anchor="middle">
<tspan>核心功能上线</tspan>
</text>
<text x="328" y="530" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#5D6D7E" text-anchor="middle">
<tspan>内测验证</tspan>
</text>
<line x1="328" y1="380" x2="328" y2="410" stroke="#4CAF50" stroke-width="2" stroke-dasharray="4,4"/>
</g>
<!-- 节点3: 2024 Q3 - 公测上线 (已完成) -->
<g id="milestone3">
<circle cx="536" cy="360" r="18" fill="url(#nodeGrad2)" stroke="#FFFFFF" stroke-width="3" filter="url(#nodeShadow)"/>
<path d="M 531,360 L 534,365 L 541,355" stroke="#FFFFFF" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
<!-- 卡片 -->
<rect x="456" y="190" width="160" height="120" rx="8" fill="#FFFFFF" stroke="#4CAF50" stroke-width="2" filter="url(#cardShadow)"/>
<text x="536" y="220" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#4CAF50" text-anchor="middle">
<tspan>2024 Q3</tspan>
</text>
<text x="536" y="245" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#2C3E50" text-anchor="middle">
<tspan>公测上线</tspan>
</text>
<text x="536" y="270" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#5D6D7E" text-anchor="middle">
<tspan>开放用户注册</tspan>
</text>
<text x="536" y="290" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#5D6D7E" text-anchor="middle">
<tspan>用户达5000+</tspan>
</text>
<line x1="536" y1="310" x2="536" y2="340" stroke="#4CAF50" stroke-width="2" stroke-dasharray="4,4"/>
</g>
<!-- 节点4: 2024 Q4 - 功能迭代 (进行中) -->
<g id="milestone4">
<circle cx="744" cy="360" r="20" fill="url(#nodeGrad1)" stroke="#FFFFFF" stroke-width="3" filter="url(#nodeShadow)"/>
<circle cx="744" cy="360" r="8" fill="#FFFFFF"/>
<!-- 卡片 -->
<rect x="664" y="430" width="160" height="120" rx="8" fill="#FFFFFF" stroke="#2196F3" stroke-width="2" filter="url(#cardShadow)"/>
<rect x="664" y="430" width="160" height="6" rx="3" fill="#2196F3"/>
<text x="744" y="460" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#2196F3" text-anchor="middle">
<tspan>2024 Q4</tspan>
</text>
<text x="744" y="485" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#2C3E50" text-anchor="middle">
<tspan>功能迭代</tspan>
</text>
<text x="744" y="510" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#5D6D7E" text-anchor="middle">
<tspan>AI功能集成</tspan>
</text>
<text x="744" y="530" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#5D6D7E" text-anchor="middle">
<tspan>多端适配</tspan>
</text>
<line x1="744" y1="380" x2="744" y2="410" stroke="#2196F3" stroke-width="2" stroke-dasharray="4,4"/>
<!-- 进行中标记 -->
<rect x="714" y="568" width="60" height="22" rx="11" fill="#2196F3"/>
<text x="744" y="583" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" font-weight="600" fill="#FFFFFF" text-anchor="middle">
<tspan>进行中</tspan>
</text>
</g>
<!-- 节点5: 2025 Q2 - 商业化 (计划中) -->
<g id="milestone5">
<circle cx="952" cy="360" r="16" fill="#E0E0E0" stroke="#FFFFFF" stroke-width="3" filter="url(#nodeShadow)"/>
<circle cx="952" cy="360" r="6" fill="#FFFFFF"/>
<!-- 卡片 -->
<rect x="872" y="190" width="160" height="120" rx="8" fill="#F8F9FA" stroke="#B0BEC5" stroke-width="2" filter="url(#cardShadow)"/>
<text x="952" y="220" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#607D8B" text-anchor="middle">
<tspan>2025 Q2</tspan>
</text>
<text x="952" y="245" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#5D6D7E" text-anchor="middle">
<tspan>商业化</tspan>
</text>
<text x="952" y="270" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="middle">
<tspan>会员体系上线</tspan>
</text>
<text x="952" y="290" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="middle">
<tspan>启动推广</tspan>
</text>
<line x1="952" y1="310" x2="952" y2="340" stroke="#B0BEC5" stroke-width="2" stroke-dasharray="4,4"/>
</g>
<!-- 节点6: 2026 Q1 - 规模化 (计划中) -->
<g id="milestone6">
<circle cx="1160" cy="360" r="16" fill="#E0E0E0" stroke="#FFFFFF" stroke-width="3" filter="url(#nodeShadow)"/>
<circle cx="1160" cy="360" r="6" fill="#FFFFFF"/>
<!-- 卡片 -->
<rect x="1080" y="430" width="160" height="120" rx="8" fill="#F8F9FA" stroke="#B0BEC5" stroke-width="2" filter="url(#cardShadow)"/>
<text x="1160" y="460" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="600" fill="#607D8B" text-anchor="middle">
<tspan>2026 Q1</tspan>
</text>
<text x="1160" y="485" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#5D6D7E" text-anchor="middle">
<tspan>规模化</tspan>
</text>
<text x="1160" y="510" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="middle">
<tspan>国际化扩张</tspan>
</text>
<text x="1160" y="530" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#7F8C8D" text-anchor="middle">
<tspan>生态建设</tspan>
</text>
<line x1="1160" y1="380" x2="1160" y2="410" stroke="#B0BEC5" stroke-width="2" stroke-dasharray="4,4"/>
</g>
</g>
<!-- 图例 -->
<g id="legend">
<circle cx="80" cy="635" r="10" fill="url(#nodeGrad2)"/>
<path d="M 75,635 L 78,640 L 85,625" stroke="#FFFFFF" stroke-width="2" fill="none" stroke-linecap="round"/>
<text x="100" y="640" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#5D6D7E">
<tspan>已完成</tspan>
</text>
<circle cx="200" cy="635" r="10" fill="url(#nodeGrad1)"/>
<circle cx="200" cy="635" r="4" fill="#FFFFFF"/>
<text x="220" y="640" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#5D6D7E">
<tspan>进行中</tspan>
</text>
<circle cx="310" cy="635" r="10" fill="#E0E0E0"/>
<circle cx="310" cy="635" r="4" fill="#FFFFFF"/>
<text x="330" y="640" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#5D6D7E">
<tspan>计划中</tspan>
</text>
</g>
<!-- 数据来源 -->
<text x="60" y="695" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#95A5A6">
<tspan>数据来源: 产品管理部门</tspan>
</text>
</svg>
templates/charts/treemap_chart.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" width="1280" height="720">
<!--
矩形树图模板
用途: 层级数据占比可视化
适用场景: 磁盘空间分析、预算分配、市场份额、组织结构
支持: 多层级矩形嵌套
-->
<defs>
<!-- 阴影 -->
<filter id="treemapShadow">
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
<feOffset dx="0" dy="1"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.15"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- 背景 -->
<rect width="1280" height="720" fill="#FFFFFF"/>
<!-- 标题 -->
<text x="60" y="55" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="32" font-weight="bold" fill="#2C3E50">
<tspan>年度预算分配总览</tspan>
</text>
<text x="60" y="90" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" fill="#7F8C8D">
<tspan>2025年度预算 · 总额 1,200万元 · 面积代表预算占比</tspan>
</text>
<!-- 矩形树图区域 -->
<g id="treemapArea" transform="translate(60, 120)">
<!--
总面积: 1000×480
主要分类及占比:
1. 人力成本 40% (400×480 左侧)
2. 运营成本 25% (右上 600×200)
3. 营销费用 20% (右中 600×160)
4. 研发投入 10% (右下左 300×120)
5. 其他支出 5% (右下右 300×120)
-->
<!-- 1. 人力成本 40% - 蓝色系 -->
<g id="hr_cost">
<rect x="0" y="0" width="400" height="480" rx="6" fill="#1976D2" filter="url(#treemapShadow)"/>
<!-- 子分类 -->
<!-- 1.1 薪资福利 70% of 40% = 28% (400×336) -->
<rect x="4" y="4" width="392" height="328" rx="4" fill="#2196F3"/>
<text x="200" y="140" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="20" font-weight="bold" fill="#FFFFFF" text-anchor="middle"><tspan>薪资福利</tspan></text>
<text x="200" y="170" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="32" font-weight="bold" fill="#FFFFFF" text-anchor="middle"><tspan>336万</tspan></text>
<text x="200" y="200" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" fill="#BBDEFB" text-anchor="middle"><tspan>28%</tspan></text>
<!-- 1.2 培训发展 15% of 40% = 6% (200×144) -->
<rect x="4" y="336" width="194" height="140" rx="4" fill="#42A5F5"/>
<text x="101" y="395" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" font-weight="600" fill="#FFFFFF" text-anchor="middle"><tspan>培训发展</tspan></text>
<text x="101" y="420" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" font-weight="bold" fill="#FFFFFF" text-anchor="middle"><tspan>72万</tspan></text>
<text x="101" y="442" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#BBDEFB" text-anchor="middle"><tspan>6%</tspan></text>
<!-- 1.3 招聘成本 15% of 40% = 6% (200×144) -->
<rect x="202" y="336" width="194" height="140" rx="4" fill="#64B5F6"/>
<text x="299" y="395" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" font-weight="600" fill="#FFFFFF" text-anchor="middle"><tspan>招聘成本</tspan></text>
<text x="299" y="420" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="18" font-weight="bold" fill="#FFFFFF" text-anchor="middle"><tspan>72万</tspan></text>
<text x="299" y="442" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#BBDEFB" text-anchor="middle"><tspan>6%</tspan></text>
<!-- 人力成本总标签 -->
<text x="200" y="30" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#BBDEFB" text-anchor="middle"><tspan>人力成本 480万 (40%)</tspan></text>
</g>
<!-- 2. 运营成本 25% - 绿色系 -->
<g id="operation_cost">
<rect x="404" y="0" width="596" height="200" rx="6" fill="#388E3C" filter="url(#treemapShadow)"/>
<!-- 2.1 办公场地 50% of 25% = 12.5% -->
<rect x="408" y="4" width="290" height="192" rx="4" fill="#4CAF50"/>
<text x="553" y="85" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" font-weight="600" fill="#FFFFFF" text-anchor="middle"><tspan>办公场地</tspan></text>
<text x="553" y="115" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="24" font-weight="bold" fill="#FFFFFF" text-anchor="middle"><tspan>150万</tspan></text>
<text x="553" y="140" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#C8E6C9" text-anchor="middle"><tspan>12.5%</tspan></text>
<!-- 2.2 设备采购 30% of 25% = 7.5% -->
<rect x="702" y="4" width="175" height="192" rx="4" fill="#66BB6A"/>
<text x="790" y="85" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" font-weight="600" fill="#FFFFFF" text-anchor="middle"><tspan>设备采购</tspan></text>
<text x="790" y="112" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="bold" fill="#FFFFFF" text-anchor="middle"><tspan>90万</tspan></text>
<text x="790" y="135" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#C8E6C9" text-anchor="middle"><tspan>7.5%</tspan></text>
<!-- 2.3 日常运营 20% of 25% = 5% -->
<rect x="881" y="4" width="115" height="192" rx="4" fill="#81C784"/>
<text x="939" y="80" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="12" font-weight="600" fill="#FFFFFF" text-anchor="middle"><tspan>日常</tspan></text>
<text x="939" y="97" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="12" font-weight="600" fill="#FFFFFF" text-anchor="middle"><tspan>运营</tspan></text>
<text x="939" y="125" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#FFFFFF" text-anchor="middle"><tspan>60万</tspan></text>
<text x="939" y="145" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="12" fill="#C8E6C9" text-anchor="middle"><tspan>5%</tspan></text>
</g>
<!-- 3. 营销费用 20% - 橙色系 -->
<g id="marketing_cost">
<rect x="404" y="204" width="596" height="156" rx="6" fill="#F57C00" filter="url(#treemapShadow)"/>
<!-- 3.1 广告投放 60% of 20% = 12% -->
<rect x="408" y="208" width="350" height="148" rx="4" fill="#FF9800"/>
<text x="583" y="270" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" font-weight="600" fill="#FFFFFF" text-anchor="middle"><tspan>广告投放</tspan></text>
<text x="583" y="300" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="22" font-weight="bold" fill="#FFFFFF" text-anchor="middle"><tspan>144万</tspan></text>
<text x="583" y="325" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#FFE0B2" text-anchor="middle"><tspan>12%</tspan></text>
<!-- 3.2 品牌活动 40% of 20% = 8% -->
<rect x="762" y="208" width="234" height="148" rx="4" fill="#FFB74D"/>
<text x="879" y="270" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" font-weight="600" fill="#E65100" text-anchor="middle"><tspan>品牌活动</tspan></text>
<text x="879" y="298" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="20" font-weight="bold" fill="#E65100" text-anchor="middle"><tspan>96万</tspan></text>
<text x="879" y="322" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="13" fill="#E65100" text-anchor="middle"><tspan>8%</tspan></text>
</g>
<!-- 4. 研发投入 10% - 紫色系 -->
<g id="rd_cost">
<rect x="404" y="364" width="296" height="116" rx="6" fill="#7B1FA2" filter="url(#treemapShadow)"/>
<rect x="408" y="368" width="288" height="108" rx="4" fill="#9C27B0"/>
<text x="552" y="410" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" font-weight="600" fill="#FFFFFF" text-anchor="middle"><tspan>研发投入</tspan></text>
<text x="552" y="440" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="24" font-weight="bold" fill="#FFFFFF" text-anchor="middle"><tspan>120万</tspan></text>
<text x="552" y="465" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#E1BEE7" text-anchor="middle"><tspan>10%</tspan></text>
</g>
<!-- 5. 其他支出 5% - 灰色系 -->
<g id="other_cost">
<rect x="704" y="364" width="296" height="116" rx="6" fill="#455A64" filter="url(#treemapShadow)"/>
<rect x="708" y="368" width="288" height="108" rx="4" fill="#607D8B"/>
<text x="852" y="410" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="16" font-weight="600" fill="#FFFFFF" text-anchor="middle"><tspan>其他支出</tspan></text>
<text x="852" y="440" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="24" font-weight="bold" fill="#FFFFFF" text-anchor="middle"><tspan>60万</tspan></text>
<text x="852" y="465" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#B0BEC5" text-anchor="middle"><tspan>5%</tspan></text>
</g>
</g>
<!-- 图例 -->
<g id="legend" transform="translate(1080, 130)">
<rect x="0" y="0" width="160" height="240" rx="8" fill="#F8F9FA" stroke="#E0E0E0" stroke-width="1"/>
<text x="80" y="28" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" font-weight="600" fill="#2C3E50" text-anchor="middle"><tspan>预算分类</tspan></text>
<line x1="15" y1="42" x2="145" y2="42" stroke="#E0E0E0" stroke-width="1"/>
<rect x="20" y="55" width="20" height="20" rx="3" fill="#2196F3"/>
<text x="50" y="70" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#5D6D7E"><tspan>人力成本 40%</tspan></text>
<rect x="20" y="85" width="20" height="20" rx="3" fill="#4CAF50"/>
<text x="50" y="100" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#5D6D7E"><tspan>运营成本 25%</tspan></text>
<rect x="20" y="115" width="20" height="20" rx="3" fill="#FF9800"/>
<text x="50" y="130" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#5D6D7E"><tspan>营销费用 20%</tspan></text>
<rect x="20" y="145" width="20" height="20" rx="3" fill="#9C27B0"/>
<text x="50" y="160" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#5D6D7E"><tspan>研发投入 10%</tspan></text>
<rect x="20" y="175" width="20" height="20" rx="3" fill="#607D8B"/>
<text x="50" y="190" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#5D6D7E"><tspan>其他支出 5%</tspan></text>
<line x1="15" y1="208" x2="145" y2="208" stroke="#E0E0E0" stroke-width="1"/>
<text x="20" y="228" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="12" fill="#7F8C8D"><tspan>总预算: 1,200万</tspan></text>
</g>
<!-- 说明 -->
<g id="note" transform="translate(1080, 390)">
<rect x="0" y="0" width="160" height="80" rx="8" fill="#E3F2FD" stroke="#2196F3" stroke-width="1"/>
<text x="15" y="25" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="12" font-weight="600" fill="#1976D2"><tspan>📊 图表说明</tspan></text>
<text x="15" y="45" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="11" fill="#5D6D7E"><tspan>矩形面积代表预算</tspan></text>
<text x="15" y="62" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="11" fill="#5D6D7E"><tspan>占比大小,颜色区</tspan></text>
<text x="15" y="75" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="11" fill="#5D6D7E"><tspan>分不同预算类别</tspan></text>
</g>
<!-- 数据来源 -->
<text x="60" y="695" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#95A5A6">
<tspan>数据来源: 财务部门年度预算报告</tspan>
</text>
</svg>
templates/charts/waterfall_chart.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" width="1280" height="720">
<!--
瀑布图模板
用途: 增减变化分析、财务分解
适用场景: 利润分解、预算变化、成本分析、收入构成
支持: 起始值 + 6个变化项 + 结束值
-->
<defs>
<!-- 增加项渐变(绿色) -->
<linearGradient id="increaseGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#4CAF50;stop-opacity:1" />
<stop offset="100%" style="stop-color:#388E3C;stop-opacity:1" />
</linearGradient>
<!-- 减少项渐变(红色) -->
<linearGradient id="decreaseGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#EF5350;stop-opacity:1" />
<stop offset="100%" style="stop-color:#D32F2F;stop-opacity:1" />
</linearGradient>
<!-- 总计项渐变(蓝色) -->
<linearGradient id="totalGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1976D2;stop-opacity:1" />
</linearGradient>
<!-- 阴影 -->
<filter id="barShadow">
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
<feOffset dx="0" dy="1"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.25"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- 背景 -->
<rect width="1280" height="720" fill="#FFFFFF"/>
<!-- 标题 -->
<text x="60" y="70" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="32" font-weight="bold" fill="#2C3E50">
<tspan>年度利润变化分析</tspan>
</text>
<text x="60" y="105" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="18" fill="#7F8C8D">
<tspan>2024年 vs 2025年 · 单位:百万元</tspan>
</text>
<!-- 图例 -->
<g id="legend" transform="translate(880, 70)">
<rect x="0" y="0" width="20" height="20" rx="3" fill="url(#totalGrad)"/>
<text x="28" y="15" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#5D6D7E">
<tspan>起止值</tspan>
</text>
<rect x="100" y="0" width="20" height="20" rx="3" fill="url(#increaseGrad)"/>
<text x="128" y="15" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#5D6D7E">
<tspan>增加</tspan>
</text>
<rect x="190" y="0" width="20" height="20" rx="3" fill="url(#decreaseGrad)"/>
<text x="218" y="15" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#5D6D7E">
<tspan>减少</tspan>
</text>
</g>
<!-- 图表区域 -->
<g id="chartArea">
<!-- Y轴网格线 -->
<line x1="140" y1="560" x2="1200" y2="560" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="140" y1="480" x2="1200" y2="480" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="140" y1="400" x2="1200" y2="400" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="140" y1="320" x2="1200" y2="320" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="140" y1="240" x2="1200" y2="240" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="140" y1="160" x2="1200" y2="160" stroke="#E0E0E0" stroke-width="1" stroke-dasharray="4,4"/>
<!-- Y轴刻度标签 -->
<text x="125" y="565" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end"><tspan>0</tspan></text>
<text x="125" y="485" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end"><tspan>100</tspan></text>
<text x="125" y="405" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end"><tspan>200</tspan></text>
<text x="125" y="325" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end"><tspan>300</tspan></text>
<text x="125" y="245" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end"><tspan>400</tspan></text>
<text x="125" y="165" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" fill="#7F8C8D" text-anchor="end"><tspan>500</tspan></text>
<!-- 坐标轴 -->
<line x1="140" y1="130" x2="140" y2="560" stroke="#95A5A6" stroke-width="2"/>
<line x1="140" y1="560" x2="1200" y2="560" stroke="#95A5A6" stroke-width="2"/>
<!--
数据说明:
- 2024年利润: 280M (起始)
- 营收增长: +120M
- 新产品: +65M
- 原材料涨价: -45M
- 人力成本: -35M
- 营销投入: -25M
- 运营优化: +40M
- 2025年利润: 400M (结束)
每100M = 80px
柱宽: 90px, 间距: 40px
-->
<!-- 柱1: 2024年利润 280M(起始值,从0开始) -->
<rect x="175" y="336" width="90" height="224" rx="4" fill="url(#totalGrad)" filter="url(#barShadow)"/>
<text x="220" y="320" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>280</tspan>
</text>
<text x="220" y="600" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#5D6D7E" text-anchor="middle">
<tspan>2024年</tspan>
</text>
<text x="220" y="618" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#5D6D7E" text-anchor="middle">
<tspan>利润</tspan>
</text>
<!-- 连接线 -->
<line x1="265" y1="336" x2="305" y2="336" stroke="#BDC3C7" stroke-width="1.5" stroke-dasharray="4,2"/>
<!-- 柱2: 营收增长 +120M(从280开始向上) -->
<rect x="305" y="240" width="90" height="96" rx="4" fill="url(#increaseGrad)" filter="url(#barShadow)"/>
<text x="350" y="225" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="15" font-weight="600" fill="#4CAF50" text-anchor="middle">
<tspan>+120</tspan>
</text>
<text x="350" y="600" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#5D6D7E" text-anchor="middle">
<tspan>营收增长</tspan>
</text>
<!-- 连接线 -->
<line x1="395" y1="240" x2="435" y2="240" stroke="#BDC3C7" stroke-width="1.5" stroke-dasharray="4,2"/>
<!-- 柱3: 新产品 +65M(从400开始向上) -->
<rect x="435" y="188" width="90" height="52" rx="4" fill="url(#increaseGrad)" filter="url(#barShadow)"/>
<text x="480" y="173" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="15" font-weight="600" fill="#4CAF50" text-anchor="middle">
<tspan>+65</tspan>
</text>
<text x="480" y="600" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#5D6D7E" text-anchor="middle">
<tspan>新产品</tspan>
</text>
<!-- 连接线 -->
<line x1="525" y1="188" x2="565" y2="188" stroke="#BDC3C7" stroke-width="1.5" stroke-dasharray="4,2"/>
<!-- 柱4: 原材料涨价 -45M(从465开始向下) -->
<rect x="565" y="188" width="90" height="36" rx="4" fill="url(#decreaseGrad)" filter="url(#barShadow)"/>
<text x="610" y="240" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="15" font-weight="600" fill="#EF5350" text-anchor="middle">
<tspan>-45</tspan>
</text>
<text x="610" y="600" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#5D6D7E" text-anchor="middle">
<tspan>原材料</tspan>
</text>
<text x="610" y="618" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#5D6D7E" text-anchor="middle">
<tspan>涨价</tspan>
</text>
<!-- 连接线 -->
<line x1="655" y1="224" x2="695" y2="224" stroke="#BDC3C7" stroke-width="1.5" stroke-dasharray="4,2"/>
<!-- 柱5: 人力成本 -35M(从420开始向下) -->
<rect x="695" y="224" width="90" height="28" rx="4" fill="url(#decreaseGrad)" filter="url(#barShadow)"/>
<text x="740" y="268" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="15" font-weight="600" fill="#EF5350" text-anchor="middle">
<tspan>-35</tspan>
</text>
<text x="740" y="600" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#5D6D7E" text-anchor="middle">
<tspan>人力成本</tspan>
</text>
<!-- 连接线 -->
<line x1="785" y1="252" x2="825" y2="252" stroke="#BDC3C7" stroke-width="1.5" stroke-dasharray="4,2"/>
<!-- 柱6: 营销投入 -25M(从385开始向下) -->
<rect x="825" y="252" width="90" height="20" rx="4" fill="url(#decreaseGrad)" filter="url(#barShadow)"/>
<text x="870" y="288" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="15" font-weight="600" fill="#EF5350" text-anchor="middle">
<tspan>-25</tspan>
</text>
<text x="870" y="600" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#5D6D7E" text-anchor="middle">
<tspan>营销投入</tspan>
</text>
<!-- 连接线 -->
<line x1="915" y1="272" x2="955" y2="272" stroke="#BDC3C7" stroke-width="1.5" stroke-dasharray="4,2"/>
<!-- 柱7: 运营优化 +40M(从360开始向上) -->
<rect x="955" y="240" width="90" height="32" rx="4" fill="url(#increaseGrad)" filter="url(#barShadow)"/>
<text x="1000" y="225" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="15" font-weight="600" fill="#4CAF50" text-anchor="middle">
<tspan>+40</tspan>
</text>
<text x="1000" y="600" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#5D6D7E" text-anchor="middle">
<tspan>运营优化</tspan>
</text>
<!-- 连接线 -->
<line x1="1045" y1="240" x2="1085" y2="240" stroke="#BDC3C7" stroke-width="1.5" stroke-dasharray="4,2"/>
<!-- 柱8: 2025年利润 400M(结束值,从0开始) -->
<rect x="1085" y="240" width="90" height="320" rx="4" fill="url(#totalGrad)" filter="url(#barShadow)"/>
<text x="1130" y="225" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="600" fill="#2C3E50" text-anchor="middle">
<tspan>400</tspan>
</text>
<text x="1130" y="600" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#5D6D7E" text-anchor="middle">
<tspan>2025年</tspan>
</text>
<text x="1130" y="618" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#5D6D7E" text-anchor="middle">
<tspan>利润</tspan>
</text>
</g>
<!-- 总结卡片 -->
<g id="summaryCard" transform="translate(60, 640)">
<rect x="0" y="0" width="500" height="50" rx="8" fill="#E8F5E9" stroke="#4CAF50" stroke-width="1"/>
<text x="20" y="22" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#388E3C">
<tspan>📈 年度利润增长:</tspan>
</text>
<text x="150" y="22" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="16" font-weight="bold" fill="#2E7D32">
<tspan>+120M (+42.9%)</tspan>
</text>
<text x="20" y="40" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="13" fill="#5D6D7E">
<tspan>增加项合计: +225M | 减少项合计: -105M | 净增长: +120M</tspan>
</text>
</g>
<!-- 数据来源 -->
<text x="1220" y="695" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
font-size="14" fill="#95A5A6" text-anchor="end">
<tspan>数据来源: 财务部门</tspan>
</text>
</svg>
templates/design_spec_reference.md
# {project_name} - Design Spec
## I. Project Information
| Item | Value |
| ---- | ----- |
| **Project Name** | {project_name} |
| **Canvas Format** | {canvas_info['name']} ({canvas_info['dimensions']}) |
| **Page Count** | [Filled by Strategist] |
| **Design Style** | {design_style} |
| **Target Audience** | [Filled by Strategist] |
| **Use Case** | [Filled by Strategist] |
| **Created Date** | {date_str} |
---
## II. Canvas Specification
| Property | Value |
| -------- | ----- |
| **Format** | {canvas_info['name']} |
| **Dimensions** | {canvas_info['dimensions']} |
| **viewBox** | `{canvas_info['viewbox']}` |
| **Margins** | [Recommended by Strategist, e.g., left/right 60px, top/bottom 50px] |
| **Content Area** | [Calculated from canvas] |
---
## III. Visual Theme
### Theme Style
- **Style**: {design_style}
- **Theme**: [Light theme / Dark theme]
- **Tone**: [Filled by Strategist, e.g., tech, professional, modern, innovative]
### Color Scheme
> Strategist should determine specific color values based on project content, industry, and brand colors
| Role | HEX | Purpose |
| ---- | --- | ------- |
| **Background** | `#......` | Page background (light theme typically white; dark theme dark gray/navy) |
| **Secondary bg** | `#......` | Card background, section background |
| **Primary** | `#......` | Title decorations, key sections, icons |
| **Accent** | `#......` | Data highlights, key information, links |
| **Secondary accent** | `#......` | Secondary emphasis, gradient transitions |
| **Body text** | `#......` | Main body text (dark theme uses light text) |
| **Secondary text** | `#......` | Captions, annotations |
| **Tertiary text** | `#......` | Supplementary info, footers |
| **Border/divider** | `#......` | Card borders, divider lines |
| **Success** | `#......` | Positive indicators (green family) |
| **Warning** | `#......` | Issue markers (red family) |
> **Reference**: Industry colors in `references/strategist.md` or `scripts/config.py` under `INDUSTRY_COLORS`
### Gradient Scheme (if needed, using SVG syntax)
```xml
<!-- Title gradient -->
<linearGradient id="titleGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#[primary]"/>
<stop offset="100%" stop-color="#[secondary accent]"/>
</linearGradient>
<!-- Background decorative gradient (note: rgba forbidden, use stop-opacity) -->
<radialGradient id="bgDecor" cx="80%" cy="20%" r="50%">
<stop offset="0%" stop-color="#[primary]" stop-opacity="0.15"/>
<stop offset="100%" stop-color="#[primary]" stop-opacity="0"/>
</radialGradient>
```
---
## IV. Typography System
### Font Plan
> Strategist should select a font preset based on content characteristics, or customize the font combination
> Preset descriptions: P1=Modern business/tech | P2=Government docs | P3=Culture/arts | P4=Traditional/conservative | P5=English-primary
**Recommended preset**: [Fill in preset code]
| Role | Chinese | English | Fallback |
| ---- | ------- | ------- | -------- |
| **Title** | [font name] | [font name] | [font name] |
| **Body** | [font name] | [font name] | [font name] |
| **Code** | - | Consolas | Monaco |
| **Emphasis** | [font name] | [font name] | [font name] |
**Font stack**: `[Fill in CSS font-family string]`
> **Reference**: Font categories in `references/design-guidelines.md`
### Font Size Hierarchy
> **Design principle**: Use body font size as baseline (1x), derive other levels proportionally
> **Unit convention**: Use px uniformly (SVG native unit) to avoid pt/px conversion errors
> **Selection principle**: Font size is based on **content density**, not design style
**Baseline**: Body font size = [fill in]px (choose 18-24px based on content density)
| Purpose | Ratio | 24px baseline (relaxed) | 18px baseline (dense) | Weight |
| ------- | ----- | ---------------------- | -------------------- | ------ |
| Cover title | 2.5-3x | 60-72px | 45-54px | Bold |
| Chapter title | 2-2.5x | 48-60px | 36-45px | Bold |
| Content title | 1.5-2x | 36-48px | 27-36px | Bold |
| Subtitle | 1.2-1.5x | 29-36px | 22-27px | SemiBold |
| **Body content** | **1x** | **24px** | **18px** | Regular |
| Annotation | 0.75-0.85x | 18-20px | 14-15px | Regular |
| Page number/date | 0.55-0.65x | 13-16px | 10-12px | Regular |
> **Tip**: Dense content (6+ points per page) use 18px; relaxed content (3-5 points per page) use 24px
---
## V. Layout Principles
### Page Structure
- **Header area**: [Height and content description]
- **Content area**: [Height and content description]
- **Footer area**: [Height and content description]
### Common Layout Modes
| Mode | Suitable Scenarios |
| ---- | ----------------- |
| **Single column centered** | Covers, conclusions, key points |
| **Left-right split (5:5)** | Comparisons, dual concepts |
| **Left-right split (4:6)** | Image-text mix |
| **Top-bottom split** | Processes, timelines |
| **Three/four column cards** | Feature lists, team introductions |
| **Matrix grid** | Comparative analysis, classifications |
### Spacing Specification
> Strategist may adjust based on project needs
| Element | Recommended Range | Current Project |
| ------- | ---------------- | --------------- |
| Card gap | 20-32px | [fill in] |
| Content block gap | 24-40px | [fill in] |
| Card padding | 20-32px | [fill in] |
| Card border radius | 8-16px | [fill in] |
| Icon-text gap | 8-16px | [fill in] |
---
## VI. Icon Usage Specification
### Source
- **Built-in icon library**: `templates/icons/` (640+ icons)
- **Usage method**: Placeholder format `{{icon:category/icon-name}}`
### Recommended Icon List (fill as needed)
| Purpose | Icon Path | Page |
| ------- | --------- | ---- |
| [example] | `{{icon:interface/check-circle}}` | Slide XX |
---
## VII. Chart Reference List (if needed)
> When the presentation includes data visualization, Strategist selects chart types from `templates/charts/charts_index.json` and lists them here for the Executor to reference.
| Chart Type | Reference Template | Used In |
| ---------- | ------------------ | ------- |
| [e.g. grouped_bar_chart] | `templates/charts/grouped_bar_chart.svg` | Slide 05 |
---
## VIII. Image Resource List (if needed)
| Filename | Dimensions | Ratio | Purpose | Type | Status | Generation Description |
| -------- | --------- | ----- | ------- | ---- | ------ | --------------------- |
| cover_bg.png | {canvas_info['dimensions']} | [ratio] | Cover background | [Background/Photography/Illustration/Diagram/Decorative] | [Pending/Existing/Placeholder] | [AI generation prompt] |
**Status descriptions**:
- **Pending** - Needs AI generation, provide detailed description
- **Existing** - User already has image, place in `images/`
- **Placeholder** - Not yet processed, use dashed border placeholder in SVG
**Type descriptions** (used by Image_Generator for prompt strategy selection):
- **Background** - Full-page background for covers/chapters, reserve text area
- **Photography** - Real scenes, people, products, architecture
- **Illustration** - Flat design, vector style, cartoon, concept diagrams
- **Diagram** - Flowcharts, architecture diagrams, concept maps
- **Decorative** - Partial decorations, textures, borders, dividers
---
## IX. Content Outline
### Part 1: [Chapter Name]
#### Slide 01 - Cover
- **Layout**: Full-screen background image + centered title
- **Title**: [Main title]
- **Subtitle**: [Subtitle]
- **Info**: [Author / Date / Organization]
#### Slide 02 - [Page Name]
- **Layout**: [Choose layout mode]
- **Title**: [Page title]
- **Chart**: [chart_type] (see VII. Chart Reference List)
- **Content**:
- [Point 1]
- [Point 2]
- [Point 3]
> **Chart field**: Only add when the page includes data visualization. Chart type must be listed in section VII.
---
[Strategist continues adding more pages based on source document content and page count planning...]
---
## X. Speaker Notes Requirements
Generate corresponding speaker note files for each page, saved to the `notes/` directory:
- **File naming**: Match SVG names, e.g., `01_cover.md`
- **Content includes**: Script key points, timing cues, transition phrases
---
## XI. Technical Constraints Reminder
### SVG Generation Must Follow:
1. viewBox: `{canvas_info['viewbox']}`
2. Background uses `<rect>` elements
3. Text wrapping uses `<tspan>` (`<foreignObject>` FORBIDDEN)
4. Transparency uses `fill-opacity` / `stroke-opacity`; `rgba()` FORBIDDEN
5. FORBIDDEN: `clipPath`, `mask`, `<style>`, `class`, `foreignObject`
6. FORBIDDEN: `textPath`, `animate*`, `script`, `marker`/`marker-end`
7. Arrows use `<polygon>` triangles instead of `<marker>`
### PPT Compatibility Rules:
- `<g opacity="...">` FORBIDDEN (group opacity); set on each child element individually
- Image transparency uses overlay mask layer (`<rect fill="bg-color" opacity="0.x"/>`)
- Inline styles only; external CSS and `@font-face` FORBIDDEN
---
## XII. Design Checklist
### Pre-generation
- [ ] Content fits page capacity
- [ ] Layout mode selected correctly
- [ ] Colors used semantically
### Post-generation
- [ ] viewBox = `{canvas_info['viewbox']}`
- [ ] No `<foreignObject>` elements
- [ ] All text readable (>=14px)
- [ ] Content within safe area
- [ ] All elements aligned to grid
- [ ] Same elements maintain consistent style
- [ ] Colors conform to spec
- [ ] CRAP four-principle check passed
---
## XIII. Next Steps
1. ✅ Design spec complete
2. **Next step**: [Choose based on image approach]
- No AI images → Invoke **Executor** role to generate SVGs
- Has AI images → Invoke **Image_Generator** role, then invoke Executor after completion
templates/icons/a.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.42436 0H9.57565L14.995 16H11.8276L10.8115 13H5.18855L4.17242 16H1.005L6.42436 0ZM6.20468 10H9.79533L8 4.69952L6.20468 10Z" fill="#000000"/>
</svg>
templates/icons/accessibility.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 8C16 12.4183 12.4183 16 8 16C3.58172 16 0 12.4183 0 8C0 3.58172 3.58172 0 8 0C12.4183 0 16 3.58172 16 8ZM9.25 3.75C9.25 4.44036 8.69036 5 8 5C7.30964 5 6.75 4.44036 6.75 3.75C6.75 3.05964 7.30964 2.5 8 2.5C8.69036 2.5 9.25 3.05964 9.25 3.75ZM12 8H9.41901L11.2047 13H9.081L8 9.97321L6.91901 13H4.79528L6.581 8H4V6H12V8Z" fill="#000000"/>
</svg>
templates/icons/activity.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 1H1V7H3.38197L4.88196 4L7.11803 4L10 9.76393L11.382 7H15V1Z" fill="#000000"/>
<path d="M15 9H12.618L11.118 12L8.88197 12L6 6.23607L4.61803 9H1V15H15V9Z" fill="#000000"/>
</svg>
templates/icons/address-card.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 2H0V14H16V2ZM5 10.5C6.38071 10.5 7.5 9.38071 7.5 8C7.5 6.61929 6.38071 5.5 5 5.5C3.61929 5.5 2.5 6.61929 2.5 8C2.5 9.38071 3.61929 10.5 5 10.5ZM10 5H14V7H10V5ZM14 9H10V11H14V9Z" fill="#000000"/>
</svg>
templates/icons/alarm-clock.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.95706 1.45712L1.45706 3.95712L0.0428467 2.54291L2.54285 0.0429077L3.95706 1.45712Z" fill="#000000"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.4999 8.50003C14.4999 9.80523 14.1152 11.0206 13.453 12.0389L15.707 14.2929L14.2928 15.7071L12.1163 13.5307C10.9954 14.449 9.56199 15 7.99986 15C6.43776 15 5.00436 14.449 3.88344 13.5307L1.70701 15.7071L0.2928 14.2929L2.54678 12.0389C1.88457 11.0206 1.49986 9.80525 1.49986 8.50003C1.49986 4.91018 4.41001 2.00003 7.99986 2.00003C11.5897 2.00003 14.4999 4.91018 14.4999 8.50003ZM6.99995 5V9.41421L9.29285 11.7071L10.7071 10.2929L8.99995 8.58579V5H6.99995Z" fill="#000000"/>
<path d="M14.5428 3.95714L12.0428 1.45714L13.457 0.0429222L15.957 2.54292L14.5428 3.95714Z" fill="#000000"/>
</svg>
templates/icons/alien.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 16L3.54223 12.3383C1.93278 11.0162 1 9.04287 1 6.96005C1 3.11612 4.15607 0 8 0C11.8439 0 15 3.11612 15 6.96005C15 9.04287 14.0672 11.0162 12.4578 12.3383L8 16ZM3 6H5C6.10457 6 7 6.89543 7 8V9L3 7.5V6ZM11 6C9.89543 6 9 6.89543 9 8V9L13 7.5V6H11Z" fill="#000000"/>
</svg>
templates/icons/align-bottom.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13 11V1H9L9 11H13Z" fill="#000000"/>
<path d="M15 15V13L1 13V15L15 15Z" fill="#000000"/>
<path d="M7 5L7 11H3L3 5H7Z" fill="#000000"/>
</svg>
templates/icons/align-center-horizontal.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 7H13V1H9L9 15H13V9H16V7Z" fill="#000000"/>
<path d="M7 12H3V9H0V7H3V4L7 4L7 12Z" fill="#000000"/>
</svg>
templates/icons/align-center-vertical.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 0H7V3H4V7H12V3H9V0Z" fill="#000000"/>
<path d="M1 13V9H15V13H9V16H7V13H1Z" fill="#000000"/>
</svg>
templates/icons/align-left.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1H3V15H1V1Z" fill="#000000"/>
<path d="M5 13H15V9H5V13Z" fill="#000000"/>
<path d="M11 7H5V3H11V7Z" fill="#000000"/>
</svg>
templates/icons/align-right.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 1H13V15H15V1Z" fill="#000000"/>
<path d="M11 13H1V9H11V13Z" fill="#000000"/>
<path d="M5 7H11V3H5V7Z" fill="#000000"/>
</svg>
templates/icons/align-text-center.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13 1H3V3H13V1Z" fill="#000000"/>
<path d="M1 5H15V7H1V5Z" fill="#000000"/>
<path d="M13 9H3V11H13V9Z" fill="#000000"/>
<path d="M15 13H1V15H15V13Z" fill="#000000"/>
</svg>
templates/icons/align-text-justify.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 1H1V3H15V1Z" fill="#000000"/>
<path d="M1 5H15V7H1V5Z" fill="#000000"/>
<path d="M15 9H1V11H15V9Z" fill="#000000"/>
<path d="M11 13H1V15H11V13Z" fill="#000000"/>
</svg>
templates/icons/align-text-left.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 1H1V3H11V1Z" fill="#000000"/>
<path d="M1 5H15V7H1V5Z" fill="#000000"/>
<path d="M11 9H1V11H11V9Z" fill="#000000"/>
<path d="M15 13H1V15H15V13Z" fill="#000000"/>
</svg>
templates/icons/align-text-right.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 1H5V3H15V1Z" fill="#000000"/>
<path d="M1 5H15V7H1V5Z" fill="#000000"/>
<path d="M15 9H5V11H15V9Z" fill="#000000"/>
<path d="M15 13H1V15H15V13Z" fill="#000000"/>
</svg>
templates/icons/align-top.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 1V3L1 3V1H15Z" fill="#000000"/>
<path d="M13 5V15H9L9 5H13Z" fill="#000000"/>
<path d="M7 11L7 5H3L3 11H7Z" fill="#000000"/>
</svg>
templates/icons/anchor.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 3C11 4.30622 10.1652 5.41746 9 5.82929V13.917C11.5125 13.4955 13.4955 11.5125 13.917 9H12V7H16V8C16 12.4183 12.4183 16 8 16C3.58172 16 0 12.4183 0 8V7H4V9H2.08296C2.50448 11.5125 4.48749 13.4955 7 13.917V5.82929C5.83481 5.41746 5 4.30622 5 3C5 1.34315 6.34315 0 8 0C9.65685 0 11 1.34315 11 3Z" fill="#000000"/>
</svg>
templates/icons/angle-down.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.00003 8.1716L3.41424 3.58582L0.585815 6.41424L8.00003 13.8285L15.4142 6.41424L12.5858 3.58582L8.00003 8.1716Z" fill="#000000"/>
</svg>
templates/icons/angle-left.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.82842 8.00003L12.4142 3.41424L9.58578 0.585815L2.17157 8.00003L9.58578 15.4142L12.4142 12.5858L7.82842 8.00003Z" fill="#000000"/>
</svg>
templates/icons/angle-right.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.1716 8.00003L3.58582 3.41424L6.41424 0.585815L13.8285 8.00003L6.41424 15.4142L3.58582 12.5858L8.1716 8.00003Z" fill="#000000"/>
</svg>
templates/icons/angle-up.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.00003 7.82842L12.5858 12.4142L15.4142 9.58578L8.00003 2.17157L0.585815 9.58578L3.41424 12.4142L8.00003 7.82842Z" fill="#000000"/>
</svg>
templates/icons/angles-down.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.70709 7.29288L7.99998 12.5858L13.2929 7.29288L14.7071 8.70709L7.99998 15.4142L1.29288 8.70709L2.70709 7.29288Z" fill="#000000"/>
<path d="M2.70709 0.792877L7.99998 6.08577L13.2929 0.792878L14.7071 2.20709L7.99998 8.9142L1.29288 2.20709L2.70709 0.792877Z" fill="#000000"/>
</svg>
templates/icons/angles-left.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.70714 13.2929L3.41424 8.00001L8.70714 2.70712L7.29292 1.29291L0.585815 8.00001L7.29292 14.7071L8.70714 13.2929Z" fill="#000000"/>
<path d="M15.2071 13.2929L9.91424 8.00001L15.2071 2.70712L13.7929 1.29291L7.08582 8.00001L13.7929 14.7071L15.2071 13.2929Z" fill="#000000"/>
</svg>
templates/icons/angles-right.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.29286 13.2929L12.5858 8.00001L7.29286 2.70712L8.70708 1.29291L15.4142 8.00001L8.70707 14.7071L7.29286 13.2929Z" fill="#000000"/>
<path d="M0.792862 13.2929L6.08576 8.00001L0.792862 2.70712L2.20708 1.29291L8.91418 8.00001L2.20707 14.7071L0.792862 13.2929Z" fill="#000000"/>
</svg>
templates/icons/angles-up.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.2929 8.70711L8 3.41421L2.70711 8.7071L1.29289 7.29289L8 0.585785L14.7071 7.29289L13.2929 8.70711Z" fill="#000000"/>
<path d="M13.2929 15.2071L8 9.91421L2.70711 15.2071L1.29289 13.7929L8 7.08578L14.7071 13.7929L13.2929 15.2071Z" fill="#000000"/>
</svg>
templates/icons/aperture.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.6906 6.00001L3.16512 1.62576C4.50811 0.605527 6.18334 0 8 0C8.37684 0 8.74759 0.0260554 9.11056 0.076463L5.6906 6.00001Z" fill="#000000"/>
<path d="M5.11325 9L1.69363 3.07705C0.632438 4.43453 0 6.14341 0 8C0 8.33866 0.0210434 8.67241 0.0618939 9H5.11325Z" fill="#000000"/>
<path d="M4.89635 15.3757C2.93947 14.5512 1.37925 12.9707 0.581517 11H7.42265L4.89635 15.3757Z" fill="#000000"/>
<path d="M8 16C7.62316 16 7.25241 15.9739 6.88944 15.9235L10.3094 10L12.8349 14.3742C11.4919 15.3945 9.81666 16 8 16Z" fill="#000000"/>
<path d="M16 8C16 9.85659 15.3676 11.5655 14.3064 12.9229L10.8868 7H15.9381C15.979 7.32759 16 7.66134 16 8Z" fill="#000000"/>
<path d="M11.1036 0.624326C13.0605 1.44877 14.6208 3.02927 15.4185 5H8.57735L11.1036 0.624326Z" fill="#000000"/>
</svg>
templates/icons/aquarius.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.50005 1.34827L8.00005 3.34827L11.5 1.34827L15.4962 3.63177L14.5039 5.36826L11.5 3.65177L8.00005 5.65177L4.50005 3.65177L1.49618 5.36826L0.503906 3.63177L4.50005 1.34827Z" fill="#000000"/>
<path d="M4.50005 10.3483L8.00005 12.3483L11.5 10.3483L15.4962 12.6318L14.5039 14.3683L11.5 12.6518L8.00005 14.6518L4.50005 12.6518L1.49618 14.3683L0.503906 12.6318L4.50005 10.3483Z" fill="#000000"/>
<path d="M4.50005 5.84827L0.503906 8.13177L1.49618 9.86826L4.50005 8.15177L8.00005 10.1518L11.5 8.15177L14.5039 9.86826L15.4962 8.13177L11.5 5.84827L8.00005 7.84827L4.50005 5.84827Z" fill="#000000"/>
</svg>
templates/icons/archive-box.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 2H0V5H16V2Z" fill="#000000"/>
<path d="M1 7H5V9H11V7H15V15H1V7Z" fill="#000000"/>
</svg>
templates/icons/aries.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 4.19056C0 2.42846 1.42846 1 3.19056 1C4.72033 1 6.03482 2.08579 6.32371 3.58803L8 12.3047L9.67629 3.58803C9.96518 2.08579 11.2797 1 12.8094 1C14.5715 1 16 2.42846 16 4.19056V6H14V4.19056C14 3.53303 13.467 3 12.8094 3C12.2386 3 11.7481 3.40516 11.6403 3.96573L9.32602 16H6.67398L4.3597 3.96573C4.2519 3.40516 3.7614 3 3.19056 3C2.53303 3 2 3.53303 2 4.19056V6H0V4.19056Z" fill="#000000"/>
</svg>
templates/icons/arrow-down-from-line.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 10L6 10L6 4L10 4L10 10L13 10V11L8 16L3 11L3 10Z" fill="#000000"/>
<path d="M2 0L14 1.90735e-06V2L2 2V0Z" fill="#000000"/>
</svg>
templates/icons/arrow-down-left.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.16421 9.66421L15.4142 3.41421L12.5858 0.585785L6.33579 6.83578L3.5 4L2 5.5V14H10.5L12 12.5L9.16421 9.66421Z" fill="#000000"/>
</svg>
templates/icons/arrow-down-right.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.66411 6.8359L3.414 0.585785L0.585571 3.41421L6.83568 9.66432L4.00002 12.5L5.50002 14H14V5.49999L12.5 3.99999L9.66411 6.8359Z" fill="#000000"/>
</svg>
templates/icons/arrow-down-short-wide.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 11H3L3 0H5L5 11H8V12L4 16L0 12V11Z" fill="#000000"/>
<path d="M16 10H10V8H16V10Z" fill="#000000"/>
<path d="M10 6H14V4H10V6Z" fill="#000000"/>
<path d="M12 2H10V0H12V2Z" fill="#000000"/>
</svg>
templates/icons/arrow-down-to-bracket.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 16H1L1 9H3L3 14H13V9H15L15 16Z" fill="#000000"/>
<path d="M12 6L9 6L9 1.74846e-07L7 0V6L4 6L4 7L8 11L12 7L12 6Z" fill="#000000"/>
</svg>
templates/icons/arrow-down-to-line.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13 7H10V0H6V7L3 7V8L8 13L13 8V7Z" fill="#000000"/>
<path d="M14 14H2V16H14V14Z" fill="#000000"/>
</svg>
templates/icons/arrow-down-wide-short.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 11H3L3 0H5L5 11H8V12L4 16L0 12V11Z" fill="#000000"/>
<path d="M16 0H10V2H16V0Z" fill="#000000"/>
<path d="M10 4H14V6H10V4Z" fill="#000000"/>
<path d="M12 8H10V10H12V8Z" fill="#000000"/>
</svg>
templates/icons/arrow-down.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 8L14 8V10L8 16L2 10V8H6V0L10 4.76995e-08V8Z" fill="#000000"/>
</svg>
templates/icons/arrow-left-arrow-right.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 16V13L16 13V11L5 11L5 8H4L3.93403e-07 12L4 16H5Z" fill="#000000"/>
<path d="M11 8L11 5L8.74227e-08 5L0 3L11 3V4.37112e-08L12 0L16 4L12 8H11Z" fill="#000000"/>
</svg>
templates/icons/arrow-left-from-line.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 3L6 6H12L12 10H6L6 13L5 13L0 8L5 3L6 3Z" fill="#000000"/>
<path d="M16 2L16 14H14L14 2L16 2Z" fill="#000000"/>
</svg>
templates/icons/arrow-left-to-line.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 13L9 10H16V6L9 6L9 3L8 3L3 8L8 13H9Z" fill="#000000"/>
<path d="M2 14L2 2L0 2L5.24537e-07 14H2Z" fill="#000000"/>
</svg>
templates/icons/arrow-left.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 10L8 14L6 14L-2.62268e-07 8L6 2L8 2L8 6L16 6L16 10L8 10Z" fill="#000000"/>
</svg>
templates/icons/arrow-right-from-bracket.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 4V7L5 7V9H11V12H12L16 8L12 4L11 4Z" fill="#000000"/>
<path d="M0 1L3.41715e-07 15H8V13H2L2 3H8L8 1L0 1Z" fill="#000000"/>
</svg>
templates/icons/arrow-right-from-line.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 3V6H4L4 10H10L10 13L11 13L16 8L11 3L10 3Z" fill="#000000"/>
<path d="M0 2L1.38281e-06 14H2L2 2L0 2Z" fill="#000000"/>
</svg>
templates/icons/arrow-right-to-bracket.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 1V15H9V13H14V3H9V1L16 1Z" fill="#000000"/>
<path d="M6 4V7L8.74229e-08 7L0 9H6V12H7L11 8L7 4H6Z" fill="#000000"/>
</svg>
templates/icons/arrow-right-to-line.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 13V10H0L1.74846e-07 6L7 6L7 3L8 3L13 8L8 13H7Z" fill="#000000"/>
<path d="M14 14V2L16 2V14H14Z" fill="#000000"/>
</svg>
templates/icons/arrow-right.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 6L8 2L10 2L16 8L10 14L8 14L8 10L-1.74845e-07 10L-3.01991e-07 6L8 6Z" fill="#000000"/>
</svg>
templates/icons/arrow-rotate-left.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 7L7 6L4.70711 3.70711L5.19868 3.21553C5.97697 2.43724 7.03256 2 8.13323 2C11.361 2 14 4.68015 14 7.93274C14 11.2589 11.3013 14 8 14C6.46292 14 4.92913 13.4144 3.75736 12.2426L2.34315 13.6569C3.90505 15.2188 5.95417 16 8 16C12.4307 16 16 12.3385 16 7.93274C16 3.60052 12.4903 0 8.13323 0C6.50213 0 4.93783 0.647954 3.78447 1.80132L3.29289 2.29289L1 0L0 1V7H6Z" fill="#000000"/>
</svg>
templates/icons/arrow-rotate-right.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 7L9 6L11.2929 3.70711L10.8013 3.21553C10.023 2.43724 8.96744 2 7.86677 2C4.63903 2 2 4.68015 2 7.93274C2 11.2589 4.69868 14 8 14C9.53708 14 11.0709 13.4144 12.2426 12.2426L13.6569 13.6569C12.095 15.2188 10.0458 16 8 16C3.56933 16 0 12.3385 0 7.93274C0 3.60052 3.50968 0 7.86677 0C9.49787 0 11.0622 0.647954 12.2155 1.80132L12.7071 2.29289L15 0L16 1V7H10Z" fill="#000000"/>
</svg>
templates/icons/arrow-trend-down.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 13L9.00001 12L11.2929 9.70712L8.50001 6.91423L5.50001 9.91423L0.292908 4.70712L1.70712 3.29291L5.50001 7.0858L8.50001 4.0858L12.7071 8.29291L15 6.00001L16 7.00001L16 13H10Z" fill="#000000"/>
</svg>
templates/icons/arrow-trend-up.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 3L9.00001 4L11.2929 6.29289L8.50001 9.08579L5.50001 6.08579L0.292908 11.2929L1.70712 12.7071L5.50001 8.91421L8.50001 11.9142L12.7071 7.70711L15 10L16 9L16 3H10Z" fill="#000000"/>
</svg>
templates/icons/arrow-turn-down-left.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 16H5L0 11L5 6H6V10H12V1H14V12H6V16Z" fill="#000000"/>
</svg>
templates/icons/arrow-turn-down-right.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 16H11L16 11L11 6H10V10H4V1H2V12H10V16Z" fill="#000000"/>
</svg>
templates/icons/arrow-turn-left-down.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.37115e-08 10L0 11L5 16L10 11V10L6 10L6 4L15 4V2L4 2L4 10H4.37115e-08Z" fill="#000000"/>
</svg>
templates/icons/arrow-turn-left-up.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.37115e-08 6L0 5L5 0L10 5V6H6L6 12H15V14H4L4 6H4.37115e-08Z" fill="#000000"/>
</svg>
templates/icons/arrow-turn-right-down.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 10V11L11 16L6 11V10L10 10V4L1 4L1 2L12 2V10H16Z" fill="#000000"/>
</svg>
templates/icons/arrow-turn-right-up.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 6V5L11 0L6 5V6H10V12H1L1 14H12V6H16Z" fill="#000000"/>
</svg>
templates/icons/arrow-turn-up-left.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 0H5L0 5L5 10H6V6H12V15H14V4H6V0Z" fill="#000000"/>
</svg>
templates/icons/arrow-turn-up-right.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 0H11L16 5L11 10H10V6H4V15H2V4H10V0Z" fill="#000000"/>
</svg>
templates/icons/arrow-u-down-left.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 15H4L0 11L4 7H5V10H11C12.6569 10 14 8.65685 14 7C14 5.34315 12.6569 4 11 4H4V2H11C13.7614 2 16 4.23858 16 7C16 9.76142 13.7614 12 11 12H5V15Z" fill="#000000"/>
</svg>
templates/icons/arrow-u-down-right.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 15H12L16 11L12 7H11V10H5C3.34315 10 2 8.65685 2 7C2 5.34315 3.34315 4 5 4H12V2H5C2.23858 2 0 4.23858 0 7C0 9.76142 2.23858 12 5 12H11V15Z" fill="#000000"/>
</svg>
templates/icons/arrow-u-left-down.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 11L1 12L5 16L9 12V11H6V5C6 3.34315 7.34315 2 9 2C10.6569 2 12 3.34315 12 5V12H14V5C14 2.23858 11.7614 3.29296e-08 9 0C6.23858 -3.29296e-08 4 2.23858 4 5V11H1Z" fill="#000000"/>
</svg>
templates/icons/arrow-u-left-up.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 5L1 4L5 0L9 4V5H6V11C6 12.6569 7.34315 14 9 14C10.6569 14 12 12.6569 12 11V4H14V11C14 13.7614 11.7614 16 9 16C6.23858 16 4 13.7614 4 11V5H1Z" fill="#000000"/>
</svg>
templates/icons/arrow-u-right-down.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 11V12L11 16L7 12V11H10V5C10 3.34315 8.65685 2 7 2C5.34315 2 4 3.34315 4 5V12H2L2 5C2 2.23858 4.23858 3.29296e-08 7 0C9.76142 -3.29296e-08 12 2.23858 12 5V11H15Z" fill="#000000"/>
</svg>
templates/icons/arrow-u-right-up.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 5V4L11 0L7 4V5H10V11C10 12.6569 8.65685 14 7 14C5.34315 14 4 12.6569 4 11V4H2L2 11C2 13.7614 4.23858 16 7 16C9.76142 16 12 13.7614 12 11V5H15Z" fill="#000000"/>
</svg>
templates/icons/arrow-u-up-left.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 1H4L0 5L4 9H5V6H11C12.6569 6 14 7.34315 14 9C14 10.6569 12.6569 12 11 12H4V14H11C13.7614 14 16 11.7614 16 9C16 6.23858 13.7614 4 11 4H5V1Z" fill="#000000"/>
</svg>
templates/icons/arrow-u-up-right.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 1H12L16 5L12 9H11V6H5C3.34315 6 2 7.34315 2 9C2 10.6569 3.34315 12 5 12H12V14H5C2.23858 14 0 11.7614 0 9C0 6.23858 2.23858 4 5 4H11V1Z" fill="#000000"/>
</svg>
templates/icons/arrow-up-arrow-down.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 5H3L3 16H5L5 5L8 5V4L4 0L0 4V5Z" fill="#000000"/>
<path d="M8 11L11 11L11 0H13L13 11H16V12L12 16L8 12V11Z" fill="#000000"/>
</svg>
templates/icons/arrow-up-from-bracket.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 5H7L7 11H9L9 5L12 5V4L8 0L4 4L4 5Z" fill="#000000"/>
<path d="M1 16L15 16V8H13V14L3 14L3 8L1 8V16Z" fill="#000000"/>
</svg>
templates/icons/arrow-up-from-line.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 6L6 6L6 12L10 12L10 6L13 6V5L8 0L3 5L3 6Z" fill="#000000"/>
<path d="M2 16L14 16V14L2 14V16Z" fill="#000000"/>
</svg>
templates/icons/arrow-up-left.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 2V10.5L3.5 12L6.33579 9.16421L12.5858 15.4142L15.4142 12.5858L9.16421 6.33579L12 3.5L10.5 2H2Z" fill="#000000"/>
</svg>
templates/icons/arrow-up-right-from-square.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 0L9 1L11.2929 3.29289L6.2929 8.29289L7.70711 9.70711L12.7071 4.7071L15 7L16 6V0H10Z" fill="#000000"/>
<path d="M1 2H6V4H3V13H12V10H14V15H1V2Z" fill="#000000"/>
</svg>
templates/icons/arrow-up-right.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 2H5.50003L4.00003 3.5L6.83581 6.33579L0.585815 12.5858L3.41424 15.4142L9.66424 9.16421L12.5 12L14 10.5L14 2Z" fill="#000000"/>
</svg>
templates/icons/arrow-up-short-wide.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 5H3L3 16H5L5 5L8 5V4L4 0L0 4V5Z" fill="#000000"/>
<path d="M16 16H10V14H16V16Z" fill="#000000"/>
<path d="M10 12H14V10H10V12Z" fill="#000000"/>
<path d="M12 8H10V6H12V8Z" fill="#000000"/>
</svg>
templates/icons/arrow-up-to-line.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13 9H10V16H6V9L3 9V8L8 3L13 8V9Z" fill="#000000"/>
<path d="M14 2H2V0H14V2Z" fill="#000000"/>
</svg>
templates/icons/arrow-up-wide-short.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 5H3L3 16H5L5 5L8 5V4L4 0L0 4V5Z" fill="#000000"/>
<path d="M16 6H10V8H16V6Z" fill="#000000"/>
<path d="M10 10H14V12H10V10Z" fill="#000000"/>
<path d="M12 14H10V16H12V14Z" fill="#000000"/>
</svg>
templates/icons/arrow-up.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 8L2 8L2 6L8 5.24536e-07L14 6L14 8L10 8L10 16L6 16L6 8Z" fill="#000000"/>
</svg>
templates/icons/arrows-left-right.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 9L6 13L5 13L-3.15903e-06 8L5 3L6 3L6 7L10 7L10 3L11 3L16 8L11 13L10 13L10 9L6 9Z" fill="#000000"/>
</svg>
templates/icons/arrows-repeat.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 8H9V4.8198L5.15728 4.05126C4.98683 4.01717 4.81343 4 4.63961 4C3.18179 4 2 5.18179 2 6.63961V9H0V6.63961C0 4.07722 2.07722 2 4.63961 2C4.94514 2 5.24992 2.03018 5.54951 2.0901L9 2.7802V0H10L14 4L10 8Z" fill="#000000"/>
<path d="M16 7V9.36039C16 11.9228 13.9228 14 11.3604 14C11.0549 14 10.7501 13.9698 10.4505 13.9099L7 13.2198V16H6L2 12L6 8H7V11.1802L10.8427 11.9487C11.0132 11.9828 11.1866 12 11.3604 12C12.8182 12 14 10.8182 14 9.36039V7H16Z" fill="#000000"/>
</svg>
templates/icons/arrows-rotate-clockwise.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 7L9 6L11.2929 3.70711L10.7071 3.12132C9.98914 2.40335 9.01536 2 8 2C6.98464 2 6.01086 2.40335 5.29289 3.12132L5.20711 3.20711C4.43421 3.98 4 5.02828 4 6.12132L4 7L2 7V6.12132C2 4.49785 2.64492 2.94086 3.79289 1.79289L3.87868 1.70711C4.97172 0.614064 6.4542 0 8 0C9.54579 0 11.0283 0.614064 12.1213 1.70711L12.7071 2.29289L15 0L16 1V7H10Z" fill="#000000"/>
<path d="M5.29289 12.8787C6.01086 13.5966 6.98464 14 8 14C9.01536 14 9.98914 13.5967 10.7071 12.8787L10.7929 12.7929C11.5658 12.02 12 10.9717 12 9.87868V9L14 9V9.87868C14 11.5022 13.3551 13.0591 12.2071 14.2071L12.1213 14.2929C11.0283 15.3859 9.5458 16 8 16C6.45421 16 4.97172 15.3859 3.87868 14.2929L3.29289 13.7071L1 16L0 15L9.53674e-07 9L6 9L7 10L4.70711 12.2929L5.29289 12.8787Z" fill="#000000"/>
</svg>
templates/icons/arrows-rotate-counter-clockwise.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 7L7 6L4.70711 3.70711L5.29289 3.12132C6.01086 2.40335 6.98464 2 8 2C9.01536 2 9.98914 2.40335 10.7071 3.12132L10.7929 3.20711C11.5658 3.98 12 5.02828 12 6.12132L12 7L14 7V6.12132C14 4.49785 13.3551 2.94086 12.2071 1.79289L12.1213 1.70711C11.0283 0.614064 9.5458 0 8 0C6.45421 0 4.97172 0.614064 3.87868 1.70711L3.29289 2.29289L1 0L0 1V7H6Z" fill="#000000"/>
<path d="M10.7071 12.8787C9.98914 13.5966 9.01536 14 8 14C6.98464 14 6.01086 13.5967 5.29289 12.8787L5.20711 12.7929C4.43421 12.02 4 10.9717 4 9.87868L4 9L2 9V9.87868C2 11.5022 2.64492 13.0591 3.79289 14.2071L3.87868 14.2929C4.97172 15.3859 6.45421 16 8 16C9.54579 16 11.0283 15.3859 12.1213 14.2929L12.7071 13.7071L15 16L16 15L16 9L10 9L9 10L11.2929 12.2929L10.7071 12.8787Z" fill="#000000"/>
</svg>
templates/icons/arrows-up-down.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 10H13V11L8 16L3 11L3 10H7L6.99999 6L3 6L3 5L8 0L13 5V6L9 6L9 10Z" fill="#000000"/>
</svg>
templates/icons/at.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 2C4.68629 2 2 4.68629 2 8C2 11.3137 4.68629 14 8 14H12V16H8C3.58172 16 0 12.4183 0 8C0 3.58172 3.58172 0 8 0C12.4183 0 16 3.58172 16 8V12H8C5.79086 12 4 10.2091 4 8C4 5.79086 5.79086 4 8 4C10.2091 4 12 5.79086 12 8V10H14V8C14 4.68629 11.3137 2 8 2ZM10 10V8C10 6.89543 9.10457 6 8 6C6.89543 6 6 6.89543 6 8C6 9.10457 6.89543 10 8 10H10Z" fill="#000000"/>
</svg>
templates/icons/axe.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 7.50002C1 3.35788 4.35786 1.52588e-05 8.5 1.52588e-05V5L9.04293 5.54293L11.293 3.29289L12.7072 4.70711L10.4571 6.95714L11 7.50002H16C16 11.6422 12.6421 15 8.5 15V10L7.95714 9.45714L1.45718 15.9571L0.0429688 14.5429L6.54292 8.04292L6.00002 7.50002H1Z" fill="#000000"/>
</svg>
templates/icons/b.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2 0V16H10.25C12.8734 16 15 13.8734 15 11.25C15 9.71329 14.2703 8.34703 13.1384 7.47882C13.6813 6.7067 14 5.76557 14 4.75C14 2.12665 11.8734 0 9.25 0H2ZM9.25 6.5C10.2165 6.5 11 5.7165 11 4.75C11 3.7835 10.2165 3 9.25 3H5V6.5H9.25ZM5 9.5V13H10.25C11.2165 13 12 12.2165 12 11.25C12 10.2835 11.2165 9.5 10.25 9.5H5Z" fill="#000000"/>
</svg>
templates/icons/badge-check.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.6563 5.24291C15.4743 5.88358 16 6.8804 16 8C16 9.11964 15.4743 10.1165 14.6562 10.7572C14.7816 11.7886 14.4485 12.8652 13.6568 13.6569C12.8651 14.4486 11.7885 14.7817 10.7571 14.6563C10.1164 15.4743 9.1196 16 8 16C6.88038 16 5.88354 15.4743 5.24288 14.6562C4.21141 14.7817 3.13481 14.4485 2.34312 13.6568C1.55143 12.8652 1.2183 11.7886 1.34372 10.7571C0.525698 10.1164 0 9.1196 0 8C0 6.88038 0.525715 5.88354 1.34376 5.24288C1.21834 4.21141 1.55147 3.13481 2.34316 2.34312C3.13485 1.55143 4.21145 1.2183 5.24291 1.34372C5.88358 0.525698 6.8804 0 8 0C9.11964 0 10.1165 0.525732 10.7572 1.3438C11.7886 1.21838 12.8652 1.55152 13.6569 2.3432C14.4486 3.13488 14.7817 4.21146 14.6563 5.24291ZM12.2071 6.20711L10.7929 4.79289L7 8.58579L5.20711 6.79289L3.79289 8.20711L7 11.4142L12.2071 6.20711Z" fill="#000000"/>
</svg>
templates/icons/badge.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 8C16 6.8804 15.4743 5.88358 14.6563 5.24291C14.7817 4.21146 14.4486 3.13488 13.6569 2.3432C12.8652 1.55152 11.7886 1.21838 10.7572 1.3438C10.1165 0.525732 9.11964 0 8 0C6.8804 0 5.88358 0.525698 5.24291 1.34372C4.21145 1.2183 3.13485 1.55143 2.34316 2.34312C1.55147 3.13481 1.21834 4.21141 1.34376 5.24288C0.525715 5.88354 0 6.88038 0 8C0 9.1196 0.525698 10.1164 1.34372 10.7571C1.2183 11.7886 1.55143 12.8652 2.34312 13.6568C3.13481 14.4485 4.21141 14.7817 5.24288 14.6562C5.88354 15.4743 6.88038 16 8 16C9.1196 16 10.1164 15.4743 10.7571 14.6563C11.7885 14.7817 12.8651 14.4486 13.6568 13.6569C14.4485 12.8652 14.7816 11.7886 14.6562 10.7572C15.4743 10.1165 16 9.11964 16 8Z" fill="#000000"/>
</svg>
templates/icons/ban.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 8C16 12.4183 12.4183 16 8 16C3.58172 16 0 12.4183 0 8C0 3.58172 3.58172 0 8 0C12.4183 0 16 3.58172 16 8ZM10.3128 12.4341C9.62116 12.7956 8.83445 13 8 13C5.23858 13 3 10.7614 3 8C3 7.16555 3.20441 6.37884 3.5659 5.68722L10.3128 12.4341ZM12.4341 10.3128L5.68722 3.5659C6.37884 3.20441 7.16555 3 8 3C10.7614 3 13 5.23858 13 8C13 8.83445 12.7956 9.62116 12.4341 10.3128Z" fill="#000000"/>
</svg>
templates/icons/baseball-bat.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 6L3.38562 11.2288L4.77124 12.6144L10 10L16 4L12 0L6 6Z" fill="#000000"/>
<path d="M0 13.5L2.5 16L3.91421 14.5858L1.41421 12.0858L0 13.5Z" fill="#000000"/>
</svg>
templates/icons/baseball.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.182 13.0781C15.318 11.6968 16 9.92803 16 8C16 6.07197 15.318 4.30324 14.182 2.92192C13.6429 3.43359 13.1852 4.0302 12.8303 4.69032L13.7037 5.00821L13.0197 6.8876L12.1462 6.5697C12.0504 7.03143 12 7.50983 12 8C12 8.49018 12.0504 8.9686 12.1463 9.43035L13.0197 9.11245L13.7037 10.9918L12.8303 11.3097C13.1852 11.9698 13.6429 12.5664 14.182 13.0781Z" fill="#000000"/>
<path d="M10.2511 10.1201L9.2609 10.4805L9.94494 12.3599L10.9353 11.9995C11.3915 12.9177 12 13.747 12.7277 14.4543C11.4032 15.4261 9.76863 16 8 16C6.23137 16 4.59679 15.4261 3.27234 14.4543C4.00001 13.747 4.60847 12.9177 5.06473 11.9995L6.05493 12.3599L6.73897 10.4805L5.74885 10.1201C5.91302 9.44027 6 8.73029 6 8C6 7.26971 5.91302 6.55974 5.74885 5.87988L6.73897 5.5195L6.05493 3.64012L5.06474 4.00052C4.60848 3.08227 4.00001 2.25299 3.27234 1.54571C4.59679 0.573931 6.23137 0 8 0C9.76863 0 11.4032 0.573931 12.7277 1.54571C12 2.25301 11.3915 3.0823 10.9352 4.00057L9.94494 3.64013L9.2609 5.51952L10.2511 5.87993C10.087 6.55978 10 7.26973 10 8C10 8.73028 10.087 9.44025 10.2511 10.1201Z" fill="#000000"/>
<path d="M1.81802 13.0781C2.35712 12.5664 2.81481 11.9698 3.16966 11.3097L2.29616 10.9918L2.9802 9.11243L3.85374 9.43037C3.94961 8.96861 4 8.49019 4 8C4 7.50981 3.94962 7.0314 3.85374 6.56964L2.9802 6.88758L2.29616 5.0082L3.16967 4.69027C2.81481 4.03017 2.35712 3.43358 1.81802 2.92192C0.682046 4.30324 0 6.07197 0 8C0 9.92803 0.682047 11.6968 1.81802 13.0781Z" fill="#000000"/>
</svg>
templates/icons/basketball.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 15.9381V9H4.93811C4.70299 10.8855 3.81171 12.5672 2.5 13.8095C3.70576 14.9514 5.26679 15.722 7 15.9381Z" fill="#000000"/>
<path d="M9 15.9381C10.7332 15.722 12.2942 14.9514 13.5 13.8095C12.1883 12.5672 11.297 10.8855 11.0619 9H9V15.9381Z" fill="#000000"/>
<path d="M11.0619 7C11.297 5.11449 12.1883 3.43275 13.5 2.1905C12.2942 1.04858 10.7332 0.278018 9 0.0618897V7H11.0619Z" fill="#000000"/>
<path d="M7 0.0618896C5.26679 0.278018 3.70576 1.04858 2.5 2.1905C3.81171 3.43275 4.70299 5.11449 4.93811 7H7V0.0618896Z" fill="#000000"/>
<path d="M16 8C16 6.43475 15.5505 4.9745 14.7735 3.74129C13.6782 4.82832 13 6.33495 13 8C13 9.66504 13.6782 11.1717 14.7735 12.2587C15.5505 11.0255 16 9.56524 16 8Z" fill="#000000"/>
<path d="M1.22651 12.2587C2.32177 11.1717 3 9.66504 3 8C3 6.33495 2.32177 4.82832 1.22651 3.74129C0.449522 4.97449 0 6.43475 0 8C0 9.56524 0.449522 11.0255 1.22651 12.2587Z" fill="#000000"/>
</svg>
templates/icons/battery-charge.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 2H8L3 8V9L6 9L5 14L0 14V2Z" fill="#000000"/>
<path d="M8 7L9 2H14V5H16V11H14V14H6L11 8V7H8Z" fill="#000000"/>
</svg>
templates/icons/battery-empty.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 2H14V5H16V11H14V14H0V2ZM2 4H12V12H2V4Z" fill="#000000"/>
</svg>
templates/icons/battery-full.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 2H0V14H14V11H16V5H14V2Z" fill="#000000"/>
</svg>
templates/icons/battery-half.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 2H14V5H16V11H14V14H0V2ZM12 4H7V12H12V4Z" fill="#000000"/>
</svg>
templates/icons/battery-slash.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 16H13L11.375 14H0V2H1.625L0 0H3L16 16Z" fill="#000000"/>
<path d="M16 11H14.9375L7.625 2H14V5H16V11Z" fill="#000000"/>
</svg>
templates/icons/bed.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 2H0V14H2V12H14V14H16V9C16 7.34315 14.6569 6 13 6H6C6 4.89543 5.10457 4 4 4H2V2Z" fill="#000000"/>
</svg>
templates/icons/bee.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11 3V4L15.3436 8.77793C15.766 9.24255 16 9.85471 16 10.4826C16 11.8633 14.8807 13 13.5 13C12.1193 13 11 11.8807 11 10.5V9.68629C11 9.79109 10.9979 9.89568 10.9939 10H5.00615C5.00206 9.89568 5 9.79109 5 9.68629V10.5C5 11.8807 3.88071 13 2.5 13C1.11929 13 0 11.8633 0 10.4826C0 9.85471 0.234044 9.24255 0.65643 8.77793L5 4V3C5 1.34315 6.34315 0 8 0C9.65685 0 11 1.34315 11 3ZM5 8H11V6H5V8Z" fill="#000000"/>
<path d="M8.65685 15.3431C9.59779 14.4022 10.2801 13.251 10.6581 12H5.34187C5.71987 13.251 6.40221 14.4022 7.34315 15.3431L8 16L8.65685 15.3431Z" fill="#000000"/>
</svg>
templates/icons/bell-slash.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 16H13L9.75 12H1V10L3 8V5C3 4.60587 3.0456 4.22239 3.13182 3.85455L0 0H3L16 16Z" fill="#000000"/>
<path d="M13 8.61539L6.25443 0.313149C6.79787 0.11067 7.38601 0 8 0C10.7614 0 13 2.23858 13 5V8.61539Z" fill="#000000"/>
<path d="M7.99999 16C6.69378 16 5.58254 15.1652 5.1707 14H8.375L9.61633 15.5278C9.14984 15.8267 8.59515 16 7.99999 16Z" fill="#000000"/>
</svg>
templates/icons/bell.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 5C3 2.23858 5.23858 0 8 0C10.7614 0 13 2.23858 13 5V8L15 10V12H1V10L3 8V5Z" fill="#000000"/>
<path d="M7.99999 16C6.69378 16 5.58254 15.1652 5.1707 14H10.8293C10.4175 15.1652 9.30621 16 7.99999 16Z" fill="#000000"/>
</svg>
templates/icons/bicycle.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.5 3C12.3284 3 13 2.32843 13 1.5C13 0.671573 12.3284 0 11.5 0C10.6716 0 10 0.671573 10 1.5C10 2.32843 10.6716 3 11.5 3Z" fill="#000000"/>
<path d="M0 12C0 10.4697 1.14578 9.20702 2.62626 9.02304L5.00001 10.6055V14.2361C4.46925 14.7111 3.76836 15 3 15C1.34315 15 0 13.6568 0 12Z" fill="#000000"/>
<path d="M11 14.2361C11.5308 14.7111 12.2316 15 13 15C14.6569 15 16 13.6569 16 12C16 10.3431 14.6569 9 13 9C12.2316 9 11.5308 9.28885 11 9.76389V14.2361Z" fill="#000000"/>
<path d="M8.28111 5.01444C8.29524 5.00503 8.31184 5 8.32881 5C8.36139 5 8.39117 5.0184 8.40574 5.04754L9.38197 7H13V5H10.618L10.1946 4.15311C9.84124 3.44641 9.11893 3 8.32881 3C7.91699 3 7.51437 3.1219 7.17171 3.35034L4.86132 4.8906C4.32322 5.24934 4 5.85327 4 6.5C4 7.14673 4.32322 7.75066 4.86132 8.1094L7 9.53518V13H9V8.46482L6.05278 6.5L8.28111 5.01444Z" fill="#000000"/>
</svg>
templates/icons/bishop.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.75926 7.65079L12.1268 3.72195L13.2712 5.72466C13.7488 6.5604 14 7.50631 14 8.46887C14 9.99625 13.3809 11.379 12.38 12.38L14 14V16H2V14L3.62003 12.38C2.61909 11.379 2 9.99625 2 8.46887C2 7.50631 2.2512 6.5604 2.72876 5.72466L6 0H10L11.0732 1.87805L7.24074 6.34921L8.75926 7.65079Z" fill="#000000"/>
</svg>
templates/icons/block-quote.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 1H1V3H15V1Z" fill="#000000"/>
<path d="M1 5H3V15H1V5Z" fill="#000000"/>
<path d="M5 13H15V15H5V13Z" fill="#000000"/>
<path d="M15 9H5V11H15V9Z" fill="#000000"/>
<path d="M5 5H15V7H5V5Z" fill="#000000"/>
</svg>
templates/icons/bluetooth.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.99995 0H8.33328L12.9999 3.5V5.56619L8.9436 8L12.9999 10.4338V12.5L8.33328 16H5.99995V9.61803L1.44716 11.8944L0.552734 10.1056L4.76388 8L0.552734 5.89443L1.44716 4.10557L5.99995 6.38197V0ZM7.99995 9.76619V13.75L10.9509 11.5368L7.99995 9.76619ZM7.99995 6.23381V2.25L10.9509 4.46323L7.99995 6.23381Z" fill="#000000"/>
</svg>
templates/icons/bold.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2 1H8.625C11.0412 1 13 2.95875 13 5.375C13 6.08661 12.8301 6.75853 12.5287 7.35243C13.4313 8.15386 14 9.32301 14 10.625C14 13.0412 12.0412 15 9.625 15H2V1ZM5.5 9.75V11.5H9.625C10.1082 11.5 10.5 11.1082 10.5 10.625C10.5 10.1418 10.1082 9.75 9.625 9.75H5.5ZM5.5 6.25H8.625C9.10825 6.25 9.5 5.85825 9.5 5.375C9.5 4.89175 9.10825 4.5 8.625 4.5H5.5V6.25Z" fill="#000000"/>
</svg>
templates/icons/bolt.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 7L10 0H8L2 7V9H7L6 16H8L14 9L14 7H9Z" fill="#000000"/>
</svg>
templates/icons/bomb.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.9569 1.45706L14.539 2.87495L13.1248 1.45706L14.5427 0.0428467L15.9569 1.45706Z" fill="#000000"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.707 5.707L13.123 4.29101L14.5354 5.70706L15.9532 4.28917L14.539 2.87495L13.123 4.28733L11.7106 2.87495L10.2928 4.29279L9 3L7.74293 4.25707C7.19135 4.08989 6.60617 4 6 4C2.68629 4 0 6.68629 0 10C0 13.3137 2.68629 16 6 16C9.31371 16 12 13.3137 12 10C12 9.39383 11.9101 8.80865 11.7429 8.25707L13 7L11.707 5.707ZM6 8C4.89543 8 4 8.89543 4 10H2C2 7.79086 3.79086 6 6 6V8Z" fill="#000000"/>
<path d="M11.7107 0.0428467L13.1248 1.45706L11.707 2.87495L10.2928 1.45706L11.7107 0.0428467Z" fill="#000000"/>
</svg>
templates/icons/bone.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.2678 7.26777C14.2915 8.24408 12.7085 8.24408 11.7322 7.26777L11.6465 7.18199L7.182 11.6464L7.26777 11.7322C8.24408 12.7085 8.24408 14.2914 7.26777 15.2677C6.29146 16.244 4.70854 16.244 3.73223 15.2677C3.24408 14.7796 2.99998 14.1398 2.99998 13.5V13H2.50434C1.86309 13.0011 1.22149 12.757 0.732233 12.2677C-0.244078 11.2914 -0.244078 9.70852 0.732233 8.7322C1.70854 7.75589 3.29146 7.75589 4.26777 8.7322L4.35358 8.81802L8.81803 4.35356L8.73223 4.26777C7.75592 3.29146 7.75592 1.70854 8.73223 0.732233C9.70854 -0.244078 11.2915 -0.244078 12.2678 0.732233C12.7581 1.22255 13.0022 1.85737 13 2.5V3H13.5C14.1398 3 14.7796 3.24407 15.2678 3.73223C16.2441 4.70854 16.2441 6.29146 15.2678 7.26777Z" fill="#000000"/>
</svg>
templates/icons/book-open.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 2H3.58579C4.49129 2 5.35971 2.35971 6 3C6.64029 3.64029 7 4.50871 7 5.41421V11H9V5.41421C9 4.50871 9.35971 3.64029 10 3C10.6403 2.35971 11.5087 2 12.4142 2H16V13H11L9 15H7L5 13H0V2Z" fill="#000000"/>
</svg>
templates/icons/book.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 0C3.34315 0 2 1.34315 2 3V13C2 14.6569 3.34315 16 5 16H14V14H4V12H14V0H5Z" fill="#000000"/>
</svg>
templates/icons/bookmark-plus.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M14 0H2V16H4L8 12L12 16H14V0ZM7 3V5H5V7H7V9H9V7H11V5H9V3H7Z" fill="#000000"/>
</svg>
templates/icons/bookmark.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 0H14V16H12L8 12L4 16H2V0Z" fill="#000000"/>
</svg>
templates/icons/books.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 1H8V15H5V1Z" fill="#000000"/>
<path d="M0 3H3V15H0V3Z" fill="#000000"/>
<path d="M12.167 3L9.34302 3.7041L12.1594 15L14.9834 14.2959L12.167 3Z" fill="#000000"/>
</svg>
templates/icons/bottle.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.707107 12.7071L3.29289 15.2929C3.74565 15.7456 4.35971 16 5 16C5.64029 16 6.25435 15.7456 6.70711 15.2929L11 11V8L12.5 6.5H12.5858L9.5 3.41421V3.5L8 5H5L0.707107 9.29289C0.254354 9.74565 0 10.3597 0 11C0 11.6403 0.254354 12.2544 0.707107 12.7071Z" fill="#000000"/>
<path d="M10.4571 1.54289L14.4571 5.54289L16 4L12 0L10.4571 1.54289Z" fill="#000000"/>
</svg>
templates/icons/bow-and-arrow.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 0L11.5 0.5V1.5L12.5572 2.0286L10.9057 3.68016C9.55098 2.62709 7.84872 2 6 2C4.33262 2 2.78439 2.5101 1.50283 3.38275C1.22497 3.57196 0.959638 3.77821 0.708351 4C0.457776 4.22116 0.221162 4.45778 0 4.70835L3.52452 8.23288L4.93874 6.81866L2.95096 4.83089C3.84475 4.3023 4.88659 4 6 4C7.29584 4 8.49573 4.4108 9.47656 5.10927L4.58583 10H3.00004L4.01735e-05 13L2.00004 14L3.00004 16L6.00004 13V11.4142L10.8908 6.52349C11.5892 7.5043 12 8.70418 12 10C12 11.1134 11.6977 12.1552 11.1691 13.049L9.18138 11.0613L7.76717 12.4755L11.2916 16C11.5422 15.7788 11.7788 15.5422 12 15.2916C12.2218 15.0404 12.428 14.775 12.6172 14.4972C13.4899 13.2156 14 11.6674 14 10C14 8.1513 13.3729 6.44906 12.3199 5.09438L13.9714 3.44281L14.5 4.5H15.5L16 0Z" fill="#000000"/>
</svg>
templates/icons/bowl.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 4H1V6C1 8.20914 2.79086 10 5 10H11C13.2091 10 15 8.20914 15 6V4Z" fill="#000000"/>
<path d="M12 12H4L4.25 14H11.75L12 12Z" fill="#000000"/>
</svg>
templates/icons/box.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 1H4L1 6V15H15V6L12 1H9V6H7V1Z" fill="#000000"/>
</svg>
templates/icons/bridge.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 4C1.65685 4 3 2.65685 3 1H5C5 2.65685 6.34315 4 8 4C9.65685 4 11 2.65685 11 1H13C13 2.65685 14.3431 4 16 4V6C14.8744 6 13.8357 5.62805 13 5.00036V10H16V12H13V15H11V12H5V15H3V12H0V10H3V5.00037C2.16434 5.62806 1.12561 6 0 6V4ZM9 5.89998C9.73888 5.75 10.4186 5.43708 11 5.00037V10H9V5.89998ZM7 5.89998C6.26112 5.75 5.58141 5.43708 5 5.00037V10H7V5.89998Z" fill="#000000"/>
</svg>
templates/icons/british-pound.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-0.5 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.5474 3.5C8.82737 3.5 8.21601 4.02747 8.11049 4.73972L7.77563 7H10V10H7.33119L6.92345 12.7522C6.85846 13.1909 6.73748 13.6091 6.56841 14H14V17H2V14C2.98006 14 3.81221 13.282 3.95584 12.3126L4.29844 10H2V7H4.74289L5.14288 4.30008C5.46632 2.11683 7.34032 0.5 9.5474 0.5C12.0065 0.5 14 2.4935 14 4.9526V5H11V4.9526C11 4.15035 10.3496 3.5 9.5474 3.5Z" fill="#000000"/>
</svg>
templates/icons/browser.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 2H0V14H16V2ZM3 6C3.55228 6 4 5.55228 4 5C4 4.44772 3.55228 4 3 4C2.44772 4 2 4.44772 2 5C2 5.55228 2.44772 6 3 6ZM7.5 5C7.5 5.55228 7.05228 6 6.5 6C5.94772 6 5.5 5.55228 5.5 5C5.5 4.44772 5.94772 4 6.5 4C7.05228 4 7.5 4.44772 7.5 5ZM10 6C10.5523 6 11 5.55228 11 5C11 4.44772 10.5523 4 10 4C9.44771 4 9 4.44772 9 5C9 5.55228 9.44771 6 10 6Z" fill="#000000"/>
</svg>
templates/icons/FULL_INDEX.md
# SVG Icon Full Index
This document contains a complete categorized list of **640+ icons**. For a quick usage guide, see [README.md](./README.md).
---
## 📍 Navigation & Arrows (50+)
**Basic Arrows**
`arrow-up` `arrow-down` `arrow-left` `arrow-right` `arrow-up-left` `arrow-up-right` `arrow-down-left` `arrow-down-right`
**Line Arrows**
`arrow-up-from-line` `arrow-up-to-line` `arrow-down-from-line` `arrow-down-to-line` `arrow-left-from-line` `arrow-left-to-line` `arrow-right-from-line` `arrow-right-to-line`
**Turn Arrows**
`arrow-turn-down-left` `arrow-turn-down-right` `arrow-turn-up-left` `arrow-turn-up-right` `arrow-u-down-left` `arrow-u-up-right`
**Trend Arrows**
`arrow-trend-up` `arrow-trend-down` `arrow-up-wide-short` `arrow-down-wide-short`
**Bidirectional Arrows**
`arrows-left-right` `arrows-up-down` `arrow-left-arrow-right` `arrow-up-arrow-down`
**Rotating Arrows**
`arrow-rotate-left` `arrow-rotate-right` `arrows-rotate-clockwise` `arrows-rotate-counter-clockwise` `arrows-repeat`
**Angle Symbols**
`angle-up` `angle-down` `angle-left` `angle-right` `angles-up` `angles-down` `angles-left` `angles-right` `caret-up` `caret-down` `caret-left` `caret-right`
**Circle Arrows**
`circle-arrow-up` `circle-arrow-down` `circle-arrow-left` `circle-arrow-right`
---
## 👤 Users & People (10+)
`user` `users` `person` `person-walking` `person-wave` `circle-user` `square-user` `group` `address-card` `head-side`
---
## 📊 Data & Charts (15+)
`chart-bar` `chart-line` `chart-pie` `arrow-trend-up` `arrow-trend-down` `gauge-high` `gauge-medium` `gauge-low` `table` `database` `server` `funnel` `filter` `sort` `sliders`
---
## 📁 Files & Folders (15+)
`file` `file-plus` `files` `folder` `folder-open` `folders` `floppy-disk` `archive-box` `clipboard` `copy` `image` `images` `newspaper` `receipt` `sticky-note`
---
## ✅ Status & Feedback (20+)
**Success / Confirmation**
`checkmark` `circle-checkmark` `square-checkmark` `badge-check` `shield-check` `thumbs-up`
**Error / Cancel**
`x` `x-1` `circle-x` `square-x` `ban` `thumbs-down`
**Warning / Info**
`circle-exclamation` `triangle-exclamation` `diamond-exclamation` `octagon-exclamation` `circle-info` `circle-question`
**Add / Remove**
`plus` `minus` `circle-plus` `circle-minus` `square-plus` `square-minus`
---
## 🔧 Tools & Actions (25+)
**Editing Tools**
`pencil` `pencil-square` `pen-nib` `brush` `paint-bucket` `paint-roller` `eyedropper` `palette` `crop` `scissors` `ruler`
**System Actions**
`cog` `sliders` `wrench` `hammer` `screwdriver` `toolbox`
**View Actions**
`magnifying-glass` `zoom-in` `zoom-out` `eye` `eye-slash` `maximize` `minimize`
**Delete Actions**
`trash` `delete`
---
## 💻 Devices & Technology (30+)
**Computing Devices**
`desktop` `laptop` `mobile` `tablet` `smartwatch` `tv` `tv-retro`
**Input Devices**
`keyboard` `mouse` `joystick` `game-controller` `d-pad`
**Storage & Connectivity**
`database` `server` `microchip` `plug` `outlet` `usb`
**Network**
`wifi` `wifi-low` `wifi-medium` `wifi-slash` `signal` `signal-fair` `signal-good` `signal-weak` `signal-slash` `bluetooth` `globe` `rss`
**Battery**
`battery-full` `battery-half` `battery-empty` `battery-charge` `battery-slash`
---
## 📱 Communication & Social (15+)
`phone` `phone-slash` `envelope` `mailbox` `paper-plane` `comment` `comment-dots` `comments` `comments-slash` `bell` `bell-slash` `at` `hashtag` `share-nodes`
---
## 🏢 Business & Finance (20+)
**Currency**
`dollar` `euro` `british-pound` `yen` `coin` `money` `wallet` `credit-card`
**Shopping**
`shopping-cart` `shopping-bag` `shopping-basket` `shop` `receipt` `tag` `ticket`
**Buildings**
`building` `city` `factory` `hospital` `museum` `castle` `home` `home-1`
---
## 🚗 Transportation & Travel (15+)
`car` `bus` `train` `plane` `ship` `truck` `bicycle` `scooter` `rocket` `route` `map` `map-pin` `location-arrow` `location-target` `signpost` `traffic-light` `traffic-cone`
---
## 🎨 Media & Entertainment (30+)
**Playback Controls**
`play` `pause` `stop` `play-pause` `fast-forward` `rewind` `skip-forward` `skip-backward` `shuffle` `arrows-repeat`
**Audio**
`volume-high` `volume-low` `volume-none` `volume-x` `volume-slash` `microphone` `microphone-slash` `headphones` `music` `waveform`
**Video & Camera**
`video` `video-camera` `video-camera-slash` `camera` `camera-slash` `film` `compact-disc`
**Gaming**
`game-controller` `joystick` `dice` `die-1` `die-2` `die-3` `die-4` `die-5` `die-6` `playing-card` `meeple`
---
## ⚽ Sports & Fitness (15+)
`soccer` `basketball` `baseball` `football` `tennis-ball` `hockey` `curling-stone` `trophy` `target` `target-arrow` `bow-and-arrow`
---
## 🍔 Food & Drinks (15+)
`burger` `pizza` `cake` `cake-slice` `cupcake` `ice-cream` `citrus-slice` `bowl` `utensils` `mug` `cocktail` `wine-glass` `soda` `bottle`
---
## 🌤️ Weather & Nature (20+)
**Weather**
`sun` `sun-cloud` `sun-fog` `moon` `moon-cloud` `moon-fog` `cloud` `cloud-fog` `cloud-rain` `cloud-snow` `cloud-lightning` `rainbow` `rainbow-cloud` `snow` `wind` `droplet` `umbrella`
**Nature**
`tree` `tree-evergreen` `leaf` `seedling` `flower` `mountains` `water` `fire`
---
## ✨ Shapes & Decorations (25+)
**Basic Shapes**
`circle` `square` `triangle` `hexagon` `octagon` `diamond-shape` `star` `star-half` `heart` `heart-half` `heart-broken`
**Special Shapes**
`sparkles` `bolt` `crown` `gem` `crystal-ball` `wand-with-sparkles`
**Card Suit Symbols**
`spade` `club` `diamond`
---
## 🔒 Security & Permissions (10+)
`lock-closed` `lock-open` `key` `key-skeleton` `keyhole` `shield` `shield-check` `shield-half` `eye` `eye-slash` `face-id`
---
## 📝 Text & Editing (20+)
**Formatting**
`bold` `italic` `underline` `strikethrough` `text` `font-case`
**Alignment**
`align-left` `align-right` `align-text-center` `align-text-justify` `align-text-left` `align-text-right` `indent` `outdent`
**Quotes & Lists**
`quote-left` `quote-right` `block-quote` `list` `list-ordered`
**Code**
`code` `code-block` `terminal`
---
## 🧭 Layout & Alignment (15+)
`align-top` `align-bottom` `align-left` `align-right` `align-center-horizontal` `align-center-vertical` `distribute-horizontal` `distribute-vertical` `grid` `grid-masonry` `columns` `rows` `layers` `frame` `component`
---
## 🔢 Numbers & Letters (46)
**Letters A-Z**
`a` `b` `c` `d` `e` `f` `g` `h` `i` `j` `k` `l` `m` `n` `o` `p` `q` `r` `s` `t` `u` `v` `w` `x-1` `y` `z`
**Numbers 0-9**
`number-0` `number-1` `number-2` `number-3` `number-4` `number-5` `number-6` `number-7` `number-8` `number-9`
**Circled Numbers**
`circle-number-0` `circle-number-1` `circle-number-2` `circle-number-3` `circle-number-4` `circle-number-5` `circle-number-6` `circle-number-7` `circle-number-8` `circle-number-9`
---
## ♈ Zodiac Signs (12)
`aries` `taurus` `gemini` `cancer` `leo` `virgo` `libra` `scorpio` `sagittarius` `capricorn` `aquarius` `pisces`
---
## ♟️ Chess Pieces (6)
`king` `queen` `rook` `bishop` `knight` `pawn`
---
## 😀 Emoticons (10)
`face-smile` `face-laugh` `face-meh` `face-sad` `face-cry` `face-angry` `face-open-mouth` `face-no-mouth` `face-melt`
---
## 🐾 Animals (10+)
`dog` `cat` `fish` `bee` `butterfly` `paw` `bone`
---
## 🎲 Other Common (30+)
**Time**
`clock` `stopwatch` `alarm-clock` `hourglass-empty` `hourglass-half-top` `hourglass-half-bottom` `calendar` `watch`
**Toggle**
`power` `toggle-circle-left` `toggle-circle-right`
**Books**
`book` `book-open` `books` `bookmark` `bookmark-plus`
**Gifts & Celebrations**
`gift` `cake` `trophy` `crown` `sparkles`
**Work**
`suitcase` `briefcase` `mortarboard`
**Miscellaneous**
`lightbulb` `flag` `anchor` `compass` `life-ring` `rocket` `robot` `ghost` `skull` `alien` `ufo`
---
## Icon Source
All icons are from [SVG Repo](https://www.svgrepo.com/), licensed under CC0 or MIT, free for commercial use.
**Icon count**: 640+
**Last updated**: 2025-12-06
templates/icons/README.md
# SVG Icon Library
This directory provides **640+ high-quality SVG icons** that can be directly embedded into SVG files generated by PPT Master.
> **Icon Source**: [SVG Repo](https://www.svgrepo.com/) - Free open-source SVG icon library
- **Full Index**: [FULL_INDEX.md](./FULL_INDEX.md) (browse as needed)
- **JSON Index**: [icons_index.json](./icons_index.json) (programmatic lookup)
---
## Usage
### Method 1: Placeholder Reference + Post-processing Embed (Recommended)
Use simple placeholder syntax **during generation**:
```xml
<use data-icon="rocket" x="100" y="200" width="48" height="48" fill="#0076A8"/>
<use data-icon="chart-bar" x="200" y="200" width="48" height="48" fill="#FF6B35"/>
```
**Attributes**:
- `data-icon` - Icon name (matches filename without .svg)
- `x`, `y` - Position
- `width`, `height` - Size (base 16px; setting 48 = 3x scale)
- `fill` - Color
**After generation**, run the tool to batch-replace all placeholders:
```bash
python3 scripts/embed_icons.py svg_output/*.svg
```
### Method 2: Direct Copy Embed
```xml
<g transform="translate(100, 200) scale(3)" fill="#0076A8">
<!-- Copy path content from rocket.svg -->
<path d="M10 16L12 14V10L13.6569 8.34314..."/>
</g>
```
**Common sizes**: `scale(2)`=32px, `scale(3)`=48px, `scale(4)`=64px
---
## Common Icon Quick Reference
| Category | Icons |
|----------|-------|
| Data & Charts | `chart-bar` `chart-line` `chart-pie` `arrow-trend-up` `database` |
| Status & Feedback | `circle-checkmark` `circle-x` `triangle-exclamation` `circle-info` |
| Users & Orgs | `user` `users` `building` `group` |
| Navigation & Arrows | `arrow-up` `arrow-down` `arrow-left` `arrow-right` |
| Business & Finance | `dollar` `wallet` `briefcase` `shopping-cart` |
| Tools & Actions | `cog` `pencil` `magnifying-glass` `trash` |
| Time & Schedule | `clock` `calendar` `stopwatch` |
| Files & Documents | `file` `folder` `clipboard` `copy` |
| Goals & Security | `target` `flag` `shield` `lock-closed` |
| Creativity & Ideas | `lightbulb` `rocket` `sparkles` `star` |
---
## Design Specs
| Parameter | Value |
|-----------|-------|
| viewBox | `0 0 16 16` |
| Base Size | 16 x 16 px |
| Style | Solid (filled) |
---
**Icon Count**: 640+ | **Full List**: [FULL_INDEX.md](./FULL_INDEX.md)
templates/README.md
# Template Resources
## Design Specification & Outline Reference
`design_spec_reference.md` is an all-in-one reference template for defining:
1. **Visual Specifications**: Canvas dimensions, color scheme, typography, layout principles
2. **Content Outline**: Slide-by-slide page structure planning
3. **Technical Constraints**: Hard requirements for SVG generation and PPT compatibility
[View Design Spec Reference](./design_spec_reference.md)
## Page Layout Templates
The `layouts/` directory contains pre-built page layout templates organized by design style:
- **General**: Versatile modern style, clean and flexible
- **Consultant**: Consulting style, professional and structured
- **Consultant Top**: Top-tier consulting style (MBB-level)
- **Academic Defense**: Academic defense style, research-oriented
- **Human browsing**: [layouts/README.md](./layouts/README.md)
- **AI / Programmatic lookup**: [layouts/layouts_index.json](./layouts/layouts_index.json)
## Chart Templates
The `charts/` directory contains 33 standard chart templates:
- KPI Cards
- Bar Chart / Stacked Bar Chart
- Line Chart / Dual-Axis Line Chart
- Donut Chart
- Radar Chart
- Funnel Chart
- Matrix (2x2)
- Timeline
- Gantt Chart
- Process Flow
- Org Chart
- **Human browsing**: [charts/README.md](./charts/README.md)
- **AI / Programmatic lookup**: [charts/charts_index.json](./charts/charts_index.json)
## Icon Library
The `icons/` directory contains 640+ vector icons sourced from [SVG Repo](https://www.svgrepo.com/).
- **Human browsing**: [icons/README.md](./icons/README.md)
- **AI / Programmatic lookup**: [icons/icons_index.json](./icons/icons_index.json)