micron renderer derivative

This commit is contained in:
randogoth 2025-10-20 15:55:47 +03:00
parent 937930ef5c
commit c9af956d0a
2 changed files with 174 additions and 446 deletions

View file

@ -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"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)",
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"!\[([^\]]*)\]\(([^)]+)\)",
self._replace_image,
text,
)
text = re.sub(
r"(?<!\!)\[([^\]]+)\]\(([^)]+)\)",
self._replace_link,
text,
)
for index, replacement in enumerate(emphasis_segments):
text = text.replace(f"\u0000EMP{index}\u0000", replacement)
for index, code in enumerate(code_segments):
text = text.replace(f"\u0000CODE{index}\u0000", f"`={code}`=")
text = CODE_STASH_RE.sub(stash_code, text)
text = STRIKETHROUGH_RE.sub(lambda m: f"~~{m.group(1)}~~", text) # keep strikethrough literal for now
text = BOLD_RE.sub(lambda m: self._apply_emphasis_spacing(m.string, m.start(), m.end(), f"`!{m.group(1)}`!"), text)
text = ITALIC_RE.sub(lambda m: self._apply_emphasis_spacing(m.string, m.start(), m.end(), f"`*{m.group(1)}`*"), text)
text = UNDERLINE_STRONG_RE.sub(lambda m: self._apply_emphasis_spacing(m.string, m.start(), m.end(), f"`!{m.group(1)}`!"), text)
text = UNDERLINE_EM_RE.sub(lambda m: self._apply_emphasis_spacing(m.string, m.start(), m.end(), f"`*{m.group(1)}`*"), text)
text = LINK_RE.sub(self._replace_link, text)
text = IMAGE_RE.sub(self._replace_image, text)
for idx, code in enumerate(code_segments):
text = text.replace(f"\u0000CODE{idx}\u0000", f"`={code}`=")
return text
def _wrap_text(
self,
text: str,
style: BlockStyle,
*,
initial_prefix: str = "",
subsequent_prefix: Optional[str] = None,
align_blocks: bool = True,
) -> 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: