svg生成器
This commit is contained in:
parent
cdb9cc29f4
commit
014aec6757
16
rust/svg/Cargo.toml
Normal file
16
rust/svg/Cargo.toml
Normal 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
28
rust/svg/eslint.config.js
Normal 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
13
rust/svg/index.html
Normal 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
29
rust/svg/package.json
Normal 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
287
rust/svg/src/App.css
Normal 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
226
rust/svg/src/App.tsx
Normal 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
26
rust/svg/src/index.css
Normal 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
245
rust/svg/src/lib.rs
Normal 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
10
rust/svg/src/main.tsx
Normal 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>,
|
||||
)
|
26
rust/svg/tsconfig.app.json
Normal file
26
rust/svg/tsconfig.app.json
Normal 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
7
rust/svg/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
24
rust/svg/tsconfig.node.json
Normal file
24
rust/svg/tsconfig.node.json
Normal 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
7
rust/svg/vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
Loading…
Reference in New Issue
Block a user