【Godot4.3】MarkDown解析和生成类 - MDdoc
概述
一年多前写过GDSCript静态函数库用来在Godot中生成MarkDown文档内容。用在了自己的插件项目Script++
中,用来快速生成脚本文件API文档的框架内容。
这次编写了一个集解析、生成MarkDown为一体的MDdoc
类,可以更轻松的解决MarkDown文档的问题。
因为基础工作在5月份已经完成,我也是拖了好久重新拾起,所以就以此文为契机,复习自己的代码,并做一个比较详尽的文档。
内部类
MDdoc
类可以将MarkDown文档元素按其顺序解析为对象后存入内部的数组。每个MarkDown文档元素对应MDdoc
的一个内部类:CodebBlock
:代码块Headding
:标题,H1-H6Paragraph
:普通段落Img
:图片UL
:无序列表OL
:有序列表Table
:表格
目前为止的内部类都相当简单,简单的属性,加上重写的_to_string()
和输出HTML用的to_html()
方法。
CodebBlock
# 代码块
class CodebBlock:
var language:String = ""
var code:String
func _to_string() -> String:
return "\n```%s\n%s\n```" % [language,code]
# 转化为HTML
func to_html() -> String:
return "\n<code>\n<pre>\n%s\n</pre>\n</code>\n" % code
Headding
# H1-H6
class Headding:
var level:int
var text:String
func _to_string() -> String:
return "%s %s" % ["#".repeat(level),text]
# 转化为HTML
func to_html() -> String:
return "\n<h{level}>{text}</h{level}>\n".format({"level":level,"text":text})
Paragraph
# 段落
class Paragraph:
var text:String
func _to_string() -> String:
return "\n%s\n" % text #"\n%s" % text
# 转化为HTML
func to_html() -> String:
return "\n<p>{text}</p>\n".format({"text":text})
Img
# 图片
class Img:
var src:String
var desc:String
func _to_string() -> String:
return "\n![%s](%s)\n" % [desc,src]
# 转化为HTML
func to_html() -> String:
return "\n<img src = \"{src}\" alt=\"{desc}\">\n".format({"src":src,"desc":desc})
UL
# 无序列表
class UL:
var list:PackedStringArray
func _to_string() -> String:
return "- " + "\n- ".join(list)
# 转化为HTML
func to_html() -> String:
return "<ul>\n\t<li>" + "\n\t<li>".join(list) + "\n</ul>\n"
OL
# 有序列表
class OL:
var list:PackedStringArray
func _to_string() -> String:
var sttr:String
for i in range(list.size()):
sttr += "%d. %s\n" % [i+1,list[i]]
return sttr
# 转化为HTML
func to_html() -> String:
return "<ol>\n\t<li>" + "\n\t<li>".join(list) + "\n</ol>\n"
Table
# 表格
class Table:
var _data:Array[PackedStringArray]
func _to_string() -> String:
var sttr:String
var hrs:PackedStringArray
hrs.resize(_data[0].size())
hrs.fill("-".repeat(3))
for y in range(_data.size()):
if y == 0: # 第1行
sttr += "| %s |\n" % " | ".join(_data[y])
sttr += "| %s |\n" % " | ".join(hrs)
else:
sttr += "| %s |\n" % " | ".join(_data[y])
return sttr
# 转化为HTML
func to_html() -> String:
var sttr:String = "<table>\n"
for y in range(_data.size()):
if y == 0:
sttr += "\t<tr>\n\t\t<th>%s\n" % "<th>".join(_data[y])
else:
sttr += "\t<tr>\n\t\t<td>%s\n" % "<td>".join(_data[y])
return sttr + "</table>"
对象数组
MDdoc
的核心是load()
和parse()
两个静态方法以及_doc
数组以及重写的_to_string()
,其中:
parse()
为最核心的解析方法,是编写的重难点,将Markdown字符串解析为MDdoc
实例load()
读取指定纯文本文件的内容,然后调用parse()
将Markdown字符串解析为MDdoc
实例parse()
解析的过程,就是用将Markdown字符串内容解析为上文中对应内部类的实例对象后按顺序添加到_doc
数组中的过程。MDdoc
的_to_string()
,其内部会遍历_doc
中的所有元素,并调用它们自身的_to_string()
,把所有内容串接为一个多行字符串,也就是整个文档的纯文本形式。
class_name MDdoc
var _doc:Array # 文档对象数组
# 加载Markdown文档解析为MDdoc实例
static func load(path:String) -> MDdoc:
...
# 将Markdown字符串解析为MDdoc实例
static func parse(md:String) -> MDdoc:
...
# print()或to_string()时返回的内容
func _to_string() -> String:
var sttr:=""
for ele in _doc:
sttr+= ele.to_string() + "\n"
return sttr
核心解析函数
MDdoc
在设计解析函数时,使用了按行解析的思路,首先用String
类型的split()
方法,将需要解析的字符串以\n
切分为字符串数组- 大量使用
String
类型的match()
(通配符匹配)方法,而不是使用RegEx
(正则表达式)。 - 事实证明,按行解析+
match()
简易匹配的方式,让解析函数的编写难度大大下降。
下面是parse()
方法的代码:
# 将Markdown字符串解析为MDdoc实例
static func parse(md:String) -> MDdoc:
# ============ 处理代码块 ============
var start_codeblock:bool # 代码块开启标记
var code_language:String
var code:PackedStringArray
# ============ 处理UL ============
var ul_list:PackedStringArray
var ol_list:PackedStringArray
var table_lines:Array[PackedStringArray]
var doc = MDdoc.new()
var lines = md.split("\n",false)
for i in range(lines.size()):
var line = lines[i]
# 代码块
if line.match("```*"):
if !start_codeblock:
code_language = line.lstrip("```")
start_codeblock = true # 代码块开启标记
else:
start_codeblock = false
doc.append_CodebBlock(code_language,"\n".join(code))
code.clear();code_language="" # 还原初始状态
else:
if start_codeblock:
code.append(line)
# 非代码块
if !start_codeblock:
# H1-H6
if is_headding(line):
var lv = get_headding_level(line)
doc.append_Headding(lv,line.lstrip("%s " % "#".repeat(lv)))
# 图片
elif line.match("![*](*)"):
var img = line.lstrip("![").rstrip(")").split("](")
doc.append_Img(img[1],img[0])
# UL
elif line.match("- *"):
ul_list.append(line.lstrip("- "))
if i < lines.size()-1: # 未达文档末尾
if !lines[i+1].match("- *") or i+1 == lines.size()-1:
doc.append_UL(ul_list)
ul_list.clear()
# OL
elif line.match("*. *"):
ol_list.append(line.split(".",true,1)[1])
if i < lines.size()-1: # 未达文档末尾
if !lines[i+1].match("*. *") or i+1 == lines.size()-1:
doc.append_OL(ol_list)
ol_list.clear()
# 表格
elif line.match("|*|"):
var li = line.lstrip("|").rstrip("|").replace("_"," ") # 去除首尾的|
if !is_table_hr(line):
table_lines.append(li.split("|"))
if i == lines.size()-1: # 最后一行
doc.append_Table(table_lines)
table_lines.clear()
else:
if !lines[i+1].match("|*|"): # 未达文档末尾
print(table_lines)
else:# 被视为普通段落
if !line.match("```*"):
doc.append_Paragraph(line)
return doc
parse()
解析时需要依赖以下几个函数:
# 是否是表格分割线
static func is_table_hr(line:String):
var li = line.lstrip("|").rstrip("|") # 去除首尾的|
return li.replace("-","").replace("|","").strip_edges() == ""
# 是否是H1-H6
static func is_headding(line:String):
var bol
for i in range(6):
if line.match("%s *" % "#".repeat(i+1)):
bol = true
return bol
# 获取标题的等级
static func get_headding_level(line:String) -> int:
var lv
for i in range(6):
if line.match("%s *" % "#".repeat(i+1)):
lv = i + 1
return lv
解析策略:
- 在解析时,将MD文档元素划分为了代码块和其他,代码块开始后将标记
start_codeblock
变量为true
,直到代码块结束,在此期间,所有的代码行将被视为代码块的内容而不会被意外解析 - 在非代码块元素的行解析时,只需要检测简单的字符串匹配模式即可,比如:
line.match("%s *" % "#".repeat(1))
可以对应# XXX
这样的一级标题"![*](*)"
匹配图片"- *"
匹配无序列表"*. *"
匹配有序列表等等
- 目前版本当然还没有加入超链接和行内代码,期待后续改进
MarkDown解析测试
我们可以使用FileAccess
读取一个.md
文件的内容,然后用MDdoc.parse()
解析纯文本。
# 读取.md的纯文本内容
var md = FileAccess.get_file_as_string("res://lib/数字与字符/test.md")
var doc = MDdoc.parse(md) # 以字符串形式解析
或者直接使用MDdoc.load()
:
# 使用MDdoc.load()简化.md读取
var doc = MDdoc.load("res://lib/数字与字符/test.md")
解析完成后我们可以使用print()
直接打印输出实例:
print(doc)
打印输出的结果就是文档本身的内容。
MarkDown生成测试
除了解析已有的MarkDown之外,MDdoc
还能从零生成MarkDown文档,而且因为依然是基于_doc
的对象数组,所以可以用to_html()
方法,轻松的转化为HTML版本。
创建空文档实例
通过new()
方法可以创建一个没有元素的MDdoc
实例:
var md = MDdoc.new() # 创建空文档实例
元素添加方法
对应每一种元素,有相应的append_
开头的方法,可以在当前新建的MDdoc
空实例上创建文档元素。
# 添加标题
func append_Headding(level:int,text:String):
var h = Headding.new()
h.level = level
h.text = text
_doc.append(h)
# 添加段落
func append_Paragraph(text:String):
var p = Paragraph.new()
p.text = text
_doc.append(p)
# 添加UL
func append_UL(list:PackedStringArray):
var ul = UL.new()
ul.list = list.duplicate()
_doc.append(ul)
# 添加UL
func append_OL(list:PackedStringArray):
var ol = OL.new()
ol.list = list.duplicate()
_doc.append(ol)
# 添加图片
func append_Img(src:String,desc:String):
var img = Img.new()
img.src = src
img.desc = desc
_doc.append(img)
# 添加代码片段
func append_CodebBlock(language:String,code:String):
var c = CodebBlock.new()
c.language = language
c.code = code
_doc.append(c)
# 添加表格
func append_Table(table_lines:Array[PackedStringArray]):
var table = Table.new()
table._data = table_lines.duplicate()
# 去除多余的空格
for y in range(table._data.size()):
for x in range(table._data[y].size()):
table._data[y][x] = table._data[y][x].strip_edges()
_doc.append(table)
生成测试
@tool
extends EditorScript
func _run() -> void:
var etd = """
数据结构
线性结构
栈
队列
双端列表
列表
非线性结构
图
树
"""
# 创建MDdoc实例
var md = MDdoc.new()
# 按顺序添加文档元素
md.append_Headding(1,"这是一个测试")
md.append_Headding(2,"概述")
md.append_Paragraph("这是一段文本")
md.append_Img("1.jpg","这是一张图片")
md.append_Paragraph("这是一段文本")
md.append_CodebBlock("swift",etd)
# 输出
print(md)
输出:
# 这是一个测试
## 概述
这是一段文本
![这是一张图片](1.jpg)
这是一段文本
```swift
数据结构
线性结构
栈
队列
双端列表
列表
非线性结构
图
树
目前版本只提供了append_
也就是尾部顺序追加的方法,后续改进版本会提供更多的元素数组操作封装。
完整代码
以下是该类的完整代码:
# ========================================================
# 名称:MDdoc
# 类型:类
# 简介:专用于解析和生成MarkDown的类
# 作者:巽星石
# Godot版本:v4.2.2.stable.official [15073afe3]
# 创建时间:2024年4月30日17:55:31
# 最后修改时间:2024年5月1日22:42:45
# ========================================================
class_name MDdoc
var _doc:Array
# ============================ 内部类 ============================
# 代码块
class CodebBlock:
var language:String = ""
var code:String
func _to_string() -> String:
return "\n```%s\n%s\n```" % [language,code]
# H1-H6
class Headding:
var level:int
var text:String
func _to_string() -> String:
return "%s %s" % ["#".repeat(level),text]
# 段落
class Paragraph:
var text:String
func _to_string() -> String:
return "p:%s" % text #"\n%s" % text
# 图片
class Img:
var src:String
var desc:String
func _to_string() -> String:
return "\n![%s](%s)\n" % [desc,src]
# 无序列表
class UL:
var list:PackedStringArray
func _to_string() -> String:
return "- " + "\n- ".join(list)
# 有序列表
class OL:
var list:PackedStringArray
func _to_string() -> String:
var sttr:String
for i in range(list.size()):
sttr += "%d. %s\n" % [i+1,list[i]]
return sttr
# 表格
class Table:
var _data:Array[PackedStringArray]
func _to_string() -> String:
var sttr:String
var hrs:PackedStringArray
hrs.resize(_data[0].size())
hrs.fill("-".repeat(3))
for y in range(_data.size()):
if y == 0: # 第1行
sttr += "| %s |\n" % " | ".join(_data[y])
sttr += "| %s |\n" % " | ".join(hrs)
else:
sttr += "| %s |\n" % " | ".join(_data[y])
return sttr
# ============================ 方法 ============================
# 添加标题
func append_Headding(level:int,text:String):
var h = Headding.new()
h.level = level
h.text = text
_doc.append(h)
# 添加段落
func append_Paragraph(text:String):
var p = Paragraph.new()
p.text = text
_doc.append(p)
# 添加UL
func append_UL(list:PackedStringArray):
var ul = UL.new()
ul.list = list.duplicate()
_doc.append(ul)
# 添加UL
func append_OL(list:PackedStringArray):
var ol = OL.new()
ol.list = list.duplicate()
_doc.append(ol)
# 添加图片
func append_Img(src:String,desc:String):
var img = Img.new()
img.src = src
img.desc = desc
_doc.append(img)
# 添加代码片段
func append_CodebBlock(language:String,code:String):
var c = CodebBlock.new()
c.language = language
c.code = code
_doc.append(c)
# 添加表格
func append_Table(table_lines:Array[PackedStringArray]):
var table = Table.new()
table._data = table_lines.duplicate()
# 去除多余的空格
for y in range(table._data.size()):
for x in range(table._data[y].size()):
table._data[y][x] = table._data[y][x].strip_edges()
_doc.append(table)
# ============================ 虚函数 ============================
func _init() -> void:
_doc = []
pass
func _to_string() -> String:
var sttr:=""
for ele in _doc:
sttr+= ele.to_string() + "\n"
return sttr
# 将Markdown文档解析为MDdoc实例
static func load(path:String) -> MDdoc:
var md = FileAccess.get_file_as_string(path)
var doc = MDdoc.new()
doc.parse(md)
return doc
# 将Markdown文档解析为MDdoc实例
static func parse(md:String) -> MDdoc:
# ============ 处理代码块 ============
var start_codeblock:bool # 代码块开启标记
var code_language:String
var code:PackedStringArray
# ============ 处理UL ============
var ul_list:PackedStringArray
var ol_list:PackedStringArray
var table_lines:Array[PackedStringArray]
var doc = MDdoc.new()
var lines = md.split("\n",false)
for i in range(lines.size()):
var line = lines[i]
# 代码块
if line.match("```*"):
if !start_codeblock:
code_language = line.lstrip("```")
start_codeblock = true # 代码块开启标记
else:
start_codeblock = false
doc.append_CodebBlock(code_language,"\n".join(code))
code.clear();code_language="" # 还原初始状态
else:
if start_codeblock:
code.append(line)
# 非代码块
if !start_codeblock:
# H1-H6
if is_headding(line):
var lv = get_headding_level(line)
doc.append_Headding(lv,line.lstrip("%s " % "#".repeat(lv)))
# 图片
elif line.match("![*](*)"):
var img = line.lstrip("![").rstrip(")").split("](")
doc.append_Img(img[1],img[0])
# UL
elif line.match("- *"):
ul_list.append(line.lstrip("- "))
if i < lines.size()-1: # 未达文档末尾
if !lines[i+1].match("- *"):
doc.append_UL(ul_list)
ul_list.clear()
# OL
elif line.match("*. *"):
ol_list.append(line.split(".",true,1)[1])
if i < lines.size()-1: # 未达文档末尾
if !lines[i+1].match("*. *"):
doc.append_OL(ol_list)
ol_list.clear()
# 表格
elif line.match("|*|"):
var li = line.lstrip("|").rstrip("|") # 去除首尾的|
if !is_table_hr(line):
table_lines.append(li.split("|"))
if i == lines.size()-1: # 最后一行
doc.append_Table(table_lines)
table_lines.clear()
else:
if !lines[i+1].match("|*|"): # 未达文档末尾
print(table_lines)
else:# 被视为普通段落
if !line.match("```*"):
doc.append_Paragraph(line)
return doc
# 是否是表格分割线
static func is_table_hr(line:String):
var li = line.lstrip("|").rstrip("|") # 去除首尾的|
return li.replace("-","").replace("|","").strip_edges() == ""
# 是否是H1-H6
static func is_headding(line:String):
var bol
for i in range(6):
if line.match("%s *" % "#".repeat(i+1)):
bol = true
return bol
# 获取标题的等级
static func get_headding_level(line:String) -> int:
var lv
for i in range(6):
if line.match("%s *" % "#".repeat(i+1)):
lv = i + 1
return lv