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()],
+})