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