Node.js 纯 JS 生成 SVG 练字纸(米字格 / 田字格)完整实现解析

在很多练字、书法、学习类应用中,我们都会遇到一个需求:
根据配置动态生成练字纸(如米字格、田字格),并导出为 SVG / PDF。

浏览器环境可以直接使用 document.createElementNS

但在 Node.js 环境 (比如服务端生成、CLI 工具、批量导出)中是没有 DOM 的

本文将完整介绍一种 "伪 DOM + SVG 序列化" 的方案,实现:

  • ✅ Node.js 下生成标准 SVG

  • ✅ 支持米字格 / 中国田字格

  • ✅ 支持主题色、边距、线宽、行距

  • ✅ 一次生成多个模板文件


一、整体思路

核心思路分为四步:

  1. 模拟 SVG DOM 元素

  2. 封装毫米 → 像素转换

  3. 实现格子绘制算法

  4. 根据模板配置批量生成 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, '&lt;')
      .replace(/>/g, '&gt;');
  }

  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, '&quot;')}"`)
        .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 结构干净,适合打印

  • 配置驱动,易扩展

  • 非常适合做练字 / 教育 / 生成工具

相关推荐
catchadmin2 小时前
成为高级 PHP 开发者需要的思维转变
开发语言·php
请告诉他2 小时前
从 Struts2 单体到 Spring Cloud 微服务:一个 P2P 系统的真实重构之路(2019 年实战复盘)
java·开发语言
cypking2 小时前
三、NestJS 开发实战文档-->集成 MySQL(TypeORM)
前端·数据库·mysql·adb·node.js
duanyuehuan2 小时前
vueRouter重置路由
前端·javascript·vue.js
雾岛听蓝2 小时前
C++ string 类解析
开发语言·c++
这周也會开心2 小时前
Java面试题2-集合+数据结构
java·开发语言·数据结构
十五年专注C++开发2 小时前
QProcess在Windows下不能正常启动exe的原因分析
开发语言·c++·windows·qprocess·createprocess
无限进步_2 小时前
C++多态全面解析:从概念到实现
开发语言·jvm·c++·ide·git·github·visual studio
.似水2 小时前
Python面向对象
开发语言·python