练字的人对纸的要求其实挺挑剔的。市面上买来的书法练习纸格子印刷的粗细、颜色深浅、留白比例往往跟自己的书写习惯对不上——要么格子线太重抢了字的气势要么米字格的斜线角度看着别扭要么想练小楷时发现手头只有大号田字格。我之前给一个做硬笔教学的朋友搭过一套在线练字页面需求很朴素能不能用纯CSS把米字格、田字格、回字格、九宫格这几种书法专用纸画出来尺寸、线宽、颜色都能调打印出来还能直接用。这件事听起来像是Canvas或者SVG的活儿但实测下来纯CSS方案在清晰度、可维护性和响应式适配上反而更省心尤其是打印场景矢量线条不会糊。这篇就把这套东西完整拆一遍。核心关键词是米字格、田字格、回字格、九宫格的CSS实现涉及背景渐变、伪元素、边框叠加、CSS变量这些技术点。适合两类人看一是做教育类、练字类前端页面的开发者二是想自己折腾一套可打印练习纸的书法爱好者。哪怕你CSS只会写个居中跟着走也能跑通因为每个格子的原理我都会拆到“为什么这么画”的层面。1. 先想清楚为什么书法格子适合用CSS画而不是图片动手之前先把这个决策讲透不然写到一半容易怀疑人生。书法练习纸的本质是什么是一组规则重复的几何线条外框、中线、对角线、内框。这类东西有三个特点——线条是纯几何的、需要精确对齐、经常要按尺寸缩放。这三点恰好是CSS背景渐变和边框的强项也是位图的弱项。用图片做格子纸最直接的问题是缩放糊。一张200×200像素的田字格PNG放到打印尺寸或者高分屏上中线会发虚斜线会有锯齿。你可能想那我导出个大图不就行了但大图又带来文件体积和内存占用的问题一页A4纸铺满格子图片方案要么重复请求同一张图浏览器缓存能救一部分要么拼一张巨图。而CSS画的线是浏览器实时渲染的任何分辨率下都是锐利的打印时更是直接走矢量路径。第二个理由是参数化。书法纸的需求差异很大小学生练字用粗一点的红色田字格成人练小楷可能要浅灰色的细线米字格还有人要虚线格。图片方案每换一种规格就得重新做图CSS方案只需要改几个变量。我朋友那个项目里格子的线宽、颜色、透明度、内框比例全是CSS变量控制的前端给个滑块就能实时预览这个体验是图片方案给不了的。第三个理由是布局对齐。一页纸铺N×N个格子格子之间还要留间距用图片得算好每个img的尺寸和margin稍有不慎就有半像素的缝隙。CSS方案里格子本身就是背景铺排靠background-size和background-repeat天然无缝间距也由背景尺寸统一控制。当然CSS方案也有它的边界得说清楚它适合规则格子不适合任意手绘风格的纹理。如果你要的是那种带纸张纤维质感、边缘毛糙的古风宣纸效果CSS渐变做不出来还是得叠一张纹理图。但对于米字格、田字格、回字格、九宫格这四种标准格CSS是更优解。下面逐个拆。2. 田字格一条中线拆成两半是最容易上手的起点田字格是四种格里结构最简单的一个正方形外框加一条横中线和一条竖中线把格子分成四个小方块。很多人第一反应是用border画外框然后里面塞两个绝对定位的div当线。能跑但格子一多DOM就爆炸了——一页纸50个格子就是150个元素维护起来很难受。更干净的做法是整个格子只用一个元素所有线条用背景渐变画。核心思路是把“线”理解成“背景色块”一条1px的竖中线本质是一个宽度1px、高度100%的色块用linear-gradient就能生成。.tian { --size: 120px; --line: #d94f4f; --weight: 1px; width: var(--size); height: var(--size); border: var(--weight) solid var(--line); background-image: linear-gradient(var(--line), var(--line)), linear-gradient(var(--line), var(--line)); background-size: var(--weight) 100%, 100% var(--weight); background-position: center center, center center; background-repeat: no-repeat; }这段代码的逻辑值得逐行讲。两个linear-gradient(var(--line), var(--line))生成的是纯色块起止色相同没有过渡第一个色块尺寸是“1px宽、100%高”定位在中心就是竖中线第二个是“100%宽、1px高”定位在中心就是横中线。background-repeat: no-repeat保证每条线只画一次。外框用border而不是背景是因为边框会占据盒模型空间格子内容区自然内缩后续如果要往格子里放字不会被线压住。这里有个容易踩的坑background-position: center在尺寸为奇数像素时会因为亚像素舍入导致线偏移半像素看起来有点糊。解决办法是让--size保持偶数或者线宽用偶数。我在项目里统一把格子尺寸约束成4的倍数实测下来线条最锐利。田字格还有个变体需求内缩的外框。有些练习纸的外框不是贴着格子边缘而是往里收一点形成“口”字套“口”字的感觉。这个用outline配合outline-offset能实现但outline在部分老浏览器上圆角支持不好。更稳的做法是再加一层背景渐变画内框或者用box-shadow: inset。我倾向后者.tian-inset { box-shadow: inset 0 0 0 var(--weight) var(--line); }inset阴影画出来的内框贴着padding box配合padding就能控制内缩距离比outline可控。这个技巧在后面回字格里还会用到先记住。3. 米字格斜线是难点两个渐变叠出对角线米字格在田字格基础上多了两条对角线形成“米”字。横竖中线好办难的是斜线——CSS背景渐变默认是水平或垂直方向的画斜线得用角度渐变而且角度和格子尺寸挂钩不是固定值。先看对角线怎么用渐变画。一条从左上到右下的对角线本质是一个沿45度方向、宽度为线宽的色带。用linear-gradient(45deg, ...)配合色标位置可以做到但更精确的方式是用repeating-linear-gradient或者直接算角度。我实测下来最稳的方案是用两个linear-gradient分别画两条对角线角度根据正方形固定为45度和-45度即135度因为正方形对角线角度恒为45度不随尺寸变化这点很省心。.mi { --size: 120px; --line: #d94f4f; --weight: 1px; width: var(--size); height: var(--size); border: var(--weight) solid var(--line); background-image: linear-gradient(var(--line), var(--line)), linear-gradient(var(--line), var(--line)), linear-gradient(45deg, transparent calc(50% - var(--weight) / 2), var(--line) calc(50% - var(--weight) / 2), var(--line) calc(50% var(--weight) / 2), transparent calc(50% var(--weight) / 2)), linear-gradient(-45deg, transparent calc(50% - var(--weight) / 2), var(--line) calc(50% - var(--weight) / 2), var(--line) calc(50% var(--weight) / 2), transparent calc(50% var(--weight) / 2)); background-size: var(--weight) 100%, 100% var(--weight), 100% 100%, 100% 100%; background-position: center; background-repeat: no-repeat; }斜线那两行的色标计算是重点。calc(50% - var(--weight) / 2)到calc(50% var(--weight) / 2)这个区间是在渐变轴上取“中心线两侧各半个线宽”的范围填成实色其余透明。这样画出来的斜线宽度正好等于--weight而且居中。为什么用50%而不是固定像素因为渐变轴的长度随格子尺寸变化用百分比才能保证线始终在正中间。这里有个实测出来的细节45度渐变的“50%”指的是渐变轴上的中点而渐变轴的长度是格子对角线长度正方形时是边长×√2所以斜线在视觉上会略微超出格子四角。要让它精确落在角上得把渐变尺寸设为100% 100%并配合background-size让渐变轴正好覆盖整个盒子。上面代码里斜线的background-size是100% 100%就是这个道理。如果写成别的值斜线会偏移。米字格还有一个常见变体是只有斜线没有中线有些字帖叫“对角线格”或者斜线用虚线。虚线斜线用渐变实现比较绕色标要做成交替的实虚段计算量大且不好维护。我的建议是虚线需求用repeating-linear-gradient沿45度方向铺但线宽和虚线间隔要用像素而非百分比否则缩放时会变形。这个取舍在项目里要提前跟需求方确认别做到一半才发现要虚线。4. 回字格内外双框box-shadow和渐变的分工回字格的结构是“口”套“口”外框、内框中间留白。它没有中线但对内外框的比例和间距有要求。很多人第一反应是套两个div外层border内层再border。DOM翻倍的问题又来了而且内框的尺寸要手动算格子一多就是灾难。纯CSS方案里回字格有两种画法各有适用场景。第一种是box-shadow: inset画内框前面提过。优点是代码短内框位置由padding或inset偏移控制语义清晰。缺点是只能画一个内框如果要“回”字套“回”字三重框就得叠多层shadow可读性下降。.hui-shadow { --size: 120px; --line: #d94f4f; --weight: 1px; --gap: 18px; width: var(--size); height: var(--size); border: var(--weight) solid var(--line); box-shadow: inset 0 0 0 var(--weight) var(--line); padding: var(--gap); box-sizing: border-box; }注意这里box-sizing: border-box很关键。不加的话padding会把盒子撑大格子尺寸失控。加了之后--size是含边框和padding的总尺寸内框自然落在距外框--gap的位置。这个方案里内框是贴着padding box的所以内框和外框之间的间距就是--gap直观好调。第二种是用背景渐变画内框适合需要精确控制内框线宽、或者内框要用不同颜色/虚线的情况。思路是把内框理解成四条边用四个渐变分别画上下左右或者用一个repeating技巧。但四条边分开画代码冗长我更推荐用两个方向的渐变叠加一个水平渐变画左右两条竖边一个垂直渐变画上下两条横边通过色标位置控制内框的偏移。.hui-gradient { --size: 120px; --line: #d94f4f; --weight: 1px; --gap: 18px; width: var(--size); height: var(--size); border: var(--weight) solid var(--line); background-image: linear-gradient(90deg, transparent var(--gap), var(--line) var(--gap), var(--line) calc(var(--gap) var(--weight)), transparent calc(var(--gap) var(--weight)), transparent calc(100% - var(--gap) - var(--weight)), var(--line) calc(100% - var(--gap) - var(--weight)), var(--line) calc(100% - var(--gap)), transparent calc(100% - var(--gap))), linear-gradient(0deg, transparent var(--gap), var(--line) var(--gap), var(--line) calc(var(--gap) var(--weight)), transparent calc(var(--gap) var(--weight)), transparent calc(100% - var(--gap) - var(--weight)), var(--line) calc(100% - var(--gap) - var(--weight)), var(--line) calc(100% - var(--gap)), transparent calc(100% - var(--gap))); background-size: 100% 100%; background-repeat: no-repeat; }这段色标看着吓人其实逻辑很直白从左往右前--gap透明外框到内框的留白接着--weight实色内框左边再透明到右边对称位置然后--weight实色内框右边最后--gap透明。垂直方向同理。两个渐变叠起来就是完整的回字内框。这个写法的好处是内框线宽、间距、颜色都能独立调而且不依赖padding格子内容区不受影响。选型建议如果只是标准回字格用box-shadow方案代码短好维护如果内框要虚线、要异色、或者要三重框用渐变方案。我在项目里两种都留了用CSS变量切换因为不同字帖的需求真的不一样。5. 九宫格3×3分割repeating渐变一次铺满九宫格是把正方形均分成3×3的九个小格需要两条横线和两条竖线。它和前面几种格的区别在于线是等距重复的这正好是repeating-linear-gradient的用武之地。用单次渐变就能把两条线一次画出来不用像田字格那样叠两个渐变。.jiu { --size: 120px; --line: #d94f4f; --weight: 1px; width: var(--size); height: var(--size); border: var(--weight) solid var(--line); background-image: repeating-linear-gradient(90deg, transparent 0, transparent calc(33.333% - var(--weight) / 2), var(--line) calc(33.333% - var(--weight) / 2), var(--line) calc(33.333% var(--weight) / 2), transparent calc(33.333% var(--weight) / 2), transparent calc(66.666% - var(--weight) / 2), var(--line) calc(66.666% - var(--weight) / 2), var(--line) calc(66.666% var(--weight) / 2), transparent calc(66.666% var(--weight) / 2), transparent 100%), repeating-linear-gradient(0deg, transparent 0, transparent calc(33.333% - var(--weight) / 2), var(--line) calc(33.333% - var(--weight) / 2), var(--line) calc(33.333% var(--weight) / 2), transparent calc(33.333% var(--weight) / 2), transparent calc(66.666% - var(--weight) / 2), var(--line) calc(66.666% - var(--weight) / 2), var(--line) calc(66.666% var(--weight) / 2), transparent calc(66.666% var(--weight) / 2), transparent 100%); background-size: 100% 100%; background-repeat: no-repeat; }这里用repeating-linear-gradient其实有点“杀鸡用牛刀”因为九宫格的线是固定两条不是无限重复。更准确的写法是用普通linear-gradient色标里写死33.333%和66.666%两个位置。但repeating的好处是如果以后要改成4×4、5×5只需要改百分比步长扩展性好。我在项目里封装了一个函数根据--n几宫格动态生成色标九宫格只是n3的特例。九宫格的精度问题值得单独说。33.333%这种无限循环小数在浏览器里会被舍入导致两条线的位置不完全对称肉眼可能看不出来但放大或者打印时能发现左边格子比右边宽一点点。解决办法有两个一是用calc(100% / 3)让浏览器自己算精度更高二是干脆用像素值前提是格子尺寸固定。我推荐calc(100% / 3)兼顾精度和响应式。/* 更精确的九宫格线位置 */ --pos1: calc(100% / 3); --pos2: calc(200% / 3);另外九宫格经常和米字格组合使用形成“九宫米字格”——九个小格每个里面还有对角线。这种复合格用CSS画就是把九宫格的渐变和米字格的斜线渐变叠在一起background-image里放四组渐变。代码会长但逻辑是清晰的加法。实测下来四组渐变叠加在120px的格子上性能没问题一页50个格子也不卡。6. 把格子铺成一页纸background-size和repeat的配合单个格子画好了接下来是铺排。书法练习纸是一页铺满格子这步用background-repeat配合background-size就能实现不需要生成一堆DOM元素。这是CSS方案相对图片和div方案最大的优势——一页纸就是一个元素。.paper { --cell: 120px; --gap: 8px; --line: #d94f4f; --weight: 1px; width: 100%; min-height: 100vh; background-color: #fff; background-image: /* 这里放单个格子的渐变组但要注意背景渐变无法直接“每个格子带边框” */ }这里有个关键限制必须讲清楚background-image的渐变是铺在整个元素上的background-size控制的是每个“平铺单元”的尺寸但边框border不能跟着平铺。也就是说如果你用border画外框铺排时只有最外层元素有边框里面的格子没有。这是CSS背景平铺的固有行为绕不过去。解决办法是把外框也画进背景渐变里。具体做法是在单个格子的渐变组里用四个渐变分别画上、下、左、右四条边框线然后整个渐变组作为background-imagebackground-size设为calc(var(--cell) var(--gap))background-repeat: repeat。这样每个平铺单元都自带完整边框铺出来就是一张完整的格子纸。.paper { --cell: 120px; --gap: 8px; --line: #d94f4f; --weight: 1px; --unit: calc(var(--cell) var(--gap)); background-color: #fff; background-image: /* 上边框 */ linear-gradient(var(--line), var(--line)), /* 下边框 */ linear-gradient(var(--line), var(--line)), /* 左边框 */ linear-gradient(var(--line), var(--line)), /* 右边框 */ linear-gradient(var(--line), var(--line)), /* 横中线 */ linear-gradient(var(--line), var(--line)), /* 竖中线 */ linear-gradient(var(--line), var(--line)); background-size: var(--cell) var(--weight), var(--cell) var(--weight), var(--weight) var(--cell), var(--weight) var(--cell), var(--weight) 100%, 100% var(--weight); background-position: 0 0, 0 100%, 0 0, 100% 0, center center, center center; background-repeat: repeat; }这段代码里前四个渐变画边框后两个画中线background-size里边框的尺寸是var(--cell) var(--weight)横边框和var(--weight) var(--cell)竖边框定位分别贴四边。background-repeat: repeat让整个渐变组按--unit平铺。注意中线的background-size写的是var(--weight) 100%和100% var(--weight)这里的100%是相对于平铺单元的高度/宽度所以中线会贯穿整个单元包括gap区域——这会导致格子之间的间隙里也有线看起来像连成一片。要解决这个问题中线的尺寸要改成var(--cell)而不是100%并且定位要配合gap偏移。更干净的做法是把gap做成格子内部的padding即平铺单元就是格子本身格子之间不留gap靠格子内部的留白制造视觉间隔。这样中线尺寸用100%就不会溢出。我在项目里最终采用的是后者--unit等于--cell格子之间不留缝视觉上的“间隔”由格子内部的留白比如中线不画到边缘来营造。这个取舍要看具体字帖风格没有绝对对错。7. 打印适配让格子纸真正能打出来用在线预览好看是一回事打印出来能用是另一回事。书法练习纸的打印有几个硬性要求线条要够细够清晰、格子尺寸要符合实际毫米数、颜色要能黑白打印。这几点CSS都能控但有几个坑得提前踩。第一个坑是打印时的背景色默认不输出。浏览器为了省墨打印时默认不打印背景色和背景图。如果你的格子全靠background-image画打印出来就是白纸。解决办法是加-webkit-print-color-adjust: exact和print-color-adjust: exact强制输出背景。这个属性在主流浏览器上都支持但要注意它只对当前元素生效得加在格子元素上。media print { .paper { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }第二个坑是尺寸单位。屏幕上用px无所谓但打印时px和实际毫米的换算依赖DPI不同打印机不一样。要让格子精确到毫米比如标准田字格是15mm×15mm得用mm单位。CSS的mm在打印时是物理毫米屏幕上是按96DPI换算的近似值所以预览和打印会有细微差异但打印结果准确。.paper { --cell: 15mm; --weight: 0.2mm; }线宽用0.2mm左右比较合适太细打印机会丢线太粗显得笨重。实测0.15mm到0.25mm是安全区间具体看打印机精度。颜色方面红色格子黑白打印会变成灰色如果用户可能要黑白打印建议提供一个“灰度模式”把--line换成#888之类的深灰对比度够又不刺眼。第三个坑是分页。一页A4纸铺格子如果格子尺寸没算好最后一行可能被切到下一页或者页面底部留一大块空白。解决办法是用page控制页边距并让格子容器的高度是格子单元高度的整数倍。page { size: A4; margin: 10mm; } .paper { height: calc(var(--unit) * 16); /* 16行正好铺满 */ }A4纸去掉10mm边距后可用高度约277mm15mm的格子加2mm间距是17mm277/17≈16.3所以铺16行比较稳。这个数得根据实际格子尺寸算不能拍脑袋。我在项目里写了个小函数输入纸张尺寸和格子尺寸自动算出行列数避免手动试错。8. 参数化封装一套CSS变量管住四种格子四种格子画法不同但对外接口应该统一。项目里我把它们封装成一套CSS变量用类名切换格子类型变量控制尺寸、线宽、颜色、间距。这样前端只需要改类名和变量不用关心内部渐变怎么写。变量名作用田字格米字格回字格九宫格--cell格子边长必用必用必用必用--line线条颜色必用必用必用必用--weight线宽必用必用必用必用--gap内框间距不用不用必用不用--n宫格数不用不用不用可选封装时有个经验把公共部分外框、尺寸、颜色抽到一个基类各格子类型只覆盖background-image和background-size。这样改公共参数时不用四处改维护成本低。.grid-base { width: var(--cell); height: var(--cell); border: var(--weight) solid var(--line); box-sizing: border-box; background-repeat: no-repeat; background-position: center; } .grid-tian { background-image: /* 田字格渐变 */; background-size: /* ... */; } .grid-mi { background-image: /* 米字格渐变 */; background-size: /* ... */; }另一个容易忽略的点是响应式。格子纸在手机上预览时固定px尺寸会溢出屏幕。解决办法是用min()把格子尺寸和视口宽度挂钩--cell: min(120px, 22vw);这样大屏上格子是120px小屏上自动缩到视口宽度的22%一行铺4个左右不会横向滚动。但要注意打印时vw没有意义得在media print里覆盖回mm单位。这个切换我在项目里用媒体查询处理实测下来预览和打印都正常。9. 几个实测踩过的坑和对应解法讲几个真金白银踩出来的问题都是文档里不会写的。坑一渐变叠加顺序导致线被盖住。background-image里多个渐变是先写的在上层后写的在下层。画米字格时如果斜线写在横竖中线前面斜线会盖住中线交叉点看起来中线断了一截。正确顺序是把中线写在前面上层斜线写在后面下层这样交叉点显示的是中线。这个顺序问题在四种格里都存在统一原则是“重要的线写前面”。坑二background-size用百分比时相对的是平铺单元而非元素。铺一页纸时如果background-size里混用了100%和固定px百分比相对的是background-size定义的那个单元尺寸不是整个元素。这个容易搞混导致线位置错乱。我的做法是铺排时全部用固定尺寸或calc不用百分比避免歧义。坑三高DPI屏幕上的1px线发虚。在2倍屏上1px的CSS线实际渲染成2个物理像素如果位置落在半像素上就会糊。解决办法是线宽用偶数px或者用0.5px配合transform: scale。实测最稳的是线宽至少1px且格子尺寸为偶数这样线永远落在整数像素上。坑四打印时边框和背景渐变对不齐。border画的外框和背景渐变画的中线在打印时可能因为舍入差半个像素看起来外框比中线粗一点。解决办法是外框也用背景渐变画和线用同一套渲染路径对齐问题就消失了。代价是代码变长但打印质量提升明显。坑五CSS变量在旧版浏览器上的兼容。var()在主流浏览器上都支持但如果项目要兼容很老的版本得提供fallback值。我的做法是变量都带默认值var(--line, #d94f4f)这样即使变量没定义也不会崩。10. 还能往哪扩展从四种格到一套练字工具这套CSS格子画法跑通之后扩展空间比想象中大。最直接的是加更多格子类型比如“米字回宫格”回字格加米字线、“田字回宫格”本质是渐变组的加法把对应渐变叠进去就行。再比如“横线格”“竖线格”用repeating-linear-gradient一行搞定适合练行书。往工具方向走可以加描红功能格子里叠一层半透明的字帖文字用background-image叠文字图或者用::before放文字调透明度。这个和格子渐变叠加不冲突因为文字层可以用伪元素单独控制。再进一步是导出PDF用浏览器的打印功能配合page就能导出不需要额外库。还有一个实用扩展是格子尺寸预设。书法练习有标准尺寸比如小学生田字格常见15mm、18mm成人小楷可能10mm。把这些预设做成CSS类或者data属性用户一键切换比手动调变量友好。我在项目里用>