2025-12-29 22:30:42 +02:00
|
|
|
# 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 = '%%%'
|
2025-12-31 13:17:39 +02:00
|
|
|
_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)>')
|
2025-12-29 22:30:42 +02:00
|
|
|
_link_begin_tag = 'LINK_BEGIN'
|
|
|
|
|
_link_end_tag = 'LINK_END'
|
2025-12-31 13:17:39 +02:00
|
|
|
_start_boundary = f'{_tag_delim}DECKTEXT_BEGIN{_tag_delim}'
|
|
|
|
|
_end_boundary = f'{_tag_delim}DECKTEXT_END{_tag_delim}'
|
2025-12-29 22:30:42 +02:00
|
|
|
_link_regex_src = f'{_tag_delim}{_link_begin_tag}:(.+?){_tag_delim}(.+?){_tag_delim}{_link_end_tag}{_tag_delim}'
|
2025-12-31 13:17:39 +02:00
|
|
|
_link_repl = f'{_tag_delim}{_link_begin_tag}:\\1{_tag_delim}\\2{_tag_delim}{_link_end_tag}{_tag_delim}'
|
2025-12-29 22:30:42 +02:00
|
|
|
_link_regex = re.compile(_link_regex_src)
|
|
|
|
|
|
2025-12-31 13:17:39 +02:00
|
|
|
# HTML sanitization and rendering
|
2025-12-29 22:30:42 +02:00
|
|
|
|
2025-12-31 13:17:39 +02:00
|
|
|
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
|
2025-12-29 22:30:42 +02:00
|
|
|
|
2025-12-31 13:17:39 +02:00
|
|
|
# (regex, repl) tuple list: order matters!
|
|
|
|
|
_render_regex_src = [
|
2025-12-31 13:41:38 +02:00
|
|
|
('<(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)>', ''),
|
2025-12-31 13:17:39 +02:00
|
|
|
('<!-- (decktext_begin|DECKTEXT_BEGIN) -->', _start_boundary),
|
|
|
|
|
('<!-- (decktext_end|DECKTEXT_END) -->', _end_boundary),
|
2025-12-31 13:41:38 +02:00
|
|
|
('<img(\s+[^>]*)?>', _replace_img_with_alt),
|
2025-12-31 13:17:39 +02:00
|
|
|
('<a\\ .*?href="(.+?)".*?>(.+?)<\\/a>', _link_repl),
|
2025-12-31 13:41:38 +02:00
|
|
|
('<(hr|HR)(\s+[^>]*)?>', f'\n{_page_boundary}\n'),
|
|
|
|
|
('<(b|B|strong|STRONG)(\s+[^>]*)?>', '**'),
|
2025-12-31 13:17:39 +02:00
|
|
|
('<\\/(b|B|strong|STRONG)>', '**'),
|
2025-12-31 13:41:38 +02:00
|
|
|
('<(i|I|em|EM)(\s+[^>]*)?>', '_'),
|
2025-12-31 13:17:39 +02:00
|
|
|
('<\\/(i|I|em|EM)>', '_'),
|
2025-12-31 13:41:38 +02:00
|
|
|
('<(tt|TT|code|CODE)(\s+[^>]*)?>', '`'),
|
2025-12-31 13:17:39 +02:00
|
|
|
('<\\/(tt|TT|code|CODE)>', '`'),
|
2025-12-31 13:41:38 +02:00
|
|
|
('<(s|S|del|DEL)(\s+[^>]*)?>', '~~'),
|
2025-12-31 13:17:39 +02:00
|
|
|
('<\\/(s|S|del|DEL)>', '~~'),
|
2025-12-31 13:41:38 +02:00
|
|
|
('<(pre|PRE|xmp|XMP)(\s+[^>]*)?>', '\n```\n'),
|
2025-12-31 13:17:39 +02:00
|
|
|
('<\\/(pre|PRE|xmp|XMP)>', '\n```\n'),
|
2025-12-31 13:41:38 +02:00
|
|
|
('<(blockquote|BLOCKQUOTE)(\s+[^>]*)?>', '\n>> '),
|
2025-12-31 13:17:39 +02:00
|
|
|
('<\\/(blockquote|BLOCKQUOTE)>', '\n'),
|
2025-12-31 13:41:38 +02:00
|
|
|
('<(li|LI)(\s+[^>]*)?>', '* '),
|
2025-12-31 13:17:39 +02:00
|
|
|
('<\\/(li|LI)>', '\n'),
|
|
|
|
|
('<.+?>', ''),
|
2025-12-31 13:48:12 +02:00
|
|
|
('©', '(C)'),
|
|
|
|
|
('™', '(TM)'),
|
|
|
|
|
('←', '<-'),
|
|
|
|
|
('→', '->'),
|
2025-12-31 13:17:39 +02:00
|
|
|
('<', '<'),
|
|
|
|
|
('>', '>'),
|
|
|
|
|
('&(ldquo|rdquo|quot);', '"'),
|
|
|
|
|
('&(rsquo|lsquo|apos);', "'"),
|
|
|
|
|
('&', '&')
|
|
|
|
|
]
|
2025-12-29 22:30:42 +02:00
|
|
|
|
2025-12-31 13:17:39 +02:00
|
|
|
_rendering_regexes = []
|
|
|
|
|
for r in _render_regex_src:
|
|
|
|
|
_rendering_regexes.append((re.compile(r[0]), r[1]))
|
2025-12-29 22:30:42 +02:00
|
|
|
|
|
|
|
|
# main class start
|
|
|
|
|
class DeckText:
|
|
|
|
|
def __init__(self, src=None):
|
|
|
|
|
self.title = ''
|
|
|
|
|
self.pages = []
|
|
|
|
|
if src:
|
|
|
|
|
self.load(src)
|
|
|
|
|
|
2025-12-31 13:17:39 +02:00
|
|
|
def sanitize_html(self, pagetext):
|
|
|
|
|
for regex in _rendering_regexes:
|
|
|
|
|
pagetext = regex[0].sub(regex[1], pagetext)
|
|
|
|
|
return pagetext.strip()
|
|
|
|
|
|
2025-12-29 22:30:42 +02:00
|
|
|
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:
|
2025-12-31 13:17:39 +02:00
|
|
|
# Stage 1: extract document title
|
2025-12-29 22:30:42 +02:00
|
|
|
try:
|
2025-12-31 13:17:39 +02:00
|
|
|
self.title = _title_regex.search(src).group(2).strip()
|
2025-12-29 22:30:42 +02:00
|
|
|
except:
|
|
|
|
|
self.title = ''
|
2025-12-31 13:17:39 +02:00
|
|
|
# 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
|
2025-12-29 22:30:42 +02:00
|
|
|
fragments = []
|
2025-12-31 13:17:39 +02:00
|
|
|
for part in docparts[1:]: # skip the part before the first marker
|
2025-12-29 22:30:42 +02:00
|
|
|
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 = []
|