mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
feat: add DateRangeIndicator component and integrate into MasonryRoot
- Introduced a new DateRangeIndicator component to display the date range and location of visible photos. - Integrated the DateRangeIndicator into the MasonryRoot component, enhancing the user interface for both mobile and desktop views. - Implemented a custom hook, useVisiblePhotosDateRange, to calculate the date range of currently visible photos. - Updated README to reflect the new project name and description. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
8
.cursor/rules/project.mdc
Normal file
8
.cursor/rules/project.mdc
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
# Project
|
||||
|
||||
这是一个现代化的照片画廊网站
|
||||
@@ -1,4 +1,4 @@
|
||||
# Photo Gallery Site
|
||||
# Iris Photo Gallery
|
||||
|
||||
一个现代化的照片画廊网站,采用 React + TypeScript 构建,支持从多种存储源(S3、GitHub)自动同步照片,具有高性能 WebGL 渲染、瀑布流布局、EXIF 信息展示、缩略图生成等功能。
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { AnimatePresence, m } from 'motion/react'
|
||||
import { memo } from 'react'
|
||||
|
||||
import { useMobile } from '~/hooks/useMobile'
|
||||
import { clsxm } from '~/lib/cn'
|
||||
import { Spring } from '~/lib/spring'
|
||||
|
||||
interface DateRangeIndicatorProps {
|
||||
dateRange: string
|
||||
location?: string
|
||||
isVisible: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const DateRangeIndicator = memo(
|
||||
({ dateRange, location, isVisible, className }: DateRangeIndicatorProps) => {
|
||||
// 解析日期范围,提取主要的日期信息
|
||||
const parseMainDate = (range: string) => {
|
||||
// 匹配跨年日期范围格式 "2022年3月 - 2023年5月"
|
||||
const crossYearMatch = range.match(
|
||||
/(\d{4})年(\d+)月\s*-\s*(\d{4})年(\d+)月/,
|
||||
)
|
||||
if (crossYearMatch) {
|
||||
const [, startYear, startMonth, endYear, endMonth] = crossYearMatch
|
||||
return `${startMonth}月 ${startYear} – ${endMonth}月 ${endYear}`
|
||||
}
|
||||
|
||||
// 匹配类似 "2022年3月30日 - 5月2日" 的格式
|
||||
const singleYearDayMatch = range.match(
|
||||
/(\d{4})年(\d+)月(\d+)日?\s*-\s*(\d+)月(\d+)日?/,
|
||||
)
|
||||
if (singleYearDayMatch) {
|
||||
const [, year, startMonth, startDay, endMonth, endDay] =
|
||||
singleYearDayMatch
|
||||
return `${startMonth}月${startDay}日–${endMonth}月${endDay}日, ${year}`
|
||||
}
|
||||
|
||||
// 匹配类似 "2022年3月 - 5月" 的格式
|
||||
const monthRangeMatch = range.match(/(\d{4})年(\d+)月\s*-\s*(\d+)月/)
|
||||
if (monthRangeMatch) {
|
||||
const [, year, startMonth, endMonth] = monthRangeMatch
|
||||
return `${startMonth}月–${endMonth}月, ${year}`
|
||||
}
|
||||
|
||||
// 匹配单个日期
|
||||
const singleDateMatch = range.match(/(\d{4})年(\d+)月(\d+)日/)
|
||||
if (singleDateMatch) {
|
||||
const [, year, month, day] = singleDateMatch
|
||||
return `${month}月${day}日, ${year}`
|
||||
}
|
||||
|
||||
// 默认返回原始字符串
|
||||
return range
|
||||
}
|
||||
|
||||
const isMobile = useMobile()
|
||||
const variants = isMobile
|
||||
? {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
},
|
||||
animate: { opacity: 1 },
|
||||
}
|
||||
: {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
x: -20,
|
||||
scale: 0.95,
|
||||
},
|
||||
animate: { opacity: 1, x: 0, scale: 1 },
|
||||
}
|
||||
|
||||
const formattedDate = parseMainDate(dateRange)
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isVisible && dateRange && (
|
||||
<m.div
|
||||
initial={variants.initial}
|
||||
animate={variants.animate}
|
||||
exit={variants.initial}
|
||||
transition={Spring.presets.snappy}
|
||||
className={clsxm(
|
||||
'border-material-opaque lg:rounded-xl border bg-black/60 p-4 shadow-2xl backdrop-blur-[70px]',
|
||||
`fixed left-4 z-50 top-4 lg:top-6 lg:left-6`,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-lg leading-tight font-bold tracking-tight text-white lg:text-4xl">
|
||||
{formattedDate}
|
||||
</span>
|
||||
{location && (
|
||||
<span className="mt-0.5 text-sm font-medium text-white/75 lg:mt-1 lg:text-lg">
|
||||
{location}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
DateRangeIndicator.displayName = 'DateRangeIndicator'
|
||||
1
apps/web/src/components/ui/date-range-indicator/index.ts
Normal file
1
apps/web/src/components/ui/date-range-indicator/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './DateRangeIndicator'
|
||||
195
apps/web/src/hooks/useVisiblePhotosDateRange.ts
Normal file
195
apps/web/src/hooks/useVisiblePhotosDateRange.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
|
||||
import type { PhotoManifest } from '~/types/photo'
|
||||
|
||||
interface DateRange {
|
||||
startDate: Date | null
|
||||
endDate: Date | null
|
||||
formattedRange: string
|
||||
location?: string
|
||||
}
|
||||
|
||||
interface VisibleRange {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to calculate the date range of currently visible photos in the viewport
|
||||
* Works with masonry onRender callback
|
||||
*/
|
||||
export const useVisiblePhotosDateRange = (_photos: PhotoManifest[]) => {
|
||||
const [dateRange, setDateRange] = useState<DateRange>({
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
formattedRange: '',
|
||||
location: undefined,
|
||||
})
|
||||
|
||||
const currentRange = useRef<VisibleRange>({ start: 0, end: 0 })
|
||||
|
||||
const getPhotoDate = useCallback((photo: PhotoManifest): Date => {
|
||||
// 优先使用 EXIF 中的拍摄时间
|
||||
if (photo.exif?.Photo?.DateTimeOriginal) {
|
||||
const dateStr = photo.exif.Photo.DateTimeOriginal as unknown as string
|
||||
// EXIF 日期格式通常是 "YYYY:MM:DD HH:mm:ss"
|
||||
const formattedDateStr = dateStr.replace(
|
||||
/^(\d{4}):(\d{2}):(\d{2})/,
|
||||
'$1-$2-$3',
|
||||
)
|
||||
const date = new Date(formattedDateStr)
|
||||
if (!Number.isNaN(date.getTime())) {
|
||||
return date
|
||||
}
|
||||
}
|
||||
|
||||
// 回退到 lastModified
|
||||
return new Date(photo.lastModified)
|
||||
}, [])
|
||||
|
||||
const formatDateRange = useCallback(
|
||||
(startDate: Date, endDate: Date): string => {
|
||||
const startYear = startDate.getFullYear()
|
||||
const endYear = endDate.getFullYear()
|
||||
const startMonth = startDate.getMonth()
|
||||
const endMonth = endDate.getMonth()
|
||||
|
||||
// 如果是同一天
|
||||
if (startDate.toDateString() === endDate.toDateString()) {
|
||||
return startDate.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
// 如果是同一年
|
||||
if (startYear === endYear) {
|
||||
// 如果是同一个月
|
||||
if (startMonth === endMonth) {
|
||||
return `${startYear}年${startDate.getMonth() + 1}月${startDate.getDate()}日 - ${endDate.getDate()}日`
|
||||
} else {
|
||||
return `${startYear}年${startDate.getMonth() + 1}月 - ${endDate.getMonth() + 1}月`
|
||||
}
|
||||
}
|
||||
|
||||
// 不同年份
|
||||
return `${startYear}年${startDate.getMonth() + 1}月 - ${endYear}年${endDate.getMonth() + 1}月`
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const extractLocation = useCallback(
|
||||
(photos: PhotoManifest[]): string | undefined => {
|
||||
// 尝试从照片标签中提取位置信息
|
||||
for (const photo of photos) {
|
||||
// 如果照片有位置标签,优先使用
|
||||
if (photo.tags) {
|
||||
const locationTag = photo.tags.find(
|
||||
(tag) =>
|
||||
tag.includes('省') ||
|
||||
tag.includes('市') ||
|
||||
tag.includes('区') ||
|
||||
tag.includes('县') ||
|
||||
tag.includes('镇') ||
|
||||
tag.includes('村') ||
|
||||
tag.includes('街道') ||
|
||||
tag.includes('路') ||
|
||||
tag.includes('北京') ||
|
||||
tag.includes('上海') ||
|
||||
tag.includes('广州') ||
|
||||
tag.includes('深圳') ||
|
||||
tag.includes('杭州') ||
|
||||
tag.includes('南京') ||
|
||||
tag.includes('成都'),
|
||||
)
|
||||
if (locationTag) {
|
||||
return locationTag
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// 计算当前可视范围内照片的日期范围
|
||||
const calculateDateRange = useCallback(
|
||||
(startIndex: number, endIndex: number, items: any[]) => {
|
||||
if (!items || items.length === 0) {
|
||||
setDateRange({
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
formattedRange: '',
|
||||
location: undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 过滤出照片类型的items (排除header等)
|
||||
const visiblePhotos = items
|
||||
.slice(startIndex, endIndex + 1)
|
||||
.filter(
|
||||
(item): item is PhotoManifest =>
|
||||
item && typeof item === 'object' && 'id' in item,
|
||||
)
|
||||
|
||||
if (visiblePhotos.length === 0) {
|
||||
setDateRange({
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
formattedRange: '',
|
||||
location: undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 计算日期范围
|
||||
const dates = visiblePhotos
|
||||
.map((photo) => getPhotoDate(photo))
|
||||
.sort((a, b) => a.getTime() - b.getTime())
|
||||
|
||||
const startDate = dates[0]
|
||||
const endDate = dates.at(-1)
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
setDateRange({
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
formattedRange: '',
|
||||
location: undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const formattedRange = formatDateRange(startDate, endDate)
|
||||
const location = extractLocation(visiblePhotos)
|
||||
|
||||
setDateRange({
|
||||
startDate,
|
||||
endDate,
|
||||
formattedRange,
|
||||
location,
|
||||
})
|
||||
|
||||
// 更新当前范围
|
||||
currentRange.current = { start: startIndex, end: endIndex }
|
||||
},
|
||||
[getPhotoDate, formatDateRange],
|
||||
)
|
||||
|
||||
// 用于传递给 masonry 的 onRender 回调
|
||||
const handleRender = useCallback(
|
||||
(startIndex: number, stopIndex: number, items: any[]) => {
|
||||
calculateDateRange(startIndex, stopIndex, items)
|
||||
},
|
||||
[calculateDateRange],
|
||||
)
|
||||
|
||||
return {
|
||||
dateRange,
|
||||
handleRender,
|
||||
currentRange: currentRange.current,
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,13 @@ import { AnimatePresence, m } from 'motion/react'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { gallerySettingAtom } from '~/atoms/app'
|
||||
import { RootPortal } from '~/components/ui/portal'
|
||||
import { DateRangeIndicator } from '~/components/ui/date-range-indicator'
|
||||
import { useScrollViewElement } from '~/components/ui/scroll-areas/hooks'
|
||||
import { useMobile } from '~/hooks/useMobile'
|
||||
import { usePhotos, usePhotoViewer } from '~/hooks/usePhotoViewer'
|
||||
import { useTypeScriptHappyCallback } from '~/hooks/useTypeScriptCallback'
|
||||
import { useVisiblePhotosDateRange } from '~/hooks/useVisiblePhotosDateRange'
|
||||
import { clsxm } from '~/lib/cn'
|
||||
import { Spring } from '~/lib/spring'
|
||||
import type { PhotoManifest } from '~/types/photo'
|
||||
|
||||
@@ -27,8 +29,11 @@ const FIRST_SCREEN_ITEMS_COUNT = 30
|
||||
export const MasonryRoot = () => {
|
||||
const { sortOrder, selectedTags } = useAtomValue(gallerySettingAtom)
|
||||
const hasAnimatedRef = useRef(false)
|
||||
const [showFloatingActions, setShowFloatingActions] = useState(false)
|
||||
|
||||
const photos = usePhotos()
|
||||
const { dateRange, handleRender } = useVisiblePhotosDateRange(photos)
|
||||
const scrollElement = useScrollViewElement()
|
||||
|
||||
const photoViewer = usePhotoViewer()
|
||||
const handleAnimationComplete = useCallback(() => {
|
||||
@@ -36,40 +41,84 @@ export const MasonryRoot = () => {
|
||||
}, [])
|
||||
const isMobile = useMobile()
|
||||
|
||||
// 监听滚动,控制浮动组件的显示
|
||||
useEffect(() => {
|
||||
if (!scrollElement) return
|
||||
|
||||
const handleScroll = () => {
|
||||
const { scrollTop } = scrollElement
|
||||
setShowFloatingActions(scrollTop > 500)
|
||||
}
|
||||
|
||||
scrollElement.addEventListener('scroll', handleScroll, { passive: true })
|
||||
return () => {
|
||||
scrollElement.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
}, [scrollElement])
|
||||
|
||||
return (
|
||||
<div className="p-1 lg:p-0 [&_*]:!select-none">
|
||||
<FloatingActionBar />
|
||||
{isMobile && <MasonryHeaderMasonryItem className="mb-1" />}
|
||||
<Masonry<MasonryItemType>
|
||||
key={`${sortOrder}-${selectedTags.join(',')}`}
|
||||
items={useMemo(
|
||||
() => (isMobile ? photos : [MasonryHeaderItem.default, ...photos]),
|
||||
[photos, isMobile],
|
||||
)}
|
||||
render={useCallback(
|
||||
(props) => (
|
||||
<MasonryItem
|
||||
{...props}
|
||||
onPhotoClick={photoViewer.openViewer}
|
||||
photos={photos}
|
||||
hasAnimated={hasAnimatedRef.current}
|
||||
onAnimationComplete={handleAnimationComplete}
|
||||
/>
|
||||
),
|
||||
[handleAnimationComplete, photoViewer.openViewer, photos],
|
||||
)}
|
||||
columnWidth={isMobile ? 150 : 300}
|
||||
columnGutter={4}
|
||||
rowGutter={4}
|
||||
itemHeightEstimate={400}
|
||||
itemKey={useTypeScriptHappyCallback((data, _index) => {
|
||||
if (data instanceof MasonryHeaderItem) {
|
||||
return 'header'
|
||||
}
|
||||
return (data as PhotoManifest).id
|
||||
}, [])}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
{/* 桌面端:左右分布 */}
|
||||
{!isMobile && (
|
||||
<>
|
||||
<DateRangeIndicator
|
||||
dateRange={dateRange.formattedRange}
|
||||
location={dateRange.location}
|
||||
isVisible={showFloatingActions && !!dateRange.formattedRange}
|
||||
/>
|
||||
<FloatingActionBar showFloatingActions={showFloatingActions} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 移动端:垂直堆叠 */}
|
||||
{isMobile && showFloatingActions && !!dateRange.formattedRange && (
|
||||
<div className="fixed top-0 right-0 left-0 z-50">
|
||||
<DateRangeIndicator
|
||||
dateRange={dateRange.formattedRange}
|
||||
location={dateRange.location}
|
||||
isVisible={true}
|
||||
className="relative top-0 left-0"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<FloatingActionBar showFloatingActions={true} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-1 lg:p-0 [&_*]:!select-none">
|
||||
{isMobile && <MasonryHeaderMasonryItem className="mb-1" />}
|
||||
<Masonry<MasonryItemType>
|
||||
key={`${sortOrder}-${selectedTags.join(',')}`}
|
||||
items={useMemo(
|
||||
() => (isMobile ? photos : [MasonryHeaderItem.default, ...photos]),
|
||||
[photos, isMobile],
|
||||
)}
|
||||
render={useCallback(
|
||||
(props) => (
|
||||
<MasonryItem
|
||||
{...props}
|
||||
onPhotoClick={photoViewer.openViewer}
|
||||
photos={photos}
|
||||
hasAnimated={hasAnimatedRef.current}
|
||||
onAnimationComplete={handleAnimationComplete}
|
||||
/>
|
||||
),
|
||||
[handleAnimationComplete, photoViewer.openViewer, photos],
|
||||
)}
|
||||
onRender={handleRender}
|
||||
columnWidth={isMobile ? 150 : 300}
|
||||
columnGutter={4}
|
||||
rowGutter={4}
|
||||
itemHeightEstimate={400}
|
||||
itemKey={useTypeScriptHappyCallback((data, _index) => {
|
||||
if (data instanceof MasonryHeaderItem) {
|
||||
return 'header'
|
||||
}
|
||||
return (data as PhotoManifest).id
|
||||
}, [])}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -154,42 +203,48 @@ export const MasonryItem = memo(
|
||||
},
|
||||
)
|
||||
|
||||
const FloatingActionBar = () => {
|
||||
const [showFloatingActions, setShowFloatingActions] = useState(false)
|
||||
const scrollElement = useScrollViewElement()
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollElement) return
|
||||
|
||||
const handleScroll = () => {
|
||||
const { scrollTop } = scrollElement
|
||||
|
||||
setShowFloatingActions(scrollTop > 500)
|
||||
}
|
||||
|
||||
scrollElement.addEventListener('scroll', handleScroll, { passive: true })
|
||||
return () => {
|
||||
scrollElement.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
}, [scrollElement])
|
||||
const FloatingActionBar = ({
|
||||
showFloatingActions,
|
||||
}: {
|
||||
showFloatingActions: boolean
|
||||
}) => {
|
||||
const isMobile = useMobile()
|
||||
|
||||
const variants = isMobile
|
||||
? {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
},
|
||||
animate: { opacity: 1 },
|
||||
}
|
||||
: {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
x: 20,
|
||||
y: 0,
|
||||
scale: 0.95,
|
||||
},
|
||||
animate: { opacity: 1, x: 0, y: 0, scale: 1 },
|
||||
}
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<RootPortal>
|
||||
{showFloatingActions && (
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: -20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -20, scale: 0.95 }}
|
||||
transition={Spring.presets.snappy}
|
||||
className="fixed top-4 left-4 z-50"
|
||||
>
|
||||
<div className="border-material-opaque rounded-xl border bg-black/60 p-3 shadow-2xl backdrop-blur-[70px]">
|
||||
<ActionGroup />
|
||||
</div>
|
||||
</m.div>
|
||||
)}
|
||||
</RootPortal>
|
||||
{showFloatingActions && (
|
||||
<m.div
|
||||
variants={variants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="initial"
|
||||
transition={Spring.presets.snappy}
|
||||
className={clsxm(
|
||||
'border-material-opaque rounded-xl border bg-black/60 p-3 shadow-2xl backdrop-blur-[70px]',
|
||||
isMobile
|
||||
? 'rounded-t-none rounded-br-none -translate-y-px'
|
||||
: 'fixed top-4 right-4 z-50 lg:top-6 lg:right-6',
|
||||
)}
|
||||
>
|
||||
<ActionGroup />
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -240,6 +240,7 @@ export const PhotoMasonryItem = ({
|
||||
width,
|
||||
height: calculatedHeight,
|
||||
}}
|
||||
data-photo-id={data.id}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
# 字体提取系统
|
||||
|
||||
## 概述
|
||||
|
||||
为了解决在某些 Linux 系统上英文字体渲染效果不佳的问题,我们开发了一个字体提取系统,可以从系统中的真实字体文件中提取字形路径,生成基于 SVG 的文本渲染器。
|
||||
|
||||
## 特性
|
||||
|
||||
- ✅ **真实字体提取**: 从系统字体文件中提取真实的字形轮廓
|
||||
- ✅ **高质量渲染**: 基于矢量路径,确保在任何尺寸下都清晰
|
||||
- ✅ **跨平台一致性**: 在所有操作系统上获得相同的渲染效果
|
||||
- ✅ **自动备份**: 提取前自动备份原有文件
|
||||
- ✅ **完整字符集**: 支持大小写字母、数字和常用符号
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 提取字体
|
||||
|
||||
```bash
|
||||
npm run extract:font
|
||||
```
|
||||
|
||||
这个命令会:
|
||||
1. 搜索系统中可用的字体文件
|
||||
2. 从字体中提取字形路径
|
||||
3. 生成新的 SVG 文本渲染器
|
||||
4. 自动备份原有文件
|
||||
|
||||
### 测试效果
|
||||
|
||||
```bash
|
||||
npm run test:svg-font
|
||||
```
|
||||
|
||||
生成对比图片来验证字体渲染效果。
|
||||
|
||||
### 生成 OG 图片
|
||||
|
||||
```bash
|
||||
npm run build:og
|
||||
```
|
||||
|
||||
使用新的字体渲染器生成 OG 图片。
|
||||
|
||||
## 支持的字体
|
||||
|
||||
系统会按优先级搜索以下字体:
|
||||
|
||||
### macOS
|
||||
- SF Compact (推荐) - Apple 的现代无衬线字体
|
||||
- Geneva - 经典的 macOS 字体
|
||||
|
||||
### Linux
|
||||
- Liberation Sans - 开源的 Arial 替代品
|
||||
- DejaVu Sans - 高质量的开源字体
|
||||
|
||||
### 通用
|
||||
- Helvetica (TTF 格式)
|
||||
- Arial
|
||||
|
||||
## 技术实现
|
||||
|
||||
### 字体加载
|
||||
使用 `opentype.js` 库解析字体文件:
|
||||
|
||||
```typescript
|
||||
const font = await opentype.load(fontPath)
|
||||
const glyph = font.charToGlyph(char)
|
||||
```
|
||||
|
||||
### 路径标准化
|
||||
将字体单位转换为标准化的 SVG 路径:
|
||||
|
||||
```typescript
|
||||
function normalizeGlyphPath(path: opentype.Path, unitsPerEm: number): string {
|
||||
const scale = 100 / unitsPerEm // 缩放到 100 单位高度
|
||||
// 转换坐标系(Y 轴翻转)
|
||||
const y = 100 - cmd.y * scale
|
||||
return commands.join(' ')
|
||||
}
|
||||
```
|
||||
|
||||
### 代码生成
|
||||
自动生成包含所有字形数据的 TypeScript 文件:
|
||||
|
||||
```typescript
|
||||
const HELVETICA_CHARACTERS: Record<string, CharacterPath> = {
|
||||
'A': {
|
||||
path: 'M 10.2 100.0 L 34.3 0.0 L 34.3 0.0 ...',
|
||||
width: 68.6,
|
||||
height: 100,
|
||||
advanceWidth: 68.6
|
||||
},
|
||||
// ... 更多字符
|
||||
}
|
||||
```
|
||||
|
||||
## 字形数据结构
|
||||
|
||||
每个字符包含以下信息:
|
||||
|
||||
```typescript
|
||||
interface CharacterPath {
|
||||
path: string // SVG 路径数据
|
||||
width: number // 字符宽度
|
||||
height: number // 字符高度(固定为 100)
|
||||
advanceWidth: number // 字符间距
|
||||
}
|
||||
```
|
||||
|
||||
## 渲染优化
|
||||
|
||||
### 填充渲染
|
||||
普通字体使用填充模式:
|
||||
```svg
|
||||
<path d="..." fill="white" />
|
||||
```
|
||||
|
||||
### 粗体渲染
|
||||
粗体字体同时使用填充和描边:
|
||||
```svg
|
||||
<path d="..." fill="white" stroke="white" stroke-width="1.5" />
|
||||
```
|
||||
|
||||
## 优势
|
||||
|
||||
1. **精确性**: 直接使用字体文件中的原始字形数据
|
||||
2. **一致性**: 在所有平台上获得相同的渲染效果
|
||||
3. **质量**: 基于矢量路径,支持任意缩放
|
||||
4. **性能**: 预处理的路径数据,运行时性能优异
|
||||
5. **兼容性**: 不依赖系统字体渲染引擎
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **字体格式**: 目前只支持 TTF 格式,不支持 TTC (TrueType Collection)
|
||||
2. **字符集**: 提取的字符集是预定义的,如需新字符需要重新提取
|
||||
3. **文件大小**: 生成的文件会比原来的手工路径稍大
|
||||
4. **字体许可**: 确保使用的字体允许提取和使用
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 字体未找到
|
||||
如果系统中没有找到合适的字体,可以:
|
||||
1. 安装推荐的字体
|
||||
2. 修改 `HELVETICA_FONT_PATHS` 数组添加自定义路径
|
||||
3. 使用在线字体服务
|
||||
|
||||
### TTC 格式错误
|
||||
如果遇到 "Unsupported OpenType signature ttcf" 错误:
|
||||
1. 寻找 TTF 格式的替代字体
|
||||
2. 使用字体转换工具将 TTC 转换为 TTF
|
||||
|
||||
### 字形缺失
|
||||
如果某些字符没有字形:
|
||||
1. 检查字体是否包含该字符
|
||||
2. 更换包含更完整字符集的字体
|
||||
3. 为缺失字符提供后备方案
|
||||
|
||||
## 未来改进
|
||||
|
||||
- [ ] 支持 TTC 格式字体
|
||||
- [ ] 支持更多字符和 Unicode 范围
|
||||
- [ ] 字形路径压缩优化
|
||||
- [ ] 支持字体变体(Light、Medium、Bold 等)
|
||||
- [ ] 在线字体提取服务
|
||||
Reference in New Issue
Block a user