# deck.text: Basic DeckText parsing support # (see decktext-spec.md for the specification) # Created by Luxferre in 2025, released into public domain import re # internal tag delimiters and boundaries _tag_delim = '%%%' _page_boundary = f'{_tag_delim}PAGE_SEP{_tag_delim}' _title_regex = re.compile('<(title|TITLE)>(.*?)<\/(title|TITLE)>') _link_regex = re.compile('<(a|A).*?(href|HREF)="(.+?)">(.+?)<\\/(a|A)>') _link_begin_tag = 'LINK_BEGIN' _link_end_tag = 'LINK_END' _start_boundary = f'{_tag_delim}DECKTEXT_BEGIN{_tag_delim}' _end_boundary = f'{_tag_delim}DECKTEXT_END{_tag_delim}' _link_regex_src = f'{_tag_delim}{_link_begin_tag}:(.+?){_tag_delim}(.+?){_tag_delim}{_link_end_tag}{_tag_delim}' _link_repl = f'{_tag_delim}{_link_begin_tag}:\\1{_tag_delim}\\2{_tag_delim}{_link_end_tag}{_tag_delim}' _link_regex = re.compile(_link_regex_src) # HTML sanitization and rendering def _replace_img_with_alt(match): tag_content = match.group(0) # Search for the alt attribute specifically within the matched img tag alt_match = re.search('alt=["\'](.*?)["\']', tag_content) if alt_match: return f'[IMG: {alt_match.group(1)}]' return '' # Return empty string if no alt is found # (regex, repl) tuple list: order matters! _render_regex_src = [ ('<(head|HEAD)(\s+[^>]*)?>.*?<\\/(head|HEAD)>', ''), ('<(script|SCRIPT)(\s+[^>]*)?>.*?<\\/(script|SCRIPT)>', ''), ('<(style|STYLE)(\s+[^>]*)?>.*?<\\/(style|STYLE)>', ''), ('<(audio|AUDIO)(\s+[^>]*)?>.*?<\\/(audio|AUDIO)>', ''), ('<(video|VIDEO)(\s+[^>]*)?>.*?<\\/(video|VIDEO)>', ''), ('<(object|OBJECT)(\s+[^>]*)?>.*?<\\/(object|OBJECT)>', ''), ('<(canvas|CANVAS)(\s+[^>]*)?>.*?<\\/(canvas|CANVAS)>', ''), ('', _start_boundary), ('', _end_boundary), (']*)?>', _replace_img_with_alt), ('(.+?)<\\/a>', _link_repl), ('<(hr|HR)(\s+[^>]*)?>', f'\n{_page_boundary}\n'), ('<(b|B|strong|STRONG)(\s+[^>]*)?>', '**'), ('<\\/(b|B|strong|STRONG)>', '**'), ('<(i|I|em|EM)(\s+[^>]*)?>', '_'), ('<\\/(i|I|em|EM)>', '_'), ('<(tt|TT|code|CODE)(\s+[^>]*)?>', '`'), ('<\\/(tt|TT|code|CODE)>', '`'), ('<(s|S|del|DEL)(\s+[^>]*)?>', '~~'), ('<\\/(s|S|del|DEL)>', '~~'), ('<(pre|PRE|xmp|XMP)(\s+[^>]*)?>', '\n```\n'), ('<\\/(pre|PRE|xmp|XMP)>', '\n```\n'), ('<(blockquote|BLOCKQUOTE)(\s+[^>]*)?>', '\n>> '), ('<\\/(blockquote|BLOCKQUOTE)>', '\n'), ('<(li|LI)(\s+[^>]*)?>', '* '), ('<\\/(li|LI)>', '\n'), ('<.+?>', ''), ('©', '(C)'), ('™', '(TM)'), ('←', '<-'), ('→', '->'), ('<', '<'), ('>', '>'), ('&(ldquo|rdquo|quot);', '"'), ('&(rsquo|lsquo|apos);', "'"), ('&', '&') ] _rendering_regexes = [] for r in _render_regex_src: _rendering_regexes.append((re.compile(r[0]), r[1])) # main class start class DeckText: def __init__(self, src=None): self.title = '' self.pages = [] if src: self.load(src) def sanitize_html(self, pagetext): for regex in _rendering_regexes: pagetext = regex[0].sub(regex[1], pagetext) return pagetext.strip() def _render_page(self, pagetext): page_links = [] # list of tuples in (url, title) format linkpool = pagetext = pagetext.strip() linknum = 1 while True: # parse links first linkmatch = _link_regex.search(linkpool) if linkmatch is None: break fullref = linkmatch.group(0) url = linkmatch.group(1).strip() title = linkmatch.group(2).strip() page_links.append((url, title)) linkpool = linkpool.replace(fullref, '') pagetext = pagetext.replace(fullref, f'{title} ([{linknum}])') linknum += 1 return { 'content': pagetext, 'links': page_links } def load(self, src:str): if len(src) > 0: # Stage 1: extract document title try: self.title = _title_regex.search(src).group(2).strip() except: self.title = '' # Stage 2: strip everything unnecessary and render almost everything src = self.sanitize_html(src) # Stage 3: determine if we need to go to the fallback mode docparts = src.split(_start_boundary) if len(docparts) < 2: # no DeckText start marker detected docparts = ['', docparts[0]] # emulate this # Stage 4: only leave the contents between the DeckText begin/end markers fragments = [] for part in docparts[1:]: # skip the part before the first marker frag = part # by default, we may not use an end marker although that's bad try: frag = part.split(_end_boundary)[0] except: pass fragments.append(frag.strip()) # parse stage: shape individual pages and pass them to the renderer self.pages = list(map(self._render_page, '\n'.join(fragments).split(_page_boundary))) else: # reset on empty string self.title = '' self.pages = []