godot_voxel/doc/tools/xml_to_markdown.py
2021-04-10 01:43:55 +01:00

445 lines
13 KiB
Python

#!/usr/bin/python3
# coding: utf-8
# Converts a Godot Class XML file to an MD file
#
# There is nothing configurable in this file
# Run it without arguments for usage
import sys
import re
import xml.etree.ElementTree as ET
from time import gmtime, strftime
import os
godot_classes_url = "https://docs.godotengine.org/en/stable/classes"
def make_link(text, url):
return "[" + text + "](" + url + ")"
def make_type(name, module_class_names):
if name == "void":
link = "#"
elif name in module_class_names:
link = name + ".md"
else:
link = godot_classes_url + "/class_" + name.lower() + ".html"
return make_link(name, link)
# Assumes text is dedented
def format_regular_text(text, module_class_names):
# Doubling line endings otherwise Mkdocs doesn't give us enough space
text = text.replace('\n', '\n\n').replace('[code]', '`').replace('[/code]', '`')
s = ""
while True:
i = text.find('[')
if i == -1:
s += text
break
s += text[:i]
text = text[i + 1:]
i = text.find(']')
# There must be a closing bracket
assert i != -1
cmd = text[:i]
text = text[i + 1:]
if cmd.startswith('url='):
# [url=xxx]text[/url]
url = cmd[len('url='):]
i = text.find('[/url]')
assert i != -1
link_text = text[:i]
s += make_link(link_text, url)
text = text[i + len('[/url]'):]
elif cmd.find(' ') == -1:
# [typename]
s += make_type(cmd, module_class_names)
else:
# TODO members and shit
s += cmd
return s
# Removes all indentation found in common with all lines
def dedent(text):
lines = text.splitlines()
min_count = 99
for line in lines:
if line.strip() == "":
continue
count = 0
for c in line:
if c == '\t':
count += 1
else:
break
min_count = min(count, min_count)
dedented_lines = []
for line in lines:
dedented_lines.append(line[min_count:])
return "\n".join(dedented_lines)
def make_text(text, module_class_names):
text = dedent(text)
s = ""
while True:
i = text.find("[codeblock]")
if i == -1:
s += format_regular_text(text, module_class_names)
break
s += format_regular_text(text[:i], module_class_names)
text = text[i + len("[codeblock]"):]
s += "```gdscript"
i = text.find("[/codeblock]")
# There must be a closing tag
assert i != -1
s += re.sub(r'^\s\s', '', text[:i])
text = text[i + len("[/codeblock]"):]
s += "\n```"
return s.strip()
def make_single_line_text(text):
s = text.strip()
s = re.sub(r'\s\s+', r' ', s)
s = s.replace("[code]", '`')
s = s.replace("[/code]", '`')
return s
# `table` is a list of rows. Rows are list of strings.
def make_table(table):
if len(table) == 0:
return ""
column_count = len(table[0])
column_widths = [0] * column_count
for row in table:
assert len(row) == column_count
for i, text in enumerate(row):
column_widths[i] = max(len(text), column_widths[i])
column_widths = [w + 1 for w in column_widths]
#total_width = sum(column_widths) + len(column_widths) + 1
s = "\n"
for row_index, row in enumerate(table):
for column_index, text in enumerate(row):
if column_index > 0:
s += " | "
s += text
colw = column_widths[column_index]
for i in range(len(text), colw):
s += " "
if row_index == 0:
s += "\n"
for column_index in range(0, column_count):
if column_index > 0:
s += " | "
colw = column_widths[column_index]
for i in range(0, colw):
s += "-"
s += "\n"
# Hack to force some space after tables. Mkdocs sticks next contents too close otherwise.
s += "<p></p>"
return s
# `args` is a list of XML elements
def make_arglist(args, module_class_names):
s = "("
for arg_index, arg in enumerate(args):
if arg_index > 0:
s += ","
s += " " + make_type(arg.attrib['type'], module_class_names) + " " + arg.attrib['name']
if 'default' in arg.attrib:
s += "=" + arg.attrib['default']
s += " )"
return s
# `items` is a list of XML elements
def make_constants(items):
s = ""
for item in items:
s += "- **" + item.attrib['name'] + "** = **" + item.attrib['value'] + "**"
text = item.text.strip()
if text != "":
s += " --- " + make_single_line_text(item.text)
s += "\n"
return s
def make_custom_internal_link(name):
assert name.find(' ') == -1
return make_link(name, "#i_" + name)
# This is a hack we can do because Markdown allows to fallback on HTML
def make_custom_internal_anchor(name):
return '<span id="i_' + name + '"></span>'
# `f_xml` is the path to the source XML file.
# `f_out` is the path to the destination file. Use '-' for to print to stdout.
# `module_class_names` is a list of strings. Each string is a class name.
def process_xml(f_xml, f_out, module_class_names):
#print("Parsing", f_xml)
# Parse XML
tree = ET.parse(f_xml)
root = tree.getroot()
if root.tag != "class":
print("Error: No class found. Not a valid Godot class XML file!\n")
sys.exit(1)
# Header
out = "# " + root.attrib['name'] + "\n\n"
out += "Inherits: " + make_type(root.attrib['inherits'], module_class_names) + "\n\n"
out += "\n"
out += make_text(root.find('brief_description').text, module_class_names) + "\n\n"
text = make_text(root.find('description').text, module_class_names)
if text != "":
out += "## Description: \n\n" + text + "\n\n"
# Tutorials
tutorials = root.find('tutorials')
if tutorials is not None:
links = tutorials.findall('link')
if len(links) > 0:
out += "## Tutorials: \n\n"
for link in links:
out += "- [" + link.attrib['title'] + "](" + link.text + ")\n"
# Properties summary
members = []
members_container = root.find('members')
if members_container is not None:
members = members_container.findall('member')
if len(members) > 0:
out += "## Properties: \n\n"
table = [["Type", "Name", "Default"]]
for member in members:
row = [
"`" + member.attrib['type'] + "`",
make_custom_internal_link(member.attrib['name'])
]
if 'default' in member.attrib:
row.append(member.attrib['default'])
else:
row.append("")
table.append(row)
out += make_table(table)
out += "\n\n"
# Methods summary
methods = []
methods_container = root.find('methods')
if methods_container is not None:
methods = methods_container.findall('method')
if len(methods) > 0:
out += "## Methods: \n\n"
table = [["Return", "Signature"]]
# TODO Remove from list if it's a getter/setter of a property
for method in methods:
signature = make_custom_internal_link(method.attrib['name']) + " "
args = method.findall('argument')
signature += make_arglist(args, module_class_names)
signature += " "
if 'qualifiers' in method.attrib:
signature += method.attrib['qualifiers']
return_node = method.find('return')
row = [
make_type(return_node.attrib['type'], module_class_names),
signature
]
table.append(row)
out += make_table(table)
out += "\n\n"
# Signals
signals = []
signals_container = root.find('signals')
if signals_container is not None:
signals = signals_container.findall('signal')
if len(signals) > 0:
out += "## Signals: \n\n"
for signal in signals:
out += "- "
out += signal.attrib['name']
args = signal.findall('argument')
out += make_arglist(args, module_class_names)
out += " \n\n"
desc = signal.find('description')
if desc is not None:
text = make_text(desc.text, module_class_names)
if text != "":
out += text
out += "\n\n"
# Enumerations and constants
constants_container = root.find('constants')
if constants_container is not None:
generic_constants = constants_container.findall('constant')
enums = {}
constants = []
for generic_constant in generic_constants:
if 'enum' in generic_constant.attrib:
name = generic_constant.attrib['enum']
if name in enums:
enum_items = enums[name]
else:
enum_items = []
enums[name] = enum_items
enum_items.append(generic_constant)
else:
constants.append(generic_constant)
# Enums
if len(enums) > 0:
out += "## Enumerations: \n\n"
for enum_name, enum_items in enums.items():
out += "enum **" + enum_name + "**: \n\n"
out += make_constants(enum_items)
out += "\n"
out += "\n"
# Constants
if len(constants) > 0:
out += "## Constants: \n\n"
out += make_constants(constants)
out += "\n"
# Property descriptions
if len(members) > 0:
out += "## Property Descriptions\n\n"
for member in members:
out += "- " + make_type(member.attrib['type'], module_class_names) \
+ make_custom_internal_anchor(member.attrib['name']) + " **" + member.attrib['name'] + "**"
if 'default' in member.attrib:
out += " = " + member.attrib['default']
out += "\n\n"
if member.text is not None:
text = make_text(member.text, module_class_names)
if text != "":
out += text
out += "\n"
out += "\n"
# Method descriptions
if len(methods) > 0:
out += "## Method Descriptions\n\n"
for method in methods:
return_node = method.find('return')
out += "- " + make_type(return_node.attrib['type'], module_class_names) \
+ make_custom_internal_anchor(method.attrib['name']) + " **" + method.attrib['name'] + "**"
args = method.findall('argument')
out += make_arglist(args, module_class_names)
out += " "
if 'qualifiers' in method.attrib:
signature += method.attrib['qualifiers']
out += "\n\n"
desc = method.find('description')
if desc is not None:
text = make_text(desc.text, module_class_names)
if text != "":
out += text
out += "\n"
out += "\n"
# Footer
out += "_Generated on " + strftime("%b %d, %Y", gmtime()) + "_\n"
#Full time stamp "%Y-%m-%d %H:%M:%S %z"
if f_out == '-':
print(out)
else:
outfile = open(f_out, mode='a', encoding='utf-8')
outfile.write(out)
def process_xml_folder(src_dir, dst_dir, verbose):
# Make output dir and remove old files
if not os.path.isdir(dst_dir):
if verbose:
print("Making output directory: " + dst_dir)
os.makedirs(dst_dir)
for i in dst_dir.glob("*.md"):
if verbose:
print("Removing old: ", i)
os.remove(i)
# Convert files to MD
xml_files = list(src_dir.glob("*.xml"))
doc_files = []
count = 0
class_names = []
for xml_file in xml_files:
class_names.append(xml_file.stem)
for src in xml_files:
dest = dst_dir / (src.stem + ".md")
if verbose:
print("Converting ", src, dest)
process_xml(src, dest, class_names)
count += 1
doc_files.append(dest)
#generate_class_index(output_path, doc_files, verbose)
#count += 1
print("Generated %d files in %s." % (count, dst_dir))
###########################
## Main()
# If called from command line
if __name__ == "__main__":
# Print usage if no args
if len(sys.argv) < 2:
print("Usage: %s infile [outfile]" % sys.argv[0])
print("Prints to stdout if outfile is not specified.\n")
sys.exit(0)
# Input file
infile = sys.argv[1]
# Print to screen if no output
if len(sys.argv) < 3:
outfile = "-"
else:
outfile = sys.argv[2]
process_xml(infile, outfile, [])