【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:Stringfunc _to_string() -> String:return "\n```%s\n%s\n```" % [language,code]# 转化为HTMLfunc to_html() -> String:return "\n<code>\n<pre>\n%s\n</pre>\n</code>\n" % code
Headding
# H1-H6
class Headding:var level:intvar text:Stringfunc _to_string() -> String:return "%s %s" % ["#".repeat(level),text]# 转化为HTMLfunc to_html() -> String:return "\n<h{level}>{text}</h{level}>\n".format({"level":level,"text":text})
Paragraph
# 段落
class Paragraph:var text:Stringfunc _to_string() -> String:return "\n%s\n" % text #"\n%s" % text# 转化为HTMLfunc to_html() -> String:return "\n<p>{text}</p>\n".format({"text":text})
Img
# 图片
class Img:var src:Stringvar desc:Stringfunc _to_string() -> String:return "\n\n" % [desc,src]# 转化为HTMLfunc to_html() -> String:return "\n<img src = \"{src}\" alt=\"{desc}\">\n".format({"src":src,"desc":desc})
UL
# 无序列表
class UL:var list:PackedStringArrayfunc _to_string() -> String:return "- " + "\n- ".join(list)# 转化为HTMLfunc to_html() -> String:return "<ul>\n\t<li>" + "\n\t<li>".join(list) + "\n</ul>\n"
OL
# 有序列表
class OL:var list:PackedStringArrayfunc _to_string() -> String:var sttr:Stringfor i in range(list.size()):sttr += "%d. %s\n" % [i+1,list[i]]return sttr# 转化为HTMLfunc 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:Stringvar hrs:PackedStringArrayhrs.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# 转化为HTMLfunc 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 MDdocvar _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:Stringvar code:PackedStringArray# ============ 处理UL ============ var ul_list:PackedStringArrayvar ol_list:PackedStringArrayvar 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 = falsedoc.append_CodebBlock(code_language,"\n".join(code))code.clear();code_language="" # 还原初始状态else:if start_codeblock:code.append(line)# 非代码块if !start_codeblock:# H1-H6if 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("doc.append_Img(img[1],img[0])# ULelif 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()# OLelif 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 bolfor i in range(6):if line.match("%s *" % "#".repeat(i+1)):bol = truereturn bol# 获取标题的等级
static func get_headding_level(line:String) -> int:var lvfor i in range(6):if line.match("%s *" % "#".repeat(i+1)):lv = i + 1return 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 = levelh.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 = srcimg.desc = desc_doc.append(img)# 添加代码片段
func append_CodebBlock(language:String,code:String):var c = CodebBlock.new()c.language = languagec.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 EditorScriptfunc _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)
输出:
# 这是一个测试
## 概述这是一段文本这是一段文本```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 MDdocvar _doc:Array
# ============================ 内部类 ============================# 代码块
class CodebBlock:var language:String = ""var code:Stringfunc _to_string() -> String:return "\n```%s\n%s\n```" % [language,code]# H1-H6
class Headding:var level:intvar text:Stringfunc _to_string() -> String:return "%s %s" % ["#".repeat(level),text]
# 段落
class Paragraph:var text:Stringfunc _to_string() -> String:return "p:%s" % text #"\n%s" % text# 图片
class Img:var src:Stringvar desc:Stringfunc _to_string() -> String:return "\n\n" % [desc,src]# 无序列表
class UL:var list:PackedStringArrayfunc _to_string() -> String:return "- " + "\n- ".join(list)# 有序列表
class OL:var list:PackedStringArrayfunc _to_string() -> String:var sttr:Stringfor 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:Stringvar hrs:PackedStringArrayhrs.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 = levelh.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 = srcimg.desc = desc_doc.append(img)# 添加代码片段
func append_CodebBlock(language:String,code:String):var c = CodebBlock.new()c.language = languagec.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 = []passfunc _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:Stringvar code:PackedStringArray# ============ 处理UL ============ var ul_list:PackedStringArrayvar ol_list:PackedStringArrayvar 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 = falsedoc.append_CodebBlock(code_language,"\n".join(code))code.clear();code_language="" # 还原初始状态else:if start_codeblock:code.append(line)# 非代码块if !start_codeblock:# H1-H6if 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("doc.append_Img(img[1],img[0])# ULelif 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()# OLelif 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 bolfor i in range(6):if line.match("%s *" % "#".repeat(i+1)):bol = truereturn bol# 获取标题的等级
static func get_headding_level(line:String) -> int:var lvfor i in range(6):if line.match("%s *" % "#".repeat(i+1)):lv = i + 1return lv