diff --git a/miniprogram/assets/icons/download.svg b/miniprogram/assets/icons/download.svg
new file mode 100644
index 0000000..0bdb236
--- /dev/null
+++ b/miniprogram/assets/icons/download.svg
@@ -0,0 +1,3 @@
+
diff --git a/miniprogram/assets/icons/export-png-s.svg b/miniprogram/assets/icons/export-png-s.svg
index 195d038..22300cc 100644
--- a/miniprogram/assets/icons/export-png-s.svg
+++ b/miniprogram/assets/icons/export-png-s.svg
@@ -1,4 +1,4 @@
diff --git a/miniprogram/assets/icons/export-svg-s.svg b/miniprogram/assets/icons/export-svg-s.svg
index c463242..83fb1bd 100644
--- a/miniprogram/assets/icons/export-svg-s.svg
+++ b/miniprogram/assets/icons/export-svg-s.svg
@@ -1,4 +1,4 @@
diff --git a/miniprogram/assets/icons/favorite-red.svg b/miniprogram/assets/icons/favorite-red.svg
new file mode 100644
index 0000000..2151bb8
--- /dev/null
+++ b/miniprogram/assets/icons/favorite-red.svg
@@ -0,0 +1,3 @@
+
diff --git a/miniprogram/pages/index/index.js b/miniprogram/pages/index/index.js
index 1eb407a..30947c2 100644
--- a/miniprogram/pages/index/index.js
+++ b/miniprogram/pages/index/index.js
@@ -8,6 +8,11 @@ const { renderSvgByApi, renderPngByApi } = require('../../utils/mp/render-api')
// const { ICON_PATHS } = require('../../config/cdn') // CDN 方案暂时注释
const COLOR_PALETTE = ['#000000', '#1d4ed8', '#047857', '#b45309', '#dc2626', '#7c3aed']
+const FONT_SIZE_MIN = 20
+const FONT_SIZE_MAX = 120
+const PREVIEW_RENDER_FONT_SIZE = 120
+const MIN_PREVIEW_IMAGE_WIDTH = 24
+const MAX_PREVIEW_IMAGE_WIDTH = 2400
// 临时使用本地图标 - 根据Figma annotation配置
const LOCAL_ICON_PATHS = {
@@ -18,6 +23,7 @@ const LOCAL_ICON_PATHS = {
// 导出按钮(根据Figma annotation)
exportSvg: '/assets/icons/export-svg-s.svg', // 紫色SVG导出按钮
exportPng: '/assets/icons/export-png-s.svg', // 蓝色PNG导出按钮
+ download: '/assets/icons/download.svg', // 下载图标
// 输入框图标
content: '/assets/icons/content.svg', // 输入框左侧绿色图标
// 字体树图标(根据Figma annotation)
@@ -43,6 +49,12 @@ function normalizeHexColor(input) {
return fallback
}
+function clampFontSize(value, fallback = PREVIEW_RENDER_FONT_SIZE) {
+ const parsed = Number(value)
+ const base = Number.isFinite(parsed) ? parsed : Number(fallback)
+ return Math.min(FONT_SIZE_MAX, Math.max(FONT_SIZE_MIN, Math.round(base)))
+}
+
function extractErrorMessage(error, fallback) {
if (!error) {
return fallback
@@ -69,16 +81,75 @@ function writePngBufferToTempFile(pngBuffer, fontName) {
return filePath
}
+function formatSvgNumber(value) {
+ const text = String(Number(value).toFixed(2))
+ return text.replace(/\.?0+$/, '')
+}
+
+function replaceSvgFillColor(svg, color) {
+ const normalizedColor = normalizeHexColor(color)
+ const source = String(svg || '')
+ if (!source) return ''
+
+ if (/]*\sfill="[^"]*"/.test(source)) {
+ return source.replace(/(]*\sfill=")[^"]*(")/, `$1${normalizedColor}$2`)
+ }
+
+ return source.replace(/]*)>/, ``)
+}
+
+function scaleSvgDimensions(svg, scale) {
+ const source = String(svg || '')
+ const safeScale = Number(scale)
+ if (!source || !Number.isFinite(safeScale) || safeScale <= 0) {
+ return source
+ }
+
+ return source
+ .replace(/width="([0-9]+(?:\.[0-9]+)?)"/, (match, width) => {
+ const scaledWidth = Number(width) * safeScale
+ return `width="${formatSvgNumber(scaledWidth)}"`
+ })
+ .replace(/height="([0-9]+(?:\.[0-9]+)?)"/, (match, height) => {
+ const scaledHeight = Number(height) * safeScale
+ return `height="${formatSvgNumber(scaledHeight)}"`
+ })
+}
+
+function applyLocalStyleToFontItem(font, fontSize, color) {
+ if (!font || !font.baseSvg) {
+ return font
+ }
+
+ const targetSize = clampFontSize(fontSize, PREVIEW_RENDER_FONT_SIZE)
+ const renderSize = Number(font.renderFontSize) > 0 ? Number(font.renderFontSize) : PREVIEW_RENDER_FONT_SIZE
+ const scale = targetSize / renderSize
+ const styledSvg = scaleSvgDimensions(replaceSvgFillColor(font.baseSvg, color), scale)
+ const scaledWidth = Number(font.baseWidth) > 0 ? Number(font.baseWidth) * scale : 0
+ const previewImageWidth = Math.max(
+ MIN_PREVIEW_IMAGE_WIDTH,
+ Math.min(MAX_PREVIEW_IMAGE_WIDTH, Math.round(scaledWidth || MIN_PREVIEW_IMAGE_WIDTH)),
+ )
+
+ return {
+ ...font,
+ svg: styledSvg,
+ previewSrc: toSvgDataUri(styledSvg),
+ previewImageStyle: `width:${previewImageWidth}px;max-width:100%;`,
+ previewError: '',
+ }
+}
+
Page({
data: {
inputText: '星程字体转换',
- fontSize: 120,
+ fontSize: FONT_SIZE_MAX,
letterSpacingInput: '0',
textColor: '#000000',
colorPalette: COLOR_PALETTE,
selectedFonts: [], // 当前已选中的字体列表
fontCategories: [], // 字体分类树
- favoriteCategories: [], // 已收藏字体分类树
+ favoriteFonts: [], // 已收藏字体平铺列表
showColorPicker: false,
favorites: [],
// 使用本地图标路径
@@ -97,6 +168,7 @@ Page({
this.fontMap = new Map()
this.generateTimer = null
+ this.categoryExpandedMap = {}
await this.bootstrap()
},
@@ -114,6 +186,13 @@ Page({
}
},
+ applyLocalPreviewStyles() {
+ const fontSize = clampFontSize(this.data.fontSize, PREVIEW_RENDER_FONT_SIZE)
+ const textColor = normalizeHexColor(this.data.textColor)
+ const selectedFonts = this.data.selectedFonts.map(font => applyLocalStyleToFontItem(font, fontSize, textColor))
+ this.setData({ selectedFonts })
+ },
+
async bootstrap() {
wx.showLoading({ title: '加载中', mask: true })
try {
@@ -127,12 +206,15 @@ Page({
this.setData({
inputText: state.inputText || this.data.inputText,
- fontSize: Number(state.fontSize) > 0 ? Number(state.fontSize) : this.data.fontSize,
+ fontSize: clampFontSize(state.fontSize, this.data.fontSize),
letterSpacingInput:
typeof state.letterSpacing === 'number' ? String(state.letterSpacing) : this.data.letterSpacingInput,
textColor: normalizeHexColor(state.textColor || this.data.textColor),
favorites,
})
+ this.categoryExpandedMap = state.categoryExpandedMap && typeof state.categoryExpandedMap === 'object'
+ ? { ...state.categoryExpandedMap }
+ : {}
// 构建字体树
this.updateFontTrees()
@@ -164,59 +246,72 @@ Page({
// 构建字体分类树
updateFontTrees() {
const categoryMap = new Map()
- const favoriteCategoryMap = new Map()
const favorites = this.data.favorites
- const keyword = (this.data.searchKeyword || '').trim().toLowerCase()
+ const selectedIdSet = new Set(this.data.selectedFonts.map(f => f.id))
+ const normalizedKeyword = (this.data.searchKeyword || '').trim().toLowerCase()
+ const selectedOnlyMode =
+ normalizedKeyword.includes('选中') ||
+ normalizedKeyword.includes('选择') ||
+ normalizedKeyword.includes('已选') ||
+ normalizedKeyword.includes('xuan')
+ const nameKeyword = selectedOnlyMode ? '' : normalizedKeyword
+ const isSearchMode = normalizedKeyword.length > 0
+ const favoriteFonts = []
this.fontMap.forEach(font => {
const category = font.category || '其他'
const isFavorite = favorites.includes(font.id)
+ const isSelected = selectedIdSet.has(font.id)
+
+ // “选中/选择/已选/xuan” 搜索模式:仅保留已选字体
+ if (selectedOnlyMode && !isSelected) {
+ return
+ }
// 应用搜索过滤
- if (keyword) {
- const matchesSearch = font.name.toLowerCase().includes(keyword) ||
- category.toLowerCase().includes(keyword)
+ if (nameKeyword) {
+ const matchesSearch = font.name.toLowerCase().includes(nameKeyword) ||
+ category.toLowerCase().includes(nameKeyword)
if (!matchesSearch) return
}
// 所有字体树
if (!categoryMap.has(category)) {
+ const expandedFromState = this.categoryExpandedMap[category]
categoryMap.set(category, {
category,
- expanded: true,
+ expanded: isSearchMode
+ ? true
+ : (typeof expandedFromState === 'boolean' ? expandedFromState : false),
fonts: [],
})
}
- const selectedIds = this.data.selectedFonts.map(f => f.id)
categoryMap.get(category).fonts.push({
...font,
- selected: selectedIds.includes(font.id),
+ selected: isSelected,
isFavorite,
})
- // 已收藏字体树
+ // 已收藏字体平铺列表
if (isFavorite) {
- if (!favoriteCategoryMap.has(category)) {
- favoriteCategoryMap.set(category, {
- category,
- expanded: true,
- fonts: [],
- })
- }
- favoriteCategoryMap.get(category).fonts.push({
+ favoriteFonts.push({
...font,
- selected: selectedIds.includes(font.id),
+ selected: isSelected,
isFavorite: true,
})
}
})
- const fontCategories = Array.from(categoryMap.values())
- const favoriteCategories = Array.from(favoriteCategoryMap.values())
+ const fontCategories = Array.from(categoryMap.values()).sort((a, b) => a.category.localeCompare(b.category))
+ favoriteFonts.sort((a, b) => {
+ const categoryCompare = String(a.category || '').localeCompare(String(b.category || ''))
+ if (categoryCompare !== 0) return categoryCompare
+ return String(a.name || '').localeCompare(String(b.name || ''))
+ })
this.setData({
fontCategories,
- favoriteCategories,
+ favoriteFonts,
})
},
@@ -307,20 +402,15 @@ Page({
if (index >= 0) {
fontCategories[index].expanded = !fontCategories[index].expanded
this.setData({ fontCategories })
- }
- },
-
- // 切换收藏分类展开/收起
- onToggleFavoriteCategory(e) {
- const category = e.currentTarget.dataset.category
- if (!category) return
-
- const favoriteCategories = this.data.favoriteCategories
- const index = favoriteCategories.findIndex(c => c.category === category)
-
- if (index >= 0) {
- favoriteCategories[index].expanded = !favoriteCategories[index].expanded
- this.setData({ favoriteCategories })
+ this.categoryExpandedMap[category] = fontCategories[index].expanded
+ saveAppState({
+ inputText: this.data.inputText,
+ selectedFontIds: this.data.selectedFonts.map(f => f.id),
+ fontSize: Number(this.data.fontSize),
+ letterSpacing: Number(this.data.letterSpacingInput || 0),
+ textColor: this.data.textColor,
+ categoryExpandedMap: this.categoryExpandedMap,
+ })
}
},
@@ -333,29 +423,31 @@ Page({
try {
const letterSpacing = Number(this.data.letterSpacingInput || 0)
- const fillColor = normalizeHexColor(this.data.textColor)
const result = await renderSvgByApi({
fontId,
text,
- fontSize: Number(this.data.fontSize),
- fillColor,
+ // 预览固定基础字号,避免字号/颜色调整时重复请求服务端
+ fontSize: PREVIEW_RENDER_FONT_SIZE,
+ fillColor: '#000000',
letterSpacing,
maxCharsPerLine: 45,
})
- const previewImageSrc = toSvgDataUri(result.svg)
-
// 更新对应字体的预览
const selectedFonts = this.data.selectedFonts
const index = selectedFonts.findIndex(f => f.id === fontId)
if (index >= 0) {
- selectedFonts[index].previewSrc = previewImageSrc
- selectedFonts[index].svg = result.svg
- selectedFonts[index].width = result.width
- selectedFonts[index].height = result.height
- selectedFonts[index].previewError = ''
+ selectedFonts[index].baseSvg = result.svg
+ selectedFonts[index].baseWidth = result.width
+ selectedFonts[index].baseHeight = result.height
+ selectedFonts[index].renderFontSize = PREVIEW_RENDER_FONT_SIZE
+ selectedFonts[index] = applyLocalStyleToFontItem(
+ selectedFonts[index],
+ Number(this.data.fontSize),
+ this.data.textColor,
+ )
this.setData({ selectedFonts })
}
} catch (error) {
@@ -365,6 +457,7 @@ Page({
if (index >= 0) {
selectedFonts[index].previewSrc = ''
selectedFonts[index].svg = ''
+ selectedFonts[index].baseSvg = ''
selectedFonts[index].previewError = (error && error.message) ? error.message : '预览生成失败'
this.setData({ selectedFonts })
}
@@ -412,23 +505,24 @@ Page({
},
onFontSizeChanging(event) {
- this.setData({ fontSize: Number(event.detail.value) || this.data.fontSize })
+ this.setData({ fontSize: clampFontSize(event.detail.value, this.data.fontSize) })
},
onFontSizeChange(event) {
- this.setData({ fontSize: Number(event.detail.value) || this.data.fontSize })
+ const newSize = clampFontSize(event.detail.value, this.data.fontSize)
+ this.setData({ fontSize: newSize })
saveAppState({
inputText: this.data.inputText,
selectedFontIds: this.data.selectedFonts.map(f => f.id),
- fontSize: Number(event.detail.value),
+ fontSize: newSize,
letterSpacing: Number(this.data.letterSpacingInput || 0),
textColor: this.data.textColor,
})
- this.scheduleGenerate()
+ this.applyLocalPreviewStyles()
},
onDecreaseFontSize() {
- const newSize = Math.max(24, this.data.fontSize - 10)
+ const newSize = Math.max(FONT_SIZE_MIN, this.data.fontSize - 10)
this.setData({ fontSize: newSize })
saveAppState({
inputText: this.data.inputText,
@@ -437,11 +531,11 @@ Page({
letterSpacing: Number(this.data.letterSpacingInput || 0),
textColor: this.data.textColor,
})
- this.scheduleGenerate()
+ this.applyLocalPreviewStyles()
},
onIncreaseFontSize() {
- const newSize = Math.min(320, this.data.fontSize + 10)
+ const newSize = Math.min(FONT_SIZE_MAX, this.data.fontSize + 10)
this.setData({ fontSize: newSize })
saveAppState({
inputText: this.data.inputText,
@@ -450,7 +544,7 @@ Page({
letterSpacing: Number(this.data.letterSpacingInput || 0),
textColor: this.data.textColor,
})
- this.scheduleGenerate()
+ this.applyLocalPreviewStyles()
},
onShowColorPicker() {
@@ -467,7 +561,7 @@ Page({
onColorInput(event) {
this.setData({ textColor: event.detail.value || '' })
- this.scheduleGenerate()
+ this.applyLocalPreviewStyles()
},
onPickColor(event) {
@@ -483,7 +577,7 @@ Page({
letterSpacing: Number(this.data.letterSpacingInput || 0),
textColor: color,
})
- this.scheduleGenerate()
+ this.applyLocalPreviewStyles()
},
onRegenerate() {
@@ -568,7 +662,10 @@ Page({
if (selectedFonts.length === 1) {
const font = selectedFonts[0]
try {
- await shareSvgFromUserTap(font.svg, font.name, this.data.inputText)
+ const exportSvg = font.baseSvg
+ ? applyLocalStyleToFontItem(font, Number(this.data.fontSize), this.data.textColor).svg
+ : font.svg
+ await shareSvgFromUserTap(exportSvg, font.name, this.data.inputText)
wx.showToast({ title: 'SVG 已分享', icon: 'success' })
} catch (error) {
showExportError('导出 SVG 失败', error, '请稍后重试')
@@ -622,14 +719,17 @@ Page({
async onExportSingleSvg(e) {
const fontId = e.currentTarget.dataset.fontId
const font = this.data.selectedFonts.find(f => f.id === fontId)
-
+
if (!font || !font.svg) {
wx.showToast({ title: '请先生成预览', icon: 'none' })
return
}
try {
- await shareSvgFromUserTap(font.svg, font.name, this.data.inputText)
+ const exportSvg = font.baseSvg
+ ? applyLocalStyleToFontItem(font, Number(this.data.fontSize), this.data.textColor).svg
+ : font.svg
+ await shareSvgFromUserTap(exportSvg, font.name, this.data.inputText)
wx.showToast({ title: 'SVG 已分享', icon: 'success' })
} catch (error) {
showExportError('导出 SVG 失败', error, '请稍后重试')
@@ -639,7 +739,7 @@ Page({
async onExportSinglePng(e) {
const fontId = e.currentTarget.dataset.fontId
const font = this.data.selectedFonts.find(f => f.id === fontId)
-
+
if (!font || !font.svg) {
wx.showToast({ title: '请先生成预览', icon: 'none' })
return
diff --git a/miniprogram/pages/index/index.wxml b/miniprogram/pages/index/index.wxml
index f22b64b..0cec0ca 100644
--- a/miniprogram/pages/index/index.wxml
+++ b/miniprogram/pages/index/index.wxml
@@ -11,24 +11,29 @@
-
+
+ {{fontSize}}
+
+
@@ -62,6 +67,7 @@
{{item.name}}
+
@@ -76,6 +82,7 @@
class="preview-image"
mode="widthFix"
src="{{item.previewSrc}}"
+ style="{{item.previewImageStyle}}"
/>
{{item.previewError}}
生成中...
@@ -112,15 +119,17 @@
src="{{icons.expandIcon}}"
style="transform: rotate({{item.expanded ? '90deg' : '0deg'}})"
/>
- {{item.category}}
+ {{item.category}}({{item.fonts.length}})
-
+
+
+
{{font.name}}
@@ -146,39 +155,22 @@
已收藏
-
-
-
-
-
-
- {{font.name}}
-
-
-
-
-
-
-
-
-
+
+
+
+ {{item.name}}
+
+
+
+
+
+
+
- 暂无收藏字体
+ 暂无收藏字体
diff --git a/miniprogram/pages/index/index.wxss b/miniprogram/pages/index/index.wxss
index 2a824eb..2e58094 100644
--- a/miniprogram/pages/index/index.wxss
+++ b/miniprogram/pages/index/index.wxss
@@ -34,17 +34,39 @@
flex: 1;
gap: 4rpx; /* 改为 2rpx 的 gap,小程序 rpx = 物理像素*2 */
padding: 0 12rpx;
+ height: 64rpx;
}
.font-size-icon {
width: 24rpx;
height: 24rpx;
flex-shrink: 0;
+ display: block;
+}
+
+.font-slider-wrap {
+ flex: 1;
+ height: 64rpx;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: flex-end;
+ position: relative;
+}
+
+.font-size-value {
+ font-size: 20rpx;
+ color: #4E5969;
+ line-height: 28rpx;
+ text-align: center;
+ margin-bottom: 4rpx;
}
.font-slider {
- flex: 1;
- height: 4rpx;
+ width: 100%;
+ height: 24rpx;
+ margin: 0;
+ padding: 0;
}
.color-picker-btn {
@@ -149,7 +171,14 @@
.export-btns-inline {
display: flex;
align-items: center;
- gap: 12rpx;
+ gap: 10rpx;
+}
+
+.download-icon {
+ width: 24rpx;
+ height: 24rpx;
+ flex-shrink: 0;
+ display: block;
}
.export-btn-sm {
@@ -163,11 +192,11 @@
}
.export-btn-sm.export-svg-btn {
- background: #8552A1;
+ background: #E3D6EE;
}
.export-btn-sm.export-png-btn {
- background: #2420A8;
+ background: #FFE4BA;
}
.export-icon-sm {
@@ -207,11 +236,12 @@
min-height: 80rpx;
display: flex;
align-items: center;
- justify-content: center;
+ justify-content: flex-start;
}
.preview-image {
- width: 100%;
+ display: block;
+ max-width: 100%;
}
.preview-loading {
@@ -259,14 +289,17 @@
display: flex;
align-items: center;
gap: 8rpx;
- padding: 4rpx 0;
- height: 56rpx;
+ padding: 0;
+ height: 40rpx;
}
.selection-header .section-title {
padding: 0;
- height: auto;
- line-height: 56rpx;
+ font-size: 28rpx;
+ line-height: 40rpx;
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
}
.search-container {
@@ -278,6 +311,7 @@
border-radius: 8rpx;
padding: 4rpx 12rpx;
height: 40rpx;
+ min-width: 0;
}
.search-icon {
@@ -311,6 +345,7 @@
}
.font-category {
+ position: relative;
margin-bottom: 14rpx;
}
@@ -322,8 +357,8 @@
}
.expand-icon {
- width: 20rpx;
- height: 20rpx;
+ width: 40rpx;
+ height: 40rpx;
transition: transform 0.3s;
flex-shrink: 0;
}
@@ -340,6 +375,10 @@
gap: 0;
}
+.select-font-list {
+ position: relative;
+}
+
.font-item {
display: flex;
align-items: center;
@@ -349,6 +388,29 @@
margin-bottom: 9rpx;
}
+.select-font-item {
+ position: relative;
+ padding-left: 28rpx;
+}
+
+.tree-vertical-line {
+ position: absolute;
+ left: 19rpx;
+ top: 42rpx;
+ bottom: 16rpx;
+ width: 1rpx;
+ background: #C9CDD4;
+}
+
+.tree-horizontal-line {
+ position: absolute;
+ left: 19rpx;
+ top: 18rpx;
+ width: 10rpx;
+ height: 1rpx;
+ background: #C9CDD4;
+}
+
.font-item-icon {
width: 18rpx;
height: 18rpx;
@@ -357,19 +419,28 @@
.font-item-name {
flex: 1;
+ min-width: 0;
font-size: 20rpx;
color: #86909C;
+ line-height: 28rpx;
+ white-space: normal;
+ word-break: break-all;
}
.font-item-actions {
display: flex;
align-items: center;
gap: 9rpx;
+ flex-shrink: 0;
}
.font-checkbox {
width: 21rpx;
height: 21rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
}
.checkbox-icon-sm {
@@ -380,11 +451,24 @@
.favorite-btn {
width: 21rpx;
height: 21rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
}
.favorite-icon {
width: 100%;
height: 100%;
+ display: block;
+}
+
+.favorite-list {
+ padding-top: 8rpx;
+}
+
+.favorite-font-item {
+ padding-left: 0;
}
.empty-favorites {