update at 2026-02-07 11:14:09
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
3
frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
5
frontend/README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Vue 3 + TypeScript + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||||
|
Before Width: | Height: | Size: 989 B After Width: | Height: | Size: 989 B |
10
frontend/assets/icons/checkbox.svg
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="none" viewBox="0 0 18 18">
|
||||||
|
<g clip-path="url(#a)">
|
||||||
|
<path fill="#8552A1" d="M9 0a9 9 0 0 0-9 9 9 9 0 0 0 9 9 9 9 0 0 0 9-9 9 9 0 0 0-9-9Zm5.934 6.21L8.16 12.988a.843.843 0 0 1-.599.247.844.844 0 0 1-.6-.247L3.066 9.09a.846.846 0 1 1 1.198-1.197L7.56 11.19l6.177-6.177a.847.847 0 1 1 1.197 1.198Z"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="a">
|
||||||
|
<path fill="#fff" d="M0 0h18v18H0z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 488 B |
21
frontend/assets/icons/choose-color.svg
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" fill="none" viewBox="0 0 36 36">
|
||||||
|
<path fill="#FCFCFC" d="M22.774 13.238a6.734 6.734 0 1 1-4.77-1.98 6.702 6.702 0 0 1 4.77 1.98Z"/>
|
||||||
|
<path fill="#00E8CF" d="M18.005 6.76v4.498a6.703 6.703 0 0 0-4.769 1.98L10.05 10.05a11.238 11.238 0 0 1 7.955-3.292Z"/>
|
||||||
|
<path fill="#70FFEF" d="M18.005.761V6.76a11.239 11.239 0 0 0-7.955 3.292L5.813 5.815A17.162 17.162 0 0 1 18.005.76Z"/>
|
||||||
|
<path fill="#0064B5" d="m10.05 10.051 3.186 3.187a6.702 6.702 0 0 0-1.98 4.768H6.759a11.239 11.239 0 0 1 3.292-7.955Z"/>
|
||||||
|
<path fill="#0091FF" d="M10.05 10.051a11.238 11.238 0 0 0-3.292 7.955H.76A17.162 17.162 0 0 1 5.813 5.815l4.237 4.236Z"/>
|
||||||
|
<path fill="#31C4FF" d="M11.257 18.006a6.7 6.7 0 0 0 1.98 4.769l-3.187 3.187a11.24 11.24 0 0 1-3.292-7.956h4.499Z"/>
|
||||||
|
<path fill="#9EEBFF" d="M6.758 18.006a11.24 11.24 0 0 0 3.292 7.956l-4.237 4.236A17.162 17.162 0 0 1 .76 18.006h5.998Z"/>
|
||||||
|
<path fill="#5F4A9E" d="M18.005 24.754v4.5a11.239 11.239 0 0 1-7.955-3.292l3.186-3.187a6.702 6.702 0 0 0 4.769 1.98Z"/>
|
||||||
|
<path fill="#9D87E0" d="M10.05 25.962a11.24 11.24 0 0 0 7.955 3.291v5.998a17.16 17.16 0 0 1-12.192-5.053l4.237-4.236Z"/>
|
||||||
|
<path fill="#FF468C" d="M25.96 25.962a11.241 11.241 0 0 1-7.955 3.291v-4.499a6.7 6.7 0 0 0 4.769-1.98l3.186 3.188Z"/>
|
||||||
|
<path fill="#FFA1C8" d="m25.96 25.962 4.236 4.236a17.162 17.162 0 0 1-12.191 5.053v-5.998a11.239 11.239 0 0 0 7.955-3.291Z"/>
|
||||||
|
<path fill="#F03049" d="M24.753 18.006h4.499a11.241 11.241 0 0 1-3.292 7.956l-3.186-3.187a6.7 6.7 0 0 0 1.979-4.769Z"/>
|
||||||
|
<path fill="#FF636E" d="M29.252 18.006h5.998a17.163 17.163 0 0 1-5.053 12.192l-4.237-4.236a11.241 11.241 0 0 0 3.292-7.956Z"/>
|
||||||
|
<path fill="#FE8205" d="M25.96 10.051a11.24 11.24 0 0 1 3.292 7.955h-4.499a6.701 6.701 0 0 0-1.98-4.768l3.187-3.187Z"/>
|
||||||
|
<path fill="#FFA426" d="M35.25 18.006h-5.998a11.24 11.24 0 0 0-3.292-7.955l4.236-4.236a17.163 17.163 0 0 1 5.054 12.191Z"/>
|
||||||
|
<path fill="#FFC247" d="m25.96 10.051-3.186 3.187a6.702 6.702 0 0 0-4.77-1.98V6.76a11.24 11.24 0 0 1 7.956 3.292Z"/>
|
||||||
|
<path fill="#FFFD78" d="M30.197 5.815 25.96 10.05a11.24 11.24 0 0 0-7.955-3.292V.761a17.162 17.162 0 0 1 12.192 5.054Z"/>
|
||||||
|
<path fill="#000" d="m32.863 10.832 1.35-.653a19.376 19.376 0 0 0-.854-1.56L32.08 9.4c.286.467.546.948.783 1.432Z"/>
|
||||||
|
<path fill="#000" d="m34.88 11.743-1.406.525c.589 1.601.924 3.284.993 4.988H29.97a11.883 11.883 0 0 0-2.967-7.186l3.186-3.185c.317.347.62.707.905 1.08l1.19-.914a17.995 17.995 0 0 0-29.629 20.35l1.277-.786a16.459 16.459 0 0 1-2.405-7.859H6.03a11.882 11.882 0 0 0 2.985 7.18l-3.188 3.187a16.61 16.61 0 0 1-.91-1.075l-1.19.914A18 18 0 0 0 34.88 11.744ZM5.822 6.883l3.185 3.187a11.883 11.883 0 0 0-2.965 7.186H1.543a16.345 16.345 0 0 1 4.28-10.372Zm16.424 15.365a5.998 5.998 0 1 1 1.757-4.242 5.96 5.96 0 0 1-1.757 4.242ZM12.2 13.26a7.424 7.424 0 0 0-1.652 3.995H7.545a10.39 10.39 0 0 1 2.533-6.116l2.121 2.12Zm10.55-1.06a7.423 7.423 0 0 0-3.995-1.651V7.546a10.39 10.39 0 0 1 6.117 2.533L22.75 12.2Zm-5.495-1.655a7.422 7.422 0 0 0-3.993 1.658l-2.124-2.123a10.39 10.39 0 0 1 6.117-2.534v3Zm-6.711 8.211A7.423 7.423 0 0 0 12.2 22.75l-2.123 2.124a10.39 10.39 0 0 1-2.533-6.117h2.999Zm2.715 5.057a7.423 7.423 0 0 0 3.996 1.65v3.004a10.39 10.39 0 0 1-6.117-2.534l2.121-2.12Zm5.496 1.654a7.423 7.423 0 0 0 3.993-1.657l2.123 2.123a10.39 10.39 0 0 1-6.116 2.534v-3Zm7.177-.594-2.121-2.121a7.423 7.423 0 0 0 1.651-3.996h3.003a10.39 10.39 0 0 1-2.533 6.117Zm2.533-7.617h-2.999a7.423 7.423 0 0 0-1.658-3.993l2.124-2.123a10.39 10.39 0 0 1 2.533 6.116Zm-9.71-15.729a16.358 16.358 0 0 1 10.36 4.31l-3.181 3.18a11.89 11.89 0 0 0-7.18-2.985V1.527Zm-1.5.018v4.5a11.89 11.89 0 0 0-7.187 2.964L6.882 5.824a16.345 16.345 0 0 1 10.373-4.279Zm-7.187 25.459a11.89 11.89 0 0 0 7.187 2.965v4.499a16.354 16.354 0 0 1-10.373-4.28l3.186-3.184Zm8.687 7.481V29.98a11.898 11.898 0 0 0 7.186-2.976l3.186 3.186a16.37 16.37 0 0 1-10.372 4.295Zm11.42-5.37-3.18-3.18a11.882 11.882 0 0 0 2.983-7.179h4.506a16.352 16.352 0 0 1-4.31 10.36Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 330 B After Width: | Height: | Size: 330 B |
5
frontend/assets/icons/export-png.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="none" viewBox="0 0 48 48">
|
||||||
|
<circle cx="24" cy="24" r="24" fill="#2420A8" opacity=".2"/>
|
||||||
|
<circle cx="24" cy="24" r="18" fill="#2420A8"/>
|
||||||
|
<path fill="#F3EDF7" d="M9 31.301v-12.3h4.296c.833 0 1.463.051 1.891.155.429.104.839.31 1.23.615 1.065.832 1.598 2.112 1.598 3.837 0 1.31-.392 2.369-1.175 3.177a3.537 3.537 0 0 1-1.35.88c-.508.184-1.129.276-1.863.276H11.46v3.36H9Zm4.131-10.391h-1.67v5.122h1.505c.795 0 1.377-.172 1.744-.514.514-.453.771-1.15.771-2.093 0-.808-.202-1.43-.606-1.864-.404-.434-.985-.651-1.744-.651Zm6.683 7.031v-8.94h4.737c.71 0 1.251.051 1.625.155.373.104.713.29 1.019.56.624.575.936 1.481.936 2.717v5.508h-2.46v-5.783c0-.44-.098-.759-.294-.955-.196-.196-.52-.293-.973-.293h-2.13v7.031h-2.46ZM34.41 19H39v8.886c0 .502-.058.949-.174 1.34a2.807 2.807 0 0 1-.505.992 2.161 2.161 0 0 1-1.02.707c-.397.128-.975.193-1.734.193h-4.902v-1.91h4.571c.539 0 .89-.082 1.056-.248.165-.165.248-.505.248-1.019h-2.258c-.723 0-1.32-.076-1.79-.23a3.381 3.381 0 0 1-1.277-.761c-.893-.845-1.34-2.008-1.34-3.488 0-1.69.545-2.92 1.634-3.69a3.806 3.806 0 0 1 1.24-.598c.434-.116.988-.174 1.661-.174Zm2.13 7.032V20.91h-2.038c-1.42 0-2.13.856-2.13 2.57 0 .832.193 1.466.578 1.9.386.435.946.652 1.68.652h1.91Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
5
frontend/assets/icons/export-svg.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="none" viewBox="0 0 48 48">
|
||||||
|
<circle cx="24" cy="24" r="24" fill="#8552A1" opacity=".2"/>
|
||||||
|
<circle cx="24" cy="24" r="18" fill="#8552A1"/>
|
||||||
|
<path fill="#F3EDF7" d="M12.062 18.99H17.4v1.942h-4.425c-.51 0-.834.025-.97.074-.287.1-.43.343-.43.728 0 .324.137.573.41.747.15.1.555.15 1.214.15h1.475c.946 0 1.692.199 2.24.597.61.448.915 1.107.915 1.979 0 .66-.187 1.263-.56 1.81-.274.436-.607.722-.999.86-.392.136-1.048.204-1.97.204H9.13V26.14h4.461c.548 0 .921-.006 1.12-.019.448-.05.672-.305.672-.765 0-.373-.149-.629-.448-.765-.149-.075-.51-.112-1.082-.112h-1.512c-.598 0-1.055-.038-1.373-.112a2.451 2.451 0 0 1-.886-.411 2.399 2.399 0 0 1-.794-.98A3.14 3.14 0 0 1 9 21.64c0-.908.33-1.63.99-2.165.398-.324 1.088-.486 2.072-.486Zm9.325 0 2.314 6.254 2.54-6.254h2.65l-3.92 9.091h-2.688l-3.622-9.09h2.726Zm12.946 0H39v9.036c0 .51-.06.964-.177 1.362-.118.398-.29.734-.514 1.008a2.196 2.196 0 0 1-1.036.719c-.404.13-.992.196-1.764.196h-4.984V29.37h4.648c.548 0 .905-.084 1.073-.252.168-.168.252-.514.252-1.037h-2.296c-.734 0-1.34-.077-1.82-.233a3.438 3.438 0 0 1-1.297-.774c-.909-.86-1.363-2.042-1.363-3.547 0-1.718.554-2.969 1.662-3.753a3.86 3.86 0 0 1 1.26-.606c.442-.119 1.005-.178 1.69-.178Zm2.166 7.15v-5.208h-2.073c-1.443 0-2.165.871-2.165 2.613 0 .847.196 1.49.588 1.932.392.442.961.663 1.708.663h1.941Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
3
frontend/assets/icons/export.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="42" fill="none" viewBox="0 0 18 42">
|
||||||
|
<path fill="#00E8CF" d="M12.723 5.08H3.775v.442c0 .513.14.917.42 1.212.263.28.652.42 1.164.42h7.2c.56 0 .987-.125 1.282-.373.264-.233.42-.59.466-1.072l.07-.955a.771.771 0 0 1 .268-.56.882.882 0 0 1 .594-.21.794.794 0 0 1 .571.28.81.81 0 0 1 .198.606l-.07.955c-.078.948-.42 1.678-1.025 2.19-.606.529-1.39.793-2.354.793h-7.2c-.947 0-1.732-.303-2.353-.909-.606-.606-.909-1.398-.909-2.377V.816c0-.218.082-.408.245-.571A.809.809 0 0 1 2.936 0h9.787c.217 0 .411.082.582.245a.764.764 0 0 1 .256.57v3.45a.754.754 0 0 1-.256.582.842.842 0 0 1-.582.233ZM.839 11.068h10.043v-.979a.78.78 0 0 1 .244-.57.809.809 0 0 1 .595-.245c.217 0 .407.082.57.245a.782.782 0 0 1 .245.57v.98h3.659c.217 0 .407.08.57.244a.782.782 0 0 1 .245.57.808.808 0 0 1-.245.595.782.782 0 0 1-.57.245h-3.659v7.572a.809.809 0 0 1-.245.595.782.782 0 0 1-.57.244.81.81 0 0 1-.595-.244.809.809 0 0 1-.244-.595v-7.572H.839a.809.809 0 0 1-.594-.245.809.809 0 0 1-.245-.594c0-.218.082-.408.245-.571a.809.809 0 0 1 .594-.245Zm11.045-9.414h-8.11V3.45h8.11V1.654ZM6.198 14.866v2.866a.809.809 0 0 1-.245.595.782.782 0 0 1-.57.244.809.809 0 0 1-.595-.244.809.809 0 0 1-.244-.595v-2.866a.81.81 0 0 1 .244-.594.809.809 0 0 1 .595-.245c.217 0 .407.082.57.245a.81.81 0 0 1 .245.594ZM1.266 41.161v-7.456c0-.233.081-.431.244-.594a.809.809 0 0 1 .595-.245c.233 0 .427.081.582.245a.83.83 0 0 1 .233.594v6.64h4.66v-9.6H2.99a.83.83 0 0 1-.594-.233.771.771 0 0 1-.245-.582v-6.268c0-.218.082-.408.245-.571a.809.809 0 0 1 .594-.245c.218 0 .408.082.571.245a.782.782 0 0 1 .245.57v5.453H7.58v-7.2a.81.81 0 0 1 .245-.594.809.809 0 0 1 .594-.245c.218 0 .408.082.571.245a.809.809 0 0 1 .245.594v7.2h3.775v-5.569a.78.78 0 0 1 .244-.57.809.809 0 0 1 .595-.245.78.78 0 0 1 .57.244.782.782 0 0 1 .245.571v6.385a.771.771 0 0 1-.245.582.801.801 0 0 1-.57.233H9.235v9.6h4.66v-6.757a.81.81 0 0 1 .245-.594.782.782 0 0 1 .57-.245.81.81 0 0 1 .595.245.81.81 0 0 1 .245.594v7.573a.809.809 0 0 1-.245.594.81.81 0 0 1-.594.245H2.105a.809.809 0 0 1-.595-.245.809.809 0 0 1-.244-.594Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 749 B After Width: | Height: | Size: 749 B |
|
Before Width: | Height: | Size: 403 B After Width: | Height: | Size: 403 B |
|
Before Width: | Height: | Size: 404 B After Width: | Height: | Size: 404 B |
|
Before Width: | Height: | Size: 304 B After Width: | Height: | Size: 304 B |
|
Before Width: | Height: | Size: 755 B After Width: | Height: | Size: 755 B |
|
Before Width: | Height: | Size: 585 B After Width: | Height: | Size: 585 B |
|
Before Width: | Height: | Size: 304 B After Width: | Height: | Size: 304 B |
|
Before Width: | Height: | Size: 749 B After Width: | Height: | Size: 749 B |
|
Before Width: | Height: | Size: 437 B After Width: | Height: | Size: 437 B |
|
Before Width: | Height: | Size: 392 B After Width: | Height: | Size: 392 B |
|
Before Width: | Height: | Size: 198 B After Width: | Height: | Size: 198 B |
|
Before Width: | Height: | Size: 392 B After Width: | Height: | Size: 392 B |
|
Before Width: | Height: | Size: 392 B After Width: | Height: | Size: 392 B |
|
Before Width: | Height: | Size: 383 B After Width: | Height: | Size: 383 B |
4
frontend/assets/icons/zhedie.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none" viewBox="0 0 15 15">
|
||||||
|
<path fill="#000" d="M15 7.5a7.5 7.5 0 1 1-15 0 7.5 7.5 0 0 1 15 0Z"/>
|
||||||
|
<path fill="#fff" d="M3 7.75A.75.75 0 0 1 3.75 7h7.5a.75.75 0 0 1 0 1.5h-7.5A.75.75 0 0 1 3 7.75Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 279 B |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
1
frontend/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 496 B |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 128 KiB |
9
frontend/assets/webicon.svg
Normal file
|
After Width: | Height: | Size: 170 KiB |
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/assets/webicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Font2SVG - 字体转SVG工具</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
30
frontend/package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"harfbuzzjs": "^0.8.0",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
|
"lodash-es": "^4.17.23",
|
||||||
|
"opentype.js": "^1.3.4",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"vue": "^3.5.24"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@unocss/preset-wind": "^66.6.0",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"@vue/tsconfig": "^0.8.1",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"unocss": "^66.6.0",
|
||||||
|
"vite": "^7.2.4",
|
||||||
|
"vite-plugin-wasm": "^3.5.0",
|
||||||
|
"vue-tsc": "^3.1.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
1817
frontend/pnpm-lock.yaml
generated
Normal file
163
frontend/public/fonts.json
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "其他字体/AlimamaDaoLiTi",
|
||||||
|
"name": "AlimamaDaoLiTi",
|
||||||
|
"filename": "AlimamaDaoLiTi.ttf",
|
||||||
|
"category": "其他字体",
|
||||||
|
"path": "/fonts/其他字体/AlimamaDaoLiTi.ttf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "其他字体/Hangeuljaemin4-Regular",
|
||||||
|
"name": "Hangeuljaemin4-Regular",
|
||||||
|
"filename": "Hangeuljaemin4-Regular.ttf",
|
||||||
|
"category": "其他字体",
|
||||||
|
"path": "/fonts/其他字体/Hangeuljaemin4-Regular.ttf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "其他字体/I.顏體",
|
||||||
|
"name": "I.顏體",
|
||||||
|
"filename": "I.顏體.ttf",
|
||||||
|
"category": "其他字体",
|
||||||
|
"path": "/fonts/其他字体/I.顏體.ttf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "其他字体/XCDUANZHUANGSONGTI",
|
||||||
|
"name": "XCDUANZHUANGSONGTI",
|
||||||
|
"filename": "XCDUANZHUANGSONGTI.ttf",
|
||||||
|
"category": "其他字体",
|
||||||
|
"path": "/fonts/其他字体/XCDUANZHUANGSONGTI.ttf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "其他字体/qiji-combo",
|
||||||
|
"name": "qiji-combo",
|
||||||
|
"filename": "qiji-combo.ttf",
|
||||||
|
"category": "其他字体",
|
||||||
|
"path": "/fonts/其他字体/qiji-combo.ttf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "其他字体/临海隶书",
|
||||||
|
"name": "临海隶书",
|
||||||
|
"filename": "临海隶书.ttf",
|
||||||
|
"category": "其他字体",
|
||||||
|
"path": "/fonts/其他字体/临海隶书.ttf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "其他字体/京華老宋体_KingHwa_OldSong",
|
||||||
|
"name": "京華老宋体_KingHwa_OldSong",
|
||||||
|
"filename": "京華老宋体_KingHwa_OldSong.ttf",
|
||||||
|
"category": "其他字体",
|
||||||
|
"path": "/fonts/其他字体/京華老宋体_KingHwa_OldSong.ttf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "其他字体/优设标题黑",
|
||||||
|
"name": "优设标题黑",
|
||||||
|
"filename": "优设标题黑.ttf",
|
||||||
|
"category": "其他字体",
|
||||||
|
"path": "/fonts/其他字体/优设标题黑.ttf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "其他字体/包图小白体",
|
||||||
|
"name": "包图小白体",
|
||||||
|
"filename": "包图小白体.ttf",
|
||||||
|
"category": "其他字体",
|
||||||
|
"path": "/fonts/其他字体/包图小白体.ttf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "其他字体/源界明朝",
|
||||||
|
"name": "源界明朝",
|
||||||
|
"filename": "源界明朝.ttf",
|
||||||
|
"category": "其他字体",
|
||||||
|
"path": "/fonts/其他字体/源界明朝.ttf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "其他字体/演示佛系体",
|
||||||
|
"name": "演示佛系体",
|
||||||
|
"filename": "演示佛系体.ttf",
|
||||||
|
"category": "其他字体",
|
||||||
|
"path": "/fonts/其他字体/演示佛系体.ttf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "其他字体/站酷快乐体",
|
||||||
|
"name": "站酷快乐体",
|
||||||
|
"filename": "站酷快乐体.ttf",
|
||||||
|
"category": "其他字体",
|
||||||
|
"path": "/fonts/其他字体/站酷快乐体.ttf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "其他字体/问藏书房",
|
||||||
|
"name": "问藏书房",
|
||||||
|
"filename": "问藏书房.ttf",
|
||||||
|
"category": "其他字体",
|
||||||
|
"path": "/fonts/其他字体/问藏书房.ttf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "其他字体/霞鹜臻楷",
|
||||||
|
"name": "霞鹜臻楷",
|
||||||
|
"filename": "霞鹜臻楷.ttf",
|
||||||
|
"category": "其他字体",
|
||||||
|
"path": "/fonts/其他字体/霞鹜臻楷.ttf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "庞门正道/庞门正道标题体",
|
||||||
|
"name": "庞门正道标题体",
|
||||||
|
"filename": "庞门正道标题体.ttf",
|
||||||
|
"category": "庞门正道",
|
||||||
|
"path": "/fonts/庞门正道/庞门正道标题体.ttf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "王漢宗/王漢宗勘亭流繁",
|
||||||
|
"name": "王漢宗勘亭流繁",
|
||||||
|
"filename": "王漢宗勘亭流繁.ttf",
|
||||||
|
"category": "王漢宗",
|
||||||
|
"path": "/fonts/王漢宗/王漢宗勘亭流繁.ttf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "王漢宗/王漢宗新潮體",
|
||||||
|
"name": "王漢宗新潮體",
|
||||||
|
"filename": "王漢宗新潮體.ttf",
|
||||||
|
"category": "王漢宗",
|
||||||
|
"path": "/fonts/王漢宗/王漢宗新潮體.ttf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "王漢宗/王漢宗波卡體空陰",
|
||||||
|
"name": "王漢宗波卡體空陰",
|
||||||
|
"filename": "王漢宗波卡體空陰.ttf",
|
||||||
|
"category": "王漢宗",
|
||||||
|
"path": "/fonts/王漢宗/王漢宗波卡體空陰.ttf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "王漢宗/王漢宗細黑體繁",
|
||||||
|
"name": "王漢宗細黑體繁",
|
||||||
|
"filename": "王漢宗細黑體繁.ttf",
|
||||||
|
"category": "王漢宗",
|
||||||
|
"path": "/fonts/王漢宗/王漢宗細黑體繁.ttf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "王漢宗/王漢宗綜藝體雙空陰",
|
||||||
|
"name": "王漢宗綜藝體雙空陰",
|
||||||
|
"filename": "王漢宗綜藝體雙空陰.ttf",
|
||||||
|
"category": "王漢宗",
|
||||||
|
"path": "/fonts/王漢宗/王漢宗綜藝體雙空陰.ttf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "王漢宗/王漢宗超明體繁",
|
||||||
|
"name": "王漢宗超明體繁",
|
||||||
|
"filename": "王漢宗超明體繁.ttf",
|
||||||
|
"category": "王漢宗",
|
||||||
|
"path": "/fonts/王漢宗/王漢宗超明體繁.ttf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "王漢宗/王漢宗酷儷海報",
|
||||||
|
"name": "王漢宗酷儷海報",
|
||||||
|
"filename": "王漢宗酷儷海報.ttf",
|
||||||
|
"category": "王漢宗",
|
||||||
|
"path": "/fonts/王漢宗/王漢宗酷儷海報.ttf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "王漢宗/王漢宗顏楷體繁",
|
||||||
|
"name": "王漢宗顏楷體繁",
|
||||||
|
"filename": "王漢宗顏楷體繁.ttf",
|
||||||
|
"category": "王漢宗",
|
||||||
|
"path": "/fonts/王漢宗/王漢宗顏楷體繁.ttf"
|
||||||
|
}
|
||||||
|
]
|
||||||
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
10
frontend/src/App.test.vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<template>
|
||||||
|
<div style="padding: 20px; background: lightblue;">
|
||||||
|
<h1>测试页面</h1>
|
||||||
|
<p>如果你能看到这个,说明 Vue 应用已经挂载成功</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
console.log('App.test.vue is loading...')
|
||||||
|
</script>
|
||||||
332
frontend/src/App.vue
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useFontLoader } from './composables/useFontLoader'
|
||||||
|
import { useUiStore } from './stores/uiStore'
|
||||||
|
import { useFontStore } from './stores/fontStore'
|
||||||
|
import { MAX_CHARS_PER_LINE, wrapTextByChars } from './utils/text-layout'
|
||||||
|
import FontSelector from './components/FontSelector.vue'
|
||||||
|
import FavoritesList from './components/FavoritesList.vue'
|
||||||
|
import SvgPreview from './components/SvgPreview.vue'
|
||||||
|
|
||||||
|
console.log('App.vue: script setup running...')
|
||||||
|
|
||||||
|
const uiStore = useUiStore()
|
||||||
|
const fontStore = useFontStore()
|
||||||
|
|
||||||
|
const fontSizePercent = computed(() => {
|
||||||
|
const raw = ((uiStore.fontSize - 10) / (500 - 10)) * 100
|
||||||
|
return Math.max(0, Math.min(100, raw))
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加载字体列表
|
||||||
|
try {
|
||||||
|
useFontLoader()
|
||||||
|
console.log('App.vue: useFontLoader called successfully')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('App.vue: Error in useFontLoader:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedInitialInput = wrapTextByChars(uiStore.inputText, MAX_CHARS_PER_LINE)
|
||||||
|
if (normalizedInitialInput !== uiStore.inputText) {
|
||||||
|
uiStore.setInputText(normalizedInitialInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExportFormat = 'svg' | 'png'
|
||||||
|
|
||||||
|
async function handleExport(format: ExportFormat) {
|
||||||
|
uiStore.retainExportItemsByFontIds(fontStore.previewFontIds)
|
||||||
|
const selectedItems = uiStore.selectedExportItems
|
||||||
|
const inputText = uiStore.inputText.trim()
|
||||||
|
|
||||||
|
if (selectedItems.length === 0) {
|
||||||
|
alert('请选择需要导出的效果')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inputText) {
|
||||||
|
alert('请输入要导出的文字')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { generateSvg } = await import('./utils/svg-builder')
|
||||||
|
const {
|
||||||
|
convertSvgToPngBlob,
|
||||||
|
downloadSvg,
|
||||||
|
downloadMultipleFiles,
|
||||||
|
downloadPngFromSvg,
|
||||||
|
generatePngFilename,
|
||||||
|
generateSvgFilename,
|
||||||
|
} = await import('./utils/download')
|
||||||
|
|
||||||
|
if (selectedItems.length === 1) {
|
||||||
|
// 单个字体,直接下载 SVG
|
||||||
|
const item = selectedItems[0]
|
||||||
|
const svgResult = await generateSvg({
|
||||||
|
text: inputText,
|
||||||
|
font: item.fontInfo.font,
|
||||||
|
fontSize: uiStore.fontSize,
|
||||||
|
fillColor: uiStore.textColor,
|
||||||
|
letterSpacing: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
if (format === 'svg') {
|
||||||
|
const filename = generateSvgFilename(inputText, svgResult.fontName)
|
||||||
|
downloadSvg(svgResult.svg, filename)
|
||||||
|
} else {
|
||||||
|
const filename = generatePngFilename(inputText, svgResult.fontName)
|
||||||
|
await downloadPngFromSvg(svgResult.svg, filename, {
|
||||||
|
width: svgResult.width,
|
||||||
|
height: svgResult.height,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// 多个字体,打包下载
|
||||||
|
const files: Array<{ name: string; content: string | Blob }> = []
|
||||||
|
|
||||||
|
for (const item of selectedItems) {
|
||||||
|
try {
|
||||||
|
const svgResult = await generateSvg({
|
||||||
|
text: inputText,
|
||||||
|
font: item.fontInfo.font,
|
||||||
|
fontSize: uiStore.fontSize,
|
||||||
|
fillColor: uiStore.textColor,
|
||||||
|
letterSpacing: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
if (format === 'svg') {
|
||||||
|
const filename = generateSvgFilename(inputText, svgResult.fontName)
|
||||||
|
files.push({
|
||||||
|
name: filename,
|
||||||
|
content: svgResult.svg
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const filename = generatePngFilename(inputText, svgResult.fontName)
|
||||||
|
const pngBlob = await convertSvgToPngBlob(svgResult.svg, {
|
||||||
|
width: svgResult.width,
|
||||||
|
height: svgResult.height,
|
||||||
|
})
|
||||||
|
files.push({
|
||||||
|
name: filename,
|
||||||
|
content: pngBlob,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`字体 ${item.fontInfo.name} 导出失败:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length > 0) {
|
||||||
|
const zipFilename = format === 'svg' ? 'font2svg-svg-export.zip' : 'font2svg-png-export.zip'
|
||||||
|
await downloadMultipleFiles(files, zipFilename)
|
||||||
|
} else {
|
||||||
|
alert(`所有字体${format.toUpperCase()}导出都失败了`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('导出失败:', error)
|
||||||
|
alert(`导出失败: ${error instanceof Error ? error.message : '未知错误'}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFontSizeChange(size: number) {
|
||||||
|
uiStore.setFontSize(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSliderInput(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
const value = Number(target.value)
|
||||||
|
if (!Number.isNaN(value)) {
|
||||||
|
uiStore.setFontSize(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTextInput(event: Event) {
|
||||||
|
const target = event.target as HTMLTextAreaElement
|
||||||
|
const wrappedText = wrapTextByChars(target.value, MAX_CHARS_PER_LINE)
|
||||||
|
if (wrappedText !== target.value) {
|
||||||
|
target.value = wrappedText
|
||||||
|
}
|
||||||
|
uiStore.setInputText(wrappedText)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('App.vue: script setup completed')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-screen h-screen box-border p-8px bg-white flex flex-col overflow-hidden">
|
||||||
|
<!-- Frame 7: 顶部工具栏 -->
|
||||||
|
<div class="flex gap-2 items-center shrink-0 h-24 px-2 py-1">
|
||||||
|
<!-- webicon - 48x48 -->
|
||||||
|
<div class="w-12 h-12 rounded-xl overflow-hidden shrink-0">
|
||||||
|
<img src="/assets/webicon.svg" alt="logo" class="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 星程字体转换 - 弹性宽度 -->
|
||||||
|
<div class="shrink-0 max-w-[225px] min-w-[120px]" style="height: 72px;">
|
||||||
|
<img src="/assets/icons/星程字体转换.svg" alt="星程SVG文字生成 TEXT to SVG" class="w-full h-full object-contain" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- slider - 增加宽度 -->
|
||||||
|
<div class="flex items-center gap-3 px-2 shrink-0 relative" style="width: 280px; height: 32px;">
|
||||||
|
<button
|
||||||
|
@click="handleFontSizeChange(uiStore.fontSize - 10)"
|
||||||
|
class="w-4 h-4 shrink-0 cursor-pointer hover:opacity-70 transition-opacity flex items-center justify-center p-0 border-0 bg-transparent"
|
||||||
|
title="减小字体"
|
||||||
|
>
|
||||||
|
<img src="/assets/icons/icons_idx%20_38.svg" alt="A-" class="w-4 h-4 object-contain" />
|
||||||
|
</button>
|
||||||
|
<div class="flex-1 h-6 flex items-center relative">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="10"
|
||||||
|
max="500"
|
||||||
|
step="1"
|
||||||
|
:value="uiStore.fontSize"
|
||||||
|
@input="handleSliderInput"
|
||||||
|
class="font-size-range w-full h-6 cursor-pointer"
|
||||||
|
:style="{ background: `linear-gradient(to right, #9b6bc2 0%, #9b6bc2 ${fontSizePercent}%, #e5e6eb ${fontSizePercent}%, #e5e6eb 100%)` }"
|
||||||
|
/>
|
||||||
|
<!-- 字体大小数字显示 -->
|
||||||
|
<div
|
||||||
|
class="absolute pointer-events-none -top-4"
|
||||||
|
:style="{ left: `calc(${fontSizePercent}% - 7px)` }"
|
||||||
|
>
|
||||||
|
<div class="text-[#8552A1] text-[12px] font-medium text-center w-6">
|
||||||
|
{{ uiStore.fontSize }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="handleFontSizeChange(uiStore.fontSize + 10)"
|
||||||
|
class="w-6 h-6 shrink-0 cursor-pointer hover:opacity-70 transition-opacity flex items-center justify-center p-0 border-0 bg-transparent"
|
||||||
|
title="增大字体"
|
||||||
|
>
|
||||||
|
<img src="/assets/icons/icons_idx%20_33.svg" alt="A+" class="w-6 h-6 object-contain" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 文字颜色选择 -->
|
||||||
|
<div class="shrink-0 relative w-9 h-9">
|
||||||
|
<label class="w-full h-full flex items-center justify-center cursor-pointer">
|
||||||
|
<img src="/assets/icons/choose-color.svg" alt="颜色" class="w-9 h-9 object-contain" />
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
:value="uiStore.textColor"
|
||||||
|
@input="uiStore.setTextColor(($event.target as HTMLInputElement).value)"
|
||||||
|
class="absolute inset-0 opacity-0 cursor-pointer"
|
||||||
|
aria-label="选择文字颜色"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Frame 14: 输入框 - 弹性宽度 -->
|
||||||
|
<div class="flex-1 min-w-[80px] bg-[#f7f8fa] rounded-lg px-2 py-1 h-12">
|
||||||
|
<textarea
|
||||||
|
:value="uiStore.inputText"
|
||||||
|
@input="handleTextInput"
|
||||||
|
placeholder="此处输入内容"
|
||||||
|
class="w-full h-full bg-transparent border-none outline-none text-base text-[#4e5969] placeholder-[#4e5969] resize-none leading-5 overflow-y-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Export Group -->
|
||||||
|
<div class="flex items-center gap-1 shrink-0 border border-[#8552A1] rounded-lg px-1 py-1 bg-[#f7f8fa] shadow-sm">
|
||||||
|
<div class="w-[18px] h-[42px] shrink-0 pointer-events-none">
|
||||||
|
<img src="/assets/icons/export.svg" alt="导出" class="w-full h-full object-contain" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="handleExport('svg')"
|
||||||
|
class="w-12 h-12 shrink-0 cursor-pointer hover:opacity-85 transition-opacity flex items-center justify-center p-0 border-0 bg-transparent"
|
||||||
|
title="导出 SVG"
|
||||||
|
>
|
||||||
|
<img src="/assets/icons/export-svg.svg" alt="导出SVG" class="w-12 h-12 object-contain" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="handleExport('png')"
|
||||||
|
class="w-12 h-12 shrink-0 cursor-pointer hover:opacity-85 transition-opacity flex items-center justify-center p-0 border-0 bg-transparent"
|
||||||
|
title="导出 PNG"
|
||||||
|
>
|
||||||
|
<img src="/assets/icons/export-png.svg" alt="导出PNG" class="w-12 h-12 object-contain" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Frame 9: 主内容区 -->
|
||||||
|
<div class="flex-1 flex gap-2 min-h-0 overflow-hidden px-2">
|
||||||
|
<!-- Frame 15: 左侧栏 - 弹性宽度 -->
|
||||||
|
<div class="flex flex-col gap-2 shrink-0 overflow-hidden" style="flex-basis: 400px; max-width: 480px; min-width: 320px;">
|
||||||
|
<!-- Frame 5: 字体选择 - 弹性高度 -->
|
||||||
|
<div class="flex-[2] border border-solid border-[#f7e0e0] rounded-lg p-1.5 flex flex-col gap-2 overflow-hidden min-h-0">
|
||||||
|
<h2 class="text-base text-black shrink-0 leading-none">字体选择</h2>
|
||||||
|
<div v-overflow-aware class="scrollbar-hover flex-1 overflow-y-auto overflow-x-hidden pr-2">
|
||||||
|
<FontSelector />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Frame 5: 已收藏字体 - 弹性高度 -->
|
||||||
|
<div class="border border-solid border-[#f7e0e0] rounded-lg p-1.5 flex flex-col gap-2 flex-1 overflow-hidden min-h-[120px]">
|
||||||
|
<h2 class="text-base text-black shrink-0 leading-none">已收藏字体</h2>
|
||||||
|
<div v-overflow-aware class="scrollbar-hover flex-1 overflow-y-auto overflow-x-hidden pr-2">
|
||||||
|
<FavoritesList />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Frame 8: 右侧预览区 - 弹性宽度 -->
|
||||||
|
<div class="flex-1 border border-solid border-[#f7e0e0] rounded-lg p-1.5 flex flex-col gap-2 overflow-hidden min-w-0">
|
||||||
|
<h2 class="text-base text-black shrink-0 leading-none">效果预览</h2>
|
||||||
|
<div v-overflow-aware class="scrollbar-hover flex-1 min-h-0 py-2 overflow-y-auto overflow-x-hidden">
|
||||||
|
<SvgPreview />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部版权 -->
|
||||||
|
<div class="text-[#86909c] text-xs text-center shrink-0 h-6 pt-4 flex items-center justify-center px-2">
|
||||||
|
@版权说明:所有字体来源互联网分享,仅供效果预览,不做下载传播,如有侵权,请告知douboer@gmail.com
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.font-size-range {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #e5e6eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-size-range::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
border: 1px solid #e5e6eb;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-size-range::-moz-range-thumb {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
border: 1px solid #e5e6eb;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-size-range::-moz-range-track {
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
74
frontend/src/components/ExportPanel.vue
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { downloadSvg, downloadMultipleFiles, generateSvgFilename } from '../utils/download'
|
||||||
|
import { useUiStore } from '../stores/uiStore'
|
||||||
|
|
||||||
|
const uiStore = useUiStore()
|
||||||
|
|
||||||
|
const selectedItems = computed(() => uiStore.selectedExportItems)
|
||||||
|
|
||||||
|
async function handleExport() {
|
||||||
|
if (selectedItems.value.length === 0) {
|
||||||
|
alert('请先选择要导出的预览项')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uiStore.isExporting = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (selectedItems.value.length === 1) {
|
||||||
|
// 单个导出
|
||||||
|
const item = selectedItems.value[0]
|
||||||
|
if (!item) return
|
||||||
|
const filename = generateSvgFilename(uiStore.inputText, item.fontInfo.name)
|
||||||
|
downloadSvg(item.svgResult.svg, filename)
|
||||||
|
} else {
|
||||||
|
// 批量导出
|
||||||
|
const files = selectedItems.value.map((item) => ({
|
||||||
|
name: generateSvgFilename(uiStore.inputText, item.fontInfo.name),
|
||||||
|
content: item.svgResult.svg,
|
||||||
|
}))
|
||||||
|
await downloadMultipleFiles(files)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Export failed:', error)
|
||||||
|
alert('导出失败,请重试')
|
||||||
|
} finally {
|
||||||
|
uiStore.isExporting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-full flex flex-col p-4">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 mb-4">导出</h2>
|
||||||
|
|
||||||
|
<div v-overflow-aware class="scrollbar-hover flex-1 overflow-auto mb-4">
|
||||||
|
<div v-if="selectedItems.length === 0" class="text-sm text-gray-500 text-center py-8">
|
||||||
|
未选择任何预览项
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in selectedItems"
|
||||||
|
:key="index"
|
||||||
|
class="text-sm text-gray-700 p-2 bg-gray-50 rounded"
|
||||||
|
>
|
||||||
|
{{ item.fontInfo.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="w-full py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:bg-gray-300 disabled:cursor-not-allowed"
|
||||||
|
:disabled="selectedItems.length === 0 || uiStore.isExporting"
|
||||||
|
@click="handleExport"
|
||||||
|
>
|
||||||
|
<span v-if="uiStore.isExporting">导出中...</span>
|
||||||
|
<span v-else>导出 SVG ({{ selectedItems.length }})</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="mt-3 text-xs text-gray-500 text-center">
|
||||||
|
点击预览项选中,然后点击导出按钮
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
83
frontend/src/components/FavoritesList.vue
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useFontStore } from '../stores/fontStore'
|
||||||
|
|
||||||
|
const fontStore = useFontStore()
|
||||||
|
|
||||||
|
const favoriteFonts = computed(() => fontStore.favoriteFonts)
|
||||||
|
|
||||||
|
function handlePreviewClick(fontId: string, event: Event) {
|
||||||
|
event.stopPropagation()
|
||||||
|
fontStore.togglePreview(fontId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFavoriteClick(fontId: string, event: Event) {
|
||||||
|
event.stopPropagation()
|
||||||
|
fontStore.toggleFavorite(fontId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFavorite(fontId: string): boolean {
|
||||||
|
return fontStore.favoriteFontIds.has(fontId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInPreview(fontId: string): boolean {
|
||||||
|
return fontStore.previewFontIds.has(fontId)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div v-if="favoriteFonts.length === 0" class="text-sm text-gray-500 text-center py-8">
|
||||||
|
暂无收藏字体
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex flex-col gap-3 favorite-indent">
|
||||||
|
<div
|
||||||
|
v-for="font in favoriteFonts"
|
||||||
|
:key="font.id"
|
||||||
|
class="flex items-center gap-2 border-b border-[#c9cdd4] pb-2"
|
||||||
|
>
|
||||||
|
<!-- 字体图标 -->
|
||||||
|
<div class="w-4 h-4 shrink-0">
|
||||||
|
<img src="/assets/icons/icons_idx%20_18.svg" alt="font" class="w-full h-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 字体名称 -->
|
||||||
|
<div class="flex-1 text-xs text-[#86909c]">
|
||||||
|
{{ font.name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 预览复选框 -->
|
||||||
|
<button
|
||||||
|
@click="handlePreviewClick(font.id, $event)"
|
||||||
|
class="w-[18px] h-[18px] shrink-0 border rounded-full flex items-center justify-center p-0 bg-transparent"
|
||||||
|
:class="isInPreview(font.id) ? 'bg-[#9b6bc2] border-[#9b6bc2]' : 'border-[#c9cdd4]'"
|
||||||
|
>
|
||||||
|
<img v-if="isInPreview(font.id)" src="/assets/icons/checkbox.svg" alt="选中" class="w-[11px] h-[9px]" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 收藏按钮 -->
|
||||||
|
<button
|
||||||
|
@click="handleFavoriteClick(font.id, $event)"
|
||||||
|
class="w-[18px] h-[17px] shrink-0 p-0 border-0 bg-transparent"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/assets/icons/icons_idx%20_19.svg"
|
||||||
|
alt="收藏"
|
||||||
|
class="w-full h-full"
|
||||||
|
:class="isFavorite(font.id) ? 'favorite-active' : ''"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.favorite-indent {
|
||||||
|
padding-left: 2ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorite-active {
|
||||||
|
filter: brightness(0) saturate(100%) invert(16%) sepia(96%) saturate(7491%) hue-rotate(356deg) brightness(99%) contrast(119%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
18
frontend/src/components/FontSelector.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useFontStore } from '../stores/fontStore'
|
||||||
|
import FontTree from './FontTree.vue'
|
||||||
|
|
||||||
|
const fontStore = useFontStore()
|
||||||
|
|
||||||
|
const fontTree = computed(() => fontStore.fontTree)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div v-if="fontTree.length === 0" class="text-sm text-gray-500 text-center py-8">
|
||||||
|
暂无字体
|
||||||
|
</div>
|
||||||
|
<FontTree v-else :nodes="fontTree" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
51
frontend/src/components/FontSizeSlider.vue
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useUiStore } from '../stores/uiStore'
|
||||||
|
|
||||||
|
const uiStore = useUiStore()
|
||||||
|
|
||||||
|
function handleSizeChange(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
uiStore.setFontSize(Number(target.value))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<label class="text-sm font-medium text-gray-700">字体大小</label>
|
||||||
|
<span class="text-sm text-gray-600">{{ uiStore.fontSize }}px</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="10"
|
||||||
|
max="500"
|
||||||
|
:value="uiStore.fontSize"
|
||||||
|
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
|
||||||
|
@input="handleSizeChange"
|
||||||
|
>
|
||||||
|
<div class="flex justify-between text-xs text-gray-500">
|
||||||
|
<span>10px</span>
|
||||||
|
<span>500px</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
input[type="range"]::-webkit-slider-thumb {
|
||||||
|
appearance: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #2563eb;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-moz-range-thumb {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #2563eb;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
172
frontend/src/components/FontTree.vue
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { FontTreeNode } from '../types/font'
|
||||||
|
import { useFontStore } from '../stores/fontStore'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
nodes: FontTreeNode[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const fontStore = useFontStore()
|
||||||
|
|
||||||
|
function toggleExpand(node: FontTreeNode) {
|
||||||
|
const next = !node.expanded
|
||||||
|
node.expanded = next
|
||||||
|
fontStore.setCategoryExpanded(node.name, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePreviewClick(node: FontTreeNode, event: Event) {
|
||||||
|
event.stopPropagation()
|
||||||
|
if (node.type === 'font' && node.fontInfo) {
|
||||||
|
fontStore.togglePreview(node.fontInfo.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFavoriteClick(node: FontTreeNode, event: Event) {
|
||||||
|
event.stopPropagation()
|
||||||
|
if (node.type === 'font' && node.fontInfo) {
|
||||||
|
fontStore.toggleFavorite(node.fontInfo.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFavorite(node: FontTreeNode): boolean {
|
||||||
|
return node.type === 'font' && node.fontInfo ? fontStore.favoriteFontIds.has(node.fontInfo.id) : false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInPreview(node: FontTreeNode): boolean {
|
||||||
|
return node.type === 'font' && node.fontInfo ? fontStore.previewFontIds.has(node.fontInfo.id) : false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-0">
|
||||||
|
<div v-for="node in nodes" :key="node.name">
|
||||||
|
<!-- 分类节点 -->
|
||||||
|
<div v-if="node.type === 'category'" class="relative mb-3">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<!-- 左侧展开图标 -->
|
||||||
|
<div class="tree-icon-wrapper">
|
||||||
|
<button
|
||||||
|
@click="toggleExpand(node)"
|
||||||
|
class="tree-toggle"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="node.expanded"
|
||||||
|
src="/assets/icons/zhedie.svg"
|
||||||
|
alt="收起"
|
||||||
|
class="w-[15px] h-[15px]"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-else
|
||||||
|
src="/assets/icons/icons_idx%20_12.svg"
|
||||||
|
alt="展开"
|
||||||
|
class="w-[15px] h-[15px]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分类标题 -->
|
||||||
|
<div
|
||||||
|
@click="toggleExpand(node)"
|
||||||
|
class="text-base font-medium text-black cursor-pointer flex-1 ml-2"
|
||||||
|
>
|
||||||
|
{{ node.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 竖直连接线 -->
|
||||||
|
<div v-if="node.expanded && node.children" class="tree-vertical-line"></div>
|
||||||
|
|
||||||
|
<!-- 字体列表 -->
|
||||||
|
<div v-if="node.expanded && node.children" class="flex flex-col gap-3 mt-3">
|
||||||
|
<div
|
||||||
|
v-for="(child, index) in node.children"
|
||||||
|
:key="child.name"
|
||||||
|
class="flex items-center gap-2 border-b border-[#c9cdd4] pb-2 relative"
|
||||||
|
>
|
||||||
|
<!-- 水平连接线 -->
|
||||||
|
<div class="tree-horizontal-line"></div>
|
||||||
|
|
||||||
|
<!-- 字体图标 -->
|
||||||
|
<div class="w-4 h-4 shrink-0 ml-[17px]">
|
||||||
|
<img src="/assets/icons/icons_idx%20_18.svg" alt="font" class="w-full h-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 字体名称 -->
|
||||||
|
<div class="flex-1 text-xs text-[#86909c]">
|
||||||
|
{{ child.name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 预览复选框 -->
|
||||||
|
<button
|
||||||
|
@click="handlePreviewClick(child, $event)"
|
||||||
|
class="w-[18px] h-[18px] shrink-0 border rounded-full flex items-center justify-center p-0 bg-transparent"
|
||||||
|
:class="isInPreview(child) ? 'bg-[#9b6bc2] border-[#9b6bc2]' : 'border-[#c9cdd4]'"
|
||||||
|
>
|
||||||
|
<img v-if="isInPreview(child)" src="/assets/icons/checkbox.svg" alt="选中" class="w-[11px] h-[9px]" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 收藏按钮 -->
|
||||||
|
<button
|
||||||
|
@click="handleFavoriteClick(child, $event)"
|
||||||
|
class="w-[18px] h-[17px] shrink-0 p-0 border-0 bg-transparent"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/assets/icons/icons_idx%20_19.svg"
|
||||||
|
alt="收藏"
|
||||||
|
class="w-full h-full"
|
||||||
|
:class="isFavorite(child) ? 'favorite-active' : ''"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tree-icon-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 17px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-toggle {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-vertical-line {
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
top: 20px;
|
||||||
|
bottom: 12px;
|
||||||
|
width: 1px;
|
||||||
|
background: #c9cdd4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-horizontal-line {
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
top: 12px;
|
||||||
|
width: 10px;
|
||||||
|
height: 1px;
|
||||||
|
background: #c9cdd4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorite-active {
|
||||||
|
filter: brightness(0) saturate(100%) invert(16%) sepia(96%) saturate(7491%) hue-rotate(356deg) brightness(99%) contrast(119%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
41
frontend/src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
defineProps<{ msg: string }>()
|
||||||
|
|
||||||
|
const count = ref(0)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h1>{{ msg }}</h1>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<button type="button" @click="count++">count is {{ count }}</button>
|
||||||
|
<p>
|
||||||
|
Edit
|
||||||
|
<code>components/HelloWorld.vue</code> to test HMR
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Check out
|
||||||
|
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||||
|
>create-vue</a
|
||||||
|
>, the official Vue + Vite starter
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Learn more about IDE Support for Vue in the
|
||||||
|
<a
|
||||||
|
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||||
|
target="_blank"
|
||||||
|
>Vue Docs Scaling up Guide</a
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
45
frontend/src/components/PreviewItem.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PreviewItem } from '../types/font'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
previewItem: PreviewItem
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
toggleSelect: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
emit('toggleSelect')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="border rounded-lg p-4 hover:shadow-md transition-shadow cursor-pointer"
|
||||||
|
:class="previewItem.selected ? 'border-blue-500 bg-blue-50' : 'border-gray-200 bg-white'"
|
||||||
|
@click="handleClick"
|
||||||
|
>
|
||||||
|
<!-- 字体名称 -->
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h3 class="font-medium text-gray-800">{{ previewItem.fontInfo.name }}</h3>
|
||||||
|
<div
|
||||||
|
class="w-5 h-5 border-2 rounded flex items-center justify-center"
|
||||||
|
:class="previewItem.selected ? 'border-blue-500 bg-blue-500' : 'border-gray-300'"
|
||||||
|
>
|
||||||
|
<span v-if="previewItem.selected" class="text-white text-xs">✓</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SVG 预览 -->
|
||||||
|
<div class="bg-gray-50 rounded p-4 flex items-center justify-center min-h-32">
|
||||||
|
<div v-html="previewItem.svgResult.svg" class="max-w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 信息 -->
|
||||||
|
<div class="mt-3 text-xs text-gray-500 flex justify-between">
|
||||||
|
<span>{{ previewItem.svgResult.width.toFixed(0) }} × {{ previewItem.svgResult.height.toFixed(0) }}</span>
|
||||||
|
<span>{{ previewItem.svgResult.fontName }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
140
frontend/src/components/SvgPreview.vue
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { useFontStore } from '../stores/fontStore'
|
||||||
|
import { useUiStore } from '../stores/uiStore'
|
||||||
|
import { generateSvg } from '../utils/svg-builder'
|
||||||
|
import type { PreviewItem as PreviewItemType } from '../types/font'
|
||||||
|
|
||||||
|
const fontStore = useFontStore()
|
||||||
|
const uiStore = useUiStore()
|
||||||
|
|
||||||
|
const previewItems = ref<PreviewItemType[]>([])
|
||||||
|
const isGenerating = ref(false)
|
||||||
|
|
||||||
|
const previewFonts = computed(() => fontStore.previewFonts)
|
||||||
|
const inputText = computed(() => uiStore.inputText)
|
||||||
|
const fontSize = computed(() => uiStore.fontSize)
|
||||||
|
const fillColor = computed(() => uiStore.textColor)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[previewFonts, inputText, fontSize, fillColor],
|
||||||
|
async () => {
|
||||||
|
await generatePreviews()
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
async function generatePreviews() {
|
||||||
|
const validPreviewFontIds = new Set(previewFonts.value.map(font => font.id))
|
||||||
|
uiStore.retainExportItemsByFontIds(validPreviewFontIds)
|
||||||
|
|
||||||
|
if (!inputText.value || inputText.value.trim() === '') {
|
||||||
|
previewItems.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fonts = previewFonts.value
|
||||||
|
if (fonts.length === 0) {
|
||||||
|
previewItems.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isGenerating.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const items: PreviewItemType[] = []
|
||||||
|
|
||||||
|
for (const fontInfo of fonts) {
|
||||||
|
if (!fontInfo.loaded) {
|
||||||
|
await fontStore.loadFont(fontInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fontInfo.font) {
|
||||||
|
try {
|
||||||
|
const svgResult = await generateSvg({
|
||||||
|
text: inputText.value,
|
||||||
|
font: fontInfo.font,
|
||||||
|
fontSize: fontSize.value,
|
||||||
|
fillColor: fillColor.value,
|
||||||
|
})
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
fontInfo,
|
||||||
|
svgResult,
|
||||||
|
selected: uiStore.selectedExportItems.some(item => item.fontInfo.id === fontInfo.id)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to generate SVG for ${fontInfo.name}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previewItems.value = items
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate previews:', error)
|
||||||
|
} finally {
|
||||||
|
isGenerating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelectItem(item: PreviewItemType) {
|
||||||
|
item.selected = !item.selected
|
||||||
|
uiStore.toggleExportItem(item)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div v-if="previewItems.length === 0" class="text-[#86909c] text-center py-20">
|
||||||
|
{{ isGenerating ? '生成预览中...' : '请选择字体并输入内容' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="flex flex-col gap-2">
|
||||||
|
<div
|
||||||
|
v-for="item in previewItems"
|
||||||
|
:key="item.fontInfo.id"
|
||||||
|
class="flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-[8px] border-b border-[#c9cdd4] pb-[8px] pr-[8px]">
|
||||||
|
<div class="w-[24px] h-[24px] shrink-0">
|
||||||
|
<img src="/assets/icons/icons_idx%20_32.svg" alt="字体" class="w-full h-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 text-xs text-[#86909c]">
|
||||||
|
{{ item.fontInfo.name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="toggleSelectItem(item)"
|
||||||
|
class="w-[18px] h-[18px] shrink-0 border rounded-full flex items-center justify-center p-0 bg-transparent"
|
||||||
|
:class="item.selected ? 'bg-[#9b6bc2] border-[#9b6bc2]' : 'border-[#c9cdd4]'"
|
||||||
|
>
|
||||||
|
<img v-if="item.selected" src="/assets/icons/checkbox.svg" alt="选中" class="w-[11px] h-[9px]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
@click="toggleSelectItem(item)"
|
||||||
|
class="bg-white px-[8px] py-[8px] cursor-pointer"
|
||||||
|
>
|
||||||
|
<div v-html="item.svgResult.svg" class="svg-preview-container"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.svg-preview-container {
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svg-preview-container :deep(svg) {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
38
frontend/src/components/TextInput.vue
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useUiStore } from '../stores/uiStore'
|
||||||
|
|
||||||
|
const uiStore = useUiStore()
|
||||||
|
const localText = ref(uiStore.inputText)
|
||||||
|
|
||||||
|
function handlePreview() {
|
||||||
|
uiStore.setInputText(localText.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
handlePreview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="block text-sm font-medium text-gray-700">输入文本</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
v-model="localText"
|
||||||
|
type="text"
|
||||||
|
placeholder="此处输入内容"
|
||||||
|
class="flex-1 px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||||
|
@click="handlePreview"
|
||||||
|
>
|
||||||
|
预览
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
68
frontend/src/composables/useFontLoader.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { useFontStore } from '../stores/fontStore'
|
||||||
|
import type { FontInfo } from '../types/font'
|
||||||
|
|
||||||
|
interface FontListItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
filename: string
|
||||||
|
category: string
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFontLoader() {
|
||||||
|
const fontStore = useFontStore()
|
||||||
|
|
||||||
|
async function loadFontList() {
|
||||||
|
console.log('Starting to load font list...')
|
||||||
|
fontStore.isLoadingFonts = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Fetching /fonts.json...')
|
||||||
|
const response = await fetch('/fonts.json')
|
||||||
|
console.log('Response status:', response.status, response.statusText)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load fonts.json: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fontList: FontListItem[] = await response.json()
|
||||||
|
console.log('Loaded font list:', fontList.length, 'fonts')
|
||||||
|
|
||||||
|
// 转换为 FontInfo
|
||||||
|
for (const item of fontList) {
|
||||||
|
const fontInfo: FontInfo = {
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
path: item.path,
|
||||||
|
category: item.category,
|
||||||
|
isFavorite: false,
|
||||||
|
loaded: false,
|
||||||
|
progress: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
fontStore.addFont(fontInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新字体树
|
||||||
|
fontStore.updateFontTree()
|
||||||
|
|
||||||
|
console.log(`Successfully loaded ${fontList.length} fonts`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load font list:', error)
|
||||||
|
alert('加载字体列表失败,请刷新页面重试')
|
||||||
|
} finally {
|
||||||
|
fontStore.isLoadingFonts = false
|
||||||
|
console.log('Font loading finished')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
console.log('useFontLoader: onMounted called')
|
||||||
|
loadFontList()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
loadFontList,
|
||||||
|
}
|
||||||
|
}
|
||||||
75
frontend/src/main.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { createApp, type Directive } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import 'virtual:uno.css'
|
||||||
|
import './style.css'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
console.log('main.ts is loading...')
|
||||||
|
console.log('App component:', App)
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(createPinia())
|
||||||
|
|
||||||
|
type OverflowAwareCleanup = {
|
||||||
|
resizeObserver: ResizeObserver
|
||||||
|
mutationObserver: MutationObserver
|
||||||
|
onMouseEnter: () => void
|
||||||
|
onWindowResize: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const overflowAwareMap = new WeakMap<HTMLElement, OverflowAwareCleanup>()
|
||||||
|
|
||||||
|
function updateOverflowState(el: HTMLElement) {
|
||||||
|
const isOverflowing = el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth
|
||||||
|
el.dataset.overflowing = isOverflowing ? 'true' : 'false'
|
||||||
|
}
|
||||||
|
|
||||||
|
const overflowAwareDirective: Directive<HTMLElement> = {
|
||||||
|
mounted(el) {
|
||||||
|
const refresh = () => updateOverflowState(el)
|
||||||
|
refresh()
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(refresh)
|
||||||
|
resizeObserver.observe(el)
|
||||||
|
|
||||||
|
const mutationObserver = new MutationObserver(refresh)
|
||||||
|
mutationObserver.observe(el, { childList: true, subtree: true, characterData: true })
|
||||||
|
|
||||||
|
const onMouseEnter = () => refresh()
|
||||||
|
const onWindowResize = () => refresh()
|
||||||
|
el.addEventListener('mouseenter', onMouseEnter)
|
||||||
|
window.addEventListener('resize', onWindowResize)
|
||||||
|
|
||||||
|
overflowAwareMap.set(el, {
|
||||||
|
resizeObserver,
|
||||||
|
mutationObserver,
|
||||||
|
onMouseEnter,
|
||||||
|
onWindowResize,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
updated(el) {
|
||||||
|
updateOverflowState(el)
|
||||||
|
},
|
||||||
|
unmounted(el) {
|
||||||
|
const cleanup = overflowAwareMap.get(el)
|
||||||
|
if (!cleanup) return
|
||||||
|
|
||||||
|
cleanup.resizeObserver.disconnect()
|
||||||
|
cleanup.mutationObserver.disconnect()
|
||||||
|
el.removeEventListener('mouseenter', cleanup.onMouseEnter)
|
||||||
|
window.removeEventListener('resize', cleanup.onWindowResize)
|
||||||
|
overflowAwareMap.delete(el)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
app.directive('overflow-aware', overflowAwareDirective)
|
||||||
|
|
||||||
|
app.config.errorHandler = (err, instance, info) => {
|
||||||
|
console.error('Vue Error:', err)
|
||||||
|
console.error('Error info:', info)
|
||||||
|
console.error('Component instance:', instance)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Mounting app to #app...')
|
||||||
|
app.mount('#app')
|
||||||
|
console.log('App mounted successfully!')
|
||||||
248
frontend/src/stores/fontStore.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import type { FontInfo, FontTreeNode } from '../types/font'
|
||||||
|
import { loadFontWithProgress } from '../utils/font-loader'
|
||||||
|
|
||||||
|
export const useFontStore = defineStore('font', () => {
|
||||||
|
function readSet(key: string): Set<string> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
if (!raw) return new Set()
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return Array.isArray(parsed) ? new Set(parsed.map(String)) : new Set()
|
||||||
|
} catch {
|
||||||
|
return new Set()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSet(key: string, value: Set<string>) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, JSON.stringify(Array.from(value)))
|
||||||
|
} catch {
|
||||||
|
// ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
const fonts = ref<FontInfo[]>([])
|
||||||
|
const selectedFontIds = ref<Set<string>>(new Set())
|
||||||
|
const favoriteFontIds = ref<Set<string>>(readSet('font.favoriteFontIds'))
|
||||||
|
const previewFontIds = ref<Set<string>>(readSet('font.previewFontIds'))
|
||||||
|
const expandedCategoryNames = ref<Set<string>>(readSet('font.expandedCategories'))
|
||||||
|
const fontTree = ref<FontTreeNode[]>([])
|
||||||
|
const isLoadingFonts = ref(false)
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const selectedFonts = computed(() => {
|
||||||
|
return fonts.value.filter(f => selectedFontIds.value.has(f.id))
|
||||||
|
})
|
||||||
|
|
||||||
|
const favoriteFonts = computed(() => {
|
||||||
|
return fonts.value.filter(f => favoriteFontIds.value.has(f.id))
|
||||||
|
})
|
||||||
|
|
||||||
|
const previewFonts = computed(() => {
|
||||||
|
return fonts.value.filter(f => previewFontIds.value.has(f.id))
|
||||||
|
})
|
||||||
|
|
||||||
|
const favoriteTree = computed(() => {
|
||||||
|
return buildFontTree(favoriteFonts.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
function addFont(fontInfo: FontInfo) {
|
||||||
|
fonts.value.push(fontInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFont(fontId: string) {
|
||||||
|
const index = fonts.value.findIndex(f => f.id === fontId)
|
||||||
|
if (index !== -1) {
|
||||||
|
fonts.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
selectedFontIds.value.delete(fontId)
|
||||||
|
favoriteFontIds.value.delete(fontId)
|
||||||
|
writeSet('font.favoriteFontIds', favoriteFontIds.value)
|
||||||
|
previewFontIds.value.delete(fontId)
|
||||||
|
writeSet('font.previewFontIds', previewFontIds.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectFont(fontId: string) {
|
||||||
|
selectedFontIds.value.add(fontId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function unselectFont(fontId: string) {
|
||||||
|
selectedFontIds.value.delete(fontId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelectFont(fontId: string) {
|
||||||
|
if (selectedFontIds.value.has(fontId)) {
|
||||||
|
unselectFont(fontId)
|
||||||
|
} else {
|
||||||
|
selectFont(fontId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
selectedFontIds.value.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
function favoriteFont(fontId: string) {
|
||||||
|
const font = fonts.value.find(f => f.id === fontId)
|
||||||
|
if (font) {
|
||||||
|
font.isFavorite = true
|
||||||
|
favoriteFontIds.value.add(fontId)
|
||||||
|
writeSet('font.favoriteFontIds', favoriteFontIds.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unfavoriteFont(fontId: string) {
|
||||||
|
const font = fonts.value.find(f => f.id === fontId)
|
||||||
|
if (font) {
|
||||||
|
font.isFavorite = false
|
||||||
|
favoriteFontIds.value.delete(fontId)
|
||||||
|
writeSet('font.favoriteFontIds', favoriteFontIds.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFavorite(fontId: string) {
|
||||||
|
if (favoriteFontIds.value.has(fontId)) {
|
||||||
|
unfavoriteFont(fontId)
|
||||||
|
} else {
|
||||||
|
favoriteFont(fontId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addToPreview(fontId: string) {
|
||||||
|
previewFontIds.value.add(fontId)
|
||||||
|
writeSet('font.previewFontIds', previewFontIds.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFromPreview(fontId: string) {
|
||||||
|
previewFontIds.value.delete(fontId)
|
||||||
|
writeSet('font.previewFontIds', previewFontIds.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePreview(fontId: string) {
|
||||||
|
if (previewFontIds.value.has(fontId)) {
|
||||||
|
removeFromPreview(fontId)
|
||||||
|
} else {
|
||||||
|
addToPreview(fontId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPreview() {
|
||||||
|
previewFontIds.value.clear()
|
||||||
|
writeSet('font.previewFontIds', previewFontIds.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCategoryExpanded(categoryName: string, expanded: boolean) {
|
||||||
|
const next = new Set(expandedCategoryNames.value)
|
||||||
|
if (expanded) {
|
||||||
|
next.add(categoryName)
|
||||||
|
} else {
|
||||||
|
next.delete(categoryName)
|
||||||
|
}
|
||||||
|
expandedCategoryNames.value = next
|
||||||
|
writeSet('font.expandedCategories', expandedCategoryNames.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFont(fontInfo: FontInfo) {
|
||||||
|
if (fontInfo.loaded || !fontInfo.path) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const font = await loadFontWithProgress(fontInfo.path, (progress) => {
|
||||||
|
fontInfo.progress = progress
|
||||||
|
})
|
||||||
|
fontInfo.font = font
|
||||||
|
fontInfo.loaded = true
|
||||||
|
fontInfo.progress = 100
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to load font ${fontInfo.name}:`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFontTree(fontList: FontInfo[]): FontTreeNode[] {
|
||||||
|
const tree: FontTreeNode[] = []
|
||||||
|
const categoryMap = new Map<string, FontTreeNode>()
|
||||||
|
|
||||||
|
for (const font of fontList) {
|
||||||
|
let categoryNode = categoryMap.get(font.category)
|
||||||
|
|
||||||
|
if (!categoryNode) {
|
||||||
|
categoryNode = {
|
||||||
|
name: font.category,
|
||||||
|
type: 'category',
|
||||||
|
children: [],
|
||||||
|
expanded: expandedCategoryNames.value.has(font.category),
|
||||||
|
selected: false,
|
||||||
|
}
|
||||||
|
categoryMap.set(font.category, categoryNode)
|
||||||
|
tree.push(categoryNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fontNode: FontTreeNode = {
|
||||||
|
name: font.name,
|
||||||
|
type: 'font',
|
||||||
|
fontInfo: font,
|
||||||
|
expanded: false,
|
||||||
|
selected: selectedFontIds.value.has(font.id),
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryNode.children!.push(fontNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按类别名称排序
|
||||||
|
tree.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
|
||||||
|
|
||||||
|
// 每个类别内的字体按名称排序
|
||||||
|
for (const category of tree) {
|
||||||
|
if (category.children) {
|
||||||
|
category.children.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tree
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFontTree() {
|
||||||
|
fontTree.value = buildFontTree(fonts.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
fonts,
|
||||||
|
selectedFontIds,
|
||||||
|
favoriteFontIds,
|
||||||
|
previewFontIds,
|
||||||
|
expandedCategoryNames,
|
||||||
|
fontTree,
|
||||||
|
isLoadingFonts,
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
selectedFonts,
|
||||||
|
favoriteFonts,
|
||||||
|
previewFonts,
|
||||||
|
favoriteTree,
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
addFont,
|
||||||
|
removeFont,
|
||||||
|
selectFont,
|
||||||
|
unselectFont,
|
||||||
|
toggleSelectFont,
|
||||||
|
clearSelection,
|
||||||
|
favoriteFont,
|
||||||
|
unfavoriteFont,
|
||||||
|
toggleFavorite,
|
||||||
|
addToPreview,
|
||||||
|
removeFromPreview,
|
||||||
|
togglePreview,
|
||||||
|
clearPreview,
|
||||||
|
setCategoryExpanded,
|
||||||
|
loadFont,
|
||||||
|
updateFontTree,
|
||||||
|
}
|
||||||
|
})
|
||||||
177
frontend/src/stores/uiStore.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { PreviewItem } from '../types/font'
|
||||||
|
|
||||||
|
export const useUiStore = defineStore('ui', () => {
|
||||||
|
function clampFontSize(size: number) {
|
||||||
|
return Math.max(10, Math.min(500, size))
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialFontSize = (() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('ui.fontSize')
|
||||||
|
const parsed = stored ? Number(stored) : NaN
|
||||||
|
return Number.isFinite(parsed) ? clampFontSize(parsed) : 100
|
||||||
|
} catch {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
const initialInputText = (() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem('ui.inputText') || ''
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
const initialTextColor = (() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem('ui.textColor') || '#000000'
|
||||||
|
} catch {
|
||||||
|
return '#000000'
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
const initialSelectedExportItems = (() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('ui.selectedExportItems')
|
||||||
|
return stored ? JSON.parse(stored) : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
const inputText = ref(initialInputText)
|
||||||
|
const fontSize = ref(initialFontSize)
|
||||||
|
const textColor = ref(initialTextColor)
|
||||||
|
const letterSpacing = ref(0)
|
||||||
|
const enableLigatures = ref(true)
|
||||||
|
|
||||||
|
// 导出相关
|
||||||
|
const selectedExportItems = ref<PreviewItem[]>(initialSelectedExportItems)
|
||||||
|
const isExporting = ref(false)
|
||||||
|
|
||||||
|
// 侧边栏状态
|
||||||
|
const isFontSelectorExpanded = ref(true)
|
||||||
|
const isFavoritesExpanded = ref(true)
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
function setInputText(text: string) {
|
||||||
|
inputText.value = text
|
||||||
|
try {
|
||||||
|
localStorage.setItem('ui.inputText', text)
|
||||||
|
} catch {
|
||||||
|
// ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFontSize(size: number) {
|
||||||
|
const clamped = clampFontSize(size)
|
||||||
|
fontSize.value = clamped
|
||||||
|
try {
|
||||||
|
localStorage.setItem('ui.fontSize', String(clamped))
|
||||||
|
} catch {
|
||||||
|
// ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTextColor(color: string) {
|
||||||
|
textColor.value = color
|
||||||
|
try {
|
||||||
|
localStorage.setItem('ui.textColor', color)
|
||||||
|
} catch {
|
||||||
|
// ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLetterSpacing(spacing: number) {
|
||||||
|
letterSpacing.value = spacing
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLigatures() {
|
||||||
|
enableLigatures.value = !enableLigatures.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExportItem(item: PreviewItem) {
|
||||||
|
const index = selectedExportItems.value.findIndex(i => i.fontInfo.id === item.fontInfo.id)
|
||||||
|
if (index >= 0) {
|
||||||
|
selectedExportItems.value.splice(index, 1)
|
||||||
|
} else {
|
||||||
|
selectedExportItems.value.push(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem('ui.selectedExportItems', JSON.stringify(selectedExportItems.value))
|
||||||
|
} catch {
|
||||||
|
// ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function retainExportItemsByFontIds(validFontIds: Set<string>) {
|
||||||
|
const nextItems = selectedExportItems.value.filter(item => validFontIds.has(item.fontInfo.id))
|
||||||
|
if (nextItems.length === selectedExportItems.value.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedExportItems.value = nextItems
|
||||||
|
try {
|
||||||
|
localStorage.setItem('ui.selectedExportItems', JSON.stringify(selectedExportItems.value))
|
||||||
|
} catch {
|
||||||
|
// ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearExportSelection() {
|
||||||
|
selectedExportItems.value = []
|
||||||
|
try {
|
||||||
|
localStorage.setItem('ui.selectedExportItems', JSON.stringify([]))
|
||||||
|
} catch {
|
||||||
|
// ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllExportItems(items: PreviewItem[]) {
|
||||||
|
selectedExportItems.value = [...items]
|
||||||
|
try {
|
||||||
|
localStorage.setItem('ui.selectedExportItems', JSON.stringify(selectedExportItems.value))
|
||||||
|
} catch {
|
||||||
|
// ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFontSelectorExpanded() {
|
||||||
|
isFontSelectorExpanded.value = !isFontSelectorExpanded.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFavoritesExpanded() {
|
||||||
|
isFavoritesExpanded.value = !isFavoritesExpanded.value
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
inputText,
|
||||||
|
fontSize,
|
||||||
|
textColor,
|
||||||
|
letterSpacing,
|
||||||
|
enableLigatures,
|
||||||
|
selectedExportItems,
|
||||||
|
isExporting,
|
||||||
|
isFontSelectorExpanded,
|
||||||
|
isFavoritesExpanded,
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
setInputText,
|
||||||
|
setFontSize,
|
||||||
|
setTextColor,
|
||||||
|
setLetterSpacing,
|
||||||
|
toggleLigatures,
|
||||||
|
toggleExportItem,
|
||||||
|
retainExportItemsByFontIds,
|
||||||
|
clearExportSelection,
|
||||||
|
selectAllExportItems,
|
||||||
|
toggleFontSelectorExpanded,
|
||||||
|
toggleFavoritesExpanded,
|
||||||
|
}
|
||||||
|
})
|
||||||
101
frontend/src/style.css
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
:root {
|
||||||
|
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
color-scheme: light;
|
||||||
|
color: #213547;
|
||||||
|
background-color: #ffffff;
|
||||||
|
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #646cff;
|
||||||
|
text-decoration: inherit;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: #535bf2;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 3.2em;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: 0.6em 1.2em;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.25s;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
border-color: #646cff;
|
||||||
|
}
|
||||||
|
button:focus,
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 4px auto -webkit-focus-ring-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 默认隐藏滚动条,鼠标悬停到具体滚动容器时再显示 */
|
||||||
|
.scrollbar-hover {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hover::-webkit-scrollbar {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hover[data-overflowing='true']:hover {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #c9cdd4 transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hover[data-overflowing='true']:hover::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hover[data-overflowing='true']:hover::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #c9cdd4;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
color: #213547;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: #747bff;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
}
|
||||||
101
frontend/src/types/font.d.ts
vendored
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import type { Font } from 'opentype.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字体文件信息
|
||||||
|
*/
|
||||||
|
export interface FontInfo {
|
||||||
|
/** 字体唯一标识 */
|
||||||
|
id: string
|
||||||
|
/** 字体文件名(不含扩展名) */
|
||||||
|
name: string
|
||||||
|
/** 字体文件路径 */
|
||||||
|
path: string
|
||||||
|
/** 字体分类(目录名) */
|
||||||
|
category: string
|
||||||
|
/** 是否已收藏 */
|
||||||
|
isFavorite: boolean
|
||||||
|
/** opentype.js Font 对象 */
|
||||||
|
font?: Font
|
||||||
|
/** 加载状态 */
|
||||||
|
loaded: boolean
|
||||||
|
/** 加载进度 0-100 */
|
||||||
|
progress: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字体树节点
|
||||||
|
*/
|
||||||
|
export interface FontTreeNode {
|
||||||
|
/** 节点名称 */
|
||||||
|
name: string
|
||||||
|
/** 节点类型 */
|
||||||
|
type: 'category' | 'font'
|
||||||
|
/** 子节点 */
|
||||||
|
children?: FontTreeNode[]
|
||||||
|
/** 字体信息(type为font时) */
|
||||||
|
fontInfo?: FontInfo
|
||||||
|
/** 是否展开 */
|
||||||
|
expanded: boolean
|
||||||
|
/** 是否选中 */
|
||||||
|
selected: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HarfBuzz shaped glyph
|
||||||
|
*/
|
||||||
|
export interface ShapedGlyph {
|
||||||
|
/** 字形索引 */
|
||||||
|
glyphIndex: number
|
||||||
|
/** X 方向前进距离 */
|
||||||
|
xAdvance: number
|
||||||
|
/** Y 方向前进距离 */
|
||||||
|
yAdvance: number
|
||||||
|
/** X 方向偏移 */
|
||||||
|
xOffset: number
|
||||||
|
/** Y 方向偏移 */
|
||||||
|
yOffset: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SVG 生成选项
|
||||||
|
*/
|
||||||
|
export interface SvgGenerateOptions {
|
||||||
|
/** 输入文本 */
|
||||||
|
text: string
|
||||||
|
/** 字体对象 */
|
||||||
|
font: Font
|
||||||
|
/** 字号(单位:像素) */
|
||||||
|
fontSize?: number
|
||||||
|
/** 文本颜色(默认#000000) */
|
||||||
|
fillColor?: string
|
||||||
|
/** 字间距(单位:em,默认0) */
|
||||||
|
letterSpacing?: number
|
||||||
|
/** 是否启用连字(默认true) */
|
||||||
|
enableLigatures?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SVG 生成结果
|
||||||
|
*/
|
||||||
|
export interface SvgGenerateResult {
|
||||||
|
/** SVG 字符串 */
|
||||||
|
svg: string
|
||||||
|
/** 宽度 */
|
||||||
|
width: number
|
||||||
|
/** 高度 */
|
||||||
|
height: number
|
||||||
|
/** 字体名称 */
|
||||||
|
fontName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预览项数据
|
||||||
|
*/
|
||||||
|
export interface PreviewItem {
|
||||||
|
/** 字体信息 */
|
||||||
|
fontInfo: FontInfo
|
||||||
|
/** SVG 结果 */
|
||||||
|
svgResult: SvgGenerateResult
|
||||||
|
/** 是否选中(用于导出) */
|
||||||
|
selected: boolean
|
||||||
|
}
|
||||||
51
frontend/src/types/modules.d.ts
vendored
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
declare module 'opentype.js' {
|
||||||
|
export interface Font {
|
||||||
|
names: {
|
||||||
|
fontFamily?: { en?: string }
|
||||||
|
fontSubfamily?: { en?: string }
|
||||||
|
fullName?: { en?: string }
|
||||||
|
postScriptName?: { en?: string }
|
||||||
|
version?: { en?: string }
|
||||||
|
}
|
||||||
|
unitsPerEm: number
|
||||||
|
ascender: number
|
||||||
|
descender: number
|
||||||
|
numGlyphs: number
|
||||||
|
glyphs: {
|
||||||
|
get(index: number): Glyph
|
||||||
|
}
|
||||||
|
charToGlyph(char: string): Glyph
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Glyph {
|
||||||
|
name: string
|
||||||
|
unicode?: number
|
||||||
|
unicodes?: number[]
|
||||||
|
index: number
|
||||||
|
advanceWidth?: number
|
||||||
|
leftSideBearing?: number
|
||||||
|
path: Path
|
||||||
|
getPath(x: number, y: number, fontSize: number): Path
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Path {
|
||||||
|
commands: PathCommand[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PathCommand =
|
||||||
|
| { type: 'M'; x: number; y: number }
|
||||||
|
| { type: 'L'; x: number; y: number }
|
||||||
|
| { type: 'Q'; x: number; y: number; x1: number; y1: number }
|
||||||
|
| { type: 'C'; x: number; y: number; x1: number; y1: number; x2: number; y2: number }
|
||||||
|
| { type: 'Z' }
|
||||||
|
|
||||||
|
export function parse(buffer: ArrayBuffer): Font
|
||||||
|
export function load(
|
||||||
|
url: string,
|
||||||
|
callback: (err: Error | null, font?: Font) => void
|
||||||
|
): void
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'harfbuzzjs' {
|
||||||
|
export default function (): Promise<any>
|
||||||
|
}
|
||||||
170
frontend/src/utils/download.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* 下载文本内容为文件
|
||||||
|
*/
|
||||||
|
export function downloadText(content: string, filename: string, mimeType = 'text/plain') {
|
||||||
|
const blob = new Blob([content], { type: mimeType })
|
||||||
|
downloadBlob(blob, filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadFileItem {
|
||||||
|
name: string
|
||||||
|
content: string | Blob | ArrayBuffer | Uint8Array
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载 Blob 为文件
|
||||||
|
*/
|
||||||
|
export function downloadBlob(blob: Blob, filename: string) {
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = filename
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载 SVG 文件
|
||||||
|
*/
|
||||||
|
export function downloadSvg(svgContent: string, filename: string) {
|
||||||
|
downloadText(svgContent, filename, 'image/svg+xml')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量下载文件(使用 JSZip)
|
||||||
|
*/
|
||||||
|
export async function downloadMultipleFiles(files: DownloadFileItem[], zipFilename = 'font2svg-export.zip') {
|
||||||
|
const JSZip = (await import('jszip')).default
|
||||||
|
const zip = new JSZip()
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
zip.file(file.name, file.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await zip.generateAsync({ type: 'blob' })
|
||||||
|
downloadBlob(blob, zipFilename)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLengthValue(value: string | null): number | null {
|
||||||
|
if (!value) return null
|
||||||
|
const match = value.match(/-?\d+(\.\d+)?/)
|
||||||
|
if (!match) return null
|
||||||
|
|
||||||
|
const parsed = Number(match[0])
|
||||||
|
return Number.isFinite(parsed) ? parsed : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSvgSize(svgContent: string): { width: number; height: number } {
|
||||||
|
const doc = new DOMParser().parseFromString(svgContent, 'image/svg+xml')
|
||||||
|
const svg = doc.documentElement
|
||||||
|
|
||||||
|
const width = parseLengthValue(svg.getAttribute('width'))
|
||||||
|
const height = parseLengthValue(svg.getAttribute('height'))
|
||||||
|
if (width && height) {
|
||||||
|
return { width, height }
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewBox = svg.getAttribute('viewBox')
|
||||||
|
if (viewBox) {
|
||||||
|
const values = viewBox.trim().split(/[\s,]+/).map(Number)
|
||||||
|
if (values.length === 4 && Number.isFinite(values[2]) && Number.isFinite(values[3])) {
|
||||||
|
return { width: Math.max(1, values[2]!), height: Math.max(1, values[3]!) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { width: 1024, height: 1024 }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function convertSvgToPngBlob(
|
||||||
|
svgContent: string,
|
||||||
|
options?: { width?: number; height?: number; scale?: number; backgroundColor?: string }
|
||||||
|
): Promise<Blob> {
|
||||||
|
const size = getSvgSize(svgContent)
|
||||||
|
const scale = options?.scale ?? 1
|
||||||
|
const width = Math.max(1, Math.round((options?.width ?? size.width) * scale))
|
||||||
|
const height = Math.max(1, Math.round((options?.height ?? size.height) * scale))
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = width
|
||||||
|
canvas.height = height
|
||||||
|
|
||||||
|
const context = canvas.getContext('2d')
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('无法创建 PNG 画布')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.backgroundColor) {
|
||||||
|
context.fillStyle = options.backgroundColor
|
||||||
|
context.fillRect(0, 0, width, height)
|
||||||
|
} else {
|
||||||
|
context.clearRect(0, 0, width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
const svgBlob = new Blob([svgContent], { type: 'image/svg+xml;charset=utf-8' })
|
||||||
|
const url = URL.createObjectURL(svgBlob)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const image = new Image()
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
image.onload = () => resolve()
|
||||||
|
image.onerror = () => reject(new Error('SVG 转 PNG 失败'))
|
||||||
|
image.src = url
|
||||||
|
})
|
||||||
|
|
||||||
|
context.drawImage(image, 0, 0, width, height)
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pngBlob = await new Promise<Blob | null>((resolve) => {
|
||||||
|
canvas.toBlob(resolve, 'image/png')
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!pngBlob) {
|
||||||
|
throw new Error('PNG 编码失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return pngBlob
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadPngFromSvg(
|
||||||
|
svgContent: string,
|
||||||
|
filename: string,
|
||||||
|
options?: { width?: number; height?: number; scale?: number; backgroundColor?: string }
|
||||||
|
) {
|
||||||
|
const blob = await convertSvgToPngBlob(svgContent, options)
|
||||||
|
downloadBlob(blob, filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理文件名(移除非法字符)
|
||||||
|
*/
|
||||||
|
export function sanitizeFilename(filename: string): string {
|
||||||
|
// 移除或替换非法字符
|
||||||
|
return filename
|
||||||
|
.replace(/[<>:"/\\|?*\x00-\x1F]/g, '_')
|
||||||
|
.replace(/\s+/g, '_')
|
||||||
|
.substring(0, 200) // 限制长度
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateBaseFilename(text: string, fontName: string): string {
|
||||||
|
const textPart = sanitizeFilename(Array.from(text).slice(0, 8).join(''))
|
||||||
|
const fontPart = sanitizeFilename(fontName.substring(0, 20))
|
||||||
|
return `${fontPart}_${textPart}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成默认的 SVG 文件名
|
||||||
|
*/
|
||||||
|
export function generateSvgFilename(text: string, fontName: string): string {
|
||||||
|
return `${generateBaseFilename(text, fontName)}.svg`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成默认的 PNG 文件名
|
||||||
|
*/
|
||||||
|
export function generatePngFilename(text: string, fontName: string): string {
|
||||||
|
return `${generateBaseFilename(text, fontName)}.png`
|
||||||
|
}
|
||||||
102
frontend/src/utils/font-loader.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import * as opentype from 'opentype.js'
|
||||||
|
import type { Font } from 'opentype.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从文件加载字体
|
||||||
|
*/
|
||||||
|
export async function loadFontFromFile(file: File): Promise<Font> {
|
||||||
|
const buffer = await file.arrayBuffer()
|
||||||
|
return opentype.parse(buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 URL 加载字体
|
||||||
|
*/
|
||||||
|
export async function loadFontFromUrl(url: string): Promise<Font> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
opentype.load(url, (err, font) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err)
|
||||||
|
} else if (font) {
|
||||||
|
resolve(font)
|
||||||
|
} else {
|
||||||
|
reject(new Error('Failed to load font'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 URL 加载字体(带进度)
|
||||||
|
*/
|
||||||
|
export async function loadFontWithProgress(
|
||||||
|
url: string,
|
||||||
|
onProgress?: (percent: number) => void
|
||||||
|
): Promise<Font> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentLength = response.headers.get('content-length')
|
||||||
|
const total = contentLength ? parseInt(contentLength) : 0
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error('Response body is null')
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
const chunks: Uint8Array[] = []
|
||||||
|
let loaded = 0
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
|
||||||
|
chunks.push(value)
|
||||||
|
loaded += value.length
|
||||||
|
|
||||||
|
if (total > 0 && onProgress) {
|
||||||
|
onProgress(Math.round((loaded / total) * 100))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并所有 chunks
|
||||||
|
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0)
|
||||||
|
const buffer = new Uint8Array(totalLength)
|
||||||
|
let offset = 0
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
buffer.set(chunk, offset)
|
||||||
|
offset += chunk.length
|
||||||
|
}
|
||||||
|
|
||||||
|
return opentype.parse(buffer.buffer)
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to load font from ${url}: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 ArrayBuffer 加载字体
|
||||||
|
*/
|
||||||
|
export async function loadFontFromBuffer(buffer: ArrayBuffer): Promise<Font> {
|
||||||
|
return opentype.parse(buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取字体信息
|
||||||
|
*/
|
||||||
|
export function getFontInfo(font: Font) {
|
||||||
|
return {
|
||||||
|
familyName: font.names.fontFamily?.en || 'Unknown',
|
||||||
|
styleName: font.names.fontSubfamily?.en || 'Regular',
|
||||||
|
fullName: font.names.fullName?.en || 'Unknown',
|
||||||
|
postScriptName: font.names.postScriptName?.en || 'Unknown',
|
||||||
|
version: font.names.version?.en || 'Unknown',
|
||||||
|
unitsPerEm: font.unitsPerEm,
|
||||||
|
ascender: font.ascender,
|
||||||
|
descender: font.descender,
|
||||||
|
numGlyphs: font.numGlyphs,
|
||||||
|
}
|
||||||
|
}
|
||||||
86
frontend/src/utils/harfbuzz.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import type { ShapedGlyph } from '../types/font'
|
||||||
|
|
||||||
|
let hb: any = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化 HarfBuzz WASM
|
||||||
|
*/
|
||||||
|
export async function initHarfbuzz() {
|
||||||
|
if (!hb) {
|
||||||
|
// 使用动态 import 避免打包时的问题
|
||||||
|
const hbModule = await import('harfbuzzjs/hb.js')
|
||||||
|
const createHB = (hbModule as any).default || hbModule
|
||||||
|
const instance = await createHB()
|
||||||
|
|
||||||
|
const hbjsModule = await import('harfbuzzjs/hbjs.js')
|
||||||
|
const hbjsFunc = (hbjsModule as any).default || hbjsModule
|
||||||
|
hb = hbjsFunc(instance)
|
||||||
|
}
|
||||||
|
return hb
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 HarfBuzz 进行 text shaping
|
||||||
|
* @param fontBuffer 字体文件的 ArrayBuffer
|
||||||
|
* @param text 要处理的文本
|
||||||
|
* @param features OpenType 特性(可选)
|
||||||
|
* @returns Shaped glyphs 数组
|
||||||
|
*/
|
||||||
|
export async function shapeText(
|
||||||
|
fontBuffer: ArrayBuffer,
|
||||||
|
text: string,
|
||||||
|
features?: string[]
|
||||||
|
): Promise<ShapedGlyph[]> {
|
||||||
|
const hb = await initHarfbuzz()
|
||||||
|
|
||||||
|
// 创建 blob
|
||||||
|
const blob = hb.createBlob(fontBuffer)
|
||||||
|
const face = hb.createFace(blob, 0)
|
||||||
|
const font = hb.createFont(face)
|
||||||
|
|
||||||
|
// 创建 buffer 并添加文本
|
||||||
|
const buffer = hb.createBuffer()
|
||||||
|
buffer.addText(text)
|
||||||
|
buffer.guessSegmentProperties()
|
||||||
|
|
||||||
|
// 如果有特性,设置特性
|
||||||
|
if (features && features.length > 0) {
|
||||||
|
// HarfBuzz features format: "+liga", "-kern", etc.
|
||||||
|
// 这里简化处理,实际使用时可能需要更复杂的特性解析
|
||||||
|
}
|
||||||
|
|
||||||
|
// 进行 shaping
|
||||||
|
hb.shape(font, buffer)
|
||||||
|
|
||||||
|
// 获取结果
|
||||||
|
const result = buffer.json(font)
|
||||||
|
|
||||||
|
// 清理资源
|
||||||
|
buffer.destroy()
|
||||||
|
font.destroy()
|
||||||
|
face.destroy()
|
||||||
|
blob.destroy()
|
||||||
|
|
||||||
|
// 转换为我们的格式
|
||||||
|
return result.map((item: any) => ({
|
||||||
|
glyphIndex: item.g,
|
||||||
|
xAdvance: item.ax || 0,
|
||||||
|
yAdvance: item.ay || 0,
|
||||||
|
xOffset: item.dx || 0,
|
||||||
|
yOffset: item.dy || 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查 HarfBuzz 是否已初始化
|
||||||
|
*/
|
||||||
|
export function isHarfbuzzInitialized(): boolean {
|
||||||
|
return hb !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 HarfBuzz 实例(如果已初始化)
|
||||||
|
*/
|
||||||
|
export function getHarfbuzz() {
|
||||||
|
return hb
|
||||||
|
}
|
||||||
344
frontend/src/utils/svg-builder.ts
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
import type { Glyph } from 'opentype.js'
|
||||||
|
import type { SvgGenerateOptions, SvgGenerateResult } from '../types/font'
|
||||||
|
import { shapeText } from './harfbuzz'
|
||||||
|
import { wrapTextByChars } from './text-layout'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化数字(移除尾随零)
|
||||||
|
*/
|
||||||
|
function formatNumber(value: number): string {
|
||||||
|
const text = value.toFixed(2).replace(/\.?0+$/, '')
|
||||||
|
return text || '0'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取字形路径的 SVG path d 属性
|
||||||
|
*/
|
||||||
|
function getGlyphPath(glyph: Glyph): string {
|
||||||
|
const path = glyph.path
|
||||||
|
if (!path || !path.commands || path.commands.length === 0) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const commands: string[] = []
|
||||||
|
for (const cmd of path.commands) {
|
||||||
|
switch (cmd.type) {
|
||||||
|
case 'M':
|
||||||
|
commands.push(`M${formatNumber(cmd.x)} ${formatNumber(cmd.y)}`)
|
||||||
|
break
|
||||||
|
case 'L':
|
||||||
|
commands.push(`L${formatNumber(cmd.x)} ${formatNumber(cmd.y)}`)
|
||||||
|
break
|
||||||
|
case 'Q':
|
||||||
|
commands.push(`Q${formatNumber(cmd.x1)} ${formatNumber(cmd.y1)} ${formatNumber(cmd.x)} ${formatNumber(cmd.y)}`)
|
||||||
|
break
|
||||||
|
case 'C':
|
||||||
|
commands.push(`C${formatNumber(cmd.x1)} ${formatNumber(cmd.y1)} ${formatNumber(cmd.x2)} ${formatNumber(cmd.y2)} ${formatNumber(cmd.x)} ${formatNumber(cmd.y)}`)
|
||||||
|
break
|
||||||
|
case 'Z':
|
||||||
|
commands.push('Z')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return commands.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算字形的边界框
|
||||||
|
*/
|
||||||
|
function getGlyphBounds(glyph: Glyph): { xMin: number; yMin: number; xMax: number; yMax: number } | null {
|
||||||
|
const path = glyph.path
|
||||||
|
if (!path || !path.commands || path.commands.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let xMin = Infinity
|
||||||
|
let yMin = Infinity
|
||||||
|
let xMax = -Infinity
|
||||||
|
let yMax = -Infinity
|
||||||
|
|
||||||
|
for (const cmd of path.commands) {
|
||||||
|
if ('x' in cmd) {
|
||||||
|
xMin = Math.min(xMin, cmd.x)
|
||||||
|
xMax = Math.max(xMax, cmd.x)
|
||||||
|
yMin = Math.min(yMin, cmd.y)
|
||||||
|
yMax = Math.max(yMax, cmd.y)
|
||||||
|
}
|
||||||
|
if ('x1' in cmd) {
|
||||||
|
xMin = Math.min(xMin, cmd.x1)
|
||||||
|
xMax = Math.max(xMax, cmd.x1)
|
||||||
|
yMin = Math.min(yMin, cmd.y1)
|
||||||
|
yMax = Math.max(yMax, cmd.y1)
|
||||||
|
}
|
||||||
|
if ('x2' in cmd) {
|
||||||
|
xMin = Math.min(xMin, cmd.x2)
|
||||||
|
xMax = Math.max(xMax, cmd.x2)
|
||||||
|
yMin = Math.min(yMin, cmd.y2)
|
||||||
|
yMax = Math.max(yMax, cmd.y2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (xMin === Infinity) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return { xMin, yMin, xMax, yMax }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GlyphRun {
|
||||||
|
glyph: Glyph
|
||||||
|
xPos: number
|
||||||
|
yPos: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 SVG(使用 HarfBuzz 进行 text shaping)
|
||||||
|
*/
|
||||||
|
export async function generateSvg(options: SvgGenerateOptions): Promise<SvgGenerateResult> {
|
||||||
|
const {
|
||||||
|
text,
|
||||||
|
font,
|
||||||
|
fontSize = 100,
|
||||||
|
fillColor = '#000000',
|
||||||
|
letterSpacing = 0,
|
||||||
|
// enableLigatures = true, // 暂时不使用
|
||||||
|
} = options
|
||||||
|
|
||||||
|
if (!text || text.trim() === '') {
|
||||||
|
throw new Error('文本内容不能为空')
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedText = wrapTextByChars(text)
|
||||||
|
|
||||||
|
// 获取字体的 ArrayBuffer(用于 HarfBuzz)
|
||||||
|
// 注意:opentype.js 的 Font 对象没有直接访问 ArrayBuffer 的方法
|
||||||
|
// 我们需要从原始数据获取,这里假设 font 对象有 outlinesFormat 属性
|
||||||
|
// 实际使用时,需要保存原始的 ArrayBuffer
|
||||||
|
|
||||||
|
// 这里先使用简化版本,不使用 HarfBuzz,直接使用 opentype.js 的基本功能
|
||||||
|
// 后续可以优化为使用 HarfBuzz
|
||||||
|
|
||||||
|
const scale = fontSize / font.unitsPerEm
|
||||||
|
const letterSpacingEm = letterSpacing * font.unitsPerEm
|
||||||
|
|
||||||
|
// 获取字形(支持手动换行 + 按 45 字自动换行)
|
||||||
|
const glyphRuns: GlyphRun[] = []
|
||||||
|
let minX: number | null = null
|
||||||
|
let minY: number | null = null
|
||||||
|
let maxX: number | null = null
|
||||||
|
let maxY: number | null = null
|
||||||
|
let maxLineAdvance = 0
|
||||||
|
|
||||||
|
const ascender = Number.isFinite(font.ascender) ? font.ascender : font.unitsPerEm * 0.8
|
||||||
|
const descender = Number.isFinite(font.descender) ? font.descender : -font.unitsPerEm * 0.2
|
||||||
|
const lineAdvance = Math.max(font.unitsPerEm * 1.2, ascender - descender)
|
||||||
|
const lines = normalizedText.split('\n')
|
||||||
|
|
||||||
|
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
||||||
|
const line = lines[lineIndex] ?? ''
|
||||||
|
const yPos = -lineIndex * lineAdvance
|
||||||
|
let xPos = 0
|
||||||
|
|
||||||
|
for (const char of Array.from(line)) {
|
||||||
|
const glyph = font.charToGlyph(char)
|
||||||
|
|
||||||
|
glyphRuns.push({
|
||||||
|
glyph,
|
||||||
|
xPos,
|
||||||
|
yPos,
|
||||||
|
})
|
||||||
|
|
||||||
|
const bounds = getGlyphBounds(glyph)
|
||||||
|
if (bounds) {
|
||||||
|
const adjustedXMin = bounds.xMin + xPos
|
||||||
|
const adjustedYMin = bounds.yMin + yPos
|
||||||
|
const adjustedXMax = bounds.xMax + xPos
|
||||||
|
const adjustedYMax = bounds.yMax + yPos
|
||||||
|
|
||||||
|
minX = minX === null ? adjustedXMin : Math.min(minX, adjustedXMin)
|
||||||
|
minY = minY === null ? adjustedYMin : Math.min(minY, adjustedYMin)
|
||||||
|
maxX = maxX === null ? adjustedXMax : Math.max(maxX, adjustedXMax)
|
||||||
|
maxY = maxY === null ? adjustedYMax : Math.max(maxY, adjustedYMax)
|
||||||
|
}
|
||||||
|
|
||||||
|
xPos += (glyph.advanceWidth || 0) + letterSpacingEm
|
||||||
|
}
|
||||||
|
|
||||||
|
maxLineAdvance = Math.max(maxLineAdvance, xPos)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minX === null || maxX === null) {
|
||||||
|
minX = 0
|
||||||
|
maxX = maxLineAdvance
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minX === null || minY === null || maxX === null || maxY === null) {
|
||||||
|
throw new Error('未生成有效字形轮廓')
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = (maxX - minX) * scale
|
||||||
|
const height = (maxY - minY) * scale
|
||||||
|
|
||||||
|
if (width <= 0 || height <= 0) {
|
||||||
|
throw new Error('计算得到的 SVG 尺寸无效')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成路径
|
||||||
|
const paths: string[] = []
|
||||||
|
for (const run of glyphRuns) {
|
||||||
|
const d = getGlyphPath(run.glyph)
|
||||||
|
if (!d) continue
|
||||||
|
|
||||||
|
const transform = `translate(${formatNumber(run.xPos)} ${formatNumber(run.yPos)})`
|
||||||
|
paths.push(` <path d="${d}" transform="${transform}"/>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paths.length === 0) {
|
||||||
|
throw new Error('未生成任何路径')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建 SVG
|
||||||
|
const viewBox = `${formatNumber(minX)} 0 ${formatNumber(maxX - minX)} ${formatNumber(maxY - minY)}`
|
||||||
|
const groupTransform = `translate(0 ${formatNumber(maxY)}) scale(1 -1)`
|
||||||
|
|
||||||
|
const svg = `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" width="${formatNumber(width)}" height="${formatNumber(height)}" preserveAspectRatio="xMidYMid meet">
|
||||||
|
<g transform="${groupTransform}" fill="${fillColor}" stroke="none">
|
||||||
|
${paths.join('\n')}
|
||||||
|
</g>
|
||||||
|
</svg>`
|
||||||
|
|
||||||
|
const fontName = font.names.fontFamily?.en || font.names.fullName?.en || 'Unknown'
|
||||||
|
|
||||||
|
return {
|
||||||
|
svg,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fontName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 SVG(使用 HarfBuzz,高级版本)
|
||||||
|
* 此版本需要原始字体的 ArrayBuffer
|
||||||
|
*/
|
||||||
|
export async function generateSvgWithHarfbuzz(
|
||||||
|
options: SvgGenerateOptions,
|
||||||
|
fontBuffer: ArrayBuffer
|
||||||
|
): Promise<SvgGenerateResult> {
|
||||||
|
const {
|
||||||
|
text,
|
||||||
|
font,
|
||||||
|
fontSize = 100,
|
||||||
|
fillColor = '#000000',
|
||||||
|
letterSpacing = 0,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
if (!text || text.trim() === '') {
|
||||||
|
throw new Error('文本内容不能为空')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 HarfBuzz 进行 text shaping
|
||||||
|
const shapedGlyphs = await shapeText(fontBuffer, text)
|
||||||
|
|
||||||
|
const scale = fontSize / font.unitsPerEm
|
||||||
|
const letterSpacingRaw = letterSpacing * font.unitsPerEm
|
||||||
|
|
||||||
|
// 计算字形位置和路径
|
||||||
|
const glyphRuns: GlyphRun[] = []
|
||||||
|
let x = 0
|
||||||
|
let y = 0
|
||||||
|
|
||||||
|
// 确定 position scale(参考 Python 版本的 _positions_scale 函数)
|
||||||
|
let posScale = 1.0
|
||||||
|
const sampleAdvance = shapedGlyphs.find(g => g.xAdvance)?.xAdvance || 0
|
||||||
|
if (Math.abs(sampleAdvance) > font.unitsPerEm * 4) {
|
||||||
|
posScale = 1 / 64.0
|
||||||
|
}
|
||||||
|
|
||||||
|
const spacingRaw = letterSpacingRaw / posScale
|
||||||
|
|
||||||
|
for (const shaped of shapedGlyphs) {
|
||||||
|
const glyph = font.glyphs.get(shaped.glyphIndex)
|
||||||
|
|
||||||
|
const xPos = (x + shaped.xOffset) * posScale
|
||||||
|
const yPos = (y + shaped.yOffset) * posScale
|
||||||
|
|
||||||
|
glyphRuns.push({
|
||||||
|
glyph,
|
||||||
|
xPos,
|
||||||
|
yPos,
|
||||||
|
})
|
||||||
|
|
||||||
|
x += shaped.xAdvance + spacingRaw
|
||||||
|
y += shaped.yAdvance
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算总边界框
|
||||||
|
let minX: number | null = null
|
||||||
|
let minY: number | null = null
|
||||||
|
let maxX: number | null = null
|
||||||
|
let maxY: number | null = null
|
||||||
|
|
||||||
|
for (const run of glyphRuns) {
|
||||||
|
const bounds = getGlyphBounds(run.glyph)
|
||||||
|
if (!bounds) continue
|
||||||
|
|
||||||
|
const { xMin, yMin, xMax, yMax } = bounds
|
||||||
|
const adjustedXMin = xMin + run.xPos
|
||||||
|
const adjustedYMin = yMin + run.yPos
|
||||||
|
const adjustedXMax = xMax + run.xPos
|
||||||
|
const adjustedYMax = yMax + run.yPos
|
||||||
|
|
||||||
|
minX = minX === null ? adjustedXMin : Math.min(minX, adjustedXMin)
|
||||||
|
minY = minY === null ? adjustedYMin : Math.min(minY, adjustedYMin)
|
||||||
|
maxX = maxX === null ? adjustedXMax : Math.max(maxX, adjustedXMax)
|
||||||
|
maxY = maxY === null ? adjustedYMax : Math.max(maxY, adjustedYMax)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minX === null || minY === null || maxX === null || maxY === null) {
|
||||||
|
throw new Error('未生成有效字形轮廓')
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = (maxX - minX) * scale
|
||||||
|
const height = (maxY - minY) * scale
|
||||||
|
|
||||||
|
if (width <= 0 || height <= 0) {
|
||||||
|
throw new Error('计算得到的 SVG 尺寸无效')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成路径
|
||||||
|
const paths: string[] = []
|
||||||
|
for (const run of glyphRuns) {
|
||||||
|
const d = getGlyphPath(run.glyph)
|
||||||
|
if (!d) continue
|
||||||
|
|
||||||
|
const transform = `translate(${formatNumber(run.xPos)} ${formatNumber(run.yPos)})`
|
||||||
|
paths.push(` <path d="${d}" transform="${transform}"/>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paths.length === 0) {
|
||||||
|
throw new Error('未生成任何路径')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建 SVG
|
||||||
|
const viewBox = `${formatNumber(minX)} 0 ${formatNumber(maxX - minX)} ${formatNumber(maxY - minY)}`
|
||||||
|
const groupTransform = `translate(0 ${formatNumber(maxY)}) scale(1 -1)`
|
||||||
|
|
||||||
|
const svg = `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" preserveAspectRatio="xMidYMid meet">
|
||||||
|
<g transform="${groupTransform}" fill="${fillColor}" stroke="none">
|
||||||
|
${paths.join('\n')}
|
||||||
|
</g>
|
||||||
|
</svg>`
|
||||||
|
|
||||||
|
const fontName = font.names.fontFamily?.en || font.names.fullName?.en || 'Unknown'
|
||||||
|
|
||||||
|
return {
|
||||||
|
svg,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fontName,
|
||||||
|
}
|
||||||
|
}
|
||||||
34
frontend/src/utils/text-layout.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
export const MAX_CHARS_PER_LINE = 45
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一换行符为 \n
|
||||||
|
*/
|
||||||
|
export function normalizeLineBreaks(text: string): string {
|
||||||
|
return text.replace(/\r\n?/g, '\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按字符数自动换行,保留用户手动换行
|
||||||
|
*/
|
||||||
|
export function wrapTextByChars(text: string, maxCharsPerLine = MAX_CHARS_PER_LINE): string {
|
||||||
|
if (maxCharsPerLine <= 0) return normalizeLineBreaks(text)
|
||||||
|
|
||||||
|
const normalized = normalizeLineBreaks(text)
|
||||||
|
const lines = normalized.split('\n')
|
||||||
|
const wrappedLines: string[] = []
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const chars = Array.from(line)
|
||||||
|
|
||||||
|
if (chars.length === 0) {
|
||||||
|
wrappedLines.push('')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < chars.length; i += maxCharsPerLine) {
|
||||||
|
wrappedLines.push(chars.slice(i, i + maxCharsPerLine).join(''))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrappedLines.join('\n')
|
||||||
|
}
|
||||||
16
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
|
}
|
||||||
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
11
frontend/uno.config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig, presetWind, presetIcons } from 'unocss'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
presets: [
|
||||||
|
presetWind(),
|
||||||
|
presetIcons({
|
||||||
|
scale: 1.2,
|
||||||
|
warn: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
15
frontend/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import UnoCSS from 'unocss/vite'
|
||||||
|
import wasm from 'vite-plugin-wasm'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue(), UnoCSS(), wasm()],
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: ['harfbuzzjs']
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
'process.env': {}
|
||||||
|
}
|
||||||
|
})
|
||||||
11
package.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "font2svg",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "cd frontend && pnpm run dev",
|
||||||
|
"build": "cd frontend && pnpm run build",
|
||||||
|
"preview": "cd frontend && pnpm run preview",
|
||||||
|
"prepare-fonts": "python3 scripts/generate-font-list.py && python3 scripts/copy-fonts.py"
|
||||||
|
}
|
||||||
|
}
|
||||||
2
run.sh
@@ -1,4 +1,6 @@
|
|||||||
|
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
|
|
||||||
|
lsof -ti:5173,5174,5175,5176 | xargs kill -9 2>/dev/null;
|
||||||
|
|
||||||
python font2svg.py --fontdir font --text "星程紫微" --outdir svg
|
python font2svg.py --fontdir font --text "星程紫微" --outdir svg
|
||||||
|
|||||||
50
scripts/copy-fonts.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
复制字体文件到 public/fonts 目录
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def copy_fonts(source_dir='font', target_dir='frontend/public/fonts'):
|
||||||
|
"""复制字体文件"""
|
||||||
|
source_path = Path(source_dir)
|
||||||
|
target_path = Path(target_dir)
|
||||||
|
|
||||||
|
if not source_path.exists():
|
||||||
|
print(f"源目录不存在: {source_dir}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 清空目标目录
|
||||||
|
if target_path.exists():
|
||||||
|
shutil.rmtree(target_path)
|
||||||
|
|
||||||
|
# 创建目标目录
|
||||||
|
target_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
copied_count = 0
|
||||||
|
|
||||||
|
# 遍历所有子目录
|
||||||
|
for category_dir in sorted(source_path.iterdir()):
|
||||||
|
if not category_dir.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
category_name = category_dir.name
|
||||||
|
category_target = target_path / category_name
|
||||||
|
category_target.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# 复制字体文件
|
||||||
|
for font_file in sorted(category_dir.iterdir()):
|
||||||
|
if font_file.suffix.lower() not in ['.ttf', '.otf']:
|
||||||
|
continue
|
||||||
|
|
||||||
|
target_file = category_target / font_file.name
|
||||||
|
shutil.copy2(font_file, target_file)
|
||||||
|
copied_count += 1
|
||||||
|
print(f"复制: {font_file} -> {target_file}")
|
||||||
|
|
||||||
|
print(f"\n总共复制了 {copied_count} 个字体文件到 {target_dir}")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
copy_fonts()
|
||||||
72
scripts/generate-font-list.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
生成字体清单 JSON 文件
|
||||||
|
扫描 font/ 目录下的所有字体文件,生成一个 JSON 文件供前端使用
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def scan_fonts(font_dir='font'):
|
||||||
|
"""扫描字体目录,返回字体信息列表"""
|
||||||
|
fonts = []
|
||||||
|
font_dir_path = Path(font_dir)
|
||||||
|
|
||||||
|
if not font_dir_path.exists():
|
||||||
|
print(f"字体目录不存在: {font_dir}")
|
||||||
|
return fonts
|
||||||
|
|
||||||
|
# 遍历所有子目录
|
||||||
|
for category_dir in sorted(font_dir_path.iterdir()):
|
||||||
|
if not category_dir.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
category_name = category_dir.name
|
||||||
|
|
||||||
|
# 遍历类别下的所有字体文件
|
||||||
|
for font_file in sorted(category_dir.iterdir()):
|
||||||
|
if font_file.suffix.lower() not in ['.ttf', '.otf']:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 生成字体信息
|
||||||
|
font_info = {
|
||||||
|
'id': f"{category_name}/{font_file.stem}",
|
||||||
|
'name': font_file.stem,
|
||||||
|
'filename': font_file.name,
|
||||||
|
'category': category_name,
|
||||||
|
'path': f"/fonts/{category_name}/{font_file.name}",
|
||||||
|
}
|
||||||
|
|
||||||
|
fonts.append(font_info)
|
||||||
|
|
||||||
|
return fonts
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""主函数"""
|
||||||
|
# 扫描字体
|
||||||
|
fonts = scan_fonts()
|
||||||
|
|
||||||
|
print(f"找到 {len(fonts)} 个字体文件")
|
||||||
|
|
||||||
|
# 保存到 JSON 文件
|
||||||
|
output_file = 'frontend/public/fonts.json'
|
||||||
|
os.makedirs(os.path.dirname(output_file), exist_ok=True)
|
||||||
|
|
||||||
|
with open(output_file, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(fonts, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f"字体清单已保存到: {output_file}")
|
||||||
|
|
||||||
|
# 统计信息
|
||||||
|
categories = {}
|
||||||
|
for font in fonts:
|
||||||
|
category = font['category']
|
||||||
|
categories[category] = categories.get(category, 0) + 1
|
||||||
|
|
||||||
|
print("\n按类别统计:")
|
||||||
|
for category, count in sorted(categories.items()):
|
||||||
|
print(f" {category}: {count} 个字体")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||