最近接到一个需求,需要实现一个根据字体包来生成不同文字图片的UI工具,下面分享我的实现思路和完整代码。
需求背景
在开发过程中,我们经常需要将文字转换为图片,比如:
- 生成特殊字体的文字图片用于UI设计
- 创建自定义图标库
- 生成水印图片
- 制作艺术字效果
传统方式需要依赖Photoshop等设计工具,效率低下。因此,我开发了一个基于Electron的字体图片生成工具,能够快速批量生成文字图片。
核心实现
1. 字体列表获取
首先需要获取系统可用的字体列表,我们这里使用font-list获取字体列表,这是一个专门用于获取系统已安装字体列表的Node.js库,具有以下特点:
- 跨平台支持:完美兼容 Windows、macOS、Linux 三大操作系统
- 准确识别:能够准确获取系统所有已安装的字体名称
- 简单易用:API简洁,一行代码即可获取完整字体列表
- 无依赖:轻量级库,不依赖其他复杂的系统工具
npm install font-list
// main.js (Electron主进程)
const { ipcMain } = require('electron')
const FontList = require('font-list')
ipcMain.handle('get-fonts-list', async () => {
return await FontList.getFonts()
})
2. 前端组件实现
<template>
<div class="font-to-image flex-column">
<el-divider content-position="left">
<el-text><el-icon><PictureRounded /></el-icon>文字生成图片</el-text>
</el-divider>
<!-- 文本设置 -->
<el-form-item label="文本设置" class="form-title"></el-form-item>
<el-form label-width="auto">
<el-form-item label="字体">
<el-select v-model="fontType" placeholder="请选择字体" @change="createImage">
<el-option v-for="item in fontList" :key="item" :label="item" :value="item"></el-option>
</el-select>
</el-form-item>
<el-form-item label="字号">
<el-input type="number" v-model="fontSize" placeholder="请输入字号" :min="0"
@change="changeSize" @input="createImage"></el-input>
</el-form-item>
<el-form-item label="颜色">
<colorInput v-model:value="color" @change="createImage"></colorInput>
</el-form-item>
<el-form-item label="文本">
<el-input type="textarea" v-model="text" placeholder="请输入文本以生成预览图"
@change="changeText" @input="changeText"></el-input>
</el-form-item>
<el-form-item label="生成模式">
<el-radio-group v-model="createMode" @change="changeText">
<el-radio border label="0">单字符模式</el-radio>
<el-radio border label="1">字符串模式</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="斜体">
<el-radio-group v-model="fontStyle" @change="createImage">
<el-radio border label="normal">标准</el-radio>
<el-radio border label="italic">斜体</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="粗细">
<el-select v-model="fontWeight" placeholder="请选择字体粗细" @change="createImage">
<el-option v-for="item in fontWeightOpt" :key="item.value"
:label="item.label" :value="item.value"></el-option>
</el-select>
</el-form-item>
</el-form>
<!-- 图片设置 -->
<el-form-item label="图片设置" class="form-title"></el-form-item>
<el-form label-width="auto" inline label-position="top">
<el-form-item label="图片高度" style="flex: 1;margin: 5px;">
<el-input type="number" v-model="imgHeight" placeholder="请输入图片高度" :min="0"
@change="createImage" @input="createImage"></el-input>
</el-form-item>
<el-form-item label="图片宽度" style="flex: 1;margin: 5px;">
<el-input type="number" v-model="imgWidth" placeholder="请输入图片宽度" :min="0"
@change="createImage" @input="createImage"></el-input>
</el-form-item>
</el-form>
<el-form label-width="auto" inline label-position="top">
<el-form-item label="X轴偏移量" style="flex: 1;margin: 5px;">
<el-input type="number" v-model="Xoffest" placeholder="请输入X轴偏移量"
@change="createImage" @input="createImage"></el-input>
</el-form-item>
<el-form-item label="Y轴偏移量" style="flex: 1;margin: 5px;">
<el-input type="number" v-model="Yoffest" placeholder="请输入Y轴偏移量"
@change="createImage" @input="createImage"></el-input>
</el-form-item>
</el-form>
<!-- 图片预览 -->
<template v-if="canvasList.length > 0">
<el-form-item label="图片预览" class="form-title"></el-form-item>
<el-scrollbar ref="scrollbar" class="preview-image-scrollbar" width="100%" @wheel="handleWheel">
<div id="previewImage" class="preview-image flex-center" style="flex-wrap: wrap;">
<template v-if="createMode == '0'">
<template v-for="item, index in canvasList" :key="index">
<canvas :width="+imgWidth" :height="+imgHeight"></canvas>
<el-input v-model="nameSpace[index]"></el-input>
<el-text type="danger" size="small"
v-show="haveSameName(nameSpace[index], index)">
注意:命名相同
</el-text>
<p style="width: 100%;"></p>
</template>
</template>
<template v-else>
<template v-for="item, index in canvasList" :key="index">
<canvas :width="+imgWidth" :height="+imgHeight"></canvas>
<el-input v-model="nameSpace[index]"></el-input>
</template>
</template>
</div>
</el-scrollbar>
<el-button class="form-title" type="primary" @click="downloadImage">导入项目</el-button>
</template>
</div>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { pinyin } from 'pinyin-pro'
import { projectStatusStore } from "@/store/projectStatus"
import { useWheel } from "@/hooks/useWheel"
const { handleWheel } = useWheel("scrollbar")
// 响应式数据
const fontType = ref("")
const fontList = ref([])
const fontSize = ref("32")
const color = ref("#000000")
const text = ref("")
const createMode = ref("1")
const fontStyle = ref("normal")
const fontWeight = ref("normal")
const imgWidth = ref(fontSize.value)
const imgHeight = ref(fontSize.value)
const Xoffest = ref("0")
const Yoffest = ref("0")
const nameSpace = ref([])
// 字体粗细选项
const fontWeightOpt = [
{ label: "标准", value: "normal" },
{ label: "粗体", value: "bold" },
{ label: "加粗", value: "bolder" },
{ label: "细体", value: "lighter" },
{ label: "100", value: "100" },
{ label: "200", value: "200" },
{ label: "300", value: "300" },
{ label: "400", value: "400" },
{ label: "500", value: "500" },
{ label: "600", value: "600" },
{ label: "700", value: "700" },
{ label: "800", value: "800" },
{ label: "900", value: "900" },
]
// 获取字体列表
const getFontList = async () => {
await ipcRenderer.invoke('get-fonts-list').then(res => {
fontList.value = res || []
if (fontList.value.length > 0) {
fontType.value = fontList.value[0]
}
})
}
getFontList()
// 字号改变时同步调整图片尺寸
const changeSize = () => {
imgWidth.value = fontSize.value
imgHeight.value = fontSize.value
createImage()
}
// 文本处理
const changeText = () => {
if (createMode.value == "0") {
// 单字符模式:去除空格和重复字符
text.value = text.value?.replace(/\s+/g, "").replace(/ /g, "")
text.value = [...new Set(text.value)].join('')
} else {
// 字符串模式:合并多个空格为单个空格
text.value = text.value?.replace(/\s+/g, " ")
}
createImage()
}
// 生成图片预览
const createImage = () => {
nextTick(async () => {
const previewImage = document.getElementById("previewImage")?.getElementsByTagName("canvas") || []
const tempCanvas = document.createElement('canvas')
const tempCtx = tempCanvas.getContext('2d')
// 计算最大文本宽度
let maxTextWidth = imgWidth.value
canvasList.value.forEach(item => {
tempCtx.font = `${fontSize.value}px ${fontType.value}`
const textWidth = tempCtx.measureText(item.text).width
maxTextWidth = Math.max(maxTextWidth, textWidth)
})
imgWidth.value = Math.ceil(Math.max(imgWidth.value, maxTextWidth))
await nextTick()
// 绘制每个canvas
for (let i = 0; i < previewImage.length; i++) {
const canvas = previewImage[i]
const ctx = canvas.getContext("2d")
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.fillStyle = color.value
ctx.font = `${fontStyle.value} ${fontWeight.value} ${fontSize.value}px ${fontType.value}`
ctx.textAlign = "center"
ctx.textBaseline = "middle"
const centerX = (+imgWidth.value + +Xoffest.value) / 2
const centerY = (+imgHeight.value + +Yoffest.value) / 2
ctx.fillText(canvasList.value[i].text, centerX, centerY)
// 生成文件名
const textValue = pinyin(createMode.value == 0 ? text.value[i] : text.value, {
v: true,
removeNonZh: false,
toneType: 'none',
nonZh: 'consecutive'
}).replace(/ /g, "_")
nameSpace.value[i] = `${fontType.value.replace(/"/g, "").replace(/ /g, "_")}_${textValue}_${color.value.replace("#", "")}_${imgWidth.value}x${imgHeight.value}_${i}.png`
}
})
}
// 检查重复文件名
const haveSameName = (name, index) => {
return nameSpace.value.some((item, i) => item == name && i != index)
}
// 计算canvas列表
const canvasList = computed(() => {
if (!text.value) return []
if (createMode.value == "0") {
// 单字符模式:每个字符生成一个图片
return text.value?.trim()?.split("")?.filter(item => item && item != " ")?.reduce((p, c) => {
p.push({ text: c })
return p
}, []) || []
} else {
// 字符串模式:整个文本生成一个图片
return [{ text: text.value }]
}
})
// 下载图片
const downloadImage = async () => {
const previewImage = document.getElementById("previewImage")?.getElementsByTagName("canvas") || []
const savePath = "下载地址"
const promiseList = []
for (let i = 0; i < previewImage.length; i++) {
const canvas = previewImage[i]
const dataURL = canvas.toDataURL('image/png')
const base64Data = dataURL.replace(/^data:image\/png;base64,/, '')
const buffer = Buffer.from(base64Data, 'base64')
const fileName = nameSpace.value[i] || "未知图片.png"
promiseList.push(
ipcRenderer.invoke('write-data-to-file', buffer, path.join(savePath, '文本图片', fileName))
)
}
Promise.all(promiseList).then(res => {
ElMessage.success("导入成功")
projectStatusStore.updateFileList(0)
})
}
</script>
<style lang="scss" scoped>
.preview-image-scrollbar {
width: 100%;
}
.form-title {
margin-top: 12px;
}
.preview-image {
height: fit-content;
width: fit-content;
flex-wrap: wrap;
canvas {
margin: 5px 0;
border: 1px solid var(--el-border-color);
border-radius: 4px;
background-image: repeating-conic-gradient(var(--el-border-color) 0 25%, transparent 0 50%);
background-size: 16px 16px;
}
.el-input, .el-text {
width: 300px;
}
}
</style>
3. Electron主进程文件操作
// main.js
const fsExtra = require("fs-extra")
const FontList = require("font-list")
const { ipcMain } = require('electron')
// 获取字体列表
ipcMain.handle("get-fonts-list", async () => {
return await FontList.getFonts()
})
// 文件写入
ipcMain.handle("write-data-to-file", (event, data, filePath, options = {}) => {
return new Promise(async (resolve, reject) => {
try {
if (filePath) {
try {
await fsExtra.ensureFile(filePath)
await fsExtra.writeFile(filePath, data, options)
resolve({ success: true, msg: "写入成功" })
} catch (error) {
resolve({ success: false, msg: "写入失败" })
}
return
}
resolve({ success: false, msg: "文件路径不存在" })
} catch (error) {
resolve({ success: false, msg: "写入失败" })
}
})
})
核心功能亮点
1. 双模式生成
- 单字符模式:每个字符生成单独的图片,自动去重
- 字符串模式:整个文本生成一个图片
2. 智能文件名生成
使用拼音转换确保文件名规范:
const textValue = pinyin(text, {
v: true,
removeNonZh: false,
toneType: 'none',
nonZh: 'consecutive'
}).replace(/ /g, "_")
3. 实时预览
通过Canvas实时渲染文字效果,所见即所得
4. 批量导出
支持一次性导出所有生成的图片到指定目录
使用效果
这个工具极大地提高了文字图片生成的效率:
- 从原来的几分钟/张减少到几秒/批
- 支持批量处理,特别适合图标库生成
- 灵活的样式配置满足不同设计需求