diff --git a/README.md b/README.md index 8863207..29b641a 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,13 @@ This repository contains the `md2txt` command line tool and supporting libraries ## CLI -- `md2txt` – converts Markdown into plain text with extensive formatting support. It ships with the default `markdown` parser and `text` renderer plugins, registers optional `micron` and `ama` renderers for Micron/Ancient Machine Book output, and exposes the core pipeline so you can add your own parser or renderer modules: +- `md2txt` – converts Markdown into plain text with extensive formatting support. It ships with the default `markdown` parser and `text` renderer plugins, registers optional `micron`, `ama`, and `gemini` renderers for Micron/Ancient Machine Book or Gemtext output, and exposes the core pipeline so you can add your own parser or renderer modules: - FIGlet-rendered headings. - Emphasis styles converted to spaced or delimited characters. - Optional alignment and margin controls via HTML `

` attributes or MultiMarkdown attribute blocks (e.g. `{:.center margin=20px}`) applied as leading spaces. - ASCII art injection with `#[label :align](art.txt)` syntax, supporting multiple art pieces per line and optional `{: .right}` style annotations. - Per-document toggles for code block wrapping, numbering, blockquote decoration, and list indentation spacing. + - Link references can be emitted per block by setting `links_per_block: true` in frontmatter; otherwise they collect at the end of the document. Gemini output uses `=>` lines while the text renderer keeps numbered footnotes. ## Usage @@ -28,6 +29,7 @@ md2txt input.md # write result to stdout md2txt input.md --width 72 # override column width md2txt input.md --renderer micron # emit Micron-formatted output md2txt input.md --renderer ama # emit AMB/AMA markup +md2txt input.md --renderer gemini # emit Gemtext for Gemini capsules md2txt input.md --renderer-option width=68 # pass KEY=VALUE to a renderer ``` @@ -60,6 +62,7 @@ hyphen_lang: en_US | `h#_font: ` | Accepts any known figlet font name or one of the keywords `caps` (force uppercase) and `title` (title case) to define headings. | `hyphenate: true` | activates PyHyphen-based wrapping (optional `hyphen_lang` defaults to `en_US`) | | `header_spacing` | controls how many blank lines precede headings (default `2`). | +| `links_per_block: true` | Emit link references directly below each block instead of collecting them at the end (supported by the text and Gemini renderers). | | `list_marker_indent: ` | Extra spaces inserted before list markers. | | `list_text_spacing: ` | Spaces between the marker and the wrapped list text. | | `margin_left`/`margin_right` | add leading/trailing spaces to body text | diff --git a/examples/loremipsum.gmi b/examples/loremipsum.gmi new file mode 100644 index 0000000..14dc913 --- /dev/null +++ b/examples/loremipsum.gmi @@ -0,0 +1,117 @@ +``` + ___ _ _ _ _ + / __| __ _| (_)___ _ _| |_(_)__ _ + \__ \/ _` | | / -_) ' \ _| / _` | + |___/\__,_|_|_\___|_||_\__|_\__,_| + +``` + +## Structa hinc vetitus factum + + +Lorem markdownum omnes tellus, in, Herculeis reciprocalPower neque promissa est +Latiae caput umero talia fidissima, nymphae. Foedera et sagittas tenetur adde +matri esse: talia manibus. + +## Fuit video longique dedisse + + +Ille stupet, ultra incunabula agendum urbes mirer aper, metu. Aegides novissima +sunt turis quatiebant umbrosaque putares [1]. Metuunt eruiturque maximus velit, +ater Vesta vulnus rustica verba! Mei vincite nulla, in Iovem: fama exurunt +vernum! + +=> #mille-parva [1] putares + + +De instat ubi induit fugientia inertes columbas tarda terga Parnasia sententia +omnia inmotusque, est et aetas sonus. Exhibuit coeperunt, canis. Suis minora, +tempore fateor neve moderamine erit. Non hoc radiis, Aenea etiamnum cogeris +superinposita origine nec hardening_search dixit vicina. Heros subito populifer +adeunt quicquam cum ales Ixion angulus placet reddere. + +## Tractaque serpens arva quoque + + +``` + <>=======() +(/\___ /|\\ ()==========<>_ + \_/ | \\ //|\ ______/ \) + \_| \\ // | \_/ + \|\/|\_ // /\/ + (oo)\ \_// / + //_/\_\/ / | + @@/ |=\ \ | + \_=\_ \ | + \==\ \|\_ snd + __(\===\( )\ + (((~) __(_/ | + (((~) \ / + ______/ / + '------' +``` + +Inprudens dum memor alma, casses dedi Sinuessa iam quid adicit +gate_truncate_wave. Geratur de [2], insolida temptat open carentia; genitorem +solet, potens. Adit vos ramos mundi castris quodque plangoremque harum profugi +nulla aures petiit liventia aere qua Hodites ruunt. Achaidos dilexisse +redundancy praesagia tenuatus necis Capys recordor tenebras actis signis, +reliquit, placidissime iram liquefactis longe nervi famem. Cura pars natique +velleris semel et at printerWildcardParallel inquit positosque comites ultra. + +=> #salientia-iungit-contra-et [2] Geratur de + + +## Quantaque augent + + +Acuti ignotis ignis, ostendit nostras terras nondum, petit utque utero [3] +sepulcrales, pluma reseratis Ismario. Tria silentia duae nymphe: certe parva +cernam et dixit. Verborum sedisti [1]. + +=> #structa-hinc-vetitus-factum [3] petit utque utero +=> #mille-parva [1] putares + + +``` + if (firewall(4, 3) == 1 * device_media_parity) { + system_copy_class += systray + oasis_wi_clean + lpi; + modelLdap.search_secondary_socket *= bounce.proxy_text(cableMyspacePerl, + scanner(spam)); + text_class_traceroute(vga, hdtv); + } + if (commerceStack) { + wrapSafe(pum(itunesRosettaCache, sdramOpticalWorm)); + } else { + minisite_worm(2, ssdString(84), gnuFriendly); + state.qwerty(moduleJavascriptFloating + ugc, koffice, + mail_system.prebinding_service_teraflops(mediaIcmpBoot)); + } + memoryHexadecimalMultitasking += oop_android; + var passive_vdu = bar; + var ddrJava = leaf(unc_web); +``` + +### Mille parva + + +Minetur et aurum! Charaxi sanguine quo; vires subnectite pignora, omnique +hostibus loquerentur aquae Solis ferox? Cum ne, putat utar ex ad aede mane. +Vitalesque geminas exstimulat, iam vivunt dat artes et quem! Iam violas molire +ipse, spoliabitur dente nervis regis, factos quae. + +> Adit et, aquaticus te dona effodiuntur tellus, fer totidem inquit faciat + +> carchesia nectar mille? Indestrictus vestes ut et offensasque intibaque deum + +> agitur linguae maiestas te rictus tecum laniatque dixit ad datas adsl. + +> Vino ut quod iaculi mutua armis uno est vos totidemque + +> exhortor [4]. Iamdudum Romanae cepi linguisque + +=> #tractaque-serpens-arva-quoque [4] exhortor + +> velit accipe idem acutis hoc sparsumque dextra. Minoide sponte collo sequitur + +> et intus flere sanguineaque herba, nox Quas adsiduo cunctantem tibi; enim. diff --git a/examples/loremipsum.md b/examples/loremipsum.md index 9f81442..8d1c665 100644 --- a/examples/loremipsum.md +++ b/examples/loremipsum.md @@ -6,6 +6,7 @@ margin_left: 4 margin_right: 4 paragraph_spacing: 2 code_block_wrap: 4 +links_per_block: true list_marker_indent: 2 list_text_spacing: 1 hyphenate: true diff --git a/examples/markdown.gmi b/examples/markdown.gmi new file mode 100644 index 0000000..d177316 --- /dev/null +++ b/examples/markdown.gmi @@ -0,0 +1,132 @@ +``` + _ _ _ _ _ _ ___ __ + | |_ / | | || |___ __ _ __| (_)_ _ __ _ ( _ )__\ \ + | ' \| | | __ / -_) _` / _` | | ' \/ _` | / _ \___| | + |_||_|_| |_||_\___\__,_\__,_|_|_||_\__, | \___/ | | + |___/ /_/ +``` + +## h2 Heading + +### h3 Heading + +``` + _ _ _ _ _ _ _ + | |__ | || | | | | | ___ __ _ __| (_)_ __ __ _ + | '_ \| || |_ | |_| |/ _ \/ _` |/ _` | | '_ \ / _` | + | | | |__ _| | _ | __/ (_| | (_| | | | | | (_| | + |_| |_| |_| |_| |_|\___|\__,_|\__,_|_|_| |_|\__, | + |___/ +``` + +``` + _ ____ _ _ _ _ + | |__ | ___| | | | | ___ __ _ __| (_)_ __ __ _ + | '_ \|___ \ | |_| |/ _ \/ _` |/ _` | | '_ \ / _` | + | | | |___) | | _ | __/ (_| | (_| | | | | | (_| | + |_| |_|____/ |_| |_|\___|\__,_|\__,_|_|_| |_|\__, | + |___/ +``` + +``` + _ __ _ _ _ _ + | |__ / /_ | | | | ___ __ _ __| (_)_ __ __ _ + | '_ \| '_ \ | |_| |/ _ \/ _` |/ _` | | '_ \ / _` | + | | | | (_) | | _ | __/ (_| | (_| | | | | | (_| | + |_| |_|\___/ |_| |_|\___|\__,_|\__,_|_|_| |_|\__, | + |___/ +``` + +## Horizontal Rules + + +--- + +--- + +--- + +## Emphasis + + +This is bold text This is bold text This is italic text This is italic text +Strikethrough + +## Blockquotes + + +> Blockquotes can also be nested... + +> by using additional greater-than signs right next to each other... + +> or with spaces between arrows. + +## Lists + + +### Unordered + + +* Create a list by starting a line with +, -, or * +* Marker character change forces new list start: +* Ac tristique libero volutpat at +* Facilisis in pretium nisl aliquet +* Nulla volutpat aliquam velit +* Very easy + + +### Ordered + + +* Lorem ipsum dolor sit amet +* Consectetur adipiscing elit +* Integer molestie lorem at massa + + +## Code + + +Inline code + +Indented code + +``` +// Some comments +line 1 of code +line 2 of code +line 3 of code +``` + + +Block code "fences" + +``` +Sample text here... +``` + +Syntax highlighting + +``` +var foo = function (bar) { + return bar++; +}; + +console.log(foo(5)); +``` + +## Links + + +link text [1] + +link with title [2] + +## Images + + +Minion [3] Stormtroopocat [4] + +=> http://dev.nodeca.com [1] link text +=> http://nodeca.github.io/pica/demo/ "title text!" [2] link with title +=> https://octodex.github.com/images/minion.png [3] Minion +=> https://octodex.github.com/images/stormtroopocat.jpg "The Stormtroopocat" [4] Stormtroopocat diff --git a/src/md2txt/cli.py b/src/md2txt/cli.py index c3e0bf5..b8c9d3e 100644 --- a/src/md2txt/cli.py +++ b/src/md2txt/cli.py @@ -22,8 +22,9 @@ from .plugins import ( ) from .renderers.text import TextRenderer -from .renderers import micron # noqa: F401 # register micron renderer plugin from .renderers import ama # noqa: F401 # register AMA renderer plugin +from .renderers import gemini # noqa: F401 # register Gemini renderer plugin +from .renderers import micron # noqa: F401 # register Micron renderer plugin def _split_option(token: str) -> Tuple[str, str]: diff --git a/src/md2txt/conversion/core.py b/src/md2txt/conversion/core.py index 9d49517..f283bff 100644 --- a/src/md2txt/conversion/core.py +++ b/src/md2txt/conversion/core.py @@ -106,6 +106,7 @@ def parse_frontmatter(lines: List[str]) -> Tuple[FrontMatter, List[str]]: blockquote_bars = _parse_bool(frontmatter.get("blockquote_bars"), True) list_marker_indent = max(0, _parse_int(frontmatter.get("list_marker_indent"), 0)) list_text_spacing = max(0, _parse_int(frontmatter.get("list_text_spacing"), 1)) + links_per_block = _parse_bool(frontmatter.get("links_per_block"), False) fm = FrontMatter( h1_font=frontmatter.get("h1_font", "standard").strip() or "standard", h2_font=frontmatter.get("h2_font", "standard").strip() or "standard", @@ -123,6 +124,7 @@ def parse_frontmatter(lines: List[str]) -> Tuple[FrontMatter, List[str]]: blockquote_bars=blockquote_bars, list_marker_indent=list_marker_indent, list_text_spacing=list_text_spacing, + links_per_block=links_per_block, ) return fm, remaining diff --git a/src/md2txt/models.py b/src/md2txt/models.py index b681528..ab09ef4 100644 --- a/src/md2txt/models.py +++ b/src/md2txt/models.py @@ -48,6 +48,7 @@ class FrontMatter: blockquote_bars: bool = True list_marker_indent: int = 0 list_text_spacing: int = 1 + links_per_block: bool = False @dataclass diff --git a/src/md2txt/renderers/__init__.py b/src/md2txt/renderers/__init__.py index 68c1c4f..b78a6bb 100644 --- a/src/md2txt/renderers/__init__.py +++ b/src/md2txt/renderers/__init__.py @@ -1,7 +1,8 @@ """Bundled renderer implementations.""" from .ama import AmaRenderer +from .gemini import GeminiRenderer from .micron import MicronRenderer from .text import TextRenderer -__all__ = ["AmaRenderer", "MicronRenderer", "TextRenderer"] +__all__ = ["AmaRenderer", "GeminiRenderer", "MicronRenderer", "TextRenderer"] diff --git a/src/md2txt/renderers/gemini.py b/src/md2txt/renderers/gemini.py new file mode 100644 index 0000000..fa364f0 --- /dev/null +++ b/src/md2txt/renderers/gemini.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +import re +import textwrap +from typing import Any, Dict, List, Optional, Tuple + +from ..models import ( + AsciiArtPayload, + BlockEvent, + BlockKind, + BlockQuotePayload, + BlockStyle, + CodeBlockPayload, + FrontMatter, + HeadingPayload, + ListItemPayload, + ParagraphPayload, + StyleUpdateEvent, +) +from ..plugins import register_renderer +from .text import ( + BOLD_RE, + CODE_STASH_RE, + IMAGE_RE, + ITALIC_RE, + LINK_RE, + STRIKETHROUGH_RE, + TextRenderer, + UNDERLINE_EM_RE, + UNDERLINE_STRONG_RE, +) + + +class GeminiRenderer: + """Render Markdown events as Gemtext suitable for the Gemini protocol.""" + + def __init__(self, frontmatter: FrontMatter, *, width: int = 80, preformatted_alt: str | None = None, **_: Any) -> None: + self.frontmatter = frontmatter + self._text_renderer = TextRenderer(width=width, frontmatter=frontmatter) + self.width = max(20, width) + self.preformatted_alt = preformatted_alt or "" + self.output: List[str] = [] + self._in_list: bool = False + self.links_per_block = frontmatter.links_per_block + self._link_indices: Dict[str, int] = {} + self._link_catalog: Dict[int, Tuple[str, Optional[str]]] = {} + self._pending_links: List[int] = [] + + def handle_event(self, event: BlockEvent | StyleUpdateEvent) -> None: + if isinstance(event, StyleUpdateEvent): + # Gemtext has no notion of per-block styling; ignore updates. + return + handler = { + BlockKind.PARAGRAPH: self._render_paragraph, + BlockKind.HEADING: self._render_heading, + BlockKind.CODE_BLOCK: self._render_code_block, + BlockKind.BLOCKQUOTE: self._render_blockquote, + BlockKind.LIST_ITEM: self._render_list_item, + BlockKind.HORIZONTAL_RULE: self._render_horizontal_rule, + BlockKind.BLANK_LINE: self._render_blank_line, + BlockKind.CUSTOM_BLOCK: self._render_custom_block, + }.get(event.kind) + if handler is not None: + handler(event.payload, event.style) + + def finalize(self) -> List[str]: + self._end_list() + self._flush_pending_links(trailing_blank=False) + return self.output + + # Rendering helpers ------------------------------------------------- + def _render_paragraph(self, payload: ParagraphPayload, _style: BlockStyle) -> None: + self._end_list() + text, links = self._process_inline(payload.text) + if text: + self._ensure_blank_line() + self.output.extend(self._wrap_text(text)) + self._handle_links(links) + + def _render_heading(self, payload: HeadingPayload, style: BlockStyle) -> None: + self._end_list() + text, links = self._process_inline(payload.text) + + def render_heading(target_style: BlockStyle) -> List[str]: + figlet = self._text_renderer._render_figlet_heading(payload.level, text, target_style) + if figlet: + banner = [line.rstrip() for line in figlet] + fence_header = self._preformatted_header() + lines = [fence_header, *banner, "```"] + return lines + + level = max(1, min(3, payload.level)) + heading = text + if not heading: + return [] + marker = "#" * level + wrapped = self._wrap_text( + heading, + initial=f"{marker} ", + subsequent=f"{marker} ", + ) + if not wrapped or wrapped[-1] != "": + wrapped.append("") + return wrapped + + lines = render_heading(style) + if not lines: + return + self._ensure_blank_line() + self.output.extend(lines) + self._handle_links(links) + + def _render_code_block(self, payload: CodeBlockPayload, _style: BlockStyle) -> None: + self._end_list() + self._ensure_blank_line() + self.output.append(self._preformatted_header()) + for line in payload.lines: + self.output.append(line.rstrip("\n")) + self.output.append("```") + + def _render_blockquote(self, payload: BlockQuotePayload, _style: BlockStyle) -> None: + self._end_list() + text, links = self._process_inline(payload.text) + if text: + self._ensure_blank_line() + wrapped = self._wrap_text(text, initial="> ", subsequent="> ") + self.output.extend(wrapped) + self._handle_links(links) + + def _render_list_item(self, payload: ListItemPayload, _style: BlockStyle) -> None: + indent = payload.indent.replace("\t", " ") + depth = max(0, len(indent) // 2) + bullet_prefix = " " * depth + "* " + continuation = " " * depth + " " + text, links = self._process_inline(payload.text) + self._begin_list() + if text: + self.output.extend(self._wrap_text(text, initial=bullet_prefix, subsequent=continuation)) + self._handle_links(links, leading_blank=False, trailing_blank=False) + + def _render_horizontal_rule(self, _payload: object, _style: BlockStyle) -> None: + self._end_list() + self._ensure_blank_line() + self.output.append("---") + + def _render_blank_line(self, *_: object) -> None: + self._end_list() + self._ensure_blank_line(force=True) + + def _render_custom_block(self, payload: AsciiArtPayload, _style: BlockStyle) -> None: + lines: List[str] = [] + for index, piece in enumerate(payload.pieces): + lines.extend(line.rstrip("\n") for line in piece.lines) + if index + 1 < len(payload.pieces): + lines.append("") + if not lines: + return + self._end_list() + self._ensure_blank_line() + self.output.append(self._preformatted_header()) + self.output.extend(lines) + self.output.append("```") + + # Internal utilities ------------------------------------------------ + def _begin_list(self) -> None: + if not self._in_list: + self._ensure_blank_line() + self._in_list = True + + def _end_list(self) -> None: + if self._in_list: + if self.output and self.output[-1] != "": + self.output.append("") + self._in_list = False + + def _ensure_blank_line(self, *, force: bool = False) -> None: + if not self.output: + return + if force or self.output[-1] != "": + self.output.append("") + + def _wrap_text( + self, + text: str, + *, + initial: str = "", + subsequent: Optional[str] = None, + ) -> List[str]: + normalized = self._normalise_whitespace(text) + if not normalized: + return [initial.rstrip()] if initial else [] + wrapper = textwrap.TextWrapper( + width=self.width, + expand_tabs=False, + replace_whitespace=False, + drop_whitespace=False, + break_long_words=False, + break_on_hyphens=True, + initial_indent=initial, + subsequent_indent=subsequent if subsequent is not None else initial, + ) + return [line.rstrip() for line in wrapper.wrap(normalized)] + + def _normalise_whitespace(self, text: str) -> str: + return re.sub(r"\s+", " ", text).strip() + + def _register_link(self, url: str, label: Optional[str]) -> int: + key = url + index = self._link_indices.get(key) + if index is None: + index = len(self._link_indices) + 1 + self._link_indices[key] = index + self._link_catalog[index] = (url, label) + else: + stored_url, stored_label = self._link_catalog.get(index, (url, None)) + if label and not stored_label: + self._link_catalog[index] = (stored_url, label) + return index + + def _process_inline(self, text: str) -> Tuple[str, List[int]]: + code_segments: List[str] = [] + indices: List[int] = [] + + def stash_code(match): + segment = match.group(0)[1:-1] + code_segments.append(segment) + return f"\u0000CODE{len(code_segments) - 1}\u0000" + + def register_link(label: str, target: str) -> str: + url = target.strip() + label_clean = label.strip() + display = label_clean or url + link_label = label_clean if label_clean and label_clean != url else None + index = self._register_link(url, link_label) + if index not in indices: + indices.append(index) + return f"{display} [{index}]" + + text = CODE_STASH_RE.sub(stash_code, text) + text = STRIKETHROUGH_RE.sub(lambda m: m.group(1), text) + text = BOLD_RE.sub(lambda m: m.group(1), text) + text = ITALIC_RE.sub(lambda m: m.group(1), text) + text = UNDERLINE_STRONG_RE.sub(lambda m: m.group(1), text) + text = UNDERLINE_EM_RE.sub(lambda m: m.group(1), text) + text = LINK_RE.sub(lambda m: register_link(m.group(1), m.group(2)), text) + text = IMAGE_RE.sub(lambda m: register_link(m.group(1), m.group(2)), text) + + for idx, code in enumerate(code_segments): + text = text.replace(f"\u0000CODE{idx}\u0000", code) + + return self._normalise_whitespace(text), indices + + def _handle_links( + self, + indices: List[int], + *, + leading_blank: bool = True, + trailing_blank: bool = True, + ) -> None: + if not indices: + return + if self.links_per_block: + self._emit_links(indices, leading_blank=leading_blank, trailing_blank=trailing_blank) + else: + for index in indices: + if index not in self._pending_links: + self._pending_links.append(index) + + def _flush_pending_links(self, *, trailing_blank: bool) -> None: + if not self._pending_links: + return + self._emit_links(self._pending_links, leading_blank=True, trailing_blank=trailing_blank) + self._pending_links.clear() + + def _emit_links( + self, + indices: List[int], + *, + leading_blank: bool = True, + trailing_blank: bool = True, + ) -> None: + if not indices: + return + trailing_blanks: List[str] = [] + while self.output and self.output[-1] == "": + trailing_blanks.append(self.output.pop()) + trailing_blanks.reverse() + if leading_blank and self.output and self.output[-1] != "": + self.output.append("") + for index in indices: + url, label = self._link_catalog.get(index, ("", None)) + label_text = f"[{index}]" + if label: + label_text = f"{label_text} {label}" + line = f"=> {url}" if not label_text else f"=> {url} {label_text}" + self.output.append(line) + if trailing_blank and not trailing_blanks and self.output and self.output[-1] != "": + self.output.append("") + if trailing_blanks: + self.output.extend(trailing_blanks) + + def _preformatted_header(self) -> str: + if self.preformatted_alt: + return f"``` {self.preformatted_alt}" + return "```" + + +def _gemini_renderer_factory(*, frontmatter: FrontMatter, **options: Any) -> GeminiRenderer: + width = int(options.get("width", 80)) + alt = options.get("preformatted_alt") + return GeminiRenderer(frontmatter, width=width, preformatted_alt=alt) + + +try: + register_renderer("gemini", _gemini_renderer_factory) +except ValueError: + pass diff --git a/src/md2txt/renderers/text.py b/src/md2txt/renderers/text.py index 6a85ca4..f7abb2d 100644 --- a/src/md2txt/renderers/text.py +++ b/src/md2txt/renderers/text.py @@ -94,6 +94,7 @@ class TextRenderer: ) self.list_marker_indent = max(0, frontmatter.list_marker_indent) self.list_text_spacing = max(0, frontmatter.list_text_spacing) + self.links_per_block = frontmatter.links_per_block self._base_style = BlockStyle( align="left", margin_left=max(0, frontmatter.margin_left), @@ -128,29 +129,56 @@ class TextRenderer: self._apply_style_to_last_block(event.spec) def finalize(self) -> List[str]: - if self.links: - if self.output and self.output[-1] != "": - self.output.append("") - for index, url in self.links: - entry = f"[{index}] {url}" - self.output.extend( - self._wrap_text( - entry, - initial_indent="", - subsequent_indent="", - style=self._base_style, - ) - ) - self._last_stylable_block = None + if self.links_per_block: + self._flush_links_after_block(trailing_blank=False) + elif self.links: + self._emit_link_entries(self.links, trailing_blank=False) + self.links.clear() + self.link_indices.clear() + self._last_stylable_block = None return self.output def _handle_block_event(self, event: BlockEvent) -> None: handler = self._handlers.get(event.kind) if handler: handler(event.payload, event.style) + if self.links_per_block: + self._flush_links_after_block() else: self._last_stylable_block = None + def _emit_link_entries(self, entries: List[tuple[int, str]], *, trailing_blank: bool) -> None: + if not entries: + return + if self.output and self.output[-1] != "": + self.output.append("") + for index, url in entries: + entry = f"[{index}] {url}" + self.output.extend( + self._wrap_text( + entry, + initial_indent="", + subsequent_indent="", + style=self._base_style, + ) + ) + if trailing_blank and self.output and self.output[-1] != "": + self.output.append("") + + def _flush_links_after_block(self, *, trailing_blank: bool = True) -> None: + if not self.links: + return + trailing_blanks: List[str] = [] + while self.output and self.output[-1] == "": + trailing_blanks.append(self.output.pop()) + trailing_blanks.reverse() + add_trailing_blank = trailing_blank and not trailing_blanks + self._emit_link_entries(self.links, trailing_blank=add_trailing_blank) + self.links.clear() + self.link_indices.clear() + if trailing_blanks: + self.output.extend(trailing_blanks) + def _render_paragraph(self, payload: ParagraphPayload, style: BlockStyle) -> None: processed = self._process_inline(payload.text) self._wrap_emit(processed, style, stylable=True, hyphenate=self.hyphenate)