diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1c8e6eb25..9c74ae26b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,6 +31,10 @@ jobs: run: | npm run build npm run build:site + - name: 📦 Build CLI + run: | + npm --prefix cli install + npm --prefix cli run build - name: ✅ Run continuous integration tests run: npm run ci diff --git a/.gitignore b/.gitignore index 403175f47..87c39da61 100644 --- a/.gitignore +++ b/.gitignore @@ -3,13 +3,20 @@ dist esm lib tsconfig.tsbuildinfo - +tsconfig.*.tsbuildinfo pnpm-lock.yaml package-lock.json coverage - +.DS_Store .claude .docusaurus build .codex/skills -.claude/skills \ No newline at end of file +.claude/skills + +# CLI package build artifacts +cli/node_modules +cli/esm +cli/lib +cli/coverage +cli/tsconfig.tsbuildinfo diff --git a/README.md b/README.md index f2ee5ecdb..48eaa8175 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,47 @@ for (const chunk of chunks) { AntV Infographic Streaming Rendering +## Non-Browser Rendering + +Render infographics to SVG strings in Node.js environment (SSR, CLI tools, etc.). + +```ts +import { renderToSVG } from '@antv/infographic/ssr'; + +const result = await renderToSVG({ + input: ` +infographic list-row-simple-horizontal-arrow +data + items: + - label: Step 1 + desc: Start + - label: Step 2 + desc: In Progress + - label: Step 3 + desc: Complete +`, +}); + +console.log(result.svg); +``` + +### CLI Tool + +For command-line usage, use the dedicated CLI package: + +```bash +# Install globally +npm install -g @antv/infographic-cli + +# Render to file +infographic input.txt -o output.svg + +# Render to stdout +infographic input.txt +``` + +See [@antv/infographic-cli](https://www.npmjs.com/package/@antv/infographic-cli) for more details. + ## 💬 Community & Communication - Submit your questions or suggestions on GitHub diff --git a/__tests__/unit/ssr/renderer.test.ts b/__tests__/unit/ssr/renderer.test.ts new file mode 100644 index 000000000..1ae60714c --- /dev/null +++ b/__tests__/unit/ssr/renderer.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest'; +import { renderToSVG } from '../../../src/ssr'; +import { getPalette } from '../../../src/renderer/palettes'; + +describe('SSR Renderer', () => { + it('should failed with unknown_key', async () => { + const syntax = `infograph template +data + items + - label Step 1`; + + const result = await renderToSVG({ + input: syntax, + }); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].code).toBe('unknown_key'); + }); + it('should failed with bad syntax', async () => { + const syntax = `infographic template +data +items + - label Step 1`; + + const result = await renderToSVG({ + input: syntax, + }); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].code).toBe('unknown_key'); + }); + it('should failed with no template', async () => { + const syntax = ` +data + items + - label Step 1`; + + const result = await renderToSVG({ + input: syntax, + }); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toBe('No template specified'); + }); + it('should handle unknown template', async () => { + const syntax = `infographic unknown-template +data + items + - label Step 1 + desc Start`; + + const result = await renderToSVG({ + input: syntax, + }); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors.some((error) => error.message === 'No such template: unknown-template')).toBe(true); + }); + + it('should render simple syntax to SVG', async () => { + const result = await renderToSVG({ + input: `infographic list-row-simple-horizontal-arrow +data + items + - label Step 1 + desc Start + - label Step 2 + desc In Progress + - label Step 3 + desc Complete`, + }); + expect(result.errors).toHaveLength(0); + expect(result.svg).toContain(''); + expect(result.svg).toContain('Step 2'); + expect(result.svg).toContain('Step 3'); + }); + + it('should handle invalid syntax and return errors', async () => { + const result = await renderToSVG({ + input: 'invalid syntax....', + }); + + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should accept options object', async () => { + const result = await renderToSVG({ + input: `infographic list-row-simple-horizontal-arrow +data + items + - label Step 1 + desc Start + - label Step 2 + desc In Progress`, + options: { + // Additional options can be passed here + themeConfig: { + palette: getPalette('spectral'), + }, + }, + }); + + expect(result.svg).toContain(' { + const result = await renderToSVG({ + input: `infographic list-row-simple-horizontal-arrow +data + items + - label 步骤 1 + desc 开始 + - label 步骤 2 + desc 进行中 + - label 步骤 3 + desc 完成`, + }); + + expect(result.svg).toContain(''); + expect(result.svg).toContain('步骤 2'); + expect(result.svg).toContain('步骤 3'); + expect(result.errors).toHaveLength(0); + }); + + it('should return warnings when present', async () => { + const result = await renderToSVG({ + input: `infographic list-row-simple-horizontal-arrow +data + items + - label Test`, + }); + + expect(result.svg).toContain(' { + const result = await renderToSVG({ + input: `infographic list-row-simple-horizontal-arrow +data + title Main Title + desc Description text + items + - label Item 1 + desc First item + - label Item 2 + desc Second item`, + }); + + expect(result.svg).toContain(' { + const result = await renderToSVG({ + input: `infographic list-row-simple-horizontal-arrow +data + items + - label 特殊字符 < > & + desc Test + - label Emoji 😀🎉 + desc Unicode`, + }); + + expect(result.svg).toContain(' { + const result = await renderToSVG({ + input: `infographic list-row-simple-horizontal-arrow +data + items + - label Test Text`, + }); + + expect(result.errors).toHaveLength(0); + const parser = new DOMParser(); + const doc = parser.parseFromString(result.svg, 'image/svg+xml'); + const foreignObject = doc.querySelector('foreignObject'); + expect(foreignObject).toBeDefined(); + const span = foreignObject!.querySelector('span'); + expect(span).toBeDefined(); + expect(span!.namespaceURI).toBe('http://www.w3.org/1999/xhtml'); + }); +}); diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 000000000..f0b054de0 --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,5 @@ +node_modules +lib +tsconfig.tsbuildinfo +coverage +output diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 000000000..4d54a7c6f --- /dev/null +++ b/cli/README.md @@ -0,0 +1,121 @@ +# @antv/infographic-cli + +CLI tool for server-side rendering (SSR) of AntV Infographic. + +## Installation + +```bash +npm install -g @antv/infographic-cli +# or +npx @antv/infographic-cli +``` + +## Usage + +### Basic Usage + +```bash +# Render to stdout +infographic input.txt + +# Render to file +infographic input.txt -o output.svg +infographic input.txt --output output.svg +``` + +### Help + +```bash +infographic --help +# or +infographic -h +``` + +## Input File Format + +The input file should contain AntV Infographic Syntax (YAML-like format). + +Example `input.txt`: + +``` +infographic list-row-simple-horizontal-arrow +data + items + - label Step 1 + desc Start + - label Step 2 + desc In Progress + - label Step 3 + desc Complete +``` + +## Examples + +This package includes a collection of example files in the `examples/` directory demonstrating various templates and features: + +- **Basic lists** - Simple horizontal layouts with arrows +- **Icons & values** - Enhanced visuals with MDI/Lucide icons +- **Timelines** - Temporal data visualization +- **Themes** - Custom color schemes and dark mode +- **Comparisons** - Two-column and quadrant layouts +- **Hierarchies** - Tree structures for organizational data +- **Charts** - Bar charts with metrics + +### Run a Single Example + +```bash +# Using npm script +npm run example examples/01-basic-list.txt -o output.svg + +# Or directly with the binary +node bin/cli.js examples/02-list-with-icons.txt -o icons.svg +``` + +### Run All Examples + +Generate SVG outputs for all examples at once: + +```bash +npm run examples:all +``` + +This will create an `output/` directory with all generated SVG files. + +### Create Your Own + +Create `example.txt`: + +``` +infographic list-row-simple-horizontal-arrow +data + title My Process + items + - label Planning + desc Define requirements + - label Development + desc Build features + - label Testing + desc Quality assurance + - label Deployment + desc Go live +``` + +Render to SVG: + +```bash +infographic example.txt -o process.svg +``` + +For more details, see [examples/README.md](./examples/README.md). + +## Requirements + +- Node.js >= 16 + +## Related + +- [@antv/infographic](https://www.npmjs.com/package/@antv/infographic) - Browser-based infographic rendering library + +## License + +MIT diff --git a/cli/__tests__/cli.test.ts b/cli/__tests__/cli.test.ts new file mode 100644 index 000000000..d852a14de --- /dev/null +++ b/cli/__tests__/cli.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { readFileSync, writeFileSync, mkdirSync, rmSync } from 'fs'; +import { join } from 'path'; +import { parseArgs } from '../src/cli'; + +describe('CLI Argument Parsing', () => { + let originalArgv: string[]; + + beforeEach(() => { + originalArgv = process.argv; + }); + + afterEach(() => { + process.argv = originalArgv; + }); + + it('should parse input file only', () => { + process.argv = ['node', 'cli.js', 'input.txt']; + const args = parseArgs(); + expect(args.inputFile).toBe('input.txt'); + expect(args.outputFile).toBeNull(); + expect(args.help).toBe(false); + }); + + it('should parse input and output with -o flag', () => { + process.argv = ['node', 'cli.js', 'input.txt', '-o', 'output.svg']; + const args = parseArgs(); + expect(args.inputFile).toBe('input.txt'); + expect(args.outputFile).toBe('output.svg'); + expect(args.help).toBe(false); + }); + + it('should parse input and output with --output flag', () => { + process.argv = ['node', 'cli.js', 'input.txt', '--output', 'output.svg']; + const args = parseArgs(); + expect(args.inputFile).toBe('input.txt'); + expect(args.outputFile).toBe('output.svg'); + expect(args.help).toBe(false); + }); + + it('should show help with -h flag', () => { + process.argv = ['node', 'cli.js', '-h']; + const args = parseArgs(); + expect(args.help).toBe(true); + }); + + it('should show help with --help flag', () => { + process.argv = ['node', 'cli.js', '--help']; + const args = parseArgs(); + expect(args.help).toBe(true); + }); + + it('should show help when no arguments provided', () => { + process.argv = ['node', 'cli.js']; + const args = parseArgs(); + expect(args.help).toBe(true); + }); +}); + +describe('CLI Integration', () => { + const testDir = join(__dirname, 'tmp'); + const inputFile = join(testDir, 'test-input.txt'); + const outputFile = join(testDir, 'test-output.svg'); + + beforeEach(() => { + // Create test directory + mkdirSync(testDir, { recursive: true }); + + // Create test input file + const testSyntax = `infographic list-row-simple-horizontal-arrow +data + items + - label Step 1 + desc Start + - label Step 2 + desc End`; + + writeFileSync(inputFile, testSyntax, 'utf-8'); + }); + + afterEach(() => { + // Clean up test directory + rmSync(testDir, { recursive: true, force: true }); + }); + + it('should read input file and render SVG', () => { + const content = readFileSync(inputFile, 'utf-8'); + expect(content).toContain('infographic list-row-simple-horizontal-arrow'); + }); + + it('should handle file I/O correctly', () => { + // Test that we can write to output file + const testContent = ''; + writeFileSync(outputFile, testContent, 'utf-8'); + const content = readFileSync(outputFile, 'utf-8'); + expect(content).toBe(testContent); + }); +}); diff --git a/cli/__tests__/setup.ts b/cli/__tests__/setup.ts new file mode 100644 index 000000000..13c8c533b --- /dev/null +++ b/cli/__tests__/setup.ts @@ -0,0 +1,12 @@ +import { beforeAll, afterAll } from 'vitest'; +import { setupDOM, teardownDOM } from '@antv/infographic/ssr'; + +// Setup jsdom environment before all tests +beforeAll(() => { + setupDOM(); +}); + +// Cleanup after all tests +afterAll(() => { + teardownDOM(); +}); diff --git a/cli/bin/cli.mjs b/cli/bin/cli.mjs new file mode 100755 index 000000000..e3a3cd245 --- /dev/null +++ b/cli/bin/cli.mjs @@ -0,0 +1,6 @@ +#!/usr/bin/env node + +// Entry point for the CLI +// This will load the compiled ESM version +const { main } = await import('../lib/cli.js'); +main(); diff --git a/cli/eslint.config.ts b/cli/eslint.config.ts new file mode 100644 index 000000000..0be8bbd6a --- /dev/null +++ b/cli/eslint.config.ts @@ -0,0 +1,20 @@ +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + ignores: ['node_modules', 'lib', 'esm', 'bin'], + }, + ...tseslint.configs.recommended, + { + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + }, + ], + }, + }, +); diff --git a/cli/examples/01-basic-list.txt b/cli/examples/01-basic-list.txt new file mode 100644 index 000000000..8dc6ffecf --- /dev/null +++ b/cli/examples/01-basic-list.txt @@ -0,0 +1,11 @@ +infographic list-row-simple-horizontal-arrow +data + title Getting Started Example + desc A simple list-based infographic + items + - label Step 1 + desc Initial setup and configuration + - label Step 2 + desc Development and testing + - label Step 3 + desc Deployment and monitoring diff --git a/cli/examples/02-list-with-icons.txt b/cli/examples/02-list-with-icons.txt new file mode 100644 index 000000000..baad47ef8 --- /dev/null +++ b/cli/examples/02-list-with-icons.txt @@ -0,0 +1,37 @@ +infographic list-row-horizontal-icon-arrow +data + title Customer Growth Engine + desc Multi-channel reach and repeat purchases + items + - label Lead Acquisition + value 18.6 + desc Channel investment and content marketing + icon mdi/rocket-launch + - label Conversion Optimization + value 12.4 + desc Lead scoring and automated follow-ups + icon mdi/progress-check + - label Loyalty Boost + value 9.8 + desc Membership programs and benefits + icon mdi/account-sync + - label Brand Advocacy + value 6.2 + desc Community rewards and referral loops + icon mdi/account-group + - label Customer Success + value 7.1 + desc Training support and activation + icon mdi/book-open-page-variant + - label Product Growth + value 10.2 + desc Trial conversion and feature nudges + icon mdi/application-brackets + - label Data Insights + value 8.5 + desc Key metrics and attribution analysis + icon mdi/chart-areaspline + - label Ecosystem Partnership + value 5.4 + desc Co-marketing and resource exchange + icon mdi/handshake diff --git a/cli/examples/03-timeline.txt b/cli/examples/03-timeline.txt new file mode 100644 index 000000000..9eefa1033 --- /dev/null +++ b/cli/examples/03-timeline.txt @@ -0,0 +1,30 @@ +infographic sequence-timeline-rounded-rect-node +data + title Internet Technology Evolution + desc Key milestones from Web 1.0 to AI era + items + - time 1991 + label World Wide Web + desc Tim Berners-Lee launched the first website + icon mdi/web + - time 2004 + label Web 2.0 Rise + desc Social media and user-generated content + icon mdi/account-multiple + - time 2007 + label Mobile Internet + desc iPhone release changed the world + icon mdi/cellphone + - time 2015 + label Cloud Native + desc Containers and microservices adoption + icon mdi/cloud + - time 2020 + label Low-Code Platforms + desc Visual development lowering barriers + icon mdi/application-brackets + - time 2023 + label AI Large Models + desc ChatGPT sparked generative AI revolution + icon mdi/brain +theme antv diff --git a/cli/examples/04-themed-dark.txt b/cli/examples/04-themed-dark.txt new file mode 100644 index 000000000..d261cbb5a --- /dev/null +++ b/cli/examples/04-themed-dark.txt @@ -0,0 +1,20 @@ +infographic list-row-simple-horizontal-arrow +theme dark + colorPrimary #61DDAA + colorBg #1F1F1F +data + title Development Process + desc Software development lifecycle stages + items + - label Planning + desc Requirements gathering and analysis + - label Design + desc Architecture and system design + - label Implementation + desc Coding and development + - label Testing + desc Quality assurance and bug fixing + - label Deployment + desc Production release + - label Maintenance + desc Ongoing support and updates diff --git a/cli/examples/05-quarterly-revenue.txt b/cli/examples/05-quarterly-revenue.txt new file mode 100644 index 000000000..bc2d977f0 --- /dev/null +++ b/cli/examples/05-quarterly-revenue.txt @@ -0,0 +1,22 @@ +infographic quadrant-quarter-simple-card +data + title Quarterly Revenue Overview + desc Business revenue data by quarter + items + - label Q1 + value 68 + desc First quarter performance + icon lucide/calendar + - label Q2 + value 82 + desc Second quarter growth + icon lucide/trending-up + - label Q3 + value 91 + desc Third quarter acceleration + icon lucide/rocket + - label Q4 + value 105 + desc Fourth quarter achievement + icon lucide/trophy +theme antv diff --git a/cli/examples/06-comparison.txt b/cli/examples/06-comparison.txt new file mode 100644 index 000000000..341e08b7f --- /dev/null +++ b/cli/examples/06-comparison.txt @@ -0,0 +1,21 @@ +infographic compare-binary-horizontal-simple-fold +data + title Product Analysis vs Competition + desc Comparative analysis to identify gaps and improvement areas + items + - label Our Product + children + - label Architecture Upgrade + desc Brand marketing strategy focuses on brand output + - label System Optimization + desc Improve performance and user experience + - label Feature Enhancement + desc Add new capabilities based on user feedback + - label Competitor Analysis + children + - label Market Position + desc Strong presence in target segments + - label Technology Stack + desc Modern and scalable infrastructure + - label User Acquisition + desc Effective growth strategies diff --git a/cli/examples/07-hierarchy.txt b/cli/examples/07-hierarchy.txt new file mode 100644 index 000000000..09e51f201 --- /dev/null +++ b/cli/examples/07-hierarchy.txt @@ -0,0 +1,29 @@ +infographic hierarchy-tree-horizontal +data + title User Research + desc Understanding user needs and pain points through research + items + - label User Research + icon mingcute/user-question-line + value 100 + children + - label Why users choose this music platform + icon mingcute/music-2-ai-line + value 80 + children + - label Which channels users learned about platform + icon mingcute/ad-circle-line + value 70 + - label What aspects attracted users + icon mingcute/mushroom-line + value 65 + - label When users use this platform + icon mingcute/time-line + value 75 + children + - label In what scenarios users engage + icon mingcute/calendar-time-add-line + value 60 + - label Which features users utilize + icon mingcute/danmaku-line + value 55 diff --git a/cli/examples/08-quadrant.txt b/cli/examples/08-quadrant.txt new file mode 100644 index 000000000..980d73599 --- /dev/null +++ b/cli/examples/08-quadrant.txt @@ -0,0 +1,21 @@ +infographic compare-quadrant +data + title Risk Control Analysis + desc Risk frequency vs. loss severity analysis + items + - label High Loss High Frequency + desc Directly avoid risks + icon mingcute/currency-bitcoin-2-fill + illus notify + - label Low Loss High Frequency + desc Implement risk control measures + icon mingcute/currency-bitcoin-fill + illus coffee + - label High Loss Low Frequency + desc Transfer risk through insurance + icon mingcute/dogecoin-doge-fill + illus diary + - label Low Loss Low Frequency + desc Accept the risk + icon mingcute/exchange-bitcoin-fill + illus invest diff --git a/cli/examples/09-chart-bars.txt b/cli/examples/09-chart-bars.txt new file mode 100644 index 000000000..c1fe6ee42 --- /dev/null +++ b/cli/examples/09-chart-bars.txt @@ -0,0 +1,21 @@ +infographic chart-bar-plain-text +data + title Annual Revenue Growth + desc Recent three years and current year target revenue comparison (in hundred million yuan) + items + - label 2021 + value 120 + desc Transformation period, steady exploration + icon lucide/sprout + - label 2022 + value 150 + desc Platform optimization, significant efficiency improvement + icon lucide/zap + - label 2023 + value 190 + desc Deepening digital integration, comprehensive growth + icon lucide/brain-circuit + - label 2024 + value 240 + desc Expanding ecosystem collaboration, reaching new heights + icon lucide/trophy diff --git a/cli/examples/10-swot-analysis.txt b/cli/examples/10-swot-analysis.txt new file mode 100644 index 000000000..6fc925a8d --- /dev/null +++ b/cli/examples/10-swot-analysis.txt @@ -0,0 +1,37 @@ +infographic compare-swot +data + title SWOT Analysis + desc Comprehensive analysis of internal and external factors to guide strategic planning + items + - label Strengths + children + - label Leading R&D capabilities + - label Complete supply chain system + - label Efficient customer service + - label Mature management team + - label Good user reputation + - label Stable product quality + - label Weaknesses + children + - label Insufficient brand exposure + - label Slow product line updates + - label Single market channel + - label High operating costs + - label Low organizational decision efficiency + - label Slowing user growth + - label Opportunities + children + - label Accelerating digital transformation + - label Continuous emerging market expansion + - label Policy support for industry development + - label Increasing intelligent application scenarios + - label Growing cross-industry cooperation + - label Consumer upgrade trends + - label Threats + children + - label Increasingly fierce competition + - label Rapidly changing user needs + - label Lowering market entry barriers + - label Rising supply chain risks + - label Intensifying data security challenges + - label Macroeconomic uncertainties diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 000000000..0e4dc022f --- /dev/null +++ b/cli/package.json @@ -0,0 +1,59 @@ +{ + "name": "@antv/infographic-cli", + "version": "0.1.0", + "description": "Tool for rendering of AntV Infographic Syntax", + "keywords": [ + "antv", + "infographic", + "cli", + "ssr", + "visualization" + ], + "repository": { + "type": "git", + "url": "git@github.com:antvis/Infographic.git", + "directory": "cli" + }, + "license": "MIT", + "author": "Aarebecca", + "type": "module", + "bin": { + "infographic": "./bin/cli.mjs" + }, + "main": "./lib/index.js", + "files": [ + "lib", + "bin", + "README.md" + ], + "scripts": { + "build": "run-s clean build:esm", + "build:esm": "tsc", + "clean": "rimraf lib tsconfig.tsbuildinfo", + "format": "prettier --write . --ignore-path ../.gitignore", + "lint": "eslint . --ext .ts", + "test": "vitest --run", + "prepublishOnly": "npm run build", + "example": "npm run build && node ./bin/cli.mjs examples/02-list-with-icons.txt --output output/02-list-with-icons.svg", + "examples:all": "node scripts/run-all-examples.mjs" + }, + "dependencies": { + "@antv/infographic": "file:../", + "linkedom": "^0.18.12", + "svgo": "^4.0.0" + }, + "devDependencies": { + "@types/node": "^24.3.1", + "eslint": "^9.35.0", + "npm-run-all": "^4.1.5", + "prettier": "^3.6.2", + "rimraf": "^6.1.0", + "typescript": "^5.9.2", + "typescript-eslint": "^8.43.0", + "vitest": "^3.2.4" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/cli/scripts/run-all-examples.mjs b/cli/scripts/run-all-examples.mjs new file mode 100644 index 000000000..2410b56a3 --- /dev/null +++ b/cli/scripts/run-all-examples.mjs @@ -0,0 +1,70 @@ +#!/usr/bin/env node + +/** + * Script to run all example files and generate SVG outputs + */ + +import { readdirSync, mkdirSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { spawn } from 'child_process'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const PROJECT_ROOT = join(__dirname, '..'); +const EXAMPLES_DIR = join(PROJECT_ROOT, 'examples'); +const OUTPUT_DIR = join(PROJECT_ROOT, 'output'); +const CLI_BIN = join(PROJECT_ROOT, 'bin', 'cli.mjs'); + +// Create output directory if it doesn't exist +if (!existsSync(OUTPUT_DIR)) { + mkdirSync(OUTPUT_DIR, { recursive: true }); +} + +// Get all .txt example files +const exampleFiles = readdirSync(EXAMPLES_DIR) + .filter((file) => file.endsWith('.txt')) + .sort(); + +console.log(`Found ${exampleFiles.length} example files\n`); + +let completed = 0; +const fails = []; +// Process each example +for (const file of exampleFiles) { + const inputPath = join(EXAMPLES_DIR, file); + const outputName = file.replace('.txt', '.svg'); + const outputPath = join(OUTPUT_DIR, outputName); + + console.log(`Processing: ${file}...`); + + const child = spawn('node', [CLI_BIN, inputPath, '-o', outputPath], { + stdio: 'inherit', + }); + + await new Promise((resolve) => { + child.on('close', (code) => { + if (code === 0) { + console.log(`✓ Generated: ${outputName}\n`); + completed++; + } else { + fails.push(file); + } + resolve(); + }); + }); +} + +// Summary +console.log('\n' + '='.repeat(50)); +console.log(`Completed: ${completed}/${exampleFiles.length}`); +if (fails.length > 0) { + console.group('Failed examples:'); + console.log(fails.join('\n')); + console.groupEnd(); +} +console.log(`Output directory: ${OUTPUT_DIR}`); +console.log('='.repeat(50)); + +process.exit(fails.length > 0 ? 128 : 0); diff --git a/cli/src/ResourceLoader.ts b/cli/src/ResourceLoader.ts new file mode 100644 index 000000000..def5bf774 --- /dev/null +++ b/cli/src/ResourceLoader.ts @@ -0,0 +1,85 @@ +import {loadSVGResource, ResourceConfig } from '@antv/infographic'; +const svgTextCache = new Map(); +const pendingRequests = new Map>(); + +export const resourceLoader = (async (config: ResourceConfig) => { + const {data, scene} = config; + + try { + const key = `${scene}::${data}`; + let svgText: string | null; + + // 1. 命中缓存 + if (svgTextCache.has(key)) { + svgText = svgTextCache.get(key)!; + } + // 2. 已有请求在进行中 + else if (pendingRequests.has(key)) { + svgText = await pendingRequests.get(key)!; + } + // 3. 发起新请求 + else { + const fetchPromise = (async () => { + try { + let url: string | null; + + if (scene === 'icon') { + url = `https://api.iconify.design/${data}.svg`; + } else if (scene === 'illus') { + url = `https://raw.githubusercontent.com/balazser/undraw-svg-collection/refs/heads/main/svgs/${data}.svg`; + } else return null; + + if (!url) return null; + + const response = await fetch(url); + + if (!response.ok) { + console.error(`HTTP ${response.status}: Failed to load ${url}`); + return null; + } + // console.log(`Successfully loaded ${url}`) + const text = await response.text(); + + if (!text || !text.trim().startsWith(' -o + // or: infographic --output + if (args.length < 1) { + return { inputFile: '', outputFile: null, help: true, disableSvgo: false }; + } + + const inputFile = args[0]; + let outputFile: string | null = null; + let disableSvgo = false; + + // Find output flag + const oIndex = args.indexOf('-o'); + const outputIndex = args.indexOf('--output'); + + if (oIndex !== -1 && args[oIndex + 1]) { + outputFile = args[oIndex + 1]; + } else if (outputIndex !== -1 && args[outputIndex + 1]) { + outputFile = args[outputIndex + 1]; + } + + // Check for disable-svgo flag + if (args.includes('--no-svgo')) { + disableSvgo = true; + } + + return { inputFile, outputFile, help: false, disableSvgo }; +} + +/** + * Display help message + */ +function showHelp(): void { + console.log(` +@antv/infographic-cli - Render AntV Infographic Syntax to SVG in command line + +Usage: + infographic [options] + +Options: + -o, --output Output SVG file path (if not specified, prints to stdout) + --no-svgo Disable SVGO optimization + -h, --help Show this help message + +Examples: + # Render to stdout + infographic input.txt + + # Render to file + infographic input.txt -o output.svg + infographic input.txt --output output.svg + +Input File Format: + The input file should contain AntV Infographic Syntax + Example: + infographic list-row-simple-horizontal-arrow + data + items + - label Step 1 + desc Start + - label Step 2 + desc In Progress + - label Step 3 + desc Complete + +The Syntax: https://infographic.antv.vision/learn/infographic-syntax/ +For more information: https://github.com/antvis/Infographic + `); +} + +/** + * Main CLI entry point + */ +async function main(): Promise { + const { inputFile, outputFile, help, disableSvgo } = parseArgs(); + + // Show help + if (help || !inputFile) { + showHelp(); + process.exit(help ? 0 : 1); + } + + try { + // Read input file + const inputPath = resolve(process.cwd(), inputFile); + const syntax = readFileSync(inputPath, 'utf-8'); + // Register resource loader (for custom resource loading logic) + registerResourceLoader(resourceLoader); + + // Render to SVG (resources are now preloaded internally) + const result = await renderToSVG({ input: syntax }); + + // Output warnings to stderr + if (result.warnings.length > 0) { + console.warn('Warnings:'); + result.warnings.forEach((warning: SyntaxError) => { + console.warn( + ` [${warning.code}] ${warning.message} (line ${warning.line})`, + ); + }); + } + + // Check for errors + if (result.errors.length > 0) { + console.error('Errors:'); + result.errors.forEach((error: SyntaxError) => { + console.error( + ` [${error.code}] ${error.message} (line ${error.line})`, + ); + }); + process.exit(1); + } + let svgString = result.svg; + if (!disableSvgo) { + try { + const optimized = svgoOptimize(svgString); + svgString = optimized.data; + } catch (_) { + // ignore svgo error + } + } + + // Write output + if (outputFile) { + const outputPath = resolve(process.cwd(), outputFile); + writeFileSync(outputPath, svgString, 'utf-8'); + console.log(`Successfully rendered to ${outputFile}`); + } else { + // Output to stdout + process.stdout.write(svgString); + } + } catch (error) { + if (error instanceof Error) { + console.error(`Error: ${error.message}`); + } else { + console.error('An unexpected error occurred'); + } + process.exit(1); + } +} + +// Export for testing +export { parseArgs, main, showHelp }; diff --git a/cli/src/index.ts b/cli/src/index.ts new file mode 100644 index 000000000..0c77639aa --- /dev/null +++ b/cli/src/index.ts @@ -0,0 +1,3 @@ +// Re-export SSR functionality from main package +export { renderToSVG, setupDOM, teardownDOM, isSSR } from '@antv/infographic/ssr'; +export type { SSRRenderOptions, SSRRenderResult } from '@antv/infographic/ssr'; diff --git a/cli/test-input.txt b/cli/test-input.txt new file mode 100644 index 000000000..d9574fc80 --- /dev/null +++ b/cli/test-input.txt @@ -0,0 +1,13 @@ +template: list-row-horizontal-icon-arrow +data: + title: Test SSR Rendering + items: + - label: Step 1 + value: 100 + icon: mdi/check-circle + - label: Step 2 + value: 200 + icon: mdi/arrow-right + - label: Step 3 + value: 300 + icon: mdi/star diff --git a/cli/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 000000000..1a967b1be --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020"], + "moduleResolution": "Bundler", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "declaration": false, + "sourceMap": false, + "outDir": "./lib", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "__tests__"] +} diff --git a/cli/vitest.config.ts b/cli/vitest.config.ts new file mode 100644 index 000000000..641850dfa --- /dev/null +++ b/cli/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + setupFiles: ['./__tests__/setup.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['src/**/*.ts'], + exclude: ['src/**/*.test.ts', 'src/**/*.spec.ts'], + }, + }, +}); diff --git a/dev/src/Infographic.tsx b/dev/src/Infographic.tsx index dc298281f..445bc9265 100644 --- a/dev/src/Infographic.tsx +++ b/dev/src/Infographic.tsx @@ -11,8 +11,14 @@ registerResourceLoader(async (config) => { const { data } = config; const type = data.startsWith('illus:') ? 'illustration' : 'icon'; const normalized = data.replace(/^illus:|^icon:/, ''); - const str = await getAsset(type, normalized); - return loadSVGResource(str); + try { + const str = await getAsset(type, normalized); + return loadSVGResource(str); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + console.error('Dev Infographic load asset error', error); + return null; + } }); export const Infographic = ({ diff --git a/dev/src/get-asset.ts b/dev/src/get-asset.ts index 433201753..285701804 100644 --- a/dev/src/get-asset.ts +++ b/dev/src/get-asset.ts @@ -5,7 +5,9 @@ const baseUrl = export async function getAsset(type: string, id: string) { const input = `${baseUrl}/assets?type=${type}&id=${id}`; - const response = await fetchWithCache(input); + const response = await fetchWithCache(input, { + signal: AbortSignal.timeout(3000) + }); const data = await response.arrayBuffer(); const result = decodeAssetByByteOffset(data); return result; diff --git a/dev/vite.config.ts b/dev/vite.config.ts index bcc061f6b..23e049519 100644 --- a/dev/vite.config.ts +++ b/dev/vite.config.ts @@ -3,7 +3,7 @@ import tsconfigPaths from 'vite-tsconfig-paths'; export default defineConfig({ server: { - host: 'local.alipay.com', + // host: 'local.alipay.com', }, plugins: [tsconfigPaths()], optimizeDeps: { diff --git a/package.json b/package.json index 34642fcd5..29c016f19 100644 --- a/package.json +++ b/package.json @@ -13,18 +13,23 @@ "exports": { ".": { "types": "./esm/index.d.ts", - "import": "./esm/index.js", - "require": "./lib/index.js" + "require": "./lib/index.js", + "import": "./esm/index.js" }, "./jsx-runtime": { "types": "./esm/jsx-runtime.d.ts", - "import": "./esm/jsx-runtime.js", - "require": "./lib/jsx-runtime.js" + "require": "./lib/jsx-runtime.js", + "import": "./esm/jsx-runtime.js" }, "./jsx-dev-runtime": { "types": "./esm/jsx-dev-runtime.d.ts", - "import": "./esm/jsx-dev-runtime.js", - "require": "./lib/jsx-dev-runtime.js" + "require": "./lib/jsx-dev-runtime.js", + "import": "./esm/jsx-dev-runtime.js" + }, + "./ssr": { + "types": "./lib/ssr/index.d.ts", + "require": "./lib/ssr/index.js", + "import": "./esm/ssr/index.js" } }, "main": "./lib/index.js", @@ -41,7 +46,7 @@ "scripts": { "build": "run-s clean build:esm build:cjs build:umd size", "build:cjs": "tsc --module commonjs --outDir lib", - "build:esm": "tsc --module ESNext --outDir esm", + "build:esm": "tsc --module ESNext --outDir esm && tsc-alias", "build:site": "npm --prefix site run build:static", "build:umd": "vite build", "build:watch": "tsc -p tsconfig.json --watch", @@ -109,6 +114,7 @@ "@types/css": "^0.0.38", "@types/culori": "^4.0.1", "@types/d3": "^7.4.3", + "@types/jsdom": "^21.1.7", "@types/lodash-es": "^4.17.12", "@types/node": "^24.3.1", "@types/tinycolor2": "^1.4.6", @@ -122,6 +128,7 @@ "husky": "^9.1.7", "jiti": "^2.5.1", "jsdom": "^27.0.0", + "linkedom": "^0.18.12", "lint-staged": "^16.1.6", "npm-run-all": "^4.1.5", "prettier": "^3.6.2", @@ -131,6 +138,7 @@ "rollup": "^4.53.2", "rollup-plugin-visualizer": "^6.0.5", "size-limit": "^11.2.0", + "tsc-alias": "^1.8.16", "typescript": "^5.9.2", "typescript-eslint": "^8.43.0", "vite": "^7.1.5", diff --git a/scripts/add-js-ext.replacer.js b/scripts/add-js-ext.replacer.js new file mode 100644 index 000000000..85f1b3d8b --- /dev/null +++ b/scripts/add-js-ext.replacer.js @@ -0,0 +1,16 @@ +export default function addJsExtensionReplacer({ orig, file, config }) { + const match = orig.match(/(['"`])(?[^'"`]+)\1/); + if (!match || !match.groups) return orig; + + const targetPath = match.groups.path; + + if (/\.(js|jsx|mjs|cjs|json)$/i.test(targetPath)) return orig; + if (targetPath.startsWith('./') || targetPath.startsWith('../')) { + return orig; // handle by tsc-alias + } + // 'css/lib/parse' is directly imported by src/exporter/font.ts + if (targetPath === 'css/lib/parse') { + return orig.replace(targetPath, targetPath+'/index.js'); + } + return orig; +} \ No newline at end of file diff --git a/site/src/content/learn/index.en.md b/site/src/content/learn/index.en.md index b9283041f..5cff744b0 100644 --- a/site/src/content/learn/index.en.md +++ b/site/src/content/learn/index.en.md @@ -167,4 +167,36 @@ onBeforeUnmount(() => { } }); + +### Using in Node.js (Non-Browser Rendering) + +In Node.js environments (such as SSR, SSG, CLI tools), you can use the `ssr` module to render infographics to SVG strings: + +```ts +import { renderToSVG } from '@antv/infographic/ssr'; + +async function renderInfographic() { + const result = await renderToSVG({ + input: ` +infographic list-row-simple-horizontal-arrow +data + items: + - label: Step 1 + desc: Start + - label: Step 2 + desc: In Progress + - label: Step 3 + desc: Complete +`, + }); + + console.log(result.svg); + console.log('Errors:', result.errors); + console.log('Warnings:', result.warnings); +} + +renderInfographic(); +``` + +See [Non-Browser Rendering](/reference/non-browser-rendering) for more details. ``` diff --git a/site/src/content/learn/index.md b/site/src/content/learn/index.md index 76752087b..859275951 100644 --- a/site/src/content/learn/index.md +++ b/site/src/content/learn/index.md @@ -168,3 +168,35 @@ onBeforeUnmount(() => { }); ``` + +### 在 Node.js 中使用(非浏览器中渲染) {#在-node-js-中使用-非浏览器中渲染} + +在 Node.js 环境中(如 SSR、CLI 工具,以及给AI用的MCP、SKILLs等),可以使用 `ssr` 模块将信息图渲染为 SVG 字符串: + +```ts +import { renderToSVG } from '@antv/infographic/ssr'; + +async function renderInfographic() { + const result = await renderToSVG({ + input: ` +infographic list-row-simple-horizontal-arrow +data + items: + - label: 步骤 1 + desc: 开始 + - label: 步骤 2 + desc: 进行中 + - label: 步骤 3 + desc: 完成 +`, + }); + + console.log(result.svg); + console.log('Errors:', result.errors); + console.log('Warnings:', result.warnings); +} + +renderInfographic(); +``` + +详见 [Non-Browser Rendering](/reference/non-browser-rendering)。 diff --git a/site/src/content/reference/non-browser-rendering.en.md b/site/src/content/reference/non-browser-rendering.en.md new file mode 100644 index 000000000..a1754193f --- /dev/null +++ b/site/src/content/reference/non-browser-rendering.en.md @@ -0,0 +1,143 @@ +# Non-Browser Rendering + +Render infographics to SVG strings in Node.js environment, suitable for SSR, CLI tools, and other non-browser scenarios like AI MCPs and SKills. + +## Basic Usage + +```ts +import { renderToSVG } from '@antv/infographic/ssr'; + +const result = await renderToSVG({ + input: ` +infographic list-row-simple-horizontal-arrow +data + items: + - label: Step 1 + desc: Start + - label: Step 2 + desc: In Progress + - label: Step 3 + desc: Complete +`, +}); + +console.log(result.svg); +console.log('Errors:', result.errors); +console.log('Warnings:', result.warnings); +``` + +## API + +### renderToSVG + +```ts +interface SSRRenderOptions { + /** Input: Antv Infographic Syntax string */ + input: string; + /** Optional infographic options */ + options?: Partial; +} + +interface SSRRenderResult { + /** SVG string */ + svg: string; + /** Error list */ + errors: SyntaxError[]; + /** Warning list */ + warnings: SyntaxError[]; +} + +function renderToSVG(options: SSRRenderOptions): Promise; +``` + +### DOM Utility Functions + +```ts +import { setupDOM, teardownDOM, isSSR } from '@antv/infographic/ssr'; + +// Setup jsdom environment (call before rendering) +setupDOM(); + +// Cleanup jsdom environment (call after rendering) +teardownDOM(); + +// Check if currently in non-browser rendering mode +isSSR(): boolean; +``` + +> **Note**: `renderToSVG` automatically handles DOM environment setup and cleanup. If you need manual control, use `setupDOM` and `teardownDOM`. + +## Complete Example + +```ts +import { renderToSVG } from '@antv/infographic/ssr'; +import { writeFileSync } from 'fs'; + +async function renderInfographicToFile() { + const syntax = ` +infographic list-row-simple-horizontal-arrow +data + title My Process + items: + - label: Planning + desc: Define requirements + - label: Development + desc: Build features + - label: Testing + desc: Quality assurance + - label: Deployment + desc: Go live +`; + + const result = await renderToSVG({ input: syntax }); + + if (result.errors.length > 0) { + console.error('Render errors:', result.errors); + process.exit(1); + } + + if (result.warnings.length > 0) { + console.warn('Render warnings:', result.warnings); + } + + writeFileSync('output.svg', result.svg, 'utf-8'); + console.log('Successfully rendered to output.svg'); +} + +renderInfographicToFile(); +``` + +## Using with Custom Resource Loader + +If you need to load custom resources in a non-browser environment, use `registerResourceLoader`. See [Custom Resource Loader](/learn/custom-resource-loader) for details. + +```ts +import { renderToSVG } from '@antv/infographic/ssr'; +import { registerResourceLoader, loadSVGResource } from '@antv/infographic'; + +registerResourceLoader(async (config) => { + const { scene = 'icon', data } = config; + + // Distinguish icon / illus based on scene + let url: string; + if (scene === 'icon') { + url = `https://api.iconify.design/${data}.svg`; + } else { + url = `https://raw.githubusercontent.com/your-org/illustrations/main/${data}.svg`; + } + + // Fetch resource and convert to standard resource object + const response = await fetch(url); + const svgString = await response.text(); + return loadSVGResource(svgString); +}); + +const result = await renderToSVG({ input: syntax }); +``` + +## Notes + +1. **Async Resources**: Icons and illustrations are automatically preloaded in non-browser mode to ensure complete rendering results +2. **Embedded Resources**: Exported SVG embeds all resources by default and can be used directly +3. **Editor Mode**: Editor functionality is automatically disabled in non-browser rendering +4. **Runtime**: Requires Node.js 16+ diff --git a/site/src/content/reference/non-browser-rendering.md b/site/src/content/reference/non-browser-rendering.md new file mode 100644 index 000000000..c89f8aa20 --- /dev/null +++ b/site/src/content/reference/non-browser-rendering.md @@ -0,0 +1,143 @@ +# 非浏览器中渲染 + +在 Node.js 环境中将信息图渲染为 SVG 字符串,适用于 SSR、CLI 工具和给AI用的MCP、SKILLs等非浏览器场景。 + +## 基本用法 + +```ts +import { renderToSVG } from '@antv/infographic/ssr'; + +const result = await renderToSVG({ + input: ` +infographic list-row-simple-horizontal-arrow +data + items: + - label: Step 1 + desc: Start + - label: Step 2 + desc: In Progress + - label: Step 3 + desc: Complete +`, +}); + +console.log(result.svg); +console.log('Errors:', result.errors); +console.log('Warnings:', result.warnings); +``` + +## API + +### renderToSVG + +```ts +interface SSRRenderOptions { + /** 输入:Antv 信息图语法字符串 */ + input: string; + /** 可选的信息图配置 */ + options?: Partial; +} + +interface SSRRenderResult { + /** SVG 字符串 */ + svg: string; + /** 错误列表 */ + errors: SyntaxError[]; + /** 警告列表 */ + warnings: SyntaxError[]; +} + +function renderToSVG(options: SSRRenderOptions): Promise; +``` + +### DOM 工具函数 + +```ts +import { setupDOM, teardownDOM, isSSR } from '@antv/infographic/ssr'; + +// 设置 jsdom 环境(渲染前调用) +setupDOM(); + +// 清理 jsdom 环境(渲染后调用) +teardownDOM(); + +// 检查当前是否在非浏览器渲染模式 +isSSR(): boolean; +``` + +> **注意**:`renderToSVG` 会自动处理 DOM 环境的设置和清理。如果需要手动控制,可以使用 `setupDOM` 和 `teardownDOM`。 + +## 完整示例 + +```ts +import { renderToSVG } from '@antv/infographic/ssr'; +import { writeFileSync } from 'fs'; + +async function renderInfographicToFile() { + const syntax = ` +infographic list-row-simple-horizontal-arrow +data + title My Process + items: + - label: Planning + desc: Define requirements + - label: Development + desc: Build features + - label: Testing + desc: Quality assurance + - label: Deployment + desc: Go live +`; + + const result = await renderToSVG({ input: syntax }); + + if (result.errors.length > 0) { + console.error('Render errors:', result.errors); + process.exit(1); + } + + if (result.warnings.length > 0) { + console.warn('Render warnings:', result.warnings); + } + + writeFileSync('output.svg', result.svg, 'utf-8'); + console.log('Successfully rendered to output.svg'); +} + +renderInfographicToFile(); +``` + +## 配合自定义资源加载器 + +如果需要在非浏览器环境中加载自定义资源,可以使用 `registerResourceLoader`。详见[自定义资源加载器](/learn/custom-resource-loader)。 + +```ts +import { renderToSVG } from '@antv/infographic/ssr'; +import { registerResourceLoader, loadSVGResource } from '@antv/infographic'; + +registerResourceLoader(async (config) => { + const { scene = 'icon', data } = config; + + // 根据 scene 区分 icon / illus + let url: string; + if (scene === 'icon') { + url = `https://api.iconify.design/${data}.svg`; + } else { + url = `https://raw.githubusercontent.com/your-org/illustrations/main/${data}.svg`; + } + + // 请求资源并转换为标准资源对象 + const response = await fetch(url); + const svgString = await response.text(); + return loadSVGResource(svgString); +}); + +const result = await renderToSVG({ input: syntax }); +``` + +## 注意事项 + +1. **异步资源**:非浏览器模式下会自动预加载图标和插图资源,确保渲染结果完整 +2. **嵌入资源**:导出的 SVG 默认嵌入所有资源,可直接使用 +3. **编辑器模式**:非浏览器渲染会自动禁用编辑器功能 +4. **运行环境**:需要 Node.js 16+ 环境 diff --git a/site/src/sidebarReference.en.json b/site/src/sidebarReference.en.json index 4a881f125..e0157785a 100644 --- a/site/src/sidebarReference.en.json +++ b/site/src/sidebarReference.en.json @@ -21,6 +21,10 @@ { "title": "Exports", "path": "/reference/infographic-exports" + }, + { + "title": "Non-Browser Rendering", + "path": "/reference/non-browser-rendering" } ] }, diff --git a/site/src/sidebarReference.json b/site/src/sidebarReference.json index 26f0175da..990bb2e2d 100644 --- a/site/src/sidebarReference.json +++ b/site/src/sidebarReference.json @@ -21,6 +21,10 @@ { "title": "导出内容", "path": "/reference/infographic-exports" + }, + { + "title": "非浏览器中渲染", + "path": "/reference/non-browser-rendering" } ] }, diff --git a/site/src/utils/compileMDX.ts b/site/src/utils/compileMDX.ts index 8979466c0..b9491a564 100644 --- a/site/src/utils/compileMDX.ts +++ b/site/src/utils/compileMDX.ts @@ -3,7 +3,7 @@ import {MDXComponents} from 'components/MDX/MDXComponents'; // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // ~~~~ IMPORTANT: BUMP THIS IF YOU CHANGE ANY CODE BELOW ~~~ -const DISK_CACHE_BREAKER = 10; +const DISK_CACHE_BREAKER = 12; // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ export default async function compileMDX( @@ -142,7 +142,7 @@ export default async function compileMDX( // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // @ts-expect-error -- default exports is existed after eval - const reactTree = fakeExports.default({}); + const reactTree = fakeExports.default({components: MDXComponents}); // Pre-process MDX output and serialize it. let {toc, children} = prepareMDX(reactTree.props.children); diff --git a/src/index.ts b/src/index.ts index d143f792f..3c0200ce4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import pkg from '../package.json'; +import pkg from '../package.json' with { type: 'json' }; export const VERSION = pkg.version; export * from './designs'; @@ -47,6 +47,7 @@ export { setDefaultFont, } from './renderer'; export { loadSVGResource, registerResourceLoader } from './resource'; +export type * from './resource/types'; export { Infographic } from './runtime'; export { parseSyntax } from './syntax'; export { getTemplate, getTemplates, registerTemplate } from './templates'; diff --git a/src/renderer/fonts/loader.ts b/src/renderer/fonts/loader.ts index b2884f5ad..13ce945e3 100644 --- a/src/renderer/fonts/loader.ts +++ b/src/renderer/fonts/loader.ts @@ -1,4 +1,5 @@ import { join, normalizeFontWeightName, splitFontFamily } from '../../utils'; +import { isNode } from '../../utils/is-node'; import { getFont, getFonts } from './registry'; export function getFontURLs(font: string): string[] { @@ -67,6 +68,13 @@ export function loadFont(svg: SVGSVGElement, font: string) { } export function loadFonts(svg: SVGSVGElement) { + // SSR environment: skip font loading + // Check both isNode and SSR flag to avoid affecting tests + const isSSRMode = isNode && (global as any).__ANTV_INFOGRAPHIC_SSR__; + if (isSSRMode) { + return; + } + const fonts = getFonts(); fonts.forEach((font) => loadFont(svg, font.fontFamily)); } diff --git a/src/renderer/fonts/registry.ts b/src/renderer/fonts/registry.ts index 0cd49f5fb..18b89ef82 100644 --- a/src/renderer/fonts/registry.ts +++ b/src/renderer/fonts/registry.ts @@ -3,7 +3,7 @@ import { decodeFontFamily, encodeFontFamily, splitFontFamily, -} from '../../utils'; +} from '../../utils/font'; const FONT_REGISTRY: Map = new Map(); diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 06506a283..d366ddd5d 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -19,6 +19,7 @@ import { setAttributes, setSVGPadding, } from '../utils'; +import { isNode } from '../utils/is-node'; import { renderBackground, renderBaseElement, @@ -48,7 +49,7 @@ export class Renderer implements IRenderer { constructor( private options: ParsedInfographicOptions, private template: SVGSVGElement, - ) {} + ) { } public getOptions(): ParsedInfographicOptions { return this.options; @@ -59,32 +60,39 @@ export class Renderer implements IRenderer { } render(): SVGSVGElement { + const isSSRMode = isNode && (global as any).__ANTV_INFOGRAPHIC_SSR__; + const svg = this.getSVG(); if (this.rendered) return svg; renderTemplate(svg, this.options); - svg.style.visibility = 'hidden'; - const observer = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - mutation.addedNodes.forEach((node) => { - if (node === svg || node.contains(svg)) { - // post render - setView(this.template, this.options); - loadFonts(this.template); - - // disconnect observer - observer.disconnect(); - svg.style.visibility = ''; - } + if (isSSRMode) { + setView(svg, this.options); + loadFonts(svg); + } else { + svg.style.visibility = 'hidden'; + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if (node === svg || node.contains(svg)) { + // post render + setView(this.template, this.options); + loadFonts(this.template); + + // disconnect observer + observer.disconnect(); + svg.style.visibility = ''; + } + }); }); }); - }); - observer.observe(document, { - childList: true, - subtree: true, - }); + observer.observe(document, { + childList: true, + subtree: true, + }); + } this.rendered = true; return svg; } @@ -105,7 +113,6 @@ function fill(svg: SVGSVGElement, options: ParsedInfographicOptions) { renderBaseElement(svg, themeConfig.base?.global); const elements = svg.querySelectorAll(`[data-element-type]`); - elements.forEach((element) => { const id = element.id || ''; if (isTitle(element)) { diff --git a/src/resource/loader.ts b/src/resource/loader.ts index e09e31455..c3ffc4ff2 100644 --- a/src/resource/loader.ts +++ b/src/resource/loader.ts @@ -53,6 +53,19 @@ async function getResource( const RESOURCE_MAP = new Map(); const RESOURCE_LOAD_MAP = new WeakMap>(); +export async function preloadResource( + scene: ResourceScene, + config: string | ResourceConfig, +): Promise { + const cfg = parseResourceConfig(config); + if (!cfg) return; + const id = getResourceId(cfg)!; + + if (!RESOURCE_MAP.has(id)) { + const resource = await getResource(scene, cfg).catch(() => null); + if (resource) RESOURCE_MAP.set(id, resource); + } +} /** * load resource into svg defs * @returns resource ref id @@ -70,8 +83,7 @@ export async function loadResource( const resource = RESOURCE_MAP.has(id) ? RESOURCE_MAP.get(id) || null - : await getResource(scene, cfg, datum); - + : await getResource(scene, cfg, datum).catch(() => null); if (!resource) return null; if (!RESOURCE_LOAD_MAP.has(svg)) RESOURCE_LOAD_MAP.set(svg, new Map()); diff --git a/src/runtime/Infographic.tsx b/src/runtime/Infographic.tsx index 3b1106cb4..f044b0d2a 100644 --- a/src/runtime/Infographic.tsx +++ b/src/runtime/Infographic.tsx @@ -69,7 +69,6 @@ export class Infographic { this.parsedOptions = parseOptions( mergeOptions(DEFAULT_OPTIONS, this.options), ); - if (warnings.length) { this.emitter.emit('warning', warnings); } @@ -101,7 +100,6 @@ export class Infographic { this.emitter.emit('error', new Error('Incomplete options')); return; } - const { container } = this.parsedOptions; const template = this.compose(parsedOptions); const renderer = new Renderer(parsedOptions, template); @@ -114,7 +112,7 @@ export class Infographic { } this.rendered = true; - this.emitter.emit('rendered', { node: this.node, options: this.options }); + this.emitter.emit('rendered', { node: this.node, options: this.options, data: parsedOptions.data }); } /** diff --git a/src/ssr/dom-shim.ts b/src/ssr/dom-shim.ts new file mode 100644 index 000000000..390b3312c --- /dev/null +++ b/src/ssr/dom-shim.ts @@ -0,0 +1,129 @@ +import { parseHTML, DOMParser, Document } from 'linkedom'; + +let globalDoc: Document | null = null; +let globalWin: any = null; + +let isSSRMode = false; + +export function isSSR(): boolean { + return isSSRMode; +} + +export function setupDOM(): { window: any; document: Document } { + if (globalDoc && globalWin) return { window: globalWin, document: globalDoc }; + + isSSRMode = true; + + const { document, window } = parseHTML('
'); + + globalDoc = document; + globalWin = window; + + (global as any).window = window; + (global as any).document = document; + (global as any).DOMParser = DOMParser; + + const domClasses = [ + 'HTMLElement', + 'HTMLDivElement', + 'HTMLSpanElement', + 'HTMLImageElement', + 'HTMLCanvasElement', + 'HTMLInputElement', + 'HTMLButtonElement', + 'Element', + 'Node', + 'Text', + 'Comment', + 'DocumentFragment', + 'Document', + 'XMLSerializer', + 'MutationObserver', + ]; + domClasses.forEach((name) => { + if ((window as any)[name]) (global as any)[name] = (window as any)[name]; + }); + + const svgClasses = [ + 'SVGElement', + 'SVGSVGElement', + 'SVGGraphicsElement', + 'SVGGElement', + 'SVGPathElement', + 'SVGRectElement', + 'SVGCircleElement', + 'SVGTextElement', + 'SVGLineElement', + 'SVGPolygonElement', + 'SVGPolylineElement', + 'SVGEllipseElement', + 'SVGImageElement', + 'SVGDefsElement', + 'SVGUseElement', + 'SVGClipPathElement', + 'SVGLinearGradientElement', + 'SVGRadialGradientElement', + 'SVGStopElement', + 'SVGPatternElement', + 'SVGMaskElement', + 'SVGForeignObjectElement', + ]; + svgClasses.forEach((name) => { + if ((window as any)[name]) (global as any)[name] = (window as any)[name]; + }); + + if (!(document as any).fonts) { + const fontSet = new Set(); + Object.defineProperty(document, 'fonts', { + value: { + add: (font: unknown) => fontSet.add(font), + delete: (font: unknown) => fontSet.delete(font), + has: (font: unknown) => fontSet.has(font), + clear: () => fontSet.clear(), + forEach: (callback: (font: unknown) => void) => fontSet.forEach(callback), + entries: () => fontSet.entries(), + keys: () => fontSet.keys(), + values: () => fontSet.values(), + [Symbol.iterator]: () => fontSet[Symbol.iterator](), + get size() { + return fontSet.size; + }, + get ready() { + return Promise.resolve(this); + }, + check: () => true, + load: () => Promise.resolve([]), + get status() { + return 'loaded'; + }, + onloading: null, + onloadingdone: null, + onloadingerror: null, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => true, + }, + configurable: true, + }); + } + + (globalThis as any).__ANTV_INFOGRAPHIC_SSR__ = true; + + (globalThis as any).requestAnimationFrame = (cb: any) => { + setImmediate(cb); + return 0; + }; + + return { window, document }; +} + +/** + * Teardown linkedom environment + * Clears global references + */ +export function teardownDOM(): void { + globalDoc = null; + globalWin = null; + isSSRMode = false; + delete (globalThis as any).__ANTV_INFOGRAPHIC_SSR__; +} diff --git a/src/ssr/index.ts b/src/ssr/index.ts new file mode 100644 index 000000000..2548cec95 --- /dev/null +++ b/src/ssr/index.ts @@ -0,0 +1,3 @@ +export { renderToSVG } from './renderer'; +export type { SSRRenderOptions, SSRRenderResult } from './renderer'; +export { setupDOM, teardownDOM, isSSR } from './dom-shim'; diff --git a/src/ssr/renderer.ts b/src/ssr/renderer.ts new file mode 100644 index 000000000..3d80f7134 --- /dev/null +++ b/src/ssr/renderer.ts @@ -0,0 +1,172 @@ +import { setupDOM } from './dom-shim'; +import { Infographic } from '../runtime/Infographic'; +import type { InfographicOptions } from '../options'; +import { parseSyntax, type SyntaxError } from '../syntax'; +import type { Data, ItemDatum } from '../types'; +import { preloadResource } from '../resource/loader'; +import { exportToSVG } from '../exporter/svg'; +import { getTemplate } from '../templates'; + +export interface SSRRenderOptions { + /** Input: Antv Infographic Syntax string */ + input: string; + options?: Partial; +} + +export interface SSRRenderResult { + /** SVG string */ + svg: string; + /** Error list */ + errors: SyntaxError[]; + /** Warning list */ + warnings: SyntaxError[]; +} + +/** + * Preload all icons and illus resources before rendering + * This is necessary in SSR environment because loadResource is async + */ +async function preloadResources( + data: Data, +): Promise { + const promises: Promise[] = []; + + // Helper to collect all icons and illus from nested items + function collectFromItem(item: ItemDatum) { + if (item.icon) { + promises.push(preloadResource('icon', item.icon!)); + } + if (item.illus) { + promises.push(preloadResource('illus', item.illus!)); + } + if (item.children) { + item.children.forEach(collectFromItem); + } + } + + // Collect from root level + if (data.illus) { + Object.values(data.illus).forEach((illus) => { + if (illus) promises.push(preloadResource('illus', illus!)); + }); + } + + // Collect from all items + if (data.items) { + data.items.forEach(collectFromItem); + } + + // Wait for all resources to load + if (promises.length > 0) { + await Promise.all(promises); + } +} + +/** + * Render infographic to SVG string in Node.js environment + * Manually controls the rendering pipeline to preload resources before rendering + */ +export async function renderToSVG( + options: SSRRenderOptions, +): Promise { + // 1. Initialize linkedom environment + const { document: globalDocument } = setupDOM(); + + const errors: SyntaxError[] = []; + const warnings: SyntaxError[] = []; + + // 2. Create virtual container (not added to DOM) + const container = globalDocument.getElementById('container') as HTMLElement; + + // 3. Prepare options (disable editor for SSR) + const ssrOptions: Partial = { + ...options.options, + container, + editable: false, + }; + + // 4. Create Infographic instance to parse options + try { + const { options: parsedOptions, errors: parseErrors, warnings: parseWarnings } = parseSyntax(options.input); + if (parseErrors.length > 0) { + // Fast fail with errors + return { svg: '', errors: parseErrors, warnings: parseWarnings }; + } + warnings.push(...parseWarnings); + if (!parsedOptions.data || !parsedOptions.data.items) { + errors.push({ + code: 'bad_syntax', + message: 'Invalid syntax: data.items is required', + path: '', + line: 0, + } as SyntaxError); + } + if (!parsedOptions.template) { + errors.push({ + code: 'bad_syntax', + message: 'No template specified', + path: '', + line: 0, + } as SyntaxError); + } else { + const template = getTemplate(parsedOptions.template); + if (!template) { + errors.push({ + code: 'bad_syntax', + message: `No such template: ${parsedOptions.template}`, + path: '', + line: 0, + } as SyntaxError); + } + } + if (parsedOptions.design && !parsedOptions.design.structure) { + errors.push({ + code: 'bad_syntax', + message: 'Invalid design structure', + path: '', + line: 0, + } as SyntaxError); + } + if (errors.length > 0) { + // Fast fail with errors + return { svg: '', errors: errors, warnings: warnings }; + } + // 5. Preload resources on rendering + await preloadResources(parsedOptions.data!); + const infographic = new Infographic({ ...ssrOptions, ...parsedOptions }); + + // Collect errors and warnings from event emitters + infographic.on('error', (error: SyntaxError) => { + errors.push(error); + throw error; + }); + infographic.on('warning', (warning: SyntaxError) => { + warnings.push(warning); + }); + const svgResultPromise = new Promise((resolve, reject) => { + infographic.on('rendered', async ({ node }) => { + try { + // 6. Export SVG after resources are preloaded + const svg = await exportToSVG(node, { embedResources: true }); + const str = svg.outerHTML; + resolve(str); + } catch (e) { + reject(e); + } + }); + }); + infographic.render(); + const svg = await svgResultPromise; + return { svg, errors, warnings }; + } catch (error) { + if (!(error instanceof SyntaxError)) { + errors.push({ + code: 'render_error', + message: error instanceof Error ? error.message : 'Unknown render error', + path: '', + line: 0, + } as any); + } + return { svg: '', errors, warnings }; + } +} diff --git a/src/utils/data.ts b/src/utils/data.ts index 898bcf1dd..1a980925e 100644 --- a/src/utils/data.ts +++ b/src/utils/data.ts @@ -1,4 +1,4 @@ -import get from 'lodash-es/get'; +import { get } from 'lodash-es'; import type { Data, ItemDatum } from '../types'; /** diff --git a/src/utils/measure-text.ts b/src/utils/measure-text.ts index 16c774540..604739a92 100644 --- a/src/utils/measure-text.ts +++ b/src/utils/measure-text.ts @@ -1,4 +1,4 @@ -import { measureText as measure, registerFont } from 'measury'; +import { measureText as measure, registerFont as registerFontInMeasury } from 'measury'; import AlibabaPuHuiTi from 'measury/fonts/AlibabaPuHuiTi-Regular'; import { TextProps } from '../jsx'; import { DEFAULT_FONT } from '../renderer'; @@ -6,7 +6,7 @@ import { encodeFontFamily } from './font'; import { isNode } from './is-node'; if (isNode) { - registerFont(AlibabaPuHuiTi); + registerFontInMeasury(AlibabaPuHuiTi); } const canUseDOM = @@ -107,7 +107,7 @@ export function measureText( if (attrs.width && attrs.height) { return { width: attrs.width, height: attrs.height }; } - + const { fontFamily = DEFAULT_FONT, fontSize = 14, @@ -126,7 +126,6 @@ export function measureText( const metrics = canUseDOM ? (measureTextInBrowser(content, options) ?? fallback()) : fallback(); - // 额外添加 1% 宽高 return { width: Math.ceil(metrics.width * 1.01), diff --git a/src/utils/recognizer.ts b/src/utils/recognizer.ts index bc90643e6..beed121de 100644 --- a/src/utils/recognizer.ts +++ b/src/utils/recognizer.ts @@ -37,11 +37,11 @@ export const isRoughVolume = (element: SVGElement) => export function isForeignObjectElement( element: any, ): element is SVGForeignObjectElement { - return element.tagName === 'foreignObject'; + return element.tagName?.toLowerCase() === 'foreignobject'; } export function isTextEntity(element: any): element is HTMLSpanElement { - return element.tagName === 'SPAN'; + return element.tagName?.toLowerCase() === 'span'; } export function isEditableText(node: SVGElement): node is TextElement { diff --git a/src/utils/svg.ts b/src/utils/svg.ts index 720460770..8c99e8be7 100644 --- a/src/utils/svg.ts +++ b/src/utils/svg.ts @@ -82,7 +82,6 @@ export function getOrCreateDefs( const defs = svg.querySelector(selector); if (defs) return defs; - const newDefs = createElement('defs'); if (defsId) newDefs.id = defsId; svg.prepend(newDefs); diff --git a/src/utils/text.ts b/src/utils/text.ts index cfe402b29..efd2a535e 100644 --- a/src/utils/text.ts +++ b/src/utils/text.ts @@ -1,4 +1,4 @@ -import camelCase from 'lodash-es/camelCase'; +import { camelCase } from 'lodash-es'; import { TextProps } from '../editor'; import type { TextAttributes, @@ -8,7 +8,10 @@ import type { } from '../types'; import { decodeFontFamily, encodeFontFamily } from './font'; import { isForeignObjectElement } from './recognizer'; +import { isNode } from './is-node'; import { createElement, setAttributes } from './svg'; +import { measureText } from './measure-text'; +import { DEFAULT_FONT } from '../renderer/fonts'; export function getTextEntity(text: SVGElement): HTMLSpanElement | null { if (!isForeignObjectElement(text)) return null; @@ -20,6 +23,8 @@ export function createTextElement( attributes: TextAttributes, ): TextElement { const entity = document.createElement('span'); + // Set xmlns on the span element (HTML content) + entity.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml'); const foreignObject = createElement( 'foreignObject', { overflow: 'visible' }, @@ -46,7 +51,6 @@ export function updateTextElement( if (entity) { Object.assign(entity.style, getTextStyle(attributes)); - if (!width || !height) { const rect = measureTextSpan(entity); if (!width && !text.hasAttribute('width')) width = String(rect.width); @@ -153,14 +157,56 @@ export function getTextStyle(attributes: TextAttributes) { : +lineHeight; if (letterSpacing) style.letterSpacing = `${letterSpacing}px`; if (strokeWidth) style.strokeWidth = `${strokeWidth}px`; - style.fontFamily = fontFamily - ? encodeFontFamily(fontFamily) - : fontFamily || ''; + if (fontFamily) { + style.fontFamily = encodeFontFamily(fontFamily); + } return style; } function measureTextSpan(span: HTMLSpanElement) { + // SSR environment: use measury library for accurate text measurement + // Check both isNode and SSR flag to avoid affecting tests + const isSSRMode = isNode && (global as any).__ANTV_INFOGRAPHIC_SSR__; + if (isSSRMode) { + const text = span.textContent || ''; + const fontSize = parseFloat(span.style.fontSize || '14'); + const fontFamily = span.style.fontFamily || DEFAULT_FONT; + const fontWeight = span.style.fontWeight || 'normal'; + const lineHeightStyle = span.style.lineHeight || '1.4'; + + // Parse line height + let lineHeight: number; + if (lineHeightStyle.endsWith('px')) { + lineHeight = parseFloat(lineHeightStyle); + } else { + const factor = parseFloat(lineHeightStyle); + lineHeight = isNaN(factor) ? fontSize * 1.4 : (factor < 4 ? fontSize * factor : factor); + } + + const metrics = measureText(text, { + fontFamily: encodeFontFamily(fontFamily), + fontSize, + fontWeight, + lineHeight, + }); + + const width = metrics.width; + const height = metrics.height; + return { + width, + height, + top: 0, + left: 0, + bottom: height, + right: width, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect; + } + + // Browser environment or test environment: use precise measurement const parentNode = span.parentNode; span.style.visibility = 'hidden'; document.body.appendChild(span); @@ -172,13 +218,25 @@ function measureTextSpan(span: HTMLSpanElement) { } export function getTextContent(text: TextElement): string { - return getTextEntity(text)?.innerText || ''; + const entity = getTextEntity(text); + if (!entity) return ''; + // Use textContent in SSR environment (jsdom has limited innerText support) + // Check both isNode and SSR flag to avoid affecting tests + const isSSRMode = isNode && (global as any).__ANTV_INFOGRAPHIC_SSR__; + return isSSRMode ? (entity.textContent || '') : (entity.innerText || ''); } export function setTextContent(text: TextElement, content: string): void { const entity = getTextEntity(text); if (entity) { - entity.innerText = content; + // Use textContent in SSR environment (jsdom has limited innerText support) + // Check both isNode and SSR flag to avoid affecting tests + const isSSRMode = isNode && (global as any).__ANTV_INFOGRAPHIC_SSR__; + if (isSSRMode) { + entity.textContent = content; + } else { + entity.innerText = content; + } } } diff --git a/tsconfig.json b/tsconfig.json index 100d13ab9..b481a08b6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,5 +22,19 @@ } }, "include": ["src"], - "exclude": ["node_modules", "dist", "lib", "esm"] + "exclude": ["node_modules", "dist", "lib", "esm"], + "tsc-alias": { + "verbose": false, + "resolveFullPaths": true, + "fileExtensions": { + "inputGlob": "{js,jsx,mjs}", + "outputCheck": ["js", "json", "jsx", "mjs"] + }, + "replacers": { + "add-js-ext": { + "enabled": true, + "file": "./scripts/add-js-ext.replacer.js" + } + } + } }