我这边也根据这里数据,实现了汉字的笔顺图和笔顺书写gif图,现将程序分享出来,有兴趣的朋友可以进行有趣的扩展
TIPs:部分代码由AI完善
一、原始数据

1.jpg (135.08 KB, 下载次数: 0)
下载附件
2025-5-22 19:57 上传

2.jpg (98.66 KB, 下载次数: 0)
下载附件
2025-5-22 19:58 上传
如上图,以“愛”字为例,它是以汉字的十进制编码命名,有两种格式,双击打开svg文件,可以看到效果如上图
二、笔顺图及书写动画实现
(一)笔顺图
[Python] 纯文本查看 复制代码# 读取svgs生成笔顺图.py
import os
import json
import xml.etree.ElementTree as ET
import 获取笔顺
from concurrent.futures import ThreadPoolExecutor
def process_svg(svg, path, index, total_num):
"""处理单个SVG文件的函数"""
try:
char_number = svg.split('-')[0]
dec_num = int(char_number)
char = chr(dec_num) # 转汉字
svg_path = os.path.join(path, svg)
# 读取SVG文件
root = ET.parse(svg_path).getroot()
# 使用命名空间来查找path元素
ns = {'svg': 'http://www.w3.org/2000/svg'}
path_elements = root.findall('.//svg:path', ns)
svg_path_data = [path.attrib['d'] for path in path_elements] # 获取笔顺内容
print(f'{index} / {total_num} 正在生成 {char} 的笔顺图...')
# 传入数据
获取笔顺.main(svg_path_data, char)
except Exception as e:
print(f"处理 {svg} 时出错: {e}")
def main():
path = r'makemeahanzi-master\svgs-still'
svgs_list = os.listdir(path)
total_num = len(svgs_list)
# 创建线程池,max_workers设置并发线程数
with ThreadPoolExecutor(max_workers=4) as executor:
# 提交所有任务到线程池
futures = []
for index, svg in enumerate(svgs_list):
futures.append(
executor.submit(process_svg, svg, path, index, total_num)
)
# 等待所有任务完成
for future in futures:
future.result() # 这里会抛出异常,如果有的话
if __name__ == '__main__':
main()
[Python] 纯文本查看 复制代码# 获取笔顺.py
from cairosvg import svg2png
import os
import re
def makedirs(path):
if not os.path.exists(path):
os.makedirs(path)
def ChangeSVG2png(svg_path, chinese):
outputpath = f'strokeOrder/{chinese}' # 笔顺保存路径
makedirs(outputpath)
# 如果笔顺图跟笔顺list相等,则说明已经生成过了
png_files = sorted([f for f in os.listdir(outputpath)
if f.startswith(f'{chinese}_') and f.endswith('.png')])
if len(png_files) == len(svg_path):
print(f'*** {chinese} 的笔顺图已经生成,跳过***')
return
svg_output = {
'width': '1024px',
'height': '1024px',
'xmlns': 'http://www.w3.org/2000/svg',
"font_color": "#000000", # 黑色
"font_color_last": "#FF1111", # 红色
# "font_color_last": "#0000FF", # 蓝色
"output_address": outputpath,
"output_filename": ''
}
if not os.path.exists(outputpath): # 为每个汉字创建文件夹
os.mkdir(outputpath)
# Grid lines (米字格)
# 在 ChangeSVG2png 函数中添加以下米字格定义(替换原来的 grid_lines)
grid_lines = [
# 对角线(长线,保持实线但更细)
'',
'',
# 中心横竖线(虚线小线段)
'',
'',
# 添加更多小线段作为辅助格子(可选)
# '',
# '',
# '',
# ''
]
if len(svg_path) == 1:
svg_code = []
svg_code_temp = ''
svg_code.append(svg_code_temp)
# Add grid lines
svg_code.extend(grid_lines)
svg_code_temp = ' '
svg_code.append(svg_code_temp)
svg_code_temp = ' '
svg_code.append(svg_code_temp)
svg_code_temp = ' '
svg_code.append(svg_code_temp)
svg_code_temp = ''
svg_code.append(svg_code_temp)
svgcode = '\n'.join(svg_code)
svg_output['output_filename'] = svg_output['output_address'] + '/' + chinese + '1.png'
try:
svg2png(bytestring=svgcode, write_to=svg_output['output_filename'])
except Exception as e:
print('error:' + str(e))
# 生成完整的笔顺图,生成svg图片
svg_code = []
svg_code_temp = ''
svg_code.append(svg_code_temp)
# Add grid lines
# svg_code.extend(grid_lines) # 这里确定是否在svg中添加米字格
svg_code_temp = ''
svg_code.append(svg_code_temp)
for j in range(len(svg_path)):
svg_code_temp = ' '
svg_code.append(svg_code_temp)
svg_code_temp = ' '
svg_code.append(svg_code_temp)
svg_code_temp = ''
svg_code.append(svg_code_temp)
svgcode = '\n'.join(svg_code)
svg_output['output_filename'] = svg_output['output_address'] + '/' + chinese + '.svg' # 修改文件扩展名为.svg
try:
with open(svg_output['output_filename'], 'w') as f:
f.write(svgcode)
except Exception as e:
print('error:' + str(e))
else:
for i in range(len(svg_path)):
svg_code = []
svg_code_temp = ''
svg_code.append(svg_code_temp)
# Add grid lines
svg_code.extend(grid_lines)
svg_code_temp = ' '
svg_code.append(svg_code_temp)
for j in range(i + 1):
if j == i:
svg_code_temp = ' '
else:
svg_code_temp = ' '
svg_code.append(svg_code_temp)
svg_code_temp = ' '
svg_code.append(svg_code_temp)
svg_code_temp = ''
svg_code.append(svg_code_temp)
svgcode = '\n'.join(svg_code)
svg_output['output_filename'] = svg_output['output_address'] + '/' + chinese + '_' + str(i + 1) + '.png'
try:
svg2png(bytestring=svgcode, write_to=svg_output['output_filename'])
except Exception as e:
print('error:' + str(e))
# 生成完整的笔顺图,生成svg图片
svg_code = []
svg_code_temp = ''
svg_code.append(svg_code_temp)
# Add grid lines
# svg_code.extend(grid_lines) # 这里确定是否在svg中添加米字格
svg_code_temp = ''
svg_code.append(svg_code_temp)
for j in range(len(svg_path)):
svg_code_temp = ' '
svg_code.append(svg_code_temp)
svg_code_temp = ' '
svg_code.append(svg_code_temp)
svg_code_temp = ''
svg_code.append(svg_code_temp)
svgcode = '\n'.join(svg_code)
svg_output['output_filename'] = svg_output['output_address'] + '/' + chinese + '.svg' # 修改文件扩展名为.svg
try:
with open(svg_output['output_filename'], 'w') as f:
f.write(svgcode)
except Exception as e:
print('error:' + str(e))
def main(svg_path, chinese):
ChangeSVG2png(svg_path, chinese)
if __name__ == '__main__':
svg_data = '''
'''
stroke_order = []
for line in svg_data.split('\n'):
if line.strip():
match = re.search(r'd="([^"]+)"', line)
if match:
path_data = match.group(1)
stroke_order.append(path_data)
# 生成笔顺图
chinese = '㲋' # 修改为您要生成的汉字
svg_path = [x for x in stroke_order]
print(svg_path)
print(len(svg_path))
main(svg_path, chinese)
注:需要安装cairosvg程序(gtk3-runtime-3.24.31-2022-01-04-ts-win64),我会在附件中提供
此程序会生成笔顺图png(旧笔顺为黑色、新笔顺为红色),并生成一个完整的svg文件(全黑)
(二)书写动画gif生成
[Python] 纯文本查看 复制代码# 生成gif.py
import os
import asyncio
from playwright.async_api import async_playwright
import subprocess
import math
from concurrent.futures import ThreadPoolExecutor
async def svg_to_gif(char, unhandled_num, svg_path, gif_path, frame_rate=12):
"""将 SVG 动画转换为 GIF,并替换蓝色为红色"""
frames_dir = os.path.join(os.path.dirname(gif_path), f"{char}_frames")
os.makedirs(frames_dir, exist_ok=True)
async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page()
await page.set_viewport_size({"width": 1024, "height": 1024})
# 读取 SVG 并替换颜色
with open(svg_path, 'r', encoding='utf-8') as f:
svg_content = f.read()
# 关键修改:替换蓝色为红色
svg_content = svg_content.replace("stroke: blue", "stroke: #FF1111")
await page.set_content(f"""
body {{ margin: 0; background: transparent; }}
svg {{
width: 1024px;
height: 1024px;
position: absolute;
left: 0;
top: 0;
}}
{svg_content}
""")
# 计算动画时长(保持不变)
await page.wait_for_selector('svg')
await page.wait_for_timeout(500)
total_duration = await page.evaluate("""() => {
const anims = Array.from(document.querySelectorAll('*'))
.flatMap(el => el.getAnimations?.() || []);
return anims.length? Math.max(...anims.map(a => {
const timing = a.effect.getComputedTiming();
return timing.endTime || timing.delay + timing.duration;
})) / 1000 : 0;
}""")
if not total_duration or math.isnan(total_duration):
print("使用默认动画时长 5 秒")
total_duration = 5.0
total_frames = int(total_duration * frame_rate)
print(f"处理 {os.path.basename(svg_path)}: 时长 {total_duration:.2f}s, 总帧数 {total_frames}")
# 捕获帧
for frame in range(total_frames):
current_time = (frame / total_frames) * total_duration * 1000
await page.evaluate("""(time) => {
document.getAnimations().forEach(anim => anim.currentTime = time);
}""", current_time)
await page.screenshot(
path=os.path.join(frames_dir, f"frame_{frame:04d}.png"),
type="png",
omit_background=True,
clip={"x": 0, "y": 0, "width": 1024, "height": 1024}
)
print(f"\r{os.path.basename(svg_path)} 渲染进度: {frame + 1}/{total_frames}", end='')
await browser.close()
# 生成 GIF
print(f"\n{os.path.basename(svg_path)} 生成 GIF...")
try:
subprocess.run([
"ffmpeg", "-y",
"-framerate", str(frame_rate),
"-i", os.path.join(frames_dir, "frame_%04d.png"),
"-vf", "split[s0][s1];[s0]palettegen
;[s1]
paletteuse",
"-loop", "0",
gif_path
], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
print(f"{unhandled_num} {os.path.basename(svg_path)} GIF 已生成: {gif_path}")
except subprocess.CalledProcessError as e:
print(f" {os.path.basename(svg_path)} FFmpeg 错误: {e}")
# 清理临时文件
for file in os.listdir(frames_dir):
os.remove(os.path.join(frames_dir, file))
os.rmdir(frames_dir)
def process_single(svg_path, char, unhandled_num, char_gif_path):
"""处理单个文件"""
char_gif_path_name = os.path.join(char_gif_path, f'{char}.gif')
asyncio.run(svg_to_gif(char, unhandled_num, svg_path, char_gif_path_name, frame_rate=15))
def main(svg_dir, max_workers=4):
"""多线程主函数"""
svg_files = []
svg_list = os.listdir(svg_dir)
# 保存路径
char_gif_path = f'Gifs'
os.makedirs(char_gif_path, exist_ok=True)
gifs_list = [gif.split('.')[0] for gif in os.listdir(char_gif_path) if gif.endswith('.gif')]
# 比对未保存的,拿来使用
svg_unhandled_list = []
for tmp in svg_list:
char_code = int(os.path.splitext(tmp)[0])
char = chr(char_code)
if not char in gifs_list:
svg_unhandled_list.append(tmp)
unhandled_num = len(svg_unhandled_list)
for svg in svg_unhandled_list:
try:
char_code = int(os.path.splitext(svg)[0])
char = chr(char_code)
svg_files.append((os.path.join(svg_dir, svg), char))
except ValueError:
print(f"跳过非数字文件名: {svg}")
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = []
for svg_path, char in svg_files:
futures.append(executor.submit(process_single, svg_path, char, unhandled_num, char_gif_path))
for future in futures:
try:
future.result() # 等待任务完成
except Exception as e:
print(f"处理出错: {e}")
if __name__ == '__main__':
# 在txt中保存进度
svg_dir = r'makemeahanzi-master\svgs'
main(svg_dir, max_workers=4) # 设置同时处理4个文件
三、效果图
3.jpg (130.82 KB, 下载次数: 0)
下载附件
2025-5-22 20:08 上传
4.jpg (1.12 MB, 下载次数: 0)
下载附件
2025-5-22 20:11 上传
愛.gif (2.53 MB, 下载次数: 0)
下载附件
2025-5-22 20:10 上传
四、分享链接
通过网盘分享的文件:汉字笔顺图及书写动画
链接: https://pan.baidu.com/s/1ns8ETsQHY8qeAbCj7tNQ9Q 提取码: 8j8y