diff --git a/.gitignore b/.gitignore index 0168e437..cab9f1cd 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ dist/ target/ Cargo.lock .vscode/ +.docs/ +.venv/ diff --git a/glad/__main__.py b/glad/__main__.py index 5403809d..19534dcf 100644 --- a/glad/__main__.py +++ b/glad/__main__.py @@ -19,7 +19,7 @@ from glad.sink import LoggingSink from glad.opener import URLOpener from glad.parse import FeatureSet -from glad.plugin import find_specifications, find_generators +from glad.plugin import find_specification_docs, find_specifications, find_generators from glad.util import parse_apis @@ -71,6 +71,11 @@ class GlobalConfig(Config): description='Makes the build reproducible by not fetching the latest ' 'specification from Khronos.' ) + DOCS = ConfigOption( + converter=bool, + default=False, + description='Include inline documentation in the generated files.' + ) def load_specifications(specification_names, opener, specification_classes=None): @@ -95,6 +100,32 @@ def load_specifications(specification_names, opener, specification_classes=None) return specifications +def load_documentations(specification_names, opener, spec_docs_classes=None): + specifications_docs = dict() + + if spec_docs_classes is None: + spec_docs_classes = find_specification_docs() + + for name in set(specification_names): + if name not in spec_docs_classes: + logger.warning('no documentation available for %r', name) + continue + + SpecificationDocs = spec_docs_classes[name] + spec_docs_file = SpecificationDocs.default_out_dir() + + if os.path.isfile(spec_docs_file): + logger.info("using local documentation: '%s'", spec_docs_file) + spec_docs = SpecificationDocs.from_file(spec_docs_file, opener=opener) + else: + logger.info('getting %r documentation from remote location', name) + spec_docs = SpecificationDocs.from_remote(opener=opener) + + specifications_docs[name] = spec_docs + + return specifications_docs + + def apis_by_specification(api_info, specifications): return groupby(api_info.items(), key=lambda api_info: specifications[api_info[1].specification]) @@ -153,9 +184,13 @@ def main(args=None): opener = URLOpener() gen_info_factory = GenerationInfo.create - specifications = load_specifications( - [value[0] for value in global_config['API'].values()], opener=opener - ) + spec_names = [value[0] for value in global_config['API'].values()] + specifications = load_specifications(spec_names, opener=opener) + + if global_config['DOCS']: + documentations = load_documentations(spec_names, opener=opener) + else: + documentations = None generator = generators[ns.subparser_name]( global_config['OUT_PATH'], opener=opener, gen_info_factory=gen_info_factory @@ -189,8 +224,12 @@ def select(specification, api, info): logging_sink.info('merged into {}'.format(feature_sets[0])) for feature_set in feature_sets: + api = feature_set.info.apis[0] + spec_docs = None + if documentations and api in documentations: + spec_docs = documentations[api].select(feature_set) logging_sink.info('generating feature set {}'.format(feature_set)) - generator.generate(specification, feature_set, config, sink=logging_sink) + generator.generate(specification, feature_set, config, sink=logging_sink, spec_docs=spec_docs) if __name__ == '__main__': diff --git a/glad/documentation.py b/glad/documentation.py new file mode 100644 index 00000000..420eb08b --- /dev/null +++ b/glad/documentation.py @@ -0,0 +1,226 @@ +import re +import zipfile +import glad.util +from pathlib import Path +from glad.parse import DocumentationSet, SpecificationDocs, CommandDocs, xml_fromstring +from glad.util import resolve_entities, suffix, raw_text + +class OpenGLRefpages(SpecificationDocs): + DOCS_NAME = 'opengl_refpages' + + URL = 'https://github.com/KhronosGroup/OpenGL-Refpages/archive/refs/heads/main.zip' + SPEC = 'gl' + + def select(self, feature_set): + current_major = max(info.version.major for info in feature_set.info) + commands = dict() + + # At the time of writing Khronos hosts documentations for gl4 and gl2.1. + available_versions = ['gl2.1'] + if current_major >= 4: + available_versions.insert(0, 'gl4') + + zf = zipfile.ZipFile(self.docs_file, 'r') + xml_files_in_version = { + version: [Path(name) for name in zf.namelist() if name.endswith('.xml') and version in name] + for version in available_versions + } + + for version_dir in available_versions: + for xml_file in xml_files_in_version[version_dir]: + with zf.open(str(xml_file)) as f: + xml_text = f.read().decode('utf-8') + parsed_docs = OpenGLRefpages.docs_from_xml( + xml_text, + version=version_dir, + filename=xml_file.stem, + ) + for command, docs in parsed_docs.items(): + commands.setdefault(command, docs) + + zf.close() + return DocumentationSet(commands=commands) + + @classmethod + def docs_from_xml(cls, xml_text, version=None, filename=None): + commands_parsed = dict() + + # Some files don't have the proper namespace declaration for MathML. + xml_text = xml_text.replace('', '') + # Entities need to be resolved before parsing. + xml_text = resolve_entities(xml_text) + + tree = xml_fromstring(xml_text.encode('utf-8')) + + # gl4 files contain large namespace declarations that pollute the tags. So we remove them. + for elem in tree.iter(): + try: + if elem.tag.startswith('{'): + elem.tag = elem.tag.split('}')[-1] + if elem.tag.contains(':'): + elem.tag = elem.tag.split(':')[-1] + for key in elem.attrib: + if key.startswith('{'): + elem.attrib[key.split('}')[-1]] = elem.attrib.pop(key) + except: + pass + + # Each refsect1 block contains a section of the documentation. + # Sections groups Description, Parameters, Notes, etc. + sections = tree.findall('.//refsect1') + + # Brief parsing + # Command brief description appears in the first 'refnamediv' block + brief_block = tree.find('.//refnamediv//refpurpose') + + if brief_block is None: + # No brief means file doesn't contain any command definitions. + return dict() + brief = suffix(".", cls.xml_text(brief_block)) + + if version == 'gl2.1': + docs_url = f'https://registry.khronos.org/OpenGL-Refpages/{version}/xhtml/{filename}.xml' + else: + docs_url = f'https://registry.khronos.org/OpenGL-Refpages/{version}/html/{filename}.xhtml' + + # Description parsing + description = [] + description_blocks = next( + (s for s in sections if raw_text(s.find('title')) == 'Description'), + None, + ) + if description_blocks is not None: + blocks = description_blocks.findall('./*') + description = list( + filter( + bool, + (re.sub(f'^{CommandDocs.BREAK}', '', cls.xml_text(p)) for p in blocks if p.tag != 'title'), + ), + ) + + # Notes parsing + notes = [] + notes_blocks = next((s for s in sections if raw_text(s.find('title')) == 'Notes'), None) + + if notes_blocks is not None: + blocks = notes_blocks.findall('./*') + notes = list( + filter( + bool, + (cls.xml_text(p) for p in blocks if p.tag != 'title'), + ), + ) + + # Parameters parsing + # Khronos specs puts all the function definitions inside funcsynopsis/funcdef blocks. + # + # However, instead of describing each function on a separate file, they group multiple + # related function definitions, whose parameters may be different, into a single file. + # This means that we have to find the correct block of parameters for each definition. + funcdefs = [ + d for d in tree.findall('.//funcsynopsis/*') + if d.find('.//funcdef') is not None + ] + + for func_def in funcdefs: + func_name = func_def.find('.//function').text + func_params = [raw_text(s) for s in func_def.findall('.//parameter')] + + + # Params are defined in a separate section, called 'Parameters for ' + # or just 'Parameters'. + params_block = next( + (s for s in sections if raw_text(s.find('title')) == f'Parameters for {func_name}'), + None, + ) + if params_block is None: + for p in (s for s in sections if raw_text(s.find('title')) == 'Parameters'): + block_params = [raw_text(n) for n in p.findall('.//term//parameter')] + # If all our func_params are contained in this block, we choose it. + if all(func_param in block_params for func_param in func_params): + params_block = p + break + + # At this point we interpret params_block=None as a void parameter list. + is_void = params_block is None + params_entries = params_block.findall('.//varlistentry/*') if not is_void else [] + + params = [] + # A description can apply for more than one param (term), so we stack them until + # we find a listitem, which is a description of a param. + terms_stack = [] + for param_or_desc in params_entries: + if param_or_desc.tag == 'term': + terms_stack.append(param_or_desc) + continue + if param_or_desc.tag == 'listitem': + # Now that we have a listitem, it is considered that all the terms + # on the stack have the same parameter description. + param_desc = cls.xml_text(param_or_desc).replace(CommandDocs.BREAK, '') + for terms in terms_stack: + param_names = [ + p.text for p in terms.findall('.//parameter') if p.text in func_params + ] + + for param_name in param_names: + params.append(CommandDocs.Param(param_name, param_desc)) + terms_stack.clear() + + commands_parsed[func_name] = CommandDocs( + func_name, brief, params, description, notes, None, None, docs_url, + ) + return commands_parsed + + @classmethod + def format(cls, e, parent=None, is_tail=False): + if is_tail: + if e.tag in ('term', 'mn', 'msub'): + return '' + return re.sub(r'\n+', '', e.tail) + + if e.tag == 'constant': + return f'`{e.text}`' + if e.tag == 'function': + return f'`{e.text}`' + if e.tag == 'emphasis': + return f'*{e.text}*' + if e.tag == 'term': + return f'\n{CommandDocs.BREAK}- {e.text.strip()}' + if e.tag == 'listitem': + if parent is None: + return f'\n{CommandDocs.BREAK}{e.text.strip()}' + if parent is not None and parent.tag == 'varlistentry': + return f'\n{CommandDocs.BREAK}{e.text.strip()}' + return f'\n{CommandDocs.BREAK}- {e.text.strip()}' + return re.sub(r'\n+', '', e.text) + + @classmethod + def xml_text(cls, e): + def paren(expr): + if re.match(r'^[a-zA-Z0-9_]+$', expr): + return expr + return f'({expr})' + + def mfenced(e): + if e.attrib['close']: + return f'{e.attrib["open"]}{", ".join(cls.xml_text(c) for c in e)}{e.attrib["close"]}' + return f'{e.attrib["open"]}{" ".join(cls.xml_text(c) for c in e)}' + + text = ''.join(glad.util.itertext( + e, + convert={ + 'table': lambda _: '(table omitted)', + 'informaltable': lambda _: '(table omitted)', + 'include': lambda _: '(table omitted)', + 'programlisting': lambda _: '(code omitted)', + 'mfrac': lambda e, : f'{paren(cls.xml_text(e[0]))}/{paren(cls.xml_text(e[1]))}', + 'msup': lambda e: f'{paren(cls.xml_text(e[0]))}^{paren(cls.xml_text(e[1]))}', + 'msub': lambda e: f'{paren(cls.xml_text(e[0]))}_{paren(cls.xml_text(e[1]))}', + 'mtd': lambda e: f'{cls.xml_text(e[0])}; ', + 'mfenced': mfenced, + }, + format=cls.format, + )) + # \u2062, \u2062, + # Invisible characters used by docs.gl to separate words. + return re.sub(r'\n?[ \u2062\u2061\t]+', ' ', text.strip()) diff --git a/glad/generator/__init__.py b/glad/generator/__init__.py index 75e017cf..5479a4ee 100644 --- a/glad/generator/__init__.py +++ b/glad/generator/__init__.py @@ -138,15 +138,16 @@ def modify_feature_set(self, spec, feature_set, config): """ return feature_set - def get_template_arguments(self, spec, feature_set, config): + def get_template_arguments(self, spec, feature_set, config, spec_docs=None): return dict( spec=spec, feature_set=feature_set, + spec_docs=spec_docs, options=config.to_dict(transform=lambda x: x.lower()), gen_info=self.gen_info_factory(self, spec, feature_set, config) ) - def generate(self, spec, feature_set, config, sink=LoggingSink(__name__)): + def generate(self, spec, feature_set, config, spec_docs=None, sink=LoggingSink(__name__)): feature_set = self.modify_feature_set(spec, feature_set, config) for template, output_path in self.get_templates(spec, feature_set, config): #try: @@ -156,7 +157,7 @@ def generate(self, spec, feature_set, config, sink=LoggingSink(__name__)): # raise ValueError('Unsupported specification/configuration') result = template.render( - **self.get_template_arguments(spec, feature_set, config) + **self.get_template_arguments(spec, feature_set, config, spec_docs=spec_docs) ) output_path = os.path.join(self.path, output_path) @@ -164,9 +165,9 @@ def generate(self, spec, feature_set, config, sink=LoggingSink(__name__)): with open(output_path, 'w') as f: f.write(result) - self.post_generate(spec, feature_set, config) + self.post_generate(spec, feature_set, config, spec_docs=None) - def post_generate(self, spec, feature_set, config): + def post_generate(self, spec, feature_set, config, spec_docs=None): pass diff --git a/glad/generator/c/__init__.py b/glad/generator/c/__init__.py index 70f27f8d..8ca2e9ce 100644 --- a/glad/generator/c/__init__.py +++ b/glad/generator/c/__init__.py @@ -378,8 +378,8 @@ def select(self, spec, api, version, profile, extensions, config, sink=LoggingSi return JinjaGenerator.select(self, spec, api, version, profile, extensions, config, sink=sink) - def get_template_arguments(self, spec, feature_set, config): - args = JinjaGenerator.get_template_arguments(self, spec, feature_set, config) + def get_template_arguments(self, spec, feature_set, config, spec_docs=None): + args = JinjaGenerator.get_template_arguments(self, spec, feature_set, config, spec_docs=spec_docs) # TODO allow MX for every specification/api if spec.name not in (VK.NAME, GL.NAME): @@ -412,7 +412,7 @@ def get_templates(self, spec, feature_set, config): return templates - def post_generate(self, spec, feature_set, config): + def post_generate(self, spec, feature_set, config, spec_docs=None): self._add_additional_headers(feature_set, config) def modify_feature_set(self, spec, feature_set, config): diff --git a/glad/generator/c/templates/template_utils.h b/glad/generator/c/templates/template_utils.h index cc5b320b..10c5eb7b 100644 --- a/glad/generator/c/templates/template_utils.h +++ b/glad/generator/c/templates/template_utils.h @@ -116,6 +116,39 @@ typedef {{ command.proto.ret|type_to_c }} (GLAD_API_PTR *{{ command.name|pfn }}) GLAD_API_CALL {{ command.name|pfn }} glad_{{ command.name }}; {% if debug %} GLAD_API_CALL {{ command.name|pfn }} glad_debug_{{ command.name }}; +{% endif %} +{% if spec_docs %} +{% set command_docs = spec_docs.docs_for_command_name(command.name) %} +{% if command_docs %} +/** +{% if command_docs.brief %} + * @brief [{{ command_docs.name }}]({{ command_docs.docs_url }}) + * — {{ command_docs.brief|wordwrap(80, wrapstring='\n * ') }} + * +{% endif %} +{% if command_docs.params|length > 0 %} +{% for param in command_docs.params %} + * @param {{ param.name }}{{ param.desc|wordwrap(80, wrapstring='\n * ') }} +{% endfor %} + * +{% endif %} +{% for paragraph in command_docs.description %} + * @details {{ paragraph|replace(command_docs.BREAK, '@details ')|wordwrap(80, wrapstring='\n * ') }} +{% endfor %} +{% if command_docs.notes|length > 0 %} + * +{% for note in command_docs.notes %} + * @note {{ note|replace(command_docs.BREAK, '@note ')|wordwrap(80, wrapstring='\n * ') }} +{% endfor %} +{% endif %} +{% if command_docs.see_also %} + * + * @see {{ command_docs.see_also|wordwrap(80, wrapstring='\n * ') }} +{% endif %} +*/ +{% endif %} +{% endif %} +{% if debug %} #define {{ command.name }} glad_debug_{{ command.name }} {% else %} #define {{ command.name }} glad_{{ command.name }} diff --git a/glad/generator/rust/__init__.py b/glad/generator/rust/__init__.py index a75efa47..1759841a 100644 --- a/glad/generator/rust/__init__.py +++ b/glad/generator/rust/__init__.py @@ -208,8 +208,8 @@ def select(self, spec, api, version, profile, extensions, config, sink=LoggingSi return JinjaGenerator.select(self, spec, api, version, profile, extensions, config, sink=sink) - def get_template_arguments(self, spec, feature_set, config): - args = JinjaGenerator.get_template_arguments(self, spec, feature_set, config) + def get_template_arguments(self, spec, feature_set, config, spec_docs=None): + args = JinjaGenerator.get_template_arguments(self, spec, feature_set, config, spec_docs=spec_docs) args.update( version=glad.__version__, diff --git a/glad/parse.py b/glad/parse.py index b1d26349..e15db6e0 100644 --- a/glad/parse.py +++ b/glad/parse.py @@ -11,25 +11,28 @@ def xml_parse(path): except ImportError: try: import xml.etree.cElementTree as etree + from xml.etree.cElementTree import XMLParser as parser except ImportError: import xml.etree.ElementTree as etree + from xml.etree.ElementTree import XMLParser as parser def xml_fromstring(argument): return etree.fromstring(argument) def xml_parse(path): - return etree.parse(path).getroot() + return etree.parse(path, parser=parser()).getroot() import re import copy import logging import os.path import warnings +from pathlib import Path from collections import defaultdict, OrderedDict, namedtuple, deque from contextlib import closing from itertools import chain from glad.opener import URLOpener -from glad.util import Version, topological_sort, memoize +from glad.util import Version, raw_text, topological_sort, memoize import glad.util logger = logging.getLogger(__name__) @@ -209,7 +212,6 @@ class Specification(object): def __init__(self, root): self.root = root - self._combined = None def _magic_require(self, api, profile): @@ -403,7 +405,7 @@ def commands(self): if len(parsed) > 0: commands.setdefault(parsed[0].name, []).extend(parsed) - # fixup aliases + # populate docs and fixup aliases for command in chain.from_iterable(commands.values()): if command.alias is not None and command.proto is None: aliased_command = command @@ -842,7 +844,7 @@ def from_element(element): # not so great workaround to get APIENTRY included in the raw output apientry.text = 'APIENTRY' - raw = ''.join(element.itertext()) + raw = raw_text(element) api = element.get('api') category = element.get('category') name = element.get('name') or element.find('name').text @@ -1142,12 +1144,13 @@ def from_element(cls, element, extnumber=None, **kwargs): class Command(IdentifiedByName): - def __init__(self, name, api=None, proto=None, params=None, alias=None): + def __init__(self, name, api=None, proto=None, params=None, alias=None, doc_comment=None): self.name = name self.api = api self.proto = proto self.params = params self.alias = alias + self.doc_comment = doc_comment if self.alias is None and self.proto is None: raise ValueError("command is neither a full command nor an alias") @@ -1453,3 +1456,90 @@ def from_element(cls, element, api=None): def __str__(self): return '{self.name}@{self.version!r}'.format(self=self) __repr__ = __str__ + + +class SpecificationDocs(object): + DOCS_NAME = None + + SPEC = None + URL = None + + def __init__(self, docs_file): + self.docs_file = docs_file + + def select(self, feature_set): + raise NotImplementedError + + @classmethod + def default_out_dir(cls): + if cls.DOCS_NAME is None: + raise ValueError('DOCS_NAME not set') + return Path('.docs') / f'{cls.DOCS_NAME}.zip' + + @classmethod + def from_url(cls, url, opener=None): + if opener is None: + opener = URLOpener.default() + + docs_file = cls.default_out_dir() + docs_file.parent.mkdir(parents=True, exist_ok=True) + + with closing(opener.urlopen(url)) as f: + raw = f.read() + + with open(docs_file, 'wb') as f: + f.write(raw) + + return cls(docs_file) + + @classmethod + def from_remote(cls, opener=None): + return cls.from_url(cls.URL, opener=opener) + + @classmethod + def from_file(cls, docs_file, opener=None): + return cls(docs_file) + + +class DocumentationSet(object): + def __init__(self, commands): + self.commands = commands + + def __str__(self): + return 'DocumentationSet(commands={})'.format(len(self.commands)) + __repr__ = __str__ + + def docs_for_command_name(self, name): + return self.commands.get(name, None) + + +class CommandDocs(object): + """ + Inline code documentation for a command/function. + """ + + # Template rendering will interpret this as a custom paragraph break. + # If no special break logic is needed, just use '\n'. + BREAK = '__BREAK__' + + class Param(namedtuple('Param', ['name', 'desc'])): + pass + + def __init__(self, name, brief, params, description, notes, errors, see_also, docs_url): + self.name = name + self.brief = brief + self.params = params + self.description = description + self.notes = notes + self.errors = errors + self.see_also = see_also + self.docs_url = docs_url + + def __str__(self): + return 'CommandDocs(brief={!r}, ' \ + 'params={!r}, description={!r}, notes={!r}, errors={!r}, see_also={!r})' \ + .format( + self.brief, self.params, self.description, self.notes, self.errors, self.see_also, + ) + + __repr__ = __str__ diff --git a/glad/plugin.py b/glad/plugin.py index ec5afe1e..6e3ddb87 100644 --- a/glad/plugin.py +++ b/glad/plugin.py @@ -13,9 +13,10 @@ def entry_points(group=None): from pkg_resources import iter_entry_points as entry_points import glad.specification +import glad.documentation from glad.generator.c import CGenerator from glad.generator.rust import RustGenerator -from glad.parse import Specification +from glad.parse import Specification, SpecificationDocs logger = logging.getLogger(__name__) @@ -23,6 +24,7 @@ def entry_points(group=None): GENERATOR_ENTRY_POINT = 'glad.generator' SPECIFICATION_ENTRY_POINT = 'glad.specification' +DOCUMENTATION_ENTRY_POINT = 'glad.documentation' DEFAULT_GENERATORS = dict( @@ -30,11 +32,15 @@ def entry_points(group=None): rust=RustGenerator ) DEFAULT_SPECIFICATIONS = dict() +DEFAULT_SPECIFICATION_DOCS = dict() for name, cls in inspect.getmembers(glad.specification, inspect.isclass): if issubclass(cls, Specification) and cls is not Specification: DEFAULT_SPECIFICATIONS[cls.NAME] = cls +for name, cls in inspect.getmembers(glad.documentation, inspect.isclass): + if issubclass(cls, SpecificationDocs) and cls is not SpecificationDocs: + DEFAULT_SPECIFICATION_DOCS[cls.SPEC] = cls def find_generators(default=None, entry_point=GENERATOR_ENTRY_POINT): generators = dict(DEFAULT_GENERATORS if default is None else default) @@ -54,3 +60,12 @@ def find_specifications(default=None, entry_point=SPECIFICATION_ENTRY_POINT): logger.debug('loaded specification %s: %s', entry_point.name, specifications[entry_point.name]) return specifications + +def find_specification_docs(default=None, entry_point=DOCUMENTATION_ENTRY_POINT): + documentations = dict(DEFAULT_SPECIFICATION_DOCS if default is None else default) + + for entry_point in entry_points(group=entry_point): + documentations[entry_point.name] = entry_point.load() + logger.debug('loaded documentation %s: %s', entry_point.name, documentations[entry_point.name]) + + return documentations diff --git a/glad/util.py b/glad/util.py index 99f543e2..ff078436 100644 --- a/glad/util.py +++ b/glad/util.py @@ -1,4 +1,5 @@ import functools +from itertools import chain import os import re import sys @@ -170,18 +171,35 @@ def memoized(*args, **kwargs): return memoize_decorator -def itertext(element, ignore=()): +def raw_text(e): + if e is None: + return '' + return ''.join(e.itertext()) + + +def _format_none(e, parent=None, is_tail=False): + return e.tail if is_tail else e.text + + +def itertext(element, parent=None, ignore=(), convert=dict(), format=_format_none): tag = element.tag + if tag in ignore: + return + if tag in convert: + yield convert[tag](element) + return + if not isinstance(tag, basestring) and tag is not None: return - if element.text: - yield element.text + if element.text is None: + element.text = '' + yield format(element, parent=parent) + for e in element: - if not e.tag in ignore: - for s in itertext(e, ignore=ignore): - yield s - if e.tail: - yield e.tail + for s in itertext(e, ignore=ignore, parent=element, convert=convert, format=format): + yield s + if e.tail: + yield format(e, parent=element, is_tail=True) def expand_type_name(name): @@ -201,3 +219,59 @@ def expand_type_name(name): prefix = upper_name.rsplit(suffix, 1)[0] return ExpandedName(prefix, suffix) + +def flatten(l): + return list(chain.from_iterable(l)) + +def prefix(prefix, text): + if not text: + return text + if text.strip().startswith(prefix): + return text + return f'{prefix}{text}' + +def suffix(suffix, text): + if not text: + return text + if text.strip().endswith(suffix): + return text + return f'{text}{suffix}' + +math_symbols_map = { + '×': '×', + '−': '-', + '⁢': ' ', + '⁡': '', + ' ': ' ', + '≠': '≠', + '≤': '≤', + '≥': '≥', + 'δ': 'Δ', + 'Δ': 'Δ', + '∂': '∂', + '″': '′', + '∞': '∞', + '+': '+', + '⋅': '⋅', + 'λ': 'λ', + '^': '^', + 'Σ': 'Σ', + '&CenterDot': '·', + '⌈': '⌈', + '⌉': '⌉', + '⌊': '⌊', + '⌋': '⌋', + '⌊': '⌊', + '⌋': '⌋', + '⌈': '⌈', + '⌉': '⌉', + '∥': '∥', + '∣': '|', + '{': '{', + '}': '}', +} + +def resolve_entities(xml_text, symbols_map=math_symbols_map): + for symbol, rep in symbols_map.items(): + xml_text = xml_text.replace(symbol, rep) + return xml_text