支持上传字体

This commit is contained in:
ymk
2025-08-05 15:33:43 +08:00
parent b6705d5155
commit 8d37708d7a
21 changed files with 182 additions and 93 deletions

View File

@@ -72,7 +72,10 @@
"defaultText": "please input your text",
"textColor": "Text Color",
"fontFamily": "Font Family",
"fontWeight": "Font Weight"
"how2UploadFont": "How to Upload Font?",
"fontWeight": "Font Weight",
"uploadFontButton": "Upload Font | Files with the same name will replace the fonts uploaded previously",
"uploadedFonts": "Uploaded Fonts"
},
"Metadata": {
"title": "Screen Designer",

View File

@@ -72,7 +72,10 @@
"defaultText": "输入您的文字",
"textColor": "文字颜色",
"fontFamily": "选择字体",
"fontWeight": "字体粗细"
"how2UploadFont": "如何上传字体?",
"fontWeight": "字体粗细",
"uploadFontButton": "上传字体 | 相同名字的文件会替换前面上传的字体",
"uploadedFonts": "已上传字体"
},
"Metadata": {
"title": "屏幕背景设计工具",

View File

@@ -15,7 +15,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"lucide-react": "^0.479.0",
"lucide-react": "^0.536.0",
"lz-string": "^1.5.0",
"motion": "^12.23.11",
"next": "15.2.4",

10
pnpm-lock.yaml generated
View File

@@ -27,8 +27,8 @@ importers:
specifier: ^4.2.0
version: 4.2.0
lucide-react:
specifier: ^0.479.0
version: 0.479.0(react@19.0.0)
specifier: ^0.536.0
version: 0.536.0(react@19.0.0)
lz-string:
specifier: ^1.5.0
version: 1.5.0
@@ -2250,8 +2250,8 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
lucide-react@0.479.0:
resolution: {integrity: sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ==}
lucide-react@0.536.0:
resolution: {integrity: sha512-2PgvNa9v+qz4Jt/ni8vPLt4jwoFybXHuubQT8fv4iCW5TjDxkbZjNZZHa485ad73NSEn/jdsEtU57eE1g+ma8A==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -5129,7 +5129,7 @@ snapshots:
dependencies:
js-tokens: 4.0.0
lucide-react@0.479.0(react@19.0.0):
lucide-react@0.536.0(react@19.0.0):
dependencies:
react: 19.0.0

View File

@@ -1,13 +0,0 @@
Copyright @ 2004 by MAGENTA Ltd. All Rights Reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions:
The above copyright and this permission notice shall be included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing the word "MgOpen", or if the modifications are accepted for inclusion in the Font Software itself by the each appointed Administrator.
This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "MgOpen" name.
The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL MAGENTA OR PERSONS OR BODIES IN CHARGE OF ADMINISTRATION AND MAINTENANCE OF THE FONT SOFTWARE BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -1,9 +1,15 @@
import { Box, Heading, Text, Link, Flex } from '@radix-ui/themes';
import Image from 'next/image';
import img from "./1024_576.png";
export default function Page() {
return (
<Flex p="4" direction={"column"} justify={"start"} className='text-left'>
<Heading as="h1" size="7" mb="4">How to Create 3D Text with the Barbie Font (Free & Online Method)</Heading>
<Flex gap={"4"} direction={"column"} justify={"start"} className='text-left'>
<Heading as="h1" size="7" mb="4" className='text-center'>How to Create 3D Text with the Barbie Font (Free & Online Method)</Heading>
<Flex justify={"center"}>
<Image src={img} alt="Barbie Font Sample" width={1024} height={576} />
</Flex>
<Text as="p" mb="4">
Want to create stylish 3D text using the iconic Barbie font? In this tutorial, you'll learn how to generate 3D Barbie-style text using free online tools — no design experience needed.

View File

@@ -1,9 +1,15 @@
import { Box, Heading, Text, Link, Flex } from '@radix-ui/themes';
import Image from 'next/image';
import img from "./1024_576.png";
export default function Page() {
return (
<Flex p="4" direction={"column"} justify={"start"} className='text-left'>
<Heading as="h1" size="7" mb="4">使3D文字(线)</Heading>
<Flex gap={"4"} direction={"column"} justify={"start"} className='text-left'>
<Heading as="h1" size="7" mb="4" className='text-center'>使3D文字(线)</Heading>
<Flex justify={"center"}>
<Image src={img} alt="Barbie Font Sample" width={1024} height={576} />
</Flex>
<Text as="p" mb="4">
使3D文字吗使线3D芭比风格文字

View File

@@ -1,7 +1,7 @@
export interface BlogItem {
id: string;
date: string;
cover: string;
cover: StaticImageData;
en: {
title: string;
summary: string;
@@ -11,11 +11,14 @@ export interface BlogItem {
summary: string;
};
}
import { StaticImageData } from "next/image";
import Cover1 from "./Create-3D-Text-with-the-Barbie-Font/512_288.png";
export const blogs = [
{
id: "Create-3D-Text-with-the-Barbie-Font",
date: "2024-01-01",
cover: "/next.svg",
cover: Cover1,
en: {
title: "How to Create 3D Text with the Barbie Font",
summary:

View File

@@ -6,6 +6,7 @@ import { useLocale, useTranslations } from 'next-intl';
import { Locales } from '@/i18n/config';
import { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import Image from 'next/image';
export default function BlogListPage() {
const locale = useLocale() as "zh" | "en";
@@ -22,13 +23,13 @@ export default function BlogListPage() {
<Card key={blog.id} size="2" style={{ maxWidth: 300, boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)' }} mx="4" my="4">
<Link href={`/${locale}/blogs/${blog.id}`} color='iris'>
<Flex direction="column" gap="4">
<Box style={{ width: '100%', height: 200, overflow: 'hidden' }}>
<img src={blog.cover} alt={blog[locale].title} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
<Box style={{ overflow: 'hidden' }}>
<Image src={blog.cover} alt={blog[locale].title} width={512} height={288} />
</Box>
<Flex direction={"column"} gap={"1"}>
<Heading as='h2' size="5" weight="bold" className='text-black dark:text-white'>{blog[locale].title}</Heading>
<Text color="gray" >{blog.date}</Text>
<Text >{blog[locale].summary}</Text>
<Text truncate={true} >{blog[locale].summary}</Text>
</Flex>
</Flex>
</Link>

View File

@@ -44,7 +44,7 @@ export async function generateMetadata({
const name = "editor";
return {
title: t("How to Create 3D Text with the Barbie Font (Free & Online Method)"),
title: t("seoTitle"),
description: t("seoDescription"),
openGraph: {
title: t("seoTitle"),

View File

@@ -3,7 +3,7 @@ import { useState, useRef, useEffect, } from "react";
import { useLocale, useTranslations } from "next-intl";
import { Eye, Download, Share } from "lucide-react";
import { BackgroundProp } from "./BackgroundSelector";
import { Text, Flex, Button, Select, AlertDialog, Code, Blockquote, Box } from "@radix-ui/themes";
import { Text, Flex, Button, Select, AlertDialog, Code, AspectRatio } from "@radix-ui/themes";
import { getPicture, resize, init as threeInit, updateBackground, updateTextProps } from "./ThreeTools";
import { TextProp } from "./TextSetting";
import { encodeText, getShareLink } from "@/lib/utils";
@@ -13,15 +13,15 @@ const Sizes = [
"1024x768",
"800x600",
]
function gcd(a: number, b: number): number {
return b === 0 ? a : gcd(b, a % b);
interface Size {
width: number;
height: number;
}
const AspectRatio = Sizes.map(o => {
const AspectRatios = Sizes.map(o => {
const [w, h] = o.split("x").map(Number);
const a = gcd(w, h);
return `${w / a}/${h / a}`;
// const a = gcd(w, h);
return w / h;
})
export default function PreviewToolbar({
background,
@@ -33,6 +33,8 @@ export default function PreviewToolbar({
let host = process.env.NEXT_PUBLIC_HOST?.substring("https://".length);
const t = useTranslations("PreviewBar");
const [aspectRadio, setAspectRadio] = useState<number>(0);
const split = Sizes[0].split("x").map(Number);
const [size, setSize] = useState<Size>({ width: split[0], height: split[1] });
const container = useRef<HTMLCanvasElement>(null);
const fullscreenElement = useRef<HTMLImageElement>(null);
const [picture, setPicture] = useState<string | null>(null);
@@ -42,6 +44,8 @@ export default function PreviewToolbar({
const updateSize = () => {
const split = Sizes[aspectRadio].split("x").map(Number);
setSize({ width: split[0], height: split[1] });
resize(split[0], split[1], container.current!.clientWidth, container.current!.clientHeight);
}
useEffect(() => {
@@ -51,8 +55,7 @@ export default function PreviewToolbar({
} else {
const box = container.current;
const split = Sizes[aspectRadio].split("x").map(Number);
const initSuccess = threeInit(box, split[0], split[1]);
console.log("three init ", initSuccess);
threeInit(box, split[0], split[1]);
}
}
@@ -64,25 +67,17 @@ export default function PreviewToolbar({
useEffect(() => {
const timeoutId = setTimeout(() => {
updateBackground(background);
console.log("background change", background);
}, 200);
return () => clearTimeout(timeoutId);
updateBackground(background);
}, [background]);
useEffect(() => {
const timeoutId = setTimeout(() => {
updateTextProps(text);
updateTextProps(text);
console.log("text change", text);
console.log("text change", text);
}, 200);
return () => clearTimeout(timeoutId);
}, [text]);
const handleDownload = () => {
@@ -179,14 +174,17 @@ export default function PreviewToolbar({
<Text>{t("mouseMiddle")}</Text>
<Text>{t("mouseRight")}</Text>
</Flex>
<canvas ref={container} className="w-full border border-gray-300" style={{
aspectRatio: AspectRatio[aspectRadio],
// backgroundColor: background.type === "color" ? background.color : "none",
backgroundImage: (background.type === "image" && background.image) ? `url(${background.image})` : "none",
backgroundRepeat: "no-repeat",
backgroundSize: "contain",
backgroundPosition: "center",
}} />
<AspectRatio ratio={AspectRatios[aspectRadio]}>
<canvas ref={container} className="w-full border border-gray-300" style={{
// backgroundColor: background.type === "color" ? background.color : "none",
backgroundImage: (background.type === "image" && background.image) ? `url(${background.image})` : "none",
backgroundRepeat: "no-repeat",
backgroundSize: "contain",
backgroundPosition: "center",
maxWidth: size.width, maxHeight: size.height
}} />
</AspectRatio>
{picture && (
<img ref={fullscreenElement} src={picture} className="w-screen h-screen" />
@@ -253,7 +251,7 @@ export default function PreviewToolbar({
<Select.Root defaultValue={`${aspectRadio}`} onValueChange={(e) => setAspectRadio(parseInt(e))}>
<Select.Trigger />
<Select.Content>
{AspectRatio.map((_, i) => <Select.Item key={i} value={i + ""}>{Sizes[i]}</Select.Item>)}
{AspectRatios.map((_, i) => <Select.Item key={i} value={i + ""}>{Sizes[i]}</Select.Item>)}
</Select.Content>
</Select.Root>
</div>

View File

@@ -1,15 +1,19 @@
import { Flex, Heading, Select } from "@radix-ui/themes";
import { useTranslations } from "next-intl";
'use client'
import { Flex, Heading, Select, Tooltip, IconButton, Link } from "@radix-ui/themes";
import { PlusIcon, MessageCircleQuestionIcon, CircleQuestionMark, CircleQuestionMarkIcon } from "lucide-react";
import { useLocale, useTranslations } from "next-intl";
import { useEffect, useState } from "react";
export const FontWeights = ["regular", "bold"];
export const FontNames = ["gentilis", "helvetiker", "optimer", "Noto_Sans_SC_zh", "Alibaba_PuHuiTi_3.0_zh"];
export const FontWeights = ["Regular", "Bold"];
export const FontNames = ["Gentilis", "Helvetiker", "Optimer", "Noto_Sans_SC_zh", "Alibaba_PuHuiTi_3.0_zh"];
type FontFrom = "local" | "upload";
type FontFrom = "online" | "upload";
export class TextProp {
text: string
color: string
fontFrom: FontFrom
font: string
fontUrl: string
weight: string
constructor(
@@ -23,6 +27,7 @@ export class TextProp {
this.color = color;
this.fontFrom = fontFrom;
this.font = font;
this.fontUrl = getOnlineFontPath(font, weight);
this.weight = weight;
}
@@ -37,28 +42,32 @@ export class TextProp {
text,
color: "#8e86fe",
font,
fontUrl: getOnlineFontPath(font, FontWeights[0]),
weight: FontWeights[0],
fontFrom: "local",
fontFrom: "online",
}
}
}
function getOnlineFontPath(fontName: string, fontWeight: String) {
let font = fontName;
if (fontName.endsWith("zh")) {
font = fontName.slice(0, -3);
}
return `https://fast3dtest.mysoul.fun/${font}_${fontWeight}.json`;
}
function containsChinese(str: string) {
return /[\u4e00-\u9fa5]/.test(str);
}
export function getFontPath(fontName: string, fontWeight: String) {
if (!fontName.endsWith("zh")) {
return `/fonts/${fontName}_${fontWeight}.typeface.json`;
} else {
fontWeight = fontWeight.charAt(0).toUpperCase() + fontWeight.slice(1);
let font = fontName.slice(0, -3);
return `https://fast3dtest.mysoul.fun/${font}_${fontWeight}.json`;
}
export interface UploadFont {
name: string;
url: string;
}
export default function TextSetting({
text,
setText,
@@ -66,8 +75,35 @@ export default function TextSetting({
text: TextProp;
setText: (text: TextProp) => void;
}) {
const locale = useLocale();
let inited = false;
const t = useTranslations("TextEditor");
const [uploadFonts, setUploadFonts] = useState<UploadFont[]>([]);
useEffect(() => {
if (uploadFonts.length > 0) {
handleSelectFont(uploadFonts[uploadFonts.length - 1].name)
} else {
if (inited) {
handleSelectFont(FontNames[0])
}
}
inited = true;
}, [uploadFonts]);
const handleSelectFont = (font: string) => {
if (FontNames.indexOf(font) !== -1) {
setText({ ...text, font: font, fontFrom: "online", fontUrl: getOnlineFontPath(font, text.weight) });
} else {
let f = uploadFonts.find((item) => item.name === font)!;
setText({ ...text, font: font, fontFrom: "upload", fontUrl: f.url });
}
};
return (
<Flex className="p-4 border rounded-lg " gap={"3"} direction={"column"}>
<Heading size={"3"} className="font-medium text-lg" >{t("title")}</Heading>
@@ -89,15 +125,69 @@ export default function TextSetting({
/>
</div>
<div className="space-y-1">
<label className="block text-sm text-muted-foreground">
{t("fontFamily")}
</label>
<Select.Root defaultValue={`${text.font}`} onValueChange={(e) => setText({ ...text, font: e })}>
<Flex gap={"2"}>
<label className="block text-sm text-muted-foreground">
{t("fontFamily")}
</label>
<Tooltip content={t("how2UploadFont")} >
<Link href={`/${locale}/blogs/Create-3D-Text-with-the-Barbie-Font`}>
<IconButton radius="full" variant="ghost" >
<CircleQuestionMarkIcon width="18" height="18" />
</IconButton>
</Link>
</Tooltip>
</Flex>
<Select.Root value={text.font} onValueChange={(e) => handleSelectFont(e)}>
<Select.Trigger />
<Select.Content>
{FontNames.map((name) => <Select.Item key={name} value={name}>{name}</Select.Item>)}
{uploadFonts.length > 0 && (
<>
<Select.Group>
<Select.Label>Upload</Select.Label>
{uploadFonts.map((f) => <Select.Item key={f.name} value={f.name}>{f.name}</Select.Item>)}
</Select.Group>
<Select.Separator />
</>
)}
<Select.Group>
<Select.Label>Online</Select.Label>
{FontNames.map((name) => <Select.Item key={name} value={name}>{name}</Select.Item>)}
</Select.Group>
</Select.Content>
</Select.Root>
<Tooltip content={t("uploadFontButton")} >
<IconButton asChild radius="full" className="ml-4">
<label className="cursor-pointer" htmlFor="fontUpload">
<input
id="fontUpload"
type="file"
accept=".json"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const fontName = file.name.replace('.json', '');
const newFont = {
name: fontName,
url: URL.createObjectURL(file)
};
setUploadFonts([...uploadFonts.filter(font => font.name !== fontName), newFont]);
};
reader.readAsText(file);
}}
/>
<PlusIcon />
</label>
</IconButton>
</Tooltip>
</div>
<div className="space-y-2">
<label className="block text-sm text-muted-foreground">
@@ -107,7 +197,7 @@ export default function TextSetting({
<Select.Root defaultValue={`${text.weight}`} onValueChange={(e) => setText({ ...text, weight: e })}>
<Select.Trigger />
<Select.Content>
{FontWeights.map((name) => <Select.Item key={name} value={name}>{name}</Select.Item>)}
{FontWeights.map((name) => <Select.Item disabled={text.fontFrom == "upload"} key={name} value={name}>{name}</Select.Item>)}
</Select.Content>
</Select.Root>

View File

@@ -1,11 +1,11 @@
import * as THREE from "three";
import { BackgroundProp } from "./BackgroundSelector";
import { Font, FontLoader } from "three/addons/loaders/FontLoader.js";
import { FontLoader } from "three/addons/loaders/FontLoader.js";
import { TextGeometry } from "three/addons/geometries/TextGeometry.js";
THREE.Cache.enabled = true;
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { TextProp, getFontPath } from "./TextSetting";
import { TextProp } from "./TextSetting";
let camera: THREE.PerspectiveCamera,
scene: THREE.Scene,
@@ -212,9 +212,7 @@ async function getTextGeometry(textProps: TextProp) {
async function loadFont(textProps: TextProp) {
const loader = new FontLoader();
let font = await loader.loadAsync(
getFontPath(textProps.font, textProps.weight)
);
let font = await loader.loadAsync(textProps.fontUrl);
return font;
}