retronews

a featureful fork of the luke8086/retronews hn+lobste.rs tui
Log | Files | Refs | README | LICENSE

commit 305022ed622631453b9de70c621c17eef38bcb5b
parent 2888c6691d8ca982b7dee46565d5d696e556ab49
Author: luke8086 <55237178+luke8086@users.noreply.github.com>
Date:   Fri, 16 Jun 2023 09:25:17 +0000

Rewrite HTML renderer to handle lobste.rs messages

Diffstat:
Mretronews.py | 326++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mtests.py | 21+++++++++++----------
Mtests/test_hn_02.out | 3++-
Mtests/test_hn_03.out | 3++-
Mtests/test_hn_04.out | 3++-
Mtests/test_hn_05.out | 3++-
Mtests/test_hn_06.out | 3++-
Mtests/test_lb_01.out | 3++-
Mtests/test_lb_02.out | 9+++++----
Mtests/test_lb_03.out | 4+++-
Mtests/test_lb_04.html | 3++-
Mtests/test_lb_04.out | 20++++++++++++++------
Mtests/test_lb_05.out | 7++++++-
Mtests/test_lb_06.out | 3++-
Mtests/test_lb_07.out | 3++-
Mtests/test_lb_08.out | 11+++++++----
Mtests/test_lb_09.out | 9++++++---
17 files changed, 335 insertions(+), 99 deletions(-)

diff --git a/retronews.py b/retronews.py @@ -303,71 +303,91 @@ class LBComment(TypedDict): parent_comment: Optional[str] -class HTMLParser(html.parser.HTMLParser): +HTML_BLOCK_TAGS = set(("root", "p", "pre", "blockquote", "ul", "ol", "li", "hr")) +HTML_INLINE_TAGS = set(("code", "a", "em", "strong", "b", "br")) +HTML_KNOWN_TAGS = HTML_BLOCK_TAGS.union(HTML_INLINE_TAGS) +HTML_AUTOCLOSE_TAGS = set(("hr", "br")) + + +@dataclasses.dataclass +class HTMLNode: + tag: str + + parent: Optional["HTMLNode"] = None + + prev_sibling: Optional["HTMLNode"] = None + next_sibling: Optional["HTMLNode"] = None + + first_child: Optional["HTMLNode"] = None + last_child: Optional["HTMLNode"] = None + + attrs: dict[str, Optional[str]] = dataclasses.field(default_factory=dict) text: str = "" - current_link: Optional[str] = None - in_pre: bool = False - - def handle_link_data(self, data: str, link: str) -> None: - if data == link: - # Data is identical to the link - self.text += data - elif data.endswith("...") and link.startswith(data[:-3]): - # Replace HN-shortened URL with the full one - self.text += link - else: - # Insert both the text and the full link - self.text += f"{data} ({link})" + pre: bool = False + + +class HTMLParser(html.parser.HTMLParser): + root_node: HTMLNode + current_node: HTMLNode + pre_level = 0 + + def __init__(self): + super().__init__() + + self.root_node = self.current_node = HTMLNode(tag="root") def handle_data(self, data: str) -> None: - if self.current_link is not None: - # Data is inside of a link - return self.handle_link_data(data, self.current_link) + if self.current_node.tag in HTML_AUTOCLOSE_TAGS: + self.handle_endtag(self.current_node.tag) - if not self.in_pre and self.text[-1:] == "\n": - # Outside of <pre>, trim any initial spacing in a line - data = data.lstrip() + node = HTMLNode(tag="text", text=data, pre=self.pre_level > 0) + html_node_append(self.current_node, node) - if not self.in_pre: - # Outside of <pre>, replace newlines with spaces - data = data.replace("\n", " ") + def handle_starttag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None: + if self.current_node.tag in HTML_AUTOCLOSE_TAGS: + self.handle_endtag(self.current_node.tag) - self.text += data + if tag not in HTML_KNOWN_TAGS: + return - def handle_starttag(self, tag: str, attr: list[tuple[str, Optional[str]]]) -> None: - if tag == "a": - self.current_link = dict(attr).get("href") - elif tag == "i": - self.text += "*" - elif tag == "pre": - self.in_pre = True + if tag == "pre": + self.pre_level += 1 + + node = HTMLNode(tag=tag, attrs=dict(attrs), pre=self.pre_level > 0) + html_node_append(self.current_node, node) + self.current_node = node def handle_endtag(self, tag: str) -> None: - if tag == "br": - self.text += "\n" - elif tag == "p": - self.text += "\n\n" - elif tag == "a": - self.current_link = None - elif tag == "i": - self.text += "*" - elif tag == "pre": - self.text += "\n" - self.in_pre = False - - -def wrap_paragraph(text: str) -> list[str]: + if tag not in HTML_KNOWN_TAGS: + return + + if tag == "pre": + self.pre_level = max(0, self.pre_level - 1) + + while True: + node = self.current_node + + if node.parent is None: + break + + self.current_node = node.parent + + if node.tag == tag: + break + + +def text_wrap(text: str, width=70) -> str: if len(text) == 0: # Preserve empty lines - return [""] + return "" if text.startswith(" "): # Preserve code indentation - return [text] + return text if REFERENCE_REX.match(text): # Keep reference numbers with long links in the same line - return [text] + return text indent = "" @@ -375,10 +395,23 @@ def wrap_paragraph(text: str) -> list[str]: # Preserve quotation symbols in subsequent lines indent = match[0] - return wrap(text, subsequent_indent=indent, break_on_hyphens=False, break_long_words=False) + lines = wrap(text, width, subsequent_indent=indent, break_on_hyphens=False, break_long_words=False) + lines = [line.rstrip() for line in lines] + return "\n".join(lines) -def sanitize_text(text: Optional[str]) -> str: + +def text_indent(text: str, initial: str, recurring: Optional[str] = None) -> str: + if recurring is None: + recurring = initial + + lines = text.split("\n") + lines = [(initial + lines[0]).rstrip()] + [(recurring + line).rstrip() for line in lines[1:]] + + return "\n".join(lines) + + +def text_sanitize(text: Optional[str]) -> str: # For safety, remove any control characters except for \n and \t # At least on HN some messages contain \x00 characters @@ -390,20 +423,191 @@ def sanitize_text(text: Optional[str]) -> str: return text -def parse_html(html: str) -> list[str]: - # This parser works well for HN messages because their markup is simple, and it can do +def html_node_children(parent: HTMLNode) -> list[HTMLNode]: + ret: list[HTMLNode] = [] + node = parent.first_child + + while node is not None: + ret.append(node) + node = node.next_sibling + + return ret + + +def html_node_append(parent: HTMLNode, child: HTMLNode) -> None: + if parent.first_child is None: + parent.first_child = child + + if parent.last_child is not None: + parent.last_child.next_sibling = child + child.prev_sibling = parent.last_child + + parent.last_child = child + child.parent = parent + + +def html_node_unlink(node: HTMLNode) -> None: + if node.prev_sibling: + node.prev_sibling.next_sibling = node.next_sibling + + if node.next_sibling: + node.next_sibling.prev_sibling = node.prev_sibling + + if node.parent and node.parent.first_child is node: + node.parent.first_child = node.next_sibling + + if node.parent and node.parent.last_child is node: + node.parent.last_child = node.prev_sibling + + node.parent = node.prev_sibling = node.next_sibling = None + + +def html_node_dump(node: HTMLNode) -> str: + lines = [] + lines.append(f"{node.tag} {repr(node.attrs)}") + + for child in html_node_children(node): + if child.tag == "text": + lines.append(" text " + repr(child.text)) + else: + lines += [" " + line for line in html_node_dump(child).split("\n")] + + return "\n".join(lines) + + +def html_node_trim_whitespace(node: HTMLNode) -> None: + if node.pre: + node.text = node.text.rstrip("\r\n\t ") + return + + text = node.text.strip("\r\n\t ") + text = re.sub(r"[\r\n\t ]+", " ", text) + text = text.replace("\x00", "\n") + text = re.sub(r" *\n *", "\n", text) + + node.text = text + + +def html_node_process_inline(node: HTMLNode, inline=False) -> str: + """Traverse tree flattening all inline nodes into text nodes""" + + if node.tag not in HTML_BLOCK_TAGS: + # Never disable inline if already enabled + inline = True + + text = "".join(html_node_process_inline(c, inline) for c in html_node_children(node)) + + if not inline: + return "" + + if node.tag == "text": + text = node.text + elif node.tag == "br": + text = "\x00" + elif node.tag == "em" or node.tag == "i": + text = f"/{text}/" + elif node.tag == "strong" or node.tag == "b": + text = f"*{text}*" + elif node.tag == "code" and not node.pre: + text = f"`{text}`" + elif node.tag == "a": + href = node.attrs.get("href", "") or "" + if text.endswith("...") and href.startswith(text[:-3]): + # Workaround for link formatting on HN + text = href + elif text != href: + text += " " + href + + node.tag = "text" + node.text = text + node.first_child = node.last_child = None + + return text + + +def html_node_process_text(node: HTMLNode): + """Traverse tree merging, trimming and pruning text nodes""" + + for child in html_node_children(node): + html_node_process_text(child) + + # Merge adjacent text nodes + for child in html_node_children(node): + prev = child.prev_sibling + if child.tag == "text" and prev is not None and prev.tag == "text" and child.pre == prev.pre: + child.text = prev.text + child.text + html_node_unlink(prev) + + # Trim whitespace from text nodes + for child in html_node_children(node): + if child.tag == "text": + html_node_trim_whitespace(child) + + # Remove empty text nodes + for child in html_node_children(node): + if child.tag == "text" and child.text == "": + html_node_unlink(child) + + +def html_node_render_block(node: HTMLNode, width=70) -> str: + if node.tag == "blockquote" or node.tag == "li" or node.tag == "pre": + width -= 2 + + parts = [] + + if node.tag == "hr": + parts.append("-" * width) + + for child in html_node_children(node): + if child.tag == "text" and child.pre: + parts.append(child.text) + + elif child.tag == "text" and not child.pre: + subparts = [text_wrap(p, width) for p in child.text.split("\n")] + parts.append("\n".join(subparts)) + + else: + parts.append(html_node_render_block(child, width)) + + if child.next_sibling is not None and child.tag != "li": + parts.append("") + + text = "\n".join(parts) + + if node.tag == "blockquote": + text = text_indent(text, "> ") + elif node.tag == "pre": + text = text_indent(text, "| ") + elif node.tag == "li": + text = text_indent(text, "- ", " ") + + return text + + +def html_render(html: str) -> str: + # This renderer works well for HN messages because their markup is simple, and it can do # some custom optimizations, like expanding ellipsis-shortened links, preserving quote # symbols in wrapped lines, and preventing references with long urls from being broken # into separate lines. For other backends it may make more sense to use an external app # (links, w3m, etc) + html = text_sanitize(html) + parser = HTMLParser() parser.feed(html) parser.close() - raw_lines = parser.text.strip("\n").split("\n") + node = parser.root_node + + log_sep = "\n" + "-" * 80 + "\n" + logging.debug(f"Initial HTML tree{log_sep}{html_node_dump(node)}{log_sep}") + + html_node_process_inline(node) + html_node_process_text(node) - return reduce(lambda acc, p: acc + wrap_paragraph(p), raw_lines, []) + logging.debug(f"Processed HTML tree{log_sep}{html_node_dump(node)}{log_sep}") + + return html_node_render_block(node) def fetch(url: str) -> str: @@ -694,7 +898,7 @@ def msg_flatten_thread(msg: Message, prefix: str = "", is_last_child: bool = Fal def msg_build_raw_lines(msg: Message) -> list[str]: - text = sanitize_text(msg.body) + text = text_sanitize(msg.body) # Unescape selected entities for better readability repl = {"&#x2F;": "/", "&#x27;": "'", "&quot;": '"'} @@ -713,7 +917,7 @@ def msg_build_lines(msg: Message) -> list[str]: "", ] - lines += parse_html(sanitize_text(msg.body)) if not msg.is_deleted else ["<deleted>"] + lines += html_render(msg.body or "").split("\n") if not msg.is_deleted else ["<deleted>"] return lines @@ -1090,7 +1294,7 @@ def app_get_pager_line_attr(app: AppState, line: str) -> int: return app.colors["nested_quote"] elif line.startswith(">"): return app.colors["quote"] - elif line.startswith(" "): + elif line.startswith("| "): return app.colors["code"] elif line == "~": return app.colors["empty_pager_line"] @@ -1211,7 +1415,7 @@ def setup_logging(path: Optional[str]) -> None: return logging.disable() format = "%(asctime)s %(levelname)s: %(message)s" - stream = open(path, "a") + stream = sys.stderr if path == "-" else open(path, "a") logging.basicConfig(format=format, level="DEBUG", stream=stream) logging.debug("Session started") @@ -1228,13 +1432,13 @@ if __name__ == "__main__": ap.add_argument("-r", "--render", metavar="PATH", default=None, help="render raw html message and quit") args = ap.parse_args() + setup_logging(args.logfile) + if (path := args.render) is not None: with open(path) as fp: - print("\n".join(parse_html(sanitize_text(fp.read())))) + print(html_render(fp.read())) sys.exit(0) - setup_logging(args.logfile) - try: db = db_init(args.db) ret = curses.wrapper(app_main, db, args.tab) diff --git a/tests.py b/tests.py @@ -1,4 +1,5 @@ import os +import subprocess import unittest import retronews @@ -6,36 +7,36 @@ import retronews TC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tests") -class TestHtmlParser(unittest.TestCase): +class TestHtmlRender(unittest.TestCase): maxDiff = None - def checkFormatting(self, name: str): + def checkRendering(self, name: str): html_path = os.path.join(TC_DIR, f"{name}.html") out_path = os.path.join(TC_DIR, f"{name}.out") with open(html_path) as fp: html = fp.read() - actual = "\n".join(retronews.parse_html(retronews.sanitize_text(html))).strip() + actual = retronews.html_render(html) if not os.path.exists(out_path): with open(out_path, "w") as fp: fp.write(actual) + return - with open(out_path) as fp: - expected = fp.read().strip() + cmd = ["diff", "-Nru", "--color=always", out_path, "-"] + proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True) + stdout, stderr = proc.communicate(input=actual) - if actual != expected: - sep = "\n" + "-" * 64 + "\n" - msg = f"\n\nExpected:{sep}{expected}{sep}\n\nActual:{sep}{actual}{sep}" - self.fail(msg) + if proc.returncode != 0: + self.fail(f"Unexpected rendering output\n{stdout}") def setup_test_cases(): tcs = [x.split(".")[0] for x in sorted(os.listdir(TC_DIR)) if x.endswith(".html")] for tc in tcs: - setattr(TestHtmlParser, tc, lambda self, tc=tc: self.checkFormatting(tc)) + setattr(TestHtmlRender, tc, lambda self, tc=tc: self.checkRendering(tc)) if __name__ == "__main__": diff --git a/tests/test_hn_02.out b/tests/test_hn_02.out @@ -5,4 +5,4 @@ > te. Noster nominati recteque no has. Lorem ipsum dolor sit amet, pro eu soleat civibus. Mel quas sensibus -te. Noster nominati recteque no has. +te. Noster nominati recteque no has. +\ No newline at end of file diff --git a/tests/test_hn_03.out b/tests/test_hn_03.out @@ -1 +1 @@ -https://example.com/foo/bar +https://example.com/foo/bar +\ No newline at end of file diff --git a/tests/test_hn_04.out b/tests/test_hn_04.out @@ -4,4 +4,4 @@ Lorem ipsum dolor sit amet, pro eu soleat civibus. Mel quas sensibus [1] - https://long.long.long.long.long.long.long.long.long.long.long.example.com -[2] https://long.long.long.long.long.long.long.long.long.long.long.example.com +[2] https://long.long.long.long.long.long.long.long.long.long.long.example.com +\ No newline at end of file diff --git a/tests/test_hn_05.out b/tests/test_hn_05.out @@ -13,4 +13,4 @@ te. Noster nominati recteque no has. | return None Lorem ipsum dolor sit amet, pro eu soleat civibus. Mel quas sensibus -te. Noster nominati recteque no has. +te. Noster nominati recteque no has. +\ No newline at end of file diff --git a/tests/test_hn_06.out b/tests/test_hn_06.out @@ -2,4 +2,4 @@ Lorem ipsum dolor sit amet, pro eu soleat civibus. | lambda L: [] if L==[] else qsort([x for x in L[1:] if x< L[0]]) + L[0:1] + qsort([x for x in L[1:] if x>=L[0]]) -Mel quas sensibus te. Noster nominati recteque no has. +Mel quas sensibus te. Noster nominati recteque no has. +\ No newline at end of file diff --git a/tests/test_lb_01.out b/tests/test_lb_01.out @@ -17,4 +17,4 @@ adipiscing elit, sed do eiusmod tempor Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Lorem ipsum dolor sit amet, consectetur -adipiscing elit, sed do eiusmod tempor +adipiscing elit, sed do eiusmod tempor +\ No newline at end of file diff --git a/tests/test_lb_02.out b/tests/test_lb_02.out @@ -5,13 +5,13 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Lorem ipsum dolor sit amet, consectetur adipiscing -elit, sed do eiusmod tempor. Lorem ipsum dolor sit amet, consectetur -adipiscing elit, sed do eiusmod tempor +elit, sed do eiusmod tempor. Lorem `ipsum` dolor sit amet, +`consectetur adipiscing` elit, sed do eiusmod tempor | def hello_world(): | print('hello') | if True: | print('world') | print('!!!') -| -| hello_world() +| +| hello_world() +\ No newline at end of file diff --git a/tests/test_lb_03.out b/tests/test_lb_03.out @@ -6,6 +6,7 @@ adipiscing elit, sed do eiusmod tempor - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. - Lorem: + - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. - Lorem ipsum dolor sit amet, consectetur. @@ -15,4 +16,4 @@ adipiscing elit, sed do eiusmod tempor Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Lorem ipsum dolor sit amet, consectetur -adipiscing elit, sed do eiusmod tempor +adipiscing elit, sed do eiusmod tempor +\ No newline at end of file diff --git a/tests/test_lb_04.html b/tests/test_lb_04.html @@ -2,7 +2,8 @@ <blockquote> <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, <a href="https://example.com/" rel="ugc">example</a> sed do eiusmod tempor. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Lorem ipsum dolor sit amet, consectetur <code>adipiscing elit</code>, sed do eiusmod tempor</p> <blockquote> -<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit</p> +<p>Lorem ipsum <em>dolor</em> <b>sit<br>dolor</b> amet, consectetur adipiscing elit</p> +<hr> <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor</p> </blockquote> <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor</p> diff --git a/tests/test_lb_04.out b/tests/test_lb_04.out @@ -2,16 +2,23 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit > Lorem ipsum dolor sit amet, consectetur adipiscing elit, example > https://example.com/ sed do eiusmod tempor. Lorem ipsum dolor sit -> amet, consectetur adipiscing elit, sed do eiusmod tempor. Lorem ipsum -> dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor -> > Lorem ipsum dolor sit amet, consectetur adipiscing elit +> amet, consectetur adipiscing elit, sed do eiusmod tempor. Lorem +> ipsum dolor sit amet, consectetur `adipiscing elit`, sed do eiusmod +> tempor +> +> > Lorem ipsum /dolor/ *sit +> > dolor* amet, consectetur adipiscing elit +> > +> > ------------------------------------------------------------------ +> > > > Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do > > eiusmod tempor. Lorem ipsum dolor sit amet, consectetur adipiscing -> > elit, sed do eiusmod tempor. Lorem ipsum dolor sit amet, consectetur -> > adipiscing elit, sed do eiusmod tempor +> > elit, sed do eiusmod tempor. Lorem ipsum dolor sit amet, +> > consectetur adipiscing elit, sed do eiusmod tempor +> > Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do > eiusmod tempor. Lorem ipsum dolor sit amet, consectetur adipiscing > elit, sed do eiusmod tempor. Lorem ipsum dolor sit amet, consectetur > adipiscing elit, sed do eiusmod tempor -Lorem ipsum dolor sit amet, consectetur adipiscing elit +Lorem ipsum dolor sit amet, consectetur adipiscing elit +\ No newline at end of file diff --git a/tests/test_lb_05.out b/tests/test_lb_05.out @@ -3,6 +3,8 @@ eiusmod tempor. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor +---------------------------------------------------------------------- + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Lorem ipsum dolor sit amet, consectetur @@ -13,7 +15,9 @@ eiusmod tempor. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor +---------------------------------------------------------------------- + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Lorem ipsum dolor sit amet, consectetur -adipiscing elit, sed do eiusmod tempor +adipiscing elit, sed do eiusmod tempor +\ No newline at end of file diff --git a/tests/test_lb_06.out b/tests/test_lb_06.out @@ -19,4 +19,4 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Lorem ipsum dolor sit amet, consectetur -adipiscing elit, sed do eiusmod tempor +adipiscing elit, sed do eiusmod tempor +\ No newline at end of file diff --git a/tests/test_lb_07.out b/tests/test_lb_07.out @@ -16,4 +16,4 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Lorem ipsum dolor sit amet, consectetur -adipiscing elit, sed do eiusmod tempor +adipiscing elit, sed do eiusmod tempor +\ No newline at end of file diff --git a/tests/test_lb_08.out b/tests/test_lb_08.out @@ -2,14 +2,16 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit > > - Lorem ipsum dolor sit amet, consectetur adipiscing elit > > - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do -> > eiusmod tempor. Lorem ipsum dolor sit amet, consectetur adipiscing -> > elit, sed do eiusmod tempor. Lorem ipsum dolor sit amet, consectetur -> > adipiscing elit, sed do eiusmod tempor +> > eiusmod tempor. Lorem ipsum dolor sit amet, consectetur +> > adipiscing elit, sed do eiusmod tempor. Lorem ipsum dolor sit +> > amet, consectetur adipiscing elit, sed do eiusmod tempor > > - Lorem ipsum dolor sit amet, consectetur adipiscing elit + > Lorem ipsum dolor sit amet, consectetur adipiscing elit +> > Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do > eiusmod tempor. Lorem ipsum dolor sit amet, consectetur adipiscing > elit, sed do eiusmod tempor. Lorem ipsum dolor sit amet, consectetur > adipiscing elit, sed do eiusmod tempor -Lorem ipsum dolor sit amet, consectetur adipiscing elit +Lorem ipsum dolor sit amet, consectetur adipiscing elit +\ No newline at end of file diff --git a/tests/test_lb_09.out b/tests/test_lb_09.out @@ -1,11 +1,13 @@ > Lorem ipsum dolor sit amet, consectetur adipiscing elit + > Lorem ipsum dolor sit amet, consectetur adipiscing elit +> > | def hello_world(): > | print('hello') > | if True: > | print('world') > | print('!!!') -> | +> | > | hello_world() Lorem ipsum dolor sit amet, consectetur adipiscing elit @@ -15,7 +17,7 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit | if True: | print('world') | print('!!!') -| +| | hello_world() -Lorem ipsum dolor sit amet, consectetur adipiscing elit +Lorem ipsum dolor sit amet, consectetur adipiscing elit +\ No newline at end of file