const { loadFontsManifest } = require('../../utils/mp/font-loader') const { loadAppState, saveAppState, loadFavorites, saveFavorites } = require('../../utils/mp/storage') const { shareSvgFromUserTap, } = require('../../utils/mp/file-export') const { savePngToAlbum } = require('../../utils/mp/canvas-export') 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 = { logo: '/assets/icons/webicon.png', fontSizeDecrease: '/assets/icons/font-size-decrease.svg', fontSizeIncrease: '/assets/icons/font-size-increase.svg', chooseColor: '/assets/icons/choose-color.svg', // 导出按钮(根据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) fontIcon: '/assets/icons/font-icon.svg', // 字体item图标 expandIcon: '/assets/icons/expand.svg', // 展开分类图标 collapseIcon: '/assets/icons/expand.svg', // 折叠使用同一图标,通过旋转实现 favoriteIcon: '/assets/icons/favorite.svg', // 收藏图标(未收藏白色底,收藏红色底) checkbox: '/assets/icons/checkbox.svg', // 复选框(未选中) checkboxChecked: '/assets/icons/checkbox-no.svg', // 复选框(已选中) search: '/assets/icons/search.svg', // 搜索图标 } function toSvgDataUri(svg) { return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}` } function normalizeHexColor(input) { const fallback = '#000000' const value = String(input || '').trim() if (/^#[0-9a-fA-F]{6}$/.test(value)) { return value } 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 } const errMsg = error.errMsg || error.message || String(error) return errMsg || fallback } function showExportError(title, error, fallback) { const message = extractErrorMessage(error, fallback) wx.showModal({ title, content: message, showCancel: false, confirmText: '知道了', }) } function writePngBufferToTempFile(pngBuffer, fontName) { const safeName = String(fontName || 'font').replace(/[<>:"/\\|?*\x00-\x1F]/g, '_').slice(0, 60) || 'font' const filePath = `${wx.env.USER_DATA_PATH}/${safeName}_${Date.now()}.png` const fs = wx.getFileSystemManager() fs.writeFileSync(filePath, pngBuffer) 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: FONT_SIZE_MAX, letterSpacingInput: '0', textColor: '#000000', colorPalette: COLOR_PALETTE, selectedFonts: [], // 当前已选中的字体列表 fontCategories: [], // 字体分类树 favoriteFonts: [], // 已收藏字体平铺列表 showColorPicker: false, favorites: [], // 使用本地图标路径 icons: LOCAL_ICON_PATHS, // 搜索功能 searchKeyword: '', showSearch: false, }, async onLoad() { // 调试:打印图标配置 console.log('========== 图标配置 ==========') console.log('icons:', this.data.icons) console.log('logo URL:', this.data.icons.logo) console.log('============================') this.fontMap = new Map() this.generateTimer = null this.categoryExpandedMap = {} await this.bootstrap() }, onShow() { const favorites = loadFavorites() this.setData({ favorites }) this.updateFontTrees() }, onUnload() { if (this.generateTimer) { clearTimeout(this.generateTimer) this.generateTimer = null } }, 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 { const state = loadAppState() const fonts = await loadFontsManifest() const favorites = loadFavorites() for (const font of fonts) { this.fontMap.set(font.id, font) } this.setData({ inputText: state.inputText || this.data.inputText, 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() // 如果有保存的选中字体,恢复它们 if (state.selectedFontIds && state.selectedFontIds.length > 0) { const selectedFonts = state.selectedFontIds .map(id => this.fontMap.get(id)) .filter(font => font) .map(font => ({ id: font.id, name: font.name, category: font.category, showInPreview: true, previewSrc: '', })) this.setData({ selectedFonts }) await this.generateAllPreviews() } } catch (error) { const message = error && error.message ? error.message : '初始化失败' wx.showToast({ title: message, icon: 'none', duration: 2200 }) } finally { wx.hideLoading() } }, // 构建字体分类树 updateFontTrees() { const categoryMap = new Map() const favorites = this.data.favorites 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 (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: isSearchMode ? true : (typeof expandedFromState === 'boolean' ? expandedFromState : false), fonts: [], }) } categoryMap.get(category).fonts.push({ ...font, selected: isSelected, isFavorite, }) // 已收藏字体平铺列表 if (isFavorite) { favoriteFonts.push({ ...font, selected: isSelected, isFavorite: true, }) } }) 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, favoriteFonts, }) }, // 切换字体选择 async onToggleFont(e) { const fontId = e.currentTarget.dataset.fontId if (!fontId) return const font = this.fontMap.get(fontId) if (!font) return const selectedFonts = this.data.selectedFonts const index = selectedFonts.findIndex(f => f.id === fontId) if (index >= 0) { // 取消选择 selectedFonts.splice(index, 1) } else { // 添加选择 selectedFonts.push({ id: font.id, name: font.name, category: font.category, showInPreview: true, previewSrc: '', }) } this.setData({ selectedFonts }) this.updateFontTrees() // 保存状态 saveAppState({ inputText: this.data.inputText, selectedFontIds: selectedFonts.map(f => f.id), fontSize: Number(this.data.fontSize), letterSpacing: Number(this.data.letterSpacingInput || 0), textColor: this.data.textColor, }) // 如果是新选中的字体,生成预览 if (index < 0) { await this.generatePreviewForFont(fontId) } }, // 切换字体在预览中的显示 onTogglePreviewFont(e) { const fontId = e.currentTarget.dataset.fontId if (!fontId) return const selectedFonts = this.data.selectedFonts const index = selectedFonts.findIndex(f => f.id === fontId) if (index >= 0) { selectedFonts[index].showInPreview = !selectedFonts[index].showInPreview this.setData({ selectedFonts }) } }, // 切换收藏 onToggleFavorite(e) { const fontId = e.currentTarget.dataset.fontId if (!fontId) return let favorites = [...this.data.favorites] const index = favorites.indexOf(fontId) if (index >= 0) { favorites.splice(index, 1) } else { favorites.push(fontId) } this.setData({ favorites }) saveFavorites(favorites) this.updateFontTrees() }, // 切换分类展开/收起 onToggleCategory(e) { const category = e.currentTarget.dataset.category if (!category) return const fontCategories = this.data.fontCategories const index = fontCategories.findIndex(c => c.category === category) if (index >= 0) { fontCategories[index].expanded = !fontCategories[index].expanded this.setData({ fontCategories }) 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, }) } }, // 生成单个字体的预览 async generatePreviewForFont(fontId) { const text = String(this.data.inputText || '') if (!text.trim()) { return } try { const letterSpacing = Number(this.data.letterSpacingInput || 0) const result = await renderSvgByApi({ fontId, text, // 预览固定基础字号,避免字号/颜色调整时重复请求服务端 fontSize: PREVIEW_RENDER_FONT_SIZE, fillColor: '#000000', letterSpacing, maxCharsPerLine: 45, }) // 更新对应字体的预览 const selectedFonts = this.data.selectedFonts const index = selectedFonts.findIndex(f => f.id === fontId) if (index >= 0) { 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) { console.error('生成预览失败', error) const selectedFonts = this.data.selectedFonts const index = selectedFonts.findIndex(f => f.id === fontId) if (index >= 0) { selectedFonts[index].previewSrc = '' selectedFonts[index].svg = '' selectedFonts[index].baseSvg = '' selectedFonts[index].previewError = (error && error.message) ? error.message : '预览生成失败' this.setData({ selectedFonts }) } } }, // 生成所有选中字体的预览 async generateAllPreviews() { const text = String(this.data.inputText || '') if (!text.trim()) { return } wx.showLoading({ title: '生成预览中', mask: true }) try { for (const font of this.data.selectedFonts) { await this.generatePreviewForFont(font.id) } } finally { wx.hideLoading() } }, scheduleGenerate() { if (this.generateTimer) { clearTimeout(this.generateTimer) } this.generateTimer = setTimeout(() => { this.generateAllPreviews() }, 260) }, onInputText(event) { this.setData({ inputText: event.detail.value || '' }) saveAppState({ inputText: event.detail.value || '', selectedFontIds: this.data.selectedFonts.map(f => f.id), fontSize: Number(this.data.fontSize), letterSpacing: Number(this.data.letterSpacingInput || 0), textColor: this.data.textColor, }) this.scheduleGenerate() }, onFontSizeChanging(event) { this.setData({ fontSize: clampFontSize(event.detail.value, this.data.fontSize) }) }, onFontSizeChange(event) { 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: newSize, letterSpacing: Number(this.data.letterSpacingInput || 0), textColor: this.data.textColor, }) this.applyLocalPreviewStyles() }, onDecreaseFontSize() { const newSize = Math.max(FONT_SIZE_MIN, this.data.fontSize - 10) this.setData({ fontSize: newSize }) saveAppState({ inputText: this.data.inputText, selectedFontIds: this.data.selectedFonts.map(f => f.id), fontSize: newSize, letterSpacing: Number(this.data.letterSpacingInput || 0), textColor: this.data.textColor, }) this.applyLocalPreviewStyles() }, onIncreaseFontSize() { const newSize = Math.min(FONT_SIZE_MAX, this.data.fontSize + 10) this.setData({ fontSize: newSize }) saveAppState({ inputText: this.data.inputText, selectedFontIds: this.data.selectedFonts.map(f => f.id), fontSize: newSize, letterSpacing: Number(this.data.letterSpacingInput || 0), textColor: this.data.textColor, }) this.applyLocalPreviewStyles() }, onShowColorPicker() { this.setData({ showColorPicker: true }) }, onHideColorPicker() { this.setData({ showColorPicker: false }) }, onStopPropagation() { // 阻止事件冒泡 }, onColorInput(event) { this.setData({ textColor: event.detail.value || '' }) this.applyLocalPreviewStyles() }, onPickColor(event) { const color = event.currentTarget.dataset.color if (!color) { return } this.setData({ textColor: color }) 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: color, }) this.applyLocalPreviewStyles() }, onRegenerate() { this.generateAllPreviews() }, onShowExportOptions() { wx.showActionSheet({ itemList: ['导出全部为 SVG', '导出全部为 PNG'], success: (res) => { if (res.tapIndex === 0) { this.exportAllSvg() } else if (res.tapIndex === 1) { this.exportAllPng() } }, }) }, async exportAllSvg() { const selectedFonts = this.data.selectedFonts.filter(f => f.showInPreview && f.svg) if (selectedFonts.length === 0) { wx.showToast({ title: '请先生成预览', icon: 'none' }) return } wx.showModal({ title: '微信限制说明', content: '微信要求 shareFileMessage 必须由单次点击直接触发,暂不支持批量自动分享 SVG,请逐个字体导出。', showCancel: false, confirmText: '知道了', }) return }, async exportAllPng() { const selectedFonts = this.data.selectedFonts.filter(f => f.showInPreview && f.svg) if (selectedFonts.length === 0) { wx.showToast({ title: '请先生成预览', icon: 'none' }) return } wx.showLoading({ title: '导出 PNG 中', mask: true }) try { for (const font of selectedFonts) { const pngBuffer = await renderPngByApi({ fontId: font.id, text: this.data.inputText, fontSize: Number(this.data.fontSize), fillColor: normalizeHexColor(this.data.textColor), letterSpacing: Number(this.data.letterSpacingInput || 0), maxCharsPerLine: 45, }) const pngPath = writePngBufferToTempFile(pngBuffer, font.name) const saveResult = await savePngToAlbum(pngPath) if (!saveResult.success) { throw saveResult.error || new Error('保存 PNG 失败') } } wx.showToast({ title: 'PNG 已保存到相册', icon: 'success' }) } catch (error) { showExportError('导出 PNG 失败', error, '请稍后重试') } finally { wx.hideLoading() } }, async onExportSvg() { const selectedFonts = this.data.selectedFonts.filter(f => f.showInPreview && f.svg) if (selectedFonts.length === 0) { wx.showToast({ title: '请先生成预览', icon: 'none' }) return } // 如果只有一个字体,直接导出 if (selectedFonts.length === 1) { const font = selectedFonts[0] try { 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, '请稍后重试') } } else { this.exportAllSvg() } }, async onExportPng() { const selectedFonts = this.data.selectedFonts.filter(f => f.showInPreview && f.svg) if (selectedFonts.length === 0) { wx.showToast({ title: '请先生成预览', icon: 'none' }) return } // 如果只有一个字体,直接导出 if (selectedFonts.length === 1) { const font = selectedFonts[0] wx.showLoading({ title: '导出 PNG 中', mask: true }) try { const pngBuffer = await renderPngByApi({ fontId: font.id, text: this.data.inputText, fontSize: Number(this.data.fontSize), fillColor: normalizeHexColor(this.data.textColor), letterSpacing: Number(this.data.letterSpacingInput || 0), maxCharsPerLine: 45, }) const pngPath = writePngBufferToTempFile(pngBuffer, font.name) const saveResult = await savePngToAlbum(pngPath) if (saveResult.success) { wx.showToast({ title: 'PNG 已保存到相册', icon: 'success' }) } else { showExportError('导出 PNG 失败', saveResult.error, '保存失败,请检查相册权限') } } catch (error) { showExportError('导出 PNG 失败', error, '请稍后重试') } finally { wx.hideLoading() } } else { this.exportAllPng() } }, // 单个字体导出 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 { 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, '请稍后重试') } }, 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 } wx.showLoading({ title: '导出 PNG 中', mask: true }) try { const pngBuffer = await renderPngByApi({ fontId: font.id, text: this.data.inputText, fontSize: Number(this.data.fontSize), fillColor: normalizeHexColor(this.data.textColor), letterSpacing: Number(this.data.letterSpacingInput || 0), maxCharsPerLine: 45, }) const pngPath = writePngBufferToTempFile(pngBuffer, font.name) const saveResult = await savePngToAlbum(pngPath) if (saveResult.success) { wx.showToast({ title: 'PNG 已保存到相册', icon: 'success' }) } else { showExportError('导出 PNG 失败', saveResult.error, '保存失败,请检查相册权限') } } catch (error) { showExportError('导出 PNG 失败', error, '请稍后重试') } finally { wx.hideLoading() } }, // 搜索功能 onToggleSearch() { this.setData({ showSearch: !this.data.showSearch }) if (!this.data.showSearch) { this.setData({ searchKeyword: '' }) this.updateFontTrees() } }, onSearchInput(e) { const keyword = e.detail.value || '' this.setData({ searchKeyword: keyword }) this.updateFontTrees() }, })