diff --git a/README.md b/README.md index 0b0151a..cfbfa85 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ This repository contains command line helpers for transforming Markdown into for ## Tools - `md2amb.py` – converts Markdown into Amber-screen formatted text (see script for details). -- `md2txt.py` – converts Markdown into 80-column, DOS-compatible plain text with extensive formatting support. It ships with the default `markdown` parser and `text` renderer plugins and exposes the core pipeline so you can add your own parser or renderer modules: +- `md2txt.py` – converts Markdown into 80-column, DOS-compatible plain text with extensive formatting support. It ships with the default `markdown` parser and `text` renderer plugins, registers an optional `micron` renderer for Micron-formatted output, and exposes the core pipeline so you can add your own parser or renderer modules: - FIGlet-rendered headings (H1–H3) driven by optional YAML frontmatter (`h1_font`, `h2_font`, `h3_font`). - H4+ headings rendered in uppercase with dashed underlines. - Emphasis styles converted to spaced or delimited characters, e.g. `**bold**` → `B O L D`, `__strong__` → `_s_t_r_o_n_g_`, `~~strike~~` → `~s~t~r~i~k~e~`. @@ -29,7 +29,7 @@ This repository contains command line helpers for transforming Markdown into for python md2txt.py input.md -o output.txt # convert to DOS-friendly text python md2txt.py input.md # write result to stdout python md2txt.py input.md --width 72 # override column width -python md2txt.py input.md --parser markdown --renderer text # explicitly select defaults +python md2txt.py input.md --parser markdown --renderer micron # emit Micron-formatted output python md2txt.py input.md --renderer-option width=68 # pass KEY=VALUE to a renderer ``` diff --git a/loremipsum.mu b/loremipsum.mu new file mode 100644 index 0000000..6c8c720 --- /dev/null +++ b/loremipsum.mu @@ -0,0 +1,237 @@ + + > Salientia + + >> 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`#mille-parva]. Metuunt eruiturque + maximus velit, ater Vesta vulnus rustica verba! Mei + vincite nulla, in Iovem: fama exurunt vernum! + + 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 + + > Include + + <>=======() + (/\___ /|\\ ()==========<>_ + \_/ | \\ //|\ ______/ \) + \_| \\ // | \_/ + \|\/|\_ // /\/ + (oo)\ \_// / + //_/\_\/ / | + @@/ |=\ \ | + \_=\_ \ | + \==\ \|\_ snd + __(\===\( )\ + (((~) __(_/ | + (((~) \ / + ______/ / + '------' + <>=======() + (/\___ /|\\ ()==========<>_ + \_/ | \\ //|\ ______/ \) + \_| \\ // | \_/ + \|\/|\_ // /\/ + (oo)\ \_// / + //_/\_\/ / | + @@/ |=\ \ | + \_=\_ \ | + \==\ \|\_ snd + __(\===\( )\ + (((~) __(_/ | + (((~) \ / + ______/ / + '------' + <>=======() + (/\___ /|\\ ()==========<>_ + \_/ | \\ //|\ ______/ \) + \_| \\ // | \_/ + \|\/|\_ // /\/ + (oo)\ \_// / + //_/\_\/ / | + @@/ |=\ \ | + \_=\_ \ | + \==\ \|\_ snd + __(\===\( )\ + (((~) __(_/ | + (((~) \ / + ______/ / + '------' + + > h1 Heading 8-) + + >> h2 Heading + + >>> h3 Heading + + >>> h4 Heading + + >>> h5 Heading + + >>> h6 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 + + 1. Lorem ipsum dolor sit amet + 2. Consectetur adipiscing elit + 3. 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`http://dev.nodeca.com] + + `[link with title`http://nodeca.github.io/pica/demo/ + "title text!"] + + >> Images + + `[Minion`https://octodex.github.com/images/minion.png] ` + [Stormtroopocat`https://octodex.github.com/images/stormt + roopocat.jpg "The Stormtroopocat"] + + Inprudens dum memor alma, casses dedi Sinuessa iam quid + adicit `=gate_truncate_wave`=. `[Geratur de`#salientia- + iungit-contra-et], 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. + + >> Quantaque augent + + Acuti ignotis ignis, ostendit nostras terras nondum, + `[petit utque utero`#structa-hinc-vetitus-factum] + sepulcrales, pluma reseratis Ismario. Tria silentia duae + `*nymphe`*: certe parva cernam et dixit. `[Verborum + sedisti`#mille-parva]. + + `= + 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`#tractaque-serpens-arva-quoque]. Iamdudum + >>>>Romanae cepi linguisque + + >>>>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/md2mu.py b/md2mu.py deleted file mode 100644 index 79f400e..0000000 --- a/md2mu.py +++ /dev/null @@ -1,99 +0,0 @@ -from mistune.core import BlockState -from mistune.util import strip_end -from mistune.renderers._list import render_list -from mistune.renderers.markdown import MarkdownRenderer -from typing import Dict, Any -from textwrap import indent - -class MicronRenderer(MarkdownRenderer): - """A renderer to format Micron text.""" - NAME = 'micron' - - def __call__(self, tokens, state: BlockState): - out = self.render_tokens(tokens, state) - # special handle for line breaks - out += '\n\n'.join(self.render_referrences(state)) + '\n' - return strip_end(out) - - def render_children(self, token, state: BlockState): - children = token['children'] - return self.render_tokens(children, state) - - def text(self, token: Dict[str, Any], state: BlockState) -> str: - return token['raw'] - - def emphasis(self, token: Dict[str, Any], state: BlockState) -> str: - return '`*' + self.render_children(token, state) + '`*' - - def strong(self, token: Dict[str, Any], state: BlockState) -> str: - return '`!' + self.render_children(token, state) + '`!' - - def link(self, token: Dict[str, Any], state: BlockState) -> str: - label = token.get('label') - text = self.render_children(token, state) - out = '`[' + text + '`' - if label: - return out + '`[' + label + '`' - attrs = token['attrs'] - url = attrs['url'] - if text == url: - return '`[' + text + '`' - elif 'mailto:' + text == url: - return '`[' + text + '`' - out += url - return out + ']' - - def image(self, token: Dict[str, Any], state: BlockState) -> str: - return self.link(token, state) - - def codespan(self, token: Dict[str, Any], state: BlockState) -> str: - return '`=' + token['raw'] + '`=' - - def linebreak(self, token: Dict[str, Any], state: BlockState) -> str: - return ' \n' - - def softbreak(self, token: Dict[str, Any], state: BlockState) -> str: - return '\n' - - def blank_line(self, token: Dict[str, Any], state: BlockState) -> str: - return '' - - def inline_html(self, token: Dict[str, Any], state: BlockState) -> str: - return '' - - def paragraph(self, token: Dict[str, Any], state: BlockState) -> str: - text = self.render_children(token, state) - return text + '\n\n' - - def heading(self, token: Dict[str, Any], state: BlockState) -> str: - level = token['attrs']['level'] - if level > 3: - level = 3 - marker = '>' * level - text = self.render_children(token, state) - return marker + ' ' + text + '\n\n' - def thematic_break(self, token: Dict[str, Any], state: BlockState) -> str: - return '-\n\n' - - def block_text(self, token: Dict[str, Any], state: BlockState) -> str: - return self.render_children(token, state) + '\n' - - def block_code(self, token: Dict[str, Any], state: BlockState) -> str: - code = token['raw'] - if code and code[-1] != '\n': - code += '\n' - marker = '`=' - return marker + '\n' + code + marker + '\n\n' - - def block_quote(self, token: Dict[str, Any], state: BlockState) -> str: - text = indent(self.render_children(token, state), '>>>>') - return text + '\n\n' - - def block_html(self, token: Dict[str, Any], state: BlockState) -> str: - return '' - - def block_error(self, token: Dict[str, Any], state: BlockState) -> str: - return '' - - def list(self, token: Dict[str, Any], state: BlockState) -> str: - return render_list(self, token, state) \ No newline at end of file diff --git a/md2txt.py b/md2txt.py index 25388b2..8eb8c50 100644 --- a/md2txt.py +++ b/md2txt.py @@ -22,6 +22,8 @@ from plugins import ( ) from text_renderer import TextRenderer +import micron_renderer # noqa: F401 # register micron renderer plugin + def _split_option(token: str) -> Tuple[str, str]: if "=" not in token: @@ -77,9 +79,13 @@ def convert_markdown( renderer_options=effective_renderer_options, base_path=base_path, ) - if not isinstance(rendered, list): - raise TypeError("Renderer plugin returned unsupported output for md2txt CLI.") - return rendered + if isinstance(rendered, list): + return rendered + if isinstance(rendered, tuple): + return list(rendered) + if isinstance(rendered, str): + return rendered.splitlines() + raise TypeError("Renderer plugin returned unsupported output for md2txt CLI.") def write_output(path: Optional[Path], lines: List[str]) -> None: diff --git a/micron_renderer.py b/micron_renderer.py new file mode 100644 index 0000000..a037a15 --- /dev/null +++ b/micron_renderer.py @@ -0,0 +1,494 @@ +from __future__ import annotations + +import re +import textwrap +from dataclasses import dataclass +from typing import Any, Callable, Dict, List, Optional, Tuple + +from md_types import ( + AsciiArtPayload, + AsciiArtPiece, + BlockEvent, + BlockKind, + BlockQuotePayload, + BlockStyle, + CodeBlockPayload, + FrontMatter, + HeadingPayload, + ListItemPayload, + ParagraphPayload, + StyleSpec, + StyleUpdateEvent, +) +from plugins import register_renderer + + +@dataclass +class _StylableBlock: + index: int + render: Callable[[BlockStyle], str] + style: BlockStyle + + +class MicronRenderer: + def __init__(self, frontmatter: FrontMatter, *, width: int = 80, **_: Any) -> None: + self.frontmatter = frontmatter + self.width = max(1, width) + self._chunks: List[str] = [] + self._trailing_newlines = 0 + self._last_stylable_block: Optional[_StylableBlock] = None + + def handle_event(self, event: BlockEvent | StyleUpdateEvent) -> None: + if isinstance(event, BlockEvent): + self._handle_block_event(event) + else: + self._apply_style_update(event.spec) + + def finalize(self) -> str: + return "".join(self._chunks).rstrip() + + def _handle_block_event(self, event: BlockEvent) -> None: + if event.kind is BlockKind.PARAGRAPH: + self._render_paragraph(event.payload, event.style) # type: ignore[arg-type] + elif event.kind is BlockKind.HEADING: + self._render_heading(event.payload, event.style) # type: ignore[arg-type] + elif event.kind is BlockKind.CODE_BLOCK: + self._render_code_block(event.payload, event.style) # type: ignore[arg-type] + elif event.kind is BlockKind.BLOCKQUOTE: + self._render_blockquote(event.payload, event.style) # type: ignore[arg-type] + elif event.kind is BlockKind.LIST_ITEM: + self._render_list_item(event.payload, event.style) # type: ignore[arg-type] + elif event.kind is BlockKind.HORIZONTAL_RULE: + self._render_horizontal_rule(event.style) + elif event.kind is BlockKind.BLANK_LINE: + self._ensure_newlines(1) + elif event.kind is BlockKind.CUSTOM_BLOCK: + self._render_custom_block(event.payload, event.style) # type: ignore[arg-type] + + def _render_paragraph(self, payload: ParagraphPayload, style: BlockStyle) -> None: + content = self._render_inline(payload.text) + if content: + def render(target_style: BlockStyle) -> str: + lines = self._wrap_text(content, target_style) + return "\n".join(lines) + + block = render(style) + self._emit_block(block, stylable=True, render_fn=render, style=style) + self._ensure_newlines(2) + + def _render_heading(self, payload: HeadingPayload, style: BlockStyle) -> None: + level = max(1, min(3, payload.level)) + marker = ">" * level + " " + text = self._render_inline(payload.text) + if text: + def render(target_style: BlockStyle) -> str: + lines = self._wrap_text(text, target_style, initial_prefix=marker) + return "\n".join(lines) + + block = render(style) + self._emit_block(block, stylable=True, render_fn=render, style=style) + self._ensure_newlines(2) + + def _render_code_block(self, payload: CodeBlockPayload, style: BlockStyle) -> None: + def render(target_style: BlockStyle) -> str: + margin = " " * max(0, target_style.margin_left) + fence = margin + "`=" + body_lines = [margin + line.rstrip("\n") for line in payload.lines] + return "\n".join([fence, *body_lines, fence]) + + block = render(style) + if block: + self._emit_block(block, stylable=False) + self._ensure_newlines(2) + + def _render_blockquote(self, payload: BlockQuotePayload, style: BlockStyle) -> None: + prefix = ">>>>" * max(1, payload.depth) + text = self._render_inline(payload.text) + if text: + def render(target_style: BlockStyle) -> str: + lines = self._wrap_text( + text, + target_style, + initial_prefix=prefix, + subsequent_prefix=prefix, + align_blocks=False, + ) + return "\n".join(lines) + + block = render(style) + self._emit_block(block, stylable=False) + self._ensure_newlines(2) + + def _render_list_item(self, payload: ListItemPayload, style: BlockStyle) -> None: + indent_text = payload.indent.replace("\t", " ") + spacing = payload.spacing if payload.spacing else " " + initial_prefix = f"{indent_text}{payload.marker}{spacing}" + subsequent_prefix = f"{indent_text}{' ' * len(payload.marker)}{spacing}" + text = self._render_inline(payload.text) + if text: + def render(target_style: BlockStyle) -> str: + lines = self._wrap_text( + text, + target_style, + initial_prefix=initial_prefix, + subsequent_prefix=subsequent_prefix, + align_blocks=False, + ) + return "\n".join(lines) + + block = render(style) + self._emit_block(block, stylable=False) + self._ensure_newlines(1) + + def _render_horizontal_rule(self, style: BlockStyle) -> None: + margin = " " * max(0, style.margin_left) + self._emit_block(margin + "-", stylable=False) + self._ensure_newlines(2) + + def _render_custom_block(self, payload: AsciiArtPayload, style: BlockStyle) -> None: + def render(target_style: BlockStyle) -> str: + lines = self._layout_ascii_pieces(payload.pieces, target_style) + return "\n".join(lines) + + block = render(style) + if block: + self._emit_block(block, stylable=True, render_fn=render, style=style) + self._ensure_newlines(2) + + def _render_inline(self, text: str) -> str: + if not text: + return "" + + code_segments: List[str] = [] + emphasis_segments: List[str] = [] + + def stash_code(match: re.Match[str]) -> str: + placeholder = f"\u0000CODE{len(code_segments)}\u0000" + code_segments.append(match.group(1)) + return placeholder + + def stash_emphasis(content: str) -> str: + placeholder = f"\u0000EMP{len(emphasis_segments)}\u0000" + emphasis_segments.append(content) + return placeholder + + text = re.sub(r"`([^`]+)`", stash_code, text) + + text = re.sub( + r"\*\*(.+?)\*\*", + lambda m: stash_emphasis(f"`!{m.group(1)}`!"), + text, + ) + text = re.sub( + r"__(.+?)__", + lambda m: stash_emphasis(f"`!{m.group(1)}`!"), + text, + ) + text = re.sub( + r"(? List[str]: + if not text: + return [] + + margin_left = max(0, style.margin_left) + margin_right = max(0, style.margin_right) + available = max(1, self.width - margin_left - margin_right) + prefix_first = initial_prefix + prefix_rest = subsequent_prefix if subsequent_prefix is not None else initial_prefix + + if len(prefix_first) >= available or len(prefix_rest) >= available: + raw_lines = [f"{prefix_first}{text}".rstrip()] + else: + wrapper = textwrap.TextWrapper( + width=available, + expand_tabs=False, + replace_whitespace=True, + drop_whitespace=True, + initial_indent=prefix_first, + subsequent_indent=prefix_rest, + ) + raw_lines = [line.rstrip() for line in wrapper.wrap(text)] + + margin = " " * margin_left + if not raw_lines: + return [] + + if align_blocks: + return [margin + self._apply_alignment(line, style, available) for line in raw_lines] + return [margin + line for line in raw_lines] + + @staticmethod + def _apply_alignment(line: str, style: BlockStyle, available: int) -> str: + align = (style.align or "left").lower() + if align == "center": + return line.strip().center(available).rstrip() + if align == "right": + return line.strip().rjust(available).rstrip() + return line.rstrip() + + def _layout_ascii_pieces(self, pieces: List[AsciiArtPiece], style: BlockStyle) -> List[str]: + if not pieces: + return [] + if len(pieces) == 1: + piece = pieces[0] + return self._align_preformatted_lines(piece.lines, style, piece.align) + + margin_left = min(max(style.margin_left, 0), self.width - 1) + margin_right = max(style.margin_right, 0) + available_width = max(1, self.width - margin_left - margin_right) + + heights = [len(piece.lines) for piece in pieces] + max_height = max(heights) if heights else 0 + widths = [self._ascii_piece_width(piece.lines) for piece in pieces] + + positions = self._compute_ascii_positions(pieces, widths, available_width) + if positions is None: + stacked: List[str] = [] + for piece in pieces: + stacked.extend(self._align_preformatted_lines(piece.lines, style, piece.align)) + return stacked + + canvas = [list(" " * available_width) for _ in range(max_height)] + for piece, pos, _width in positions: + for row_index in range(max_height): + if row_index >= len(piece.lines): + continue + line = piece.lines[row_index].rstrip("\n") + for col_index, char in enumerate(line): + target_index = pos + col_index + if target_index >= available_width: + break + if char != " ": + canvas[row_index][target_index] = char + + raw_rows = ["".join(row).rstrip() for row in canvas] + return self._align_preformatted_lines(raw_rows, style) + + def _ascii_piece_width(self, lines: List[str]) -> int: + return max((len(line.rstrip("\n")) for line in lines), default=0) + + def _align_preformatted_lines( + self, + lines: List[str], + style: BlockStyle, + explicit_align: Optional[str] = None, + ) -> List[str]: + if not lines: + return [] + margin_left = min(max(style.margin_left, 0), self.width - 1) + margin_right = max(style.margin_right, 0) + available_width = max(1, self.width - margin_left - margin_right) + processed = [line.rstrip("\n") for line in lines] + block_width = max((len(line) for line in processed), default=0) + extra_space = max(0, available_width - block_width) + align = (explicit_align or style.align or "left").lower() + if align in {"center", "centre"}: + align_offset = extra_space // 2 + elif align == "right": + align_offset = extra_space + else: + align_offset = 0 + max_indent = max(0, self.width - block_width) + indent = min(margin_left + align_offset, max_indent) + indent_str = " " * indent + return [indent_str + line for line in processed] + + def _compute_ascii_positions( + self, + pieces: List[AsciiArtPiece], + widths: List[int], + available_width: int, + ) -> Optional[List[Tuple[AsciiArtPiece, int, int]]]: + if available_width <= 0: + return None + + gap = 4 + left_cursor = 0 + right_cursor = available_width + placement_map: Dict[int, Tuple[int, int]] = {} + + left_indices: List[int] = [] + center_indices: List[int] = [] + right_indices: List[int] = [] + + for index, piece in enumerate(pieces): + align = (piece.align or "left").lower() + if align == "right": + right_indices.append(index) + elif align == "center": + center_indices.append(index) + else: + left_indices.append(index) + + for index in left_indices: + width = min(widths[index], available_width) + pos = left_cursor + left_cursor = min(available_width, pos + width + gap) + placement_map[index] = (pos, width) + + for index in reversed(right_indices): + width = min(widths[index], available_width) + pos = max(0, right_cursor - width) + right_cursor = max(0, pos - gap) + placement_map[index] = (pos, width) + + center_used = False + for index in center_indices: + width = min(widths[index], available_width) + pos = max(0, (available_width - width) // 2) + if center_used: + pos = left_cursor + left_cursor = min(available_width, pos + width + gap) + else: + center_used = True + placement_map[index] = (pos, width) + + for i in range(len(pieces)): + if i not in placement_map: + continue + pos_i, width_i = placement_map[i] + end_i = pos_i + width_i + for j in range(i + 1, len(pieces)): + if j not in placement_map: + continue + pos_j, width_j = placement_map[j] + if pos_i <= pos_j < end_i or pos_j <= pos_i < pos_j + width_j: + return None + + ordered_positions: List[Tuple[AsciiArtPiece, int, int]] = [] + for index, piece in enumerate(pieces): + if index not in placement_map: + continue + pos, width = placement_map[index] + ordered_positions.append((piece, pos, width)) + return ordered_positions + + def _replace_link(self, match: re.Match[str]) -> str: + label = match.group(1).strip() + url = match.group(2).strip() + if not label: + return url + if label == url or url == f"mailto:{label}": + return f"`[{label}`" + return f"`[{label}`{url}]" + + def _replace_image(self, match: re.Match[str]) -> str: + alt = match.group(1).strip() + url = match.group(2).strip() + if not alt: + return f"`[{url}`" + return f"`[{alt}`{url}]" + + def _write(self, text: str) -> None: + if not text: + return + self._chunks.append(text) + if text.endswith("\n"): + newline_count = len(text) - len(text.rstrip("\n")) + prefix = text[:-newline_count] + if prefix: + self._trailing_newlines = newline_count + else: + self._trailing_newlines += newline_count + else: + self._trailing_newlines = 0 + + def _ensure_newlines(self, count: int) -> None: + if self._trailing_newlines < count: + needed = count - self._trailing_newlines + self._write("\n" * needed) + + def _emit_block( + self, + content: str, + *, + stylable: bool, + render_fn: Optional[Callable[[BlockStyle], str]] = None, + style: Optional[BlockStyle] = None, + ) -> None: + if not content: + if stylable: + self._last_stylable_block = None + return + index = len(self._chunks) + self._write(content) + if stylable and render_fn is not None and style is not None: + self._last_stylable_block = _StylableBlock( + index=index, + render=render_fn, + style=BlockStyle( + align=style.align, + margin_left=style.margin_left, + margin_right=style.margin_right, + ), + ) + else: + self._last_stylable_block = None + + def _apply_style_update(self, spec: StyleSpec) -> None: + if self._last_stylable_block is None: + return + block = self._last_stylable_block + new_style = self._combine_styles(block.style, spec) + updated = block.render(new_style) + self._chunks[block.index] = updated + block.style = new_style + + @staticmethod + def _combine_styles(base: BlockStyle, spec: StyleSpec) -> BlockStyle: + if spec is None: + return BlockStyle( + align=base.align, + margin_left=base.margin_left, + margin_right=base.margin_right, + ) + return BlockStyle( + align=spec.align or base.align, + margin_left=spec.margin_left if spec.margin_left is not None else base.margin_left, + margin_right=spec.margin_right if spec.margin_right is not None else base.margin_right, + ) + + +def _micron_renderer_factory(*, frontmatter: FrontMatter, **options: Any) -> MicronRenderer: + return MicronRenderer(frontmatter, **options) + + +try: + register_renderer("micron", _micron_renderer_factory) +except ValueError: + pass