Electron + Vue 3 打造文字转图片工具,支持实时预览和批量导出

202 阅读3分钟

最近接到一个需求,需要实现一个根据字体包来生成不同文字图片的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. 批量导出

支持一次性导出所有生成的图片到指定目录

使用效果

这个工具极大地提高了文字图片生成的效率:

  • 从原来的几分钟/张减少到几秒/批
  • 支持批量处理,特别适合图标库生成
  • 灵活的样式配置满足不同设计需求