在很多练字、书法、学习类应用中,我们都会遇到一个需求:
根据配置动态生成练字纸(如米字格、田字格),并导出为 SVG / PDF。
浏览器环境可以直接使用 document.createElementNS,
但在 Node.js 环境 (比如服务端生成、CLI 工具、批量导出)中是没有 DOM 的。
本文将完整介绍一种 "伪 DOM + SVG 序列化" 的方案,实现:
-
✅ Node.js 下生成标准 SVG
-
✅ 支持米字格 / 中国田字格
-
✅ 支持主题色、边距、线宽、行距
-
✅ 一次生成多个模板文件
一、整体思路
核心思路分为四步:
-
模拟 SVG DOM 元素
-
封装毫米 → 像素转换
-
实现格子绘制算法
-
根据模板配置批量生成 SVG 文件
最终效果是:

bash
// new.mjs
import { writeFileSync, mkdirSync } from 'fs';
function createFakeElement(tagName) {
const attrs = {};
const children = [];
let textContent = '';
const DEFAULT_FONT_STACK =
'"Arial","Microsoft YaHei","PingFang SC","Hiragino Sans GB","SimSun","WenQuanYi Micro Hei",sans-serif';
function getCurrentDimension(attrName) {
const val = attrs[attrName];
if (val === undefined || val === null) return 0;
const num = parseFloat(val);
return isNaN(num) ? 0 : num;
}
function escapeXML(s) {
return String(s)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
}
const element = {
tagName,
/* ---------------- DOM-like API ---------------- */
getAttribute(name) {
return attrs[name] ?? null;
},
setAttribute(name, value) {
attrs[name] = String(value ?? '');
},
appendChild(child) {
if (child === null || child === undefined) return;
// ✅ 支持 appendChild("文字")
if (typeof child === 'string') {
textContent += child;
return;
}
children.push(child);
},
// ✅ 关键:支持 z 里的 textContent 写法
set textContent(v) {
textContent = String(v ?? '');
},
get textContent() {
return textContent;
},
/* --------- SVG width / height 兼容 --------- */
get width() {
return { baseVal: { value: getCurrentDimension('width') } };
},
get height() {
return { baseVal: { value: getCurrentDimension('height') } };
},
/* ---------------- 序列化 ---------------- */
get outerHTML() {
const attrStrEntries = Object.entries(attrs);
// 可选:自动给 text 补字体(你之前那段是对的)
if (tagName === 'text' && !attrs['font-family']) {
attrStrEntries.push(['font-family', DEFAULT_FONT_STACK]);
}
const attrStr = attrStrEntries
.map(([k, v]) => `${k}="${v.replace(/"/g, '"')}"`)
.join(' ');
const selfClosing = new Set([
'rect', 'circle', 'ellipse', 'line', 'path', 'polygon',
'polyline', 'use', 'image', 'stop'
]);
// text 永远不能自闭合
if (selfClosing.has(tagName) && children.length === 0 && !textContent) {
return `<${tagName}${attrStr ? ' ' + attrStr : ''}/>`;
}
const startTag = `<${tagName}${attrStr ? ' ' + attrStr : ''}>`;
const endTag = `</${tagName}>`;
const inner =
escapeXML(textContent) +
children.map(c => c.outerHTML).join('\n');
return `${startTag}${inner}${endTag}`;
}
};
return element;
}
let w = (t) => createFakeElement(t);
let k = (t) => Math.round((96 / 25.4) * t);
//w = (t) => document.createElementNS("http://www.w3.org/2000/svg", t),
let j = function (t) {
let e =
arguments.length > 1 && void 0 !== arguments[1]
? arguments[1]
: 0.1;
return t && !(t <= 0) && isFinite(t) ? t : e;
},
N = function (t) {
let e =
arguments.length > 1 && void 0 !== arguments[1]
? arguments[1]
: 0.1,
i = k(j(t, e));
return i <= 0 ? k(e) : i;
};
function getThemeColors(themeName, overrideColor) {
const themes = {
default: {
name: "default",
colors: {
primary: "#3B82F6",
secondary: "#6366F1",
background: "#FFFFFF",
text: "#1F2937",
lines: "#808080",
accent: "#4F46E5",
},
fonts: { heading: "Arial, sans-serif", body: "Arial, sans-serif" },
},
night: {
name: "night",
colors: {
primary: "#6366F1",
secondary: "#8B5CF6",
background: "#111827",
text: "#F9FAFB",
lines: "#4B5563",
accent: "#EC4899",
},
fonts: { heading: "Arial, sans-serif", body: "Arial, sans-serif" },
},
sepia: {
name: "sepia",
colors: {
primary: "#9F7F63",
secondary: "#8D7B68",
background: "#F5F0E6",
text: "#4B3621",
lines: "#A67C52",
accent: "#C8A27C",
},
fonts: { heading: "Georgia, serif", body: "Georgia, serif" },
},
classic: {
name: "classic",
colors: {
primary: "#1e3a8a",
secondary: "#4b5563",
background: "#f8f8f8",
text: "#111827",
lines: "#374151",
accent: "#b91c1c",
},
fonts: { heading: "Georgia, serif", body: "Georgia, serif" },
},
vintage: {
name: "vintage",
colors: {
primary: "#7c3aed",
secondary: "#6b7280",
background: "#fff8e1",
text: "#4b5563",
lines: "#9ca3af",
accent: "#d97706",
},
fonts: { heading: "Palatino, serif", body: "Palatino, serif" },
},
dark: {
name: "dark",
colors: {
primary: "#60a5fa",
secondary: "#9ca3af",
background: "#1f2937",
text: "#f9fafb",
lines: "#6b7280",
accent: "#fbbf24",
},
fonts: { heading: "system-ui, sans-serif", body: "system-ui, sans-serif" },
},
pastel: {
name: "pastel",
colors: {
primary: "#8b5cf6",
secondary: "#9ca3af",
background: "#f0fdf4",
text: "#4b5563",
lines: "#94a3b8",
accent: "#ec4899",
},
fonts: { heading: "Avenir, system-ui, sans-serif", body: "Avenir, system-ui, sans-serif" },
},
minimalist: {
name: "minimalist",
colors: {
primary: "#000000",
secondary: "#404040",
background: "#FFFFFF",
text: "#333333",
lines: "#CCCCCC",
accent: "#808080",
},
fonts: { heading: "Helvetica, Arial, sans-serif", body: "Helvetica, Arial, sans-serif" },
},
nature: {
name: "nature",
colors: {
primary: "#059669",
secondary: "#6b7280",
background: "#ecfdf5",
text: "#1f2937",
lines: "#10b981",
accent: "#f59e0b",
},
fonts: { heading: "system-ui, sans-serif", body: "system-ui, sans-serif" },
},
custom: {
name: "custom",
colors: {
primary: "#3b82f6",
secondary: "#6b7280",
background: "#ffffff",
text: "#1f2937",
lines: "#808080",
accent: "#f59e0b",
},
fonts: { heading: "system-ui, sans-serif", body: "system-ui, sans-serif" },
},
ocean: {
name: "ocean",
colors: {
primary: "#0077B6",
secondary: "#00B4D8",
background: "#F0FAFF",
text: "#003049",
lines: "#90E0EF",
accent: "#023E8A",
},
fonts: { heading: "Georgia, serif", body: "Arial, sans-serif" },
},
forest: {
name: "forest",
colors: {
primary: "#2D6A4F",
secondary: "#40916C",
background: "#F6FFF8",
text: "#1B4332",
lines: "#95D5B2",
accent: "#52B788",
},
fonts: { heading: "Verdana, Geneva, sans-serif", body: "Verdana, Geneva, sans-serif" },
},
sunset: {
name: "sunset",
colors: {
primary: "#E76F51",
secondary: "#F4A261",
background: "#FFF8F0",
text: "#5E3023",
lines: "#FFD6BA",
accent: "#E9C46A",
},
fonts: { heading: "Trebuchet MS, sans-serif", body: "Trebuchet MS, sans-serif" },
},
tech: {
name: "tech",
colors: {
primary: "#3A0CA3",
secondary: "#4361EE",
background: "#F0F4FF",
text: "#14213D",
lines: "#4CC9F0",
accent: "#7209B7",
},
fonts: { heading: "Courier New, monospace", body: "Courier New, monospace" },
},
elegant: {
name: "elegant",
colors: {
primary: "#7D5A50",
secondary: "#B4846C",
background: "#FDF6EC",
text: "#543C32",
lines: "#E7BC91",
accent: "#A47148",
},
fonts: { heading: "Garamond, serif", body: "Garamond, serif" },
},
creative: {
name: "creative",
colors: {
primary: "#FF6B6B",
secondary: "#FFD93D",
background: "#FFFFFF",
text: "#453C67",
lines: "#4D96FF",
accent: "#6BCB77",
},
fonts: { heading: "Comic Sans MS, cursive", body: "Arial, sans-serif" },
}
};
const templates = [
{
id: "project_planning",
name: "项目规划纸",
description: "帮助规划和跟踪项目进度的专业模板",
paperType: "project_planning",
paperSize: "a4",
lineColor: "#4361EE",
lineSpacing: 7,
lineWidth: 0.5,
lineStyle: "solid",
margins: { top: 25, right: 20, bottom: 25, left: 30 },
theme: "tech",
background: {
color: "#f8fafc",
pattern: "none",
patternColor: "#CCCCCC",
patternOpacity: 0.1,
},
watermark: {
enabled: !1,
text: "",
opacity: 0.1,
angle: -45,
fontSize: 48,
color: "#888888",
},
pages: { count: 1, numberingEnabled: !0, startNumber: 1 },
},
{
id: "meeting_note",
name: "会议笔记纸",
description: "结构化的会议记录模板,包含议程和行动项",
paperType: "meeting_note",
paperSize: "a4",
lineColor: "#4b5563",
lineSpacing: 8,
lineWidth: 0.5,
lineStyle: "solid",
margins: { top: 40, right: 20, bottom: 30, left: 20 },
theme: "elegant",
background: {
color: "#ffffff",
pattern: "none",
patternColor: "#e0e0e0",
patternOpacity: 0.1,
},
watermark: {
enabled: !1,
text: "",
opacity: 0.15,
angle: -45,
fontSize: 48,
color: "#888888",
},
pages: { count: 1, numberingEnabled: !0, startNumber: 1 },
},
];
return { line: overrideColor || (themes[themeName]?.line || "#808080") };
}
// ===============================================
const z = {
chinesetianzi: (t, e) => {
try {
var i, r, n, o;
let {
lineSpacing: l,
lineWidth: s,
margins: d,
theme: u,
lineColor: p,
} = e;
const themeColors = getThemeColors(u, p);
let c = p || themeColors.line;
let b = (null === (o = e.specialConfig) || void 0 === o ? void 0 : null === (n = o.calligraphy) || void 0 === n ? void 0 : n.guideColor) || themeColors.line;
let g = parseInt(t.getAttribute("width") || "0");
let h = parseInt(t.getAttribute("height") || "0");
if (g <= 0 || h <= 0) {
console.error("SVG尺寸无效", g, h);
return;
}
let m = g - k(d.left) - k(d.right);
let f = h - k(d.top) - k(d.bottom);
let A = N(l, 0.1);
let x = Math.floor(m / A);
let S = Math.floor(f / A);
let y = (g - x * A) / 2;
let C = (h - S * A) / 2;
let v = w("g");
v.setAttribute("stroke", c);
v.setAttribute("stroke-width", s.toString());
v.setAttribute("fill", "none");
for (let t = 0; t <= S; t++) {
let e = C + t * A;
let i = w("line");
i.setAttribute("x1", y.toString());
i.setAttribute("y1", e.toString());
i.setAttribute("x2", (y + x * A).toString());
i.setAttribute("y2", e.toString());
v.appendChild(i);
}
for (let t = 0; t <= x; t++) {
let e = y + t * A;
let i = w("line");
i.setAttribute("x1", e.toString());
i.setAttribute("y1", C.toString());
i.setAttribute("x2", e.toString());
i.setAttribute("y2", (C + S * A).toString());
v.appendChild(i);
}
let j = w("g");
j.setAttribute("stroke", b);
j.setAttribute("stroke-width", (0.7 * s).toString());
j.setAttribute("stroke-opacity", "0.7");
for (let t = 0; t < S; t++)
for (let e = 0; e < x; e++) {
let i = y + e * A;
let r = C + t * A;
let n = w("line");
n.setAttribute("x1", (i + A / 2).toString());
n.setAttribute("y1", r.toString());
n.setAttribute("x2", (i + A / 2).toString());
n.setAttribute("y2", (r + A).toString());
j.appendChild(n);
let o = w("line");
o.setAttribute("x1", i.toString());
o.setAttribute("y1", (r + A / 2).toString());
o.setAttribute("x2", (i + A).toString());
o.setAttribute("y2", (r + A / 2).toString());
j.appendChild(o);
}
let z = w("rect");
z.setAttribute("x", y.toString());
z.setAttribute("y", C.toString());
z.setAttribute("width", (x * A).toString());
z.setAttribute("height", (S * A).toString());
z.setAttribute("fill", "none");
z.setAttribute("stroke", c);
z.setAttribute("stroke-width", (1.2 * s).toString());
t.appendChild(v);
t.appendChild(j);
t.appendChild(z);
} catch (e) {
console.error("Error rendering ChineseTianZiGe paper:", e);
try {
let e = parseInt(t.getAttribute("width") || "0");
let i = parseInt(t.getAttribute("height") || "0");
let r = w("rect");
r.setAttribute("x", "10");
r.setAttribute("y", "10");
r.setAttribute("width", (e - 20).toString());
r.setAttribute("height", (i - 20).toString());
r.setAttribute("fill", "none");
r.setAttribute("stroke", "#8B4513");
r.setAttribute("stroke-width", "1");
let n = w("text");
n.setAttribute("x", (e / 2).toString());
n.setAttribute("y", (i / 2).toString());
n.setAttribute("text-anchor", "middle");
n.setAttribute("font-family", "Arial");
n.setAttribute("font-size", "14");
n.setAttribute("fill", "#8B4513");
n.textContent = "渲染错误,请尝试调整参数";
t.appendChild(r);
t.appendChild(n);
} catch (t) {
console.error("备用渲染也失败:", t);
}
}
},
mizige: (t, e) => {
try {
var i, r, n, o;
let {
lineSpacing: l,
lineWidth: s,
margins: d,
theme: u,
lineColor: p,
} = e;
const themeColors = getThemeColors(u, p);
let c = p || themeColors.line;
let b = (null === (o = e.specialConfig) || void 0 === o ? void 0 : null === (n = o.calligraphy) || void 0 === n ? void 0 : n.guideColor) || themeColors.line;
let g = parseInt(t.getAttribute("width") || "0");
let h = parseInt(t.getAttribute("height") || "0");
if (g <= 0 || h <= 0) {
console.error("SVG尺寸无效", g, h);
return;
}
let m = g - k(d.left) - k(d.right);
let f = h - k(d.top) - k(d.bottom);
let A = N(l, 0.1);
let x = Math.floor(m / A);
let S = Math.floor(f / A);
let y = k(d.left);
let C = k(d.top);
let v = w("g");
v.setAttribute("stroke", c);
v.setAttribute("stroke-width", s.toString());
v.setAttribute("fill", "none");
for (let t = 0; t <= S; t++) {
let e = C + t * A;
let i = w("line");
i.setAttribute("x1", y.toString());
i.setAttribute("y1", e.toString());
i.setAttribute("x2", (y + x * A).toString());
i.setAttribute("y2", e.toString());
v.appendChild(i);
}
for (let t = 0; t <= x; t++) {
let e = y + t * A;
let i = w("line");
i.setAttribute("x1", e.toString());
i.setAttribute("y1", C.toString());
i.setAttribute("x2", e.toString());
i.setAttribute("y2", (C + S * A).toString());
v.appendChild(i);
}
let j = w("g");
j.setAttribute("stroke", b);
j.setAttribute("stroke-width", (0.7 * s).toString());
j.setAttribute("stroke-opacity", "0.7");
for (let t = 0; t < S; t++)
for (let e = 0; e < x; e++) {
let i = y + e * A;
let r = C + t * A;
let n = w("line");
n.setAttribute("x1", i.toString());
n.setAttribute("y1", r.toString());
n.setAttribute("x2", (i + A).toString());
n.setAttribute("y2", (r + A).toString());
j.appendChild(n);
let o = w("line");
o.setAttribute("x1", (i + A).toString());
o.setAttribute("y1", r.toString());
o.setAttribute("x2", i.toString());
o.setAttribute("y2", (r + A).toString());
j.appendChild(o);
let l = w("line");
l.setAttribute("x1", (i + A / 2).toString());
l.setAttribute("y1", r.toString());
l.setAttribute("x2", (i + A / 2).toString());
l.setAttribute("y2", (r + A).toString());
j.appendChild(l);
let a = w("line");
a.setAttribute("x1", i.toString());
a.setAttribute("y1", (r + A / 2).toString());
a.setAttribute("x2", (i + A).toString());
a.setAttribute("y2", (r + A / 2).toString());
j.appendChild(a);
}
let z = w("rect");
z.setAttribute("x", y.toString());
z.setAttribute("y", C.toString());
z.setAttribute("width", (x * A).toString());
z.setAttribute("height", (S * A).toString());
z.setAttribute("fill", "none");
z.setAttribute("stroke", c);
z.setAttribute("stroke-width", (1.2 * s).toString());
t.appendChild(v);
t.appendChild(j);
t.appendChild(z);
} catch (e) {
console.error("Error rendering MiziGe paper:", e);
try {
let e = parseInt(t.getAttribute("width") || "0");
let i = parseInt(t.getAttribute("height") || "0");
let r = w("rect");
r.setAttribute("x", "10");
r.setAttribute("y", "10");
r.setAttribute("width", (e - 20).toString());
r.setAttribute("height", (i - 20).toString());
r.setAttribute("fill", "none");
r.setAttribute("stroke", "#8B4513");
r.setAttribute("stroke-width", "1");
let n = w("text");
n.setAttribute("x", (e / 2).toString());
n.setAttribute("y", (i / 2).toString());
n.setAttribute("text-anchor", "middle");
n.setAttribute("font-family", "Arial");
n.setAttribute("font-size", "14");
n.setAttribute("fill", "#8B4513");
n.textContent = "渲染错误,请尝试调整参数";
t.appendChild(r);
t.appendChild(n);
} catch (t) {
console.error("备用渲染也失败:", t);
}
}
},
};
// ===============================================
// ========== 【3. 模板配置】==========
const templates = [
{
id: "mizige",
name: "米字格",
description: "传统书法练习用米字格,适合练习楷书和行书",
paperType: "mizige",
paperSize: "a4",
lineColor: "#8B4513",
lineSpacing: 20,
lineWidth: 0.6,
lineStyle: "solid",
margins: { top: 9, right: 9, bottom: 9, left: 9 },
theme: "vintage",
background: {
color: "#FFF8E6",
pattern: "none",
patternColor: "#E7BC91",
patternOpacity: 0.1,
},
watermark: {
enabled: !1,
text: "",
opacity: 0.1,
angle: -45,
fontSize: 48,
color: "#888888",
},
pages: { count: 1, numberingEnabled: !1, startNumber: 1 },
specialConfig: {
calligraphy: { showGuides: !0, guideColor: "#B4846C" },
},
},
{
id: "chinesetianzi",
name: "中国田字格",
description: "传统书法田字格,适合练习汉字基本结构",
paperType: "chinesetianzi",
paperSize: "a4",
lineColor: "#8B4513",
lineSpacing: 20,
lineWidth: 0.6,
lineStyle: "solid",
margins: { top: 9, right: 9, bottom: 9, left: 9 },
theme: "vintage",
background: {
color: "#FFF8E6",
pattern: "none",
patternColor: "#E7BC91",
patternOpacity: 0.1,
},
watermark: {
enabled: !1,
text: "",
opacity: 0.1,
angle: -45,
fontSize: 48,
color: "#888888",
},
pages: { count: 1, numberingEnabled: !1, startNumber: 1 },
specialConfig: {
calligraphy: { showGuides: !0, guideColor: "#B4846C" },
},
},
];
const folder = 'restored_papers';
mkdirSync(folder, { recursive: true });
for (const config of templates) {
try {
const W = k(210), H = k(297);
const svg = w('svg');
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
svg.setAttribute('width', String(W));
svg.setAttribute('height', String(H));
const bgColor = config.background?.color || '#ffffff';
const bgRect = w('rect');
bgRect.setAttribute('x', '0');
bgRect.setAttribute('y', '0');
bgRect.setAttribute('width', String(W));
bgRect.setAttribute('height', String(H));
bgRect.setAttribute('fill', bgColor);
svg.appendChild(bgRect); // 背景必须在最底层,所以先 append
if (!z[config.paperType]) {
throw new Error(`未实现的纸张类型: ${config.paperType}`);
}
z[config.paperType](svg, config);
const svgStrTemp = svg.outerHTML;
const texts = [...svgStrTemp.matchAll(/<text[^>]*>([\s\S]*?)<\/text>/g)]
.map(m => m[1].trim());
console.log(`📄 ${config.id}: 共 ${texts.length} 个 text`);
texts.forEach((t, i) => {
console.log(` TEXT[${i}] =`, JSON.stringify(t));
});
const svgString = `<?xml version="1.0" encoding="UTF-8"?>\n${svg.outerHTML}`;
const filename = `${folder}/${config.name}.svg`;
writeFileSync(filename, svgString, 'utf8');
console.log(` 生成成功: ${filename}`);
} catch (err) {
console.error(` 生成失败 (${config.id}):`, err.message);
}
}
console.log(`\n共生成 ${templates.length} 个 SVG 文件,保存在 ./${folder}/ 目录中。`);
运行代码
node new.mjs
生成结果:
restored_papers/
├── 米字格.svg
└── 中国田字格.svg
九、总结
✅ 本方案优点:
-
100% Node.js 可运行
-
不依赖浏览器
-
SVG 结构干净,适合打印
-
配置驱动,易扩展
-
非常适合做练字 / 教育 / 生成工具