From c9af956d0ad1a98b43f9595bc0436bfd8bb9bac3 Mon Sep 17 00:00:00 2001 From: randogoth Date: Mon, 20 Oct 2025 15:55:47 +0300 Subject: [PATCH] micron renderer derivative --- micron.mu | 103 +++++++++ micron_renderer.py | 517 +++++++-------------------------------------- 2 files changed, 174 insertions(+), 446 deletions(-) create mode 100644 micron.mu diff --git a/micron.mu b/micron.mu new file mode 100644 index 0000000..bb8a885 --- /dev/null +++ b/micron.mu @@ -0,0 +1,103 @@ +> 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/] + +>> Images + +`[Minion`https://octodex.github.com/images/minion.png] +`[Stormtroopocat`https://octodex.github.com/images/stormtroopocat.jpg] \ No newline at end of file diff --git a/micron_renderer.py b/micron_renderer.py index a037a15..244b97a 100644 --- a/micron_renderer.py +++ b/micron_renderer.py @@ -1,404 +1,98 @@ from __future__ import annotations -import re -import textwrap -from dataclasses import dataclass -from typing import Any, Callable, Dict, List, Optional, Tuple +from functools import partial +from typing import Any, List -from md_types import ( - AsciiArtPayload, - AsciiArtPiece, - BlockEvent, - BlockKind, - BlockQuotePayload, - BlockStyle, - CodeBlockPayload, - FrontMatter, - HeadingPayload, - ListItemPayload, - ParagraphPayload, - StyleSpec, - StyleUpdateEvent, -) +from md_types import AsciiArtPayload, BlockQuotePayload, BlockStyle, CodeBlockPayload, FrontMatter, HeadingPayload, ListItemPayload, ParagraphPayload from plugins import register_renderer +from text_renderer import ( + BOLD_RE, + CODE_STASH_RE, + IMAGE_RE, + ITALIC_RE, + LINK_RE, + STRIKETHROUGH_RE, + TextRenderer, + UNDERLINE_EM_RE, + UNDERLINE_STRONG_RE, +) -@dataclass -class _StylableBlock: - index: int - render: Callable[[BlockStyle], str] - style: BlockStyle - - -class MicronRenderer: +class MicronRenderer(TextRenderer): 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] + super().__init__(width, frontmatter) + # Micron format inlines links directly, so suppress link collection overhead. + self.links.clear() + self.link_indices.clear() 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) + processed = self._process_inline(payload.text) + self._wrap_emit(processed, style, stylable=True, hyphenate=self.hyphenate) + if self.paragraph_spacing > 0: + self.output.extend([""] * self.paragraph_spacing) def _render_heading(self, payload: HeadingPayload, style: BlockStyle) -> None: + self._ensure_header_spacing() 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) + marker = ">" * level + line = f"{marker} {self._process_inline(payload.text)}".rstrip() + self._emit_block([line, ""], stylable=False) 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) + margin_left, _, _ = self._margins(style) + indent = " " * margin_left + body = [f"{indent}`=", *[f"{indent}{line.rstrip()}" for line in payload.lines], f"{indent}`="] + self._emit_block(body, stylable=False) + self.output.append("") 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) + processed = self._process_inline(payload.text) + indent = ">>>>" * max(1, payload.depth) + self._wrap_emit(processed, style, initial_indent=indent, subsequent_indent=indent, hyphenate=self.hyphenate) + self.output.append("") 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) + base_indent = payload.indent.replace("\t", " ") + marker_indent = " " * self.list_marker_indent + marker = payload.marker + spacing = " " * self.list_text_spacing + initial = f"{base_indent}{marker_indent}{marker}{spacing}" + subsequent = f"{base_indent}{marker_indent}{' ' * len(marker)}{spacing}" + processed = self._process_inline(payload.text) + self._wrap_emit(processed, style, initial_indent=initial, subsequent_indent=subsequent, hyphenate=self.hyphenate) 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) + render = partial(self._layout_ascii_pieces, payload.pieces) + self._emit_block(render(style), stylable=True, render_fn=render, style=style) - 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 "" + def _render_horizontal_rule(self, _payload: object, style: BlockStyle) -> None: + margin_left, _, available = self._margins(style) + self._emit_block([" " * margin_left + "-" * available], stylable=False) + # Inline transformations ------------------------------------------------- + def _process_inline(self, text: str) -> str: 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_code(match): + segment = match.group(0) + code_segments.append(segment[1:-1]) + return f"\u0000CODE{len(code_segments) - 1}\u0000" - 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: + def _replace_link(match) -> str: label = match.group(1).strip() url = match.group(2).strip() if not label: @@ -407,85 +101,16 @@ class MicronRenderer: return f"`[{label}`" return f"`[{label}`{url}]" - def _replace_image(self, match: re.Match[str]) -> str: + @staticmethod + def _replace_image(match) -> 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, - ) + return f"`[{alt or url}`{url}]" def _micron_renderer_factory(*, frontmatter: FrontMatter, **options: Any) -> MicronRenderer: - return MicronRenderer(frontmatter, **options) + width = int(options.get("width", 80)) + return MicronRenderer(frontmatter, width=width) try: