Files
t-deckard/deck/text.py
T
2025-12-31 13:48:12 +02:00

134 lines
4.8 KiB
Python

# 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)>', ''),
('<!-- (decktext_begin|DECKTEXT_BEGIN) -->', _start_boundary),
('<!-- (decktext_end|DECKTEXT_END) -->', _end_boundary),
('<img(\s+[^>]*)?>', _replace_img_with_alt),
('<a\\ .*?href="(.+?)".*?>(.+?)<\\/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'),
('<.+?>', ''),
('&copy;', '(C)'),
('&trade;', '(TM)'),
('&larr;', '<-'),
('&rarr;', '->'),
('&lt;', '<'),
('&gt;', '>'),
('&(ldquo|rdquo|quot);', '"'),
('&(rsquo|lsquo|apos);', "'"),
('&amp;', '&')
]
_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 = []