svg生成器

This commit is contained in:
lsy 2024-12-03 19:20:49 +08:00
parent cdb9cc29f4
commit 014aec6757
13 changed files with 944 additions and 0 deletions

16
rust/svg/Cargo.toml Normal file
View File

@ -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"

28
rust/svg/eslint.config.js Normal file
View File

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

13
rust/svg/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

29
rust/svg/package.json Normal file
View File

@ -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"
}
}

287
rust/svg/src/App.css Normal file
View File

@ -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;
}
}

226
rust/svg/src/App.tsx Normal file
View File

@ -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<File | null>(null);
const [svg, setSvg] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div className="container">
<h1> SVG </h1>
<div className="input-group">
<label>
(TTF/OTF):
<input
type="file"
accept=".ttf,.otf"
onChange={handleFileChange}
disabled={isLoading}
/>
</label>
</div>
<div className="input-group">
<label>
:
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
disabled={isLoading}
placeholder="请输入要转换的文字"
/>
</label>
</div>
<div className="input-group">
<label>
:
<input
type="number"
value={fontSize}
onChange={(e) => setFontSize(Number(e.target.value))}
min="8"
max="200"
disabled={isLoading}
/>
</label>
</div>
<button
className="convert-button"
onClick={handleConvert}
disabled={!fontFile || isLoading}
>
{isLoading ? '转换中...' : '生成预览'}
</button>
{isLoading && <div className="loading"> SVG...</div>}
{svg && (
<div className="svg-preview">
<h3>:</h3>
<div dangerouslySetInnerHTML={{ __html: svg }} />
<div className="button-group">
<button
className="download-button"
onClick={handleDownload}
>
SVG
</button>
<button
className="download-button"
onClick={handleDownloadCss}
>
CSS
</button>
</div>
</div>
)}
</div>
);
};
export default App;

26
rust/svg/src/index.css Normal file
View File

@ -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;
}

245
rust/svg/src/lib.rs Normal file
View File

@ -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<u8>,
units_per_em: f32,
ascender: f32,
descender: f32,
}
#[wasm_bindgen]
impl FontHandler {
#[wasm_bindgen(constructor)]
pub fn new(font_data: &[u8]) -> Result<FontHandler, JsValue> {
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<String, JsValue> {
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();
}

10
rust/svg/src/main.tsx Normal file
View File

@ -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(
<StrictMode>
<App />
</StrictMode>,
)

View File

@ -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"]
}

7
rust/svg/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -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"]
}

7
rust/svg/vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})