diff --git a/rust/svg/Cargo.toml b/rust/svg/Cargo.toml new file mode 100644 index 0000000..f6e8555 --- /dev/null +++ b/rust/svg/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "font-to-svg" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wasm-bindgen = "0.2" +js-sys = "0.3" +ttf-parser = "0.25.1" +svg = "0.18.0" +web-sys = { version = "0.3", features = ["console"] } +base64 = "0.22.1" +console_error_panic_hook = "0.1.7" \ No newline at end of file diff --git a/rust/svg/eslint.config.js b/rust/svg/eslint.config.js new file mode 100644 index 0000000..092408a --- /dev/null +++ b/rust/svg/eslint.config.js @@ -0,0 +1,28 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +) diff --git a/rust/svg/index.html b/rust/svg/index.html new file mode 100644 index 0000000..e4b78ea --- /dev/null +++ b/rust/svg/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/rust/svg/package.json b/rust/svg/package.json new file mode 100644 index 0000000..d063282 --- /dev/null +++ b/rust/svg/package.json @@ -0,0 +1,29 @@ +{ + "name": "svg", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@eslint/js": "^9.15.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "eslint": "^9.15.0", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.14", + "globals": "^15.12.0", + "typescript": "~5.6.2", + "typescript-eslint": "^8.15.0", + "vite": "^6.0.1" + } +} diff --git a/rust/svg/src/App.css b/rust/svg/src/App.css new file mode 100644 index 0000000..bd593b2 --- /dev/null +++ b/rust/svg/src/App.css @@ -0,0 +1,287 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} + +.App { + max-width: 800px; + margin: 0 auto; + padding: 20px; +} + +.input-group { + margin: 20px 0; +} + +.input-group input { + margin: 10px 0; + padding: 8px; + width: 100%; + max-width: 300px; +} + +.button { + background-color: #4CAF50; + border: none; + color: white; + padding: 15px 32px; + text-align: center; + text-decoration: none; + display: inline-block; + font-size: 16px; + margin: 4px 2px; + cursor: pointer; + border-radius: 4px; +} + +.button:disabled { + background-color: #cccccc; + cursor: not-allowed; +} + +.svg-preview { + margin-top: 20px; + padding: 20px; + border: 1px solid #ddd; + border-radius: 8px; + background: #fff; +} + +.svg-preview svg { + max-width: 100%; + height: auto; + min-height: 200px; +} + +/* 深色模式支持 */ +@media (prefers-color-scheme: dark) { + .svg-preview { + background: #1a1a1a; + border-color: #333; + } + + .svg-preview svg { + color: #fff; + } +} + +.error { + color: red; + margin: 10px 0; +} + +.container { + max-width: 800px; + margin: 0 auto; + padding: 20px; +} + +.input-group { + margin: 20px 0; +} + +.input-group label { + display: block; + margin-bottom: 5px; +} + +.input-group input { + width: 100%; + padding: 8px; + margin-top: 5px; +} + +.loading { + text-align: center; + padding: 20px; + color: #666; +} + +.svg-preview { + margin-top: 20px; + padding: 20px; + border: 1px solid #ddd; + border-radius: 4px; +} + +.svg-preview h3 { + margin-top: 0; +} + +.convert-button { + background-color: #4CAF50; + border: none; + color: white; + padding: 12px 24px; + text-align: center; + text-decoration: none; + display: inline-block; + font-size: 16px; + margin: 20px 0; + cursor: pointer; + border-radius: 4px; + transition: background-color 0.3s; +} + +.convert-button:hover { + background-color: #45a049; +} + +.convert-button:disabled { + background-color: #cccccc; + cursor: not-allowed; +} + +.download-button { + background-color: #2196F3; + border: none; + color: white; + padding: 12px 24px; + text-align: center; + text-decoration: none; + display: inline-block; + font-size: 16px; + margin: 10px 0; + cursor: pointer; + border-radius: 4px; + transition: background-color 0.3s; +} + +.download-button:hover { + background-color: #1976D2; +} + +.button-group { + display: flex; + gap: 10px; + justify-content: center; + margin-top: 15px; +} + +.button-group .download-button { + margin: 0; +} + +.animated-text { + max-width: 100%; + height: auto; + min-height: 200px; +} + +.animated-text path { + fill: transparent; + stroke: currentColor; + stroke-width: 2; + stroke-dashoffset: var(--path-length); + stroke-dasharray: var(--path-length) var(--path-length); + animation: logo-anim 15s cubic-bezier(0.4, 0, 0.2, 1) infinite; + transform-origin: center; + stroke-linecap: round; + stroke-linejoin: round; +} + +@keyframes logo-anim { + 0% { + stroke-dashoffset: var(--path-length); + stroke-dasharray: var(--path-length) var(--path-length); + opacity: 0; + fill: transparent; + } + + 5% { + opacity: 1; + stroke-dashoffset: var(--path-length); + stroke-dasharray: var(--path-length) var(--path-length); + } + + /* 慢速绘画过程 */ + 50% { + stroke-dashoffset: 0; + stroke-dasharray: var(--path-length) var(--path-length); + fill: transparent; + } + + /* 慢慢填充效果 */ + 60% { + stroke-dashoffset: 0; + stroke-dasharray: var(--path-length) var(--path-length); + fill: currentColor; + opacity: 1; + } + + /* 保持填充状态 */ + 75% { + stroke-dashoffset: 0; + stroke-dasharray: var(--path-length) var(--path-length); + fill: currentColor; + opacity: 1; + } + + /* 变回线条 */ + 85% { + stroke-dashoffset: 0; + stroke-dasharray: var(--path-length) var(--path-length); + fill: transparent; + opacity: 1; + } + + /* 线条消失 */ + 95% { + stroke-dashoffset: var(--path-length); + stroke-dasharray: var(--path-length) var(--path-length); + fill: transparent; + opacity: 1; + } + + 100% { + stroke-dashoffset: var(--path-length); + stroke-dasharray: var(--path-length) var(--path-length); + fill: transparent; + opacity: 0; + } +} + +/* 确保在暗色模式下的颜色正确 */ +@media (prefers-color-scheme: dark) { + .animated-text path { + stroke: currentColor; + } +} diff --git a/rust/svg/src/App.tsx b/rust/svg/src/App.tsx new file mode 100644 index 0000000..605292a --- /dev/null +++ b/rust/svg/src/App.tsx @@ -0,0 +1,226 @@ +import React, { useState } from 'react'; +import './App.css'; + +const App: React.FC = () => { + const [text, setText] = useState('Hello World'); + const [fontSize, setFontSize] = useState(100); + const [fontFile, setFontFile] = useState(null); + const [svg, setSvg] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const handleFileChange = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files[0]) { + setFontFile(e.target.files[0]); + } + }; + + const generateSvg = async () => { + if (!fontFile) return; + + setIsLoading(true); + try { + const wasm = await import('../pkg/font_to_svg'); + await wasm.default(); + wasm.init_panic_hook(); + + const arrayBuffer = await fontFile.arrayBuffer(); + const fontHandler = new wasm.FontHandler(new Uint8Array(arrayBuffer)); + const svgString = fontHandler.generate_svg(text, fontSize); + + setSvg(svgString); + } catch (error) { + console.error('Error generating SVG:', error); + } finally { + setIsLoading(false); + } + }; + + const handleConvert = () => { + if (fontFile) { + generateSvg(); + } + }; + + const handleDownload = () => { + if (svg) { + const blob = new Blob([svg], { type: 'image/svg+xml' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${text}.svg`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + }; + + const handleDownloadCss = () => { + const animationCss = ` +.animated-text { + max-width: 100%; + height: auto; +} + +.animated-text path { + fill: transparent; + stroke: currentColor; + stroke-width: 2; + stroke-dashoffset: var(--path-length); + stroke-dasharray: var(--path-length) var(--path-length); + animation: logo-anim 15s cubic-bezier(0.4, 0, 0.2, 1) infinite; + transform-origin: center; + stroke-linecap: round; + stroke-linejoin: round; +} + +@keyframes logo-anim { + 0% { + stroke-dashoffset: var(--path-length); + stroke-dasharray: var(--path-length) var(--path-length); + opacity: 0; + fill: transparent; + } + + 5% { + opacity: 1; + stroke-dashoffset: var(--path-length); + stroke-dasharray: var(--path-length) var(--path-length); + } + + /* 慢速绘画过程 */ + 50% { + stroke-dashoffset: 0; + stroke-dasharray: var(--path-length) var(--path-length); + fill: transparent; + } + + /* 慢慢填充效果 */ + 60% { + stroke-dashoffset: 0; + stroke-dasharray: var(--path-length) var(--path-length); + fill: currentColor; + opacity: 1; + } + + /* 保持填充状态 */ + 75% { + stroke-dashoffset: 0; + stroke-dasharray: var(--path-length) var(--path-length); + fill: currentColor; + opacity: 1; + } + + /* 变回线条 */ + 85% { + stroke-dashoffset: 0; + stroke-dasharray: var(--path-length) var(--path-length); + fill: transparent; + opacity: 1; + } + + /* 线条消失 */ + 95% { + stroke-dashoffset: var(--path-length); + stroke-dasharray: var(--path-length) var(--path-length); + fill: transparent; + opacity: 1; + } + + 100% { + stroke-dashoffset: var(--path-length); + stroke-dasharray: var(--path-length) var(--path-length); + fill: transparent; + opacity: 0; + } +}`; + + const blob = new Blob([animationCss], { type: 'text/css' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${text}-animation.css`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + return ( +
+

字体 SVG 转换器

+ +
+ +
+ +
+ +
+ +
+ +
+ + + + {isLoading &&
正在生成 SVG...
} + + {svg && ( +
+

预览:

+
+
+ + +
+
+ )} +
+ ); +}; + +export default App; \ No newline at end of file diff --git a/rust/svg/src/index.css b/rust/svg/src/index.css new file mode 100644 index 0000000..ca73bd3 --- /dev/null +++ b/rust/svg/src/index.css @@ -0,0 +1,26 @@ +.App { + text-align: center; + padding: 20px; +} + +input { + margin: 10px; + padding: 8px; + font-size: 16px; +} + +button { + padding: 10px 20px; + font-size: 16px; + cursor: pointer; +} + +button:disabled { + background-color: #ccc; +} + +#svgContainer { + margin-top: 20px; + border: 1px solid #ddd; + padding: 10px; +} diff --git a/rust/svg/src/lib.rs b/rust/svg/src/lib.rs new file mode 100644 index 0000000..b21a52d --- /dev/null +++ b/rust/svg/src/lib.rs @@ -0,0 +1,245 @@ +// src/lib.rs +use wasm_bindgen::prelude::*; +use ttf_parser::{Face, GlyphId, OutlineBuilder}; +use svg::Document; +use svg::node::element::{Path, Text, SVG}; +use std::collections::HashMap; +use console_error_panic_hook; + +struct PathBuilder { + path_data: String, + x_offset: f32, + y_offset: f32, + scale: f32, + path_length: f32, + current_x: f32, + current_y: f32, + min_x: f32, + max_x: f32, + min_y: f32, + max_y: f32, +} + +impl PathBuilder { + fn new(x_offset: f32, y_offset: f32, scale: f32) -> Self { + PathBuilder { + path_data: String::new(), + x_offset, + y_offset, + scale, + path_length: 0.0, + current_x: 0.0, + current_y: 0.0, + min_x: f32::MAX, + max_x: f32::MIN, + min_y: f32::MAX, + max_y: f32::MIN, + } + } + + fn calculate_distance(&self, x1: f32, y1: f32, x2: f32, y2: f32) -> f32 { + let dx = x2 - x1; + let dy = y2 - y1; + (dx * dx + dy * dy).sqrt() + } + + fn calculate_curve_length(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) -> f32 { + let segments = 10; + let mut length = 0.0; + let mut prev_x = self.current_x; + let mut prev_y = self.current_y; + + for i in 1..=segments { + let t = i as f32 / segments as f32; + let t2 = t * t; + let t3 = t2 * t; + let mt = 1.0 - t; + let mt2 = mt * mt; + let mt3 = mt2 * mt; + + let x = mt3 * self.current_x + 3.0 * mt2 * t * x1 + 3.0 * mt * t2 * x2 + t3 * x; + let y = mt3 * self.current_y + 3.0 * mt2 * t * y1 + 3.0 * mt * t2 * y2 + t3 * y; + + length += self.calculate_distance(prev_x, prev_y, x, y); + prev_x = x; + prev_y = y; + } + + length + } + + fn update_bounds(&mut self, x: f32, y: f32) { + self.min_x = self.min_x.min(x); + self.max_x = self.max_x.max(x); + self.min_y = self.min_y.min(y); + self.max_y = self.max_y.max(y); + } +} + +impl OutlineBuilder for PathBuilder { + fn move_to(&mut self, x: f32, y: f32) { + let scaled_x = x * self.scale + self.x_offset; + let scaled_y = self.y_offset - y * self.scale; + self.update_bounds(scaled_x, scaled_y); + self.path_data.push_str(&format!("M {} {} ", scaled_x, scaled_y)); + self.current_x = scaled_x; + self.current_y = scaled_y; + } + + fn line_to(&mut self, x: f32, y: f32) { + let scaled_x = x * self.scale + self.x_offset; + let scaled_y = self.y_offset - y * self.scale; + self.update_bounds(scaled_x, scaled_y); + let length = self.calculate_distance(self.current_x, self.current_y, scaled_x, scaled_y); + self.path_length += length; + self.path_data.push_str(&format!("L {} {} ", scaled_x, scaled_y)); + self.current_x = scaled_x; + self.current_y = scaled_y; + } + + fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) { + let scaled_x1 = x1 * self.scale + self.x_offset; + let scaled_y1 = self.y_offset - y1 * self.scale; + let scaled_x = x * self.scale + self.x_offset; + let scaled_y = self.y_offset - y * self.scale; + self.update_bounds(scaled_x1, scaled_y1); + self.update_bounds(scaled_x, scaled_y); + + let length = self.calculate_distance(self.current_x, self.current_y, scaled_x1, scaled_y1) + + self.calculate_distance(scaled_x1, scaled_y1, scaled_x, scaled_y); + self.path_length += length; + + self.path_data.push_str(&format!("Q {} {} {} {} ", scaled_x1, scaled_y1, scaled_x, scaled_y)); + self.current_x = scaled_x; + self.current_y = scaled_y; + } + + fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) { + let scaled_x1 = x1 * self.scale + self.x_offset; + let scaled_y1 = self.y_offset - y1 * self.scale; + let scaled_x2 = x2 * self.scale + self.x_offset; + let scaled_y2 = self.y_offset - y2 * self.scale; + let scaled_x = x * self.scale + self.x_offset; + let scaled_y = self.y_offset - y * self.scale; + self.update_bounds(scaled_x1, scaled_y1); + self.update_bounds(scaled_x2, scaled_y2); + self.update_bounds(scaled_x, scaled_y); + + let length = self.calculate_curve_length(scaled_x1, scaled_y1, scaled_x2, scaled_y2, scaled_x, scaled_y); + self.path_length += length; + + self.path_data.push_str(&format!("C {} {} {} {} {} {} ", + scaled_x1, scaled_y1, scaled_x2, scaled_y2, scaled_x, scaled_y)); + self.current_x = scaled_x; + self.current_y = scaled_y; + } + + fn close(&mut self) { + if self.current_x != self.x_offset || self.current_y != self.y_offset { + let length = self.calculate_distance(self.current_x, self.current_y, self.x_offset, self.y_offset); + self.path_length += length; + } + self.path_data.push('Z'); + } +} + +#[wasm_bindgen] +pub struct FontHandler { + font_data: Vec, + units_per_em: f32, + ascender: f32, + descender: f32, +} + +#[wasm_bindgen] +impl FontHandler { + #[wasm_bindgen(constructor)] + pub fn new(font_data: &[u8]) -> Result { + let font_data = font_data.to_vec(); // Own the data + let face = Face::parse(&font_data, 0) + .map_err(|e| JsValue::from_str(&format!("Failed to parse font: {:?}", e)))?; + + Ok(FontHandler { + units_per_em: face.units_per_em() as f32, + ascender: face.ascender() as f32, + descender: face.descender() as f32, + font_data, + }) + } + + #[wasm_bindgen] + pub fn generate_svg(&self, text: &str, font_size: f32) -> Result { + let face = Face::parse(&self.font_data, 0) + .map_err(|e| JsValue::from_str(&format!("Failed to parse font: {:?}", e)))?; + + let scale_factor = font_size / self.units_per_em; + let baseline = font_size * 1.2; + + let mut x_position = 0.0; + let mut paths = Vec::new(); + let mut total_width = 0.0; + + let mut min_x = f32::MAX; + let mut max_x = f32::MIN; + let mut min_y = f32::MAX; + let mut max_y = f32::MIN; + + for c in text.chars() { + if let Some(glyph_id) = face.glyph_index(c) { + if let Some(advance) = face.glyph_hor_advance(glyph_id) { + total_width += advance as f32 * scale_factor; + } + } + } + + let margin = font_size * 0.5; + let total_width = total_width + margin * 2.0; + x_position = margin; + + for c in text.chars() { + if let Some(glyph_id) = face.glyph_index(c) { + if let Some(advance) = face.glyph_hor_advance(glyph_id) { + let mut builder = PathBuilder::new(x_position, baseline, scale_factor); + + if let Some(_) = face.outline_glyph(glyph_id, &mut builder) { + if !builder.path_data.is_empty() { + min_x = min_x.min(builder.min_x); + max_x = max_x.max(builder.max_x); + min_y = min_y.min(builder.min_y); + max_y = max_y.max(builder.max_y); + + paths.push(Path::new() + .set("d", builder.path_data) + .set("fill", "currentColor")); + } + } + + x_position += advance as f32 * scale_factor; + } + } + } + + let padding = font_size * 0.1; + let view_box_x = min_x - padding; + let view_box_y = min_y - padding; + let view_box_width = (max_x - min_x) + padding * 2.0; + let view_box_height = (max_y - min_y) + padding * 2.0; + + let mut document = Document::new() + .set("viewBox", (view_box_x, view_box_y, view_box_width, view_box_height)) + .set("class", "animated-text") + .set("preserveAspectRatio", "xMidYMid meet") + .set("style", "width: 100%; height: 100%;"); + + for path in paths { + document = document.add(path); + } + + Ok(document.to_string()) + } +} + +#[wasm_bindgen] +pub fn init_panic_hook() { + console_error_panic_hook::set_once(); +} \ No newline at end of file diff --git a/rust/svg/src/main.tsx b/rust/svg/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/rust/svg/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/rust/svg/tsconfig.app.json b/rust/svg/tsconfig.app.json new file mode 100644 index 0000000..c2d501f --- /dev/null +++ b/rust/svg/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "incremental": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/rust/svg/tsconfig.json b/rust/svg/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/rust/svg/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/rust/svg/tsconfig.node.json b/rust/svg/tsconfig.node.json new file mode 100644 index 0000000..db0becc --- /dev/null +++ b/rust/svg/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/rust/svg/vite.config.ts b/rust/svg/vite.config.ts new file mode 100644 index 0000000..8b0f57b --- /dev/null +++ b/rust/svg/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], +})