前言
朋友们,晚上好。今天我想和大家分享一下我近期与 AI 协作,使用 Python+Flask 进行开发的经历。
本次开发的软件名字叫“田字格生成器”,已经部署上线,可以点击这里使用。
立项原因
家母最近参与了洛阳日报社的托管班暑期托管活动,担任硬笔书法教师。这是她第一次在正式的单位进行授课,没有教材,需要她自己准备。
我并不知道母亲是怎么准备教材的。前几天的下午, 我在衔接班的空隙,看到母亲用尺子在一张纸上画着什么。走近一看,霍!纸上打印的是她用手机拍下来的范字,糊成了保密级画质。而她拿着尺子,是为了手动将模糊的田字格虚线描实。
而目光一转,她旁边还有一摞跟教辅一样厚的原始稿件等待处理,这得处理到猴年马月!于是我毫不犹豫地对她说:“交给我吧!”
就这样,一个名为“田字格批量生成器”的文件夹就被新建了。
需求确定
母亲的要求有很多,比如:
- 一页A4纸,放一个田字格,撑满整张纸,贴到黑板上给学生讲;
- 一页A4纸,放一个学生的名字,横版,撑满整张纸,用来教学生写自己的名字;
- 一页A4纸,放一张字帖;
- 一页A4纸,放一页竖版的字帖;
- 一页A4纸,放30个字,用作自己练习,6行5列。
而网上的田字格在线生成器并不能满足母亲的需求,所以,Cursor,启动!
基础功能
我先将需求投喂给 Cursor,接着,它帮我写出了如下界面的程序:

主要功能:用户往编辑框里输入需要生成的汉字→选择模式→程序根据对应的模式生成 PDF 文件并输出到 output 目录下。
当我把生成好的 PDF 拿给母亲看的时候,她果断地摇了摇头:
你这个字体面积占整个田字格面积的占比太小了。
你刚才给我看的那个字帖生成网页,那个网页的田字格我觉得符合我的需求。
于是我便把网络上的田字格字帖生成器打开了,母亲仔细看了看,认可地点点头,要求我按照它一样做。
为此,我将那个田字格生成器作为 HTML 保存了下来,并放到了项目根目录,和 Cursor 说:
模仿 xxxxxx.html 的田字格排版形式,重新排版现有的田字格。
等到 AI 生成完毕,我再次测试让母亲看,这次她终于接受了。
网页版改编&上线
既然母亲已经接受了 AI 生成的田字格样式,那么怎么排版就是我的事情了。在向 AI 投喂了一大堆提示词之后,终于生成了以下样式的田字格生成器:

期间还有一个小插曲:我在让 AI 编写出 6 行 5 列的排版时,错将“6 行 5 列”表达成了“5 行 6 列”,AI 生成了以下样式的排版:

由于这整体看起来太像是正方形了,所以我把它错看成了 6 行 6 列。于是我竭尽全力地让 AI “纠正”,却无济于事。我笑骂了 AI 几句,便想着放弃,关闭了 Cursor。
当我把这份废稿发给母亲的时候,母亲的一句话点醒了我——是我数错了:

得知这个消息的我兴奋极了,连忙再次打开 Cursor,输入提示词,等待改编完毕,运行,生成,成功了!
源代码
因为我的 Github 账号的 2FA 验证器被我卸载了,同时,恢复密钥也被我误删了,所以我的 Github 账号(Rise-forever)现在完全用不了了。
这个程序就先在这里开源吧,后续我可能会在 CNB 上开源。
以下是主程序(.py)的源码,命名为 app.py:
from flask import Flask, render_template, request, send_file, redirect, url_for, flash
import os
import io
import random
import string
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import A4
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from werkzeug.utils import secure_filename
app = Flask(__name__)
app.secret_key = 'tianzige_secret_key'
# 注册楷体字体(确保simkai.ttf在同目录下)
pdfmetrics.registerFont(TTFont('KaiTi', 'simkai.ttf'))
def random_str(length=6):
return ''.join(random.choices(string.ascii_letters + string.digits, k=length))
def draw_tianzige(c, x, y, size, border_width: float = 2.0, dash_width: float = 1.0, dash_color=(0,0,0), mode=None):
cx = x + size/2
cy = y + size/2
if mode == 'zitieborder':
c.setLineWidth(dash_width)
c.setStrokeColorRGB(*dash_color)
segs = 5
gap_ratio = 0.3
for i in range(segs):
seg_len = (size/2) / (segs + (segs-1)*gap_ratio)
gap = seg_len * gap_ratio
y1 = cy + i * (seg_len + gap)
y2 = y1 + seg_len
if y2 <= y + size:
c.line(cx, y1, cx, y2)
for i in range(segs):
seg_len = (size/2) / (segs + (segs-1)*gap_ratio)
gap = seg_len * gap_ratio
y1 = cy - i * (seg_len + gap)
y2 = y1 - seg_len
if y2 >= y:
c.line(cx, y1, cx, y2)
for i in range(segs):
seg_len = (size/2) / (segs + (segs-1)*gap_ratio)
gap = seg_len * gap_ratio
x1 = cx - i * (seg_len + gap)
x2 = x1 - seg_len
if x2 >= x:
c.line(x1, cy, x2, cy)
for i in range(segs):
seg_len = (size/2) / (segs + (segs-1)*gap_ratio)
gap = seg_len * gap_ratio
x1 = cx + i * (seg_len + gap)
x2 = x1 + seg_len
if x2 <= x + size:
c.line(x1, cy, x2, cy)
c.setLineWidth(border_width)
c.setStrokeColorRGB(0,0,0)
c.rect(x, y, size, size)
return
dash = [8, 5]
c.setLineWidth(dash_width)
c.setStrokeColorRGB(*dash_color)
for i in range(10):
y1 = y + i * size / 10
y2 = y + (i + 1) * size / 10
c.setDash(dash)
c.line(x + size/2, y1, x + size/2, y2)
for i in range(10):
x1 = x + i * size / 10
x2 = x + (i + 1) * size / 10
c.setDash(dash)
c.line(x1, y + size/2, x2, y + size/2)
c.setDash([])
c.setLineWidth(border_width)
c.setStrokeColorRGB(0,0,0)
c.rect(x, y, size, size)
def generate_single_page(chars):
buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=A4)
width, height = A4
margin = 20
size = min(width, height) - 2 * margin
x = (width - size) / 2
y = (height - size) / 2
font_size = int(size * 0.725)
for char in chars:
draw_tianzige(c, x, y, size, border_width=4.0, dash_width=3.0, dash_color=(0.2,0.2,0.2))
c.setFont("KaiTi", font_size)
c.setFillColorRGB(0, 0, 0)
c.drawCentredString(width/2, height/2 - size*0.25, char)
c.showPage()
c.save()
buffer.seek(0)
return buffer
def generate_zi_tie(chars):
buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=A4)
width, height = A4
mm = 2.8346
grid_size = 15 * mm
gap = 2 * mm
cols = 12
rows = 15
total_grid_h = rows * grid_size + (rows - 1) * gap
total_grid_w = cols * grid_size
margin_x = max((width - total_grid_w) / 2, 0)
margin_y = max((height - total_grid_h) / 2, 0)
font_size = int(grid_size * 0.725)
chars = list(chars)
page_count = (len(chars) + rows - 1) // rows
char_idx = 0
for page in range(page_count):
for row in range(rows):
if char_idx >= len(chars):
break
char = chars[char_idx]
for col in range(cols):
gx = margin_x + col * grid_size
gy = height - margin_y - (row + 1) * grid_size - row * gap
draw_tianzige(c, gx, gy, grid_size, border_width=0.8, dash_width=0.5, dash_color=(0.69,0.69,0.69), mode='zitieborder')
if col == 0:
c.setFont("KaiTi", font_size)
c.setFillColorRGB(0, 0, 0)
c.drawCentredString(gx + grid_size/2, gy + grid_size/2 - grid_size*0.25, char)
char_idx += 1
c.showPage()
c.save()
buffer.seek(0)
return buffer
def generate_name_per_page(text):
buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=(842, 595))
width, height = 842, 595
margin = 20
names = [n.strip() for n in text.split() if n.strip()]
for name in names:
n_chars = len(name)
if n_chars == 0:
continue
size = min((width - 2*margin) / n_chars, height - 2*margin)
font_size = int(size * 0.725)
y = (height - size) / 2
start_x = (width - size * n_chars) / 2
for i, char in enumerate(name):
gx = start_x + i * size
draw_tianzige(c, gx, y, size, border_width=4.0, dash_width=3.0, dash_color=(0.2,0.2,0.2))
c.setFont("KaiTi", font_size)
c.setFillColorRGB(0, 0, 0)
c.drawCentredString(gx + size/2, height/2 - size*0.25, char)
c.showPage()
c.save()
buffer.seek(0)
return buffer
def generate_zi_tie_vertical(chars):
buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=A4)
width, height = A4
mm = 2.8346
grid_size = 15 * mm
gap = 2 * mm
cols = 12
rows = 15
total_grid_h = rows * grid_size + (rows - 1) * gap
total_grid_w = cols * grid_size
margin_x = max((width - total_grid_w) / 2, 0)
margin_y = max((height - total_grid_h) / 2, 0)
font_size = int(grid_size * 0.725)
chars = list(chars)
page_count = (len(chars) + cols - 1) // cols
char_idx = 0
for page in range(page_count):
# 获取当前页的字符(最多12个)
page_chars = []
for col in range(cols):
if char_idx < len(chars):
page_chars.append(chars[char_idx])
char_idx += 1
else:
page_chars.append('') # 空字符占位
# 绘制当前页
for col in range(cols):
char = page_chars[col]
for row in range(rows):
gx = margin_x + col * grid_size
gy = height - margin_y - (row + 1) * grid_size - row * gap
draw_tianzige(c, gx, gy, grid_size, border_width=0.8, dash_width=0.5, dash_color=(0.69,0.69,0.69), mode='zitieborder')
# 在第1、6、11行显示范字(如果该列有字符)
if char and (row == 0 or row == 5 or row == 10):
c.setFont("KaiTi", font_size)
c.setFillColorRGB(0, 0, 0)
c.drawCentredString(gx + grid_size/2, gy + grid_size/2 - grid_size*0.25, char)
c.showPage()
c.save()
buffer.seek(0)
return buffer
def generate_5x6_mode(chars):
buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=A4)
width, height = A4
mm = 2.8346
cols = 5 # 改为5列
rows = 6 # 改为6行
# 计算田字格大小,让总宽度贴边A4纸宽度
gap = 1.5 * mm # 间距
# 计算田字格大小和边距,确保左右边距相等
margin_x = 10 * mm # 设置左右边距
available_width = width - 2 * margin_x
grid_size = (available_width - (cols - 1) * gap) / cols # 计算田字格大小
# 重新计算边距确保居中
total_grid_width = cols * grid_size + (cols - 1) * gap
margin_x = (width - total_grid_width) / 2 # 确保左右边距相等
# 计算总高度和垂直边距
total_grid_h = rows * grid_size + (rows - 1) * gap
margin_y = max((height - total_grid_h) / 2, 15 * mm)
font_size = int(grid_size * 0.725)
chars = list(chars)
page_count = (len(chars) + 29) // 30 # 每页30个格子
char_idx = 0
for page in range(page_count):
# 先绘制所有田字格(6行5列)
for row in range(rows):
for col in range(cols):
gx = margin_x + col * (grid_size + gap)
gy = height - margin_y - (row + 1) * grid_size - row * gap
# 加重框线、虚线和字体颜色
draw_tianzige(c, gx, gy, grid_size, border_width=3.0, dash_width=2.5, dash_color=(0.2,0.2,0.2), mode='zitieborder')
# 再填充字符(从左往右,从上往下)
for row in range(rows):
for col in range(cols):
if char_idx < len(chars):
gx = margin_x + col * (grid_size + gap)
gy = height - margin_y - (row + 1) * grid_size - row * gap
c.setFont("KaiTi", font_size)
c.setFillColorRGB(0, 0, 0) # 纯黑色字体
c.drawCentredString(gx + grid_size/2, gy + grid_size/2 - grid_size*0.25, chars[char_idx])
char_idx += 1
c.showPage()
c.save()
buffer.seek(0)
return buffer
@app.route('/', methods=['GET', 'POST'])
def index():
if request.method == 'POST':
chars = request.form.get('chars', '').strip()
mode = request.form.get('mode', '1')
if not chars:
flash('请输入要生成的汉字或名字!')
return redirect(url_for('index'))
if mode == '1':
pdf_buffer = generate_single_page(chars)
elif mode == '2':
pdf_buffer = generate_zi_tie(chars)
elif mode == '3':
pdf_buffer = generate_name_per_page(chars)
elif mode == '4':
pdf_buffer = generate_zi_tie_vertical(chars)
elif mode == '5':
pdf_buffer = generate_5x6_mode(chars)
else:
flash('请选择生成模式!')
return redirect(url_for('index'))
# 文件名:前三个字+随机串
base = chars[:3] if len(chars) >= 3 else chars
filename = f"{base}{random_str(6)}.pdf"
filename = secure_filename(filename)
return send_file(pdf_buffer, as_attachment=True, download_name=filename, mimetype='application/pdf')
return render_template('index.html')
if __name__ == '__main__':
app.run(debug=True)
以下是 index.html 的源码,index.html 需要在主程序所在目录新建一个名为“templates”的文件夹,并将 HTML 放进去。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>田字格批量生成器</title>
<style>
body { font-family: '微软雅黑', Arial, sans-serif; background: #f7f7f7; }
.container { max-width: 420px; margin: 60px auto; background: #fff; border-radius: 8px; box-shadow: 0 2px 8px #ccc; padding: 32px 28px; }
h2 { text-align: center; margin-bottom: 24px; }
label { display: block; margin: 16px 0 6px 0; font-size: 16px; }
input[type="text"] { display: block; box-sizing: border-box; width: 100%; padding: 8px 10px; font-size: 18px; border: 1px solid #bbb; border-radius: 4px; margin: 0 auto; }
.modes { margin: 18px 0 22px 0; }
.modes label { display: block; font-size: 15px; margin-bottom: 6px; }
button { width: 100%; background: #2d8cf0; color: #fff; border: none; border-radius: 4px; padding: 12px 0; font-size: 18px; cursor: pointer; }
button:hover { background: #1976d2; }
.msg { color: #d32f2f; text-align: center; margin-bottom: 10px; }
</style>
</head>
<body>
<div class="container">
<h2>田字格批量生成器</h2>
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="msg">{{ messages[0] }}</div>
{% endif %}
{% endwith %}
<form method="post">
<label for="chars">请输入汉字或名字(名字用空格分隔):</label>
<input type="text" id="chars" name="chars" required>
<div class="modes">
<label><input type="radio" name="mode" value="1" checked> 一张字一页模式</label>
<label><input type="radio" name="mode" value="2"> 字帖模式</label>
<label><input type="radio" name="mode" value="3"> 一个名字一页模式</label>
<label><input type="radio" name="mode" value="4"> 字帖模式(竖版)</label>
<label><input type="radio" name="mode" value="5"> 5×6模式</label>
</div>
<button type="submit" id="generateBtn">生成PDF</button>
<div style="text-align:center;margin-top:18px;">
<a href="#" id="helpLink" style="color:#1976d2;font-size:15px;text-decoration:underline;">使用说明</a>
</div>
</form>
</div>
<script>
// 拦截表单提交,异步生成PDF
const form = document.querySelector('form');
const btn = document.getElementById('generateBtn');
form.onsubmit = async function(e) {
e.preventDefault();
btn.disabled = true;
btn.textContent = '生成中...';
const formData = new FormData(form);
const res = await fetch('/', {
method: 'POST',
body: formData
});
if (res.ok) {
// 生成成功,弹窗选择
const blob = await res.blob();
const filename = decodeURIComponent(res.headers.get('Content-Disposition').split('filename=')[1].replace(/['"]/g, ''));
showChoiceDialog(blob, filename);
} else {
alert('生成失败,请重试!');
}
btn.disabled = false;
btn.textContent = '生成PDF';
};
function showChoiceDialog(blob, filename) {
// 创建弹窗
const mask = document.createElement('div');
mask.style = 'position:fixed;left:0;top:0;width:100vw;height:100vh;background:rgba(0,0,0,0.25);z-index:9999;display:flex;align-items:center;justify-content:center;';
const dialog = document.createElement('div');
dialog.style = 'background:#fff;padding:32px 28px 28px 28px;border-radius:8px;box-shadow:0 2px 8px #888;text-align:center;min-width:260px;position:relative;';
// 右上角关闭符号
const closeBtn = document.createElement('span');
closeBtn.innerHTML = '×';
closeBtn.title = '关闭';
closeBtn.style = 'position:absolute;top:10px;right:14px;font-size:22px;color:#888;cursor:pointer;font-weight:bold;';
closeBtn.onmouseover = function(){closeBtn.style.color='#d32f2f';};
closeBtn.onmouseout = function(){closeBtn.style.color='#888';};
closeBtn.onclick = function(){ mask.remove(); };
dialog.appendChild(closeBtn);
// 选项按钮纵向排列
const content = document.createElement('div');
content.innerHTML = `<div style='font-size:18px;margin-bottom:22px;margin-top:6px;'>PDF已生成,选择操作:</div>`;
const btnBox = document.createElement('div');
btnBox.style = 'display:flex;flex-direction:column;gap:18px;align-items:center;';
const dlBtn = document.createElement('button');
dlBtn.id = 'dlBtn';
dlBtn.textContent = '直接下载';
dlBtn.style = 'padding:8px 28px 8px 28px;font-size:16px;';
const shareBtn = document.createElement('button');
shareBtn.id = 'shareBtn';
shareBtn.textContent = '转发给好友';
shareBtn.style = 'padding:8px 28px 8px 28px;font-size:16px;';
btnBox.appendChild(dlBtn);
btnBox.appendChild(shareBtn);
content.appendChild(btnBox);
dialog.appendChild(content);
mask.appendChild(dialog);
document.body.appendChild(mask);
// 下载
dlBtn.onclick = function() {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(()=>{
URL.revokeObjectURL(url);
a.remove();
mask.remove();
}, 100);
};
// 转发
shareBtn.onclick = function() {
// 生成临时下载链接
const url = URL.createObjectURL(blob);
dialog.innerHTML = `<div style='font-size:17px;margin-bottom:12px;'>将下方链接复制发给好友:</div>
<input type='text' value='${url}' style='width:90%;padding:6px 8px;font-size:15px;' readonly onclick='this.select()'>
<div style='margin-top:18px;'><button id='closeBtn' style='padding:7px 22px;font-size:15px;'>关闭</button></div>`;
dialog.querySelector('#closeBtn').onclick = function() {
setTimeout(()=>{
URL.revokeObjectURL(url);
mask.remove();
}, 100);
};
};
}
// 使用说明弹窗
const helpContent = `
<h2>欢迎使用田字格生成器!</h2>
<p>接下来,请您了解一些基本操作。</p>
<h3>“一个字一页模式”</h3>
<p>使用此模式,您可以生成如下图所示的田字格文件:</p>
<img src="54a0a9d835e408fccb3e91d862aa16f2.png" alt="截图" style="zoom:30%;" />
<p>只需选择“一个字一页模式”,在页面上方的输入框内输入您需要生成的文字,输入完毕后,点击页面下方的“生成PDF”按钮,即可生成。</p>
<h3>“字帖模式”</h3>
<p>使用此模式,您可以生成如下图所示的田字格文件:</p>
<img src="d2319a550bb80518617218ac5075ce62.png" alt="截图" style="zoom:35%;" />
<p>只需选择“字帖模式”,在页面上方的输入框内输入您需要生成的文字,输入完毕后,点击页面下方的“生成PDF”按钮,即可生成。</p>
<h3>“字帖模式(竖版)”</h3>
<p>使用此模式,您可以生成竖版字帖,范字出现在每列最上方的田字格内,从左往右排列,自动分页。</p>
<p>只需选择“字帖模式(竖版)”,在页面上方的输入框内输入您需要生成的文字,输入完毕后,点击页面下方的“生成PDF”按钮,即可生成。</p>
<h3>“5×6模式”</h3>
<p>使用此模式,您可以生成5列6行的田字格,每页固定30个格子,文字从左往右、从上往下排列,自动分页。</p>
<p>只需选择"5×6模式",在页面上方的输入框内输入您需要生成的文字,输入完毕后,点击页面下方的"生成PDF"按钮,即可生成。</p>
<h3>“一个名字一页模式”</h3>
<p>使用此模式,您可以生成如下图所示的田字格文件:</p>
<img src="0ceb31f5ec3f99632f7533791595de9c.png" alt="截图" style="zoom:35%;" />
<p><b>注意,这里是重头戏!</b>您需要在每输入一个人名之后,在输入框中敲击一下“空格”键,这样,程序才可以正常识别出人名,并为其分页生成。</p>
<h3>生成之后...</h3>
<p>不论您使用什么模式,点击“生成PDF”按钮之后,稍等片刻,程序会弹窗提醒您生成完毕。</p>
<img src="1b71d31f5ac4f2f108146933c1d29c43.png" alt="截图" style="zoom:45%;" />
<p>此时,您可以选择“直接下载”或者“转发给好友”。我们更建议您选择“直接下载”,这样可以在微信中直接预览。</p>
`;
function showHelpDialog() {
const mask = document.createElement('div');
mask.style = 'position:fixed;left:0;top:0;width:100vw;height:100vh;background:rgba(0,0,0,0.25);z-index:9999;display:flex;align-items:center;justify-content:center;';
const dialog = document.createElement('div');
dialog.style = 'background:#fff;padding:32px 28px 28px 28px;border-radius:8px;box-shadow:0 2px 8px #888;text-align:left;min-width:320px;max-width:520px;max-height:80vh;overflow:auto;position:relative;';
dialog.innerHTML = `<div style='margin-top:8px;'>${helpContent}</div>`;
// 右上角关闭符号(最后append,确保在最上层)
const closeBtn = document.createElement('span');
closeBtn.innerHTML = '×';
closeBtn.title = '关闭';
closeBtn.style = 'position:absolute;top:10px;right:14px;font-size:22px;color:#888;cursor:pointer;font-weight:bold;z-index:10000;';
closeBtn.onmouseover = function(){closeBtn.style.color='#d32f2f';};
closeBtn.onmouseout = function(){closeBtn.style.color='#888';};
closeBtn.onclick = function(){ mask.remove(); };
dialog.appendChild(closeBtn);
mask.appendChild(dialog);
document.body.appendChild(mask);
}
document.getElementById('helpLink').onclick = function(e) {
e.preventDefault();
showHelpDialog();
};
</script>
</body>
</html>
结语
这次开发,真正让我体会到了科技改变生活。
另外,再提一嘴,蓝屏工具箱将在近一个月内发布,大家可以去关注蓝屏工具箱官网。
参与讨论