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

103
micron.mu Normal file
View file

@ -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]

View file

@ -1,404 +1,98 @@
from __future__ import annotations from __future__ import annotations
import re from functools import partial
import textwrap from typing import Any, List
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Tuple
from md_types import ( from md_types import AsciiArtPayload, BlockQuotePayload, BlockStyle, CodeBlockPayload, FrontMatter, HeadingPayload, ListItemPayload, ParagraphPayload
AsciiArtPayload,
AsciiArtPiece,
BlockEvent,
BlockKind,
BlockQuotePayload,
BlockStyle,
CodeBlockPayload,
FrontMatter,
HeadingPayload,
ListItemPayload,
ParagraphPayload,
StyleSpec,
StyleUpdateEvent,
)
from plugins import register_renderer 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 MicronRenderer(TextRenderer):
class _StylableBlock:
index: int
render: Callable[[BlockStyle], str]
style: BlockStyle
class MicronRenderer:
def __init__(self, frontmatter: FrontMatter, *, width: int = 80, **_: Any) -> None: def __init__(self, frontmatter: FrontMatter, *, width: int = 80, **_: Any) -> None:
self.frontmatter = frontmatter super().__init__(width, frontmatter)
self.width = max(1, width) # Micron format inlines links directly, so suppress link collection overhead.
self._chunks: List[str] = [] self.links.clear()
self._trailing_newlines = 0 self.link_indices.clear()
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: def _render_paragraph(self, payload: ParagraphPayload, style: BlockStyle) -> None:
content = self._render_inline(payload.text) processed = self._process_inline(payload.text)
if content: self._wrap_emit(processed, style, stylable=True, hyphenate=self.hyphenate)
def render(target_style: BlockStyle) -> str: if self.paragraph_spacing > 0:
lines = self._wrap_text(content, target_style) self.output.extend([""] * self.paragraph_spacing)
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: def _render_heading(self, payload: HeadingPayload, style: BlockStyle) -> None:
self._ensure_header_spacing()
level = max(1, min(3, payload.level)) level = max(1, min(3, payload.level))
marker = ">" * level + " " marker = ">" * level
text = self._render_inline(payload.text) line = f"{marker} {self._process_inline(payload.text)}".rstrip()
if text: self._emit_block([line, ""], stylable=False)
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_code_block(self, payload: CodeBlockPayload, style: BlockStyle) -> None:
def render(target_style: BlockStyle) -> str: margin_left, _, _ = self._margins(style)
margin = " " * max(0, target_style.margin_left) indent = " " * margin_left
fence = margin + "`=" body = [f"{indent}`=", *[f"{indent}{line.rstrip()}" for line in payload.lines], f"{indent}`="]
body_lines = [margin + line.rstrip("\n") for line in payload.lines] self._emit_block(body, stylable=False)
return "\n".join([fence, *body_lines, fence]) self.output.append("")
block = render(style)
if block:
self._emit_block(block, stylable=False)
self._ensure_newlines(2)
def _render_blockquote(self, payload: BlockQuotePayload, style: BlockStyle) -> None: def _render_blockquote(self, payload: BlockQuotePayload, style: BlockStyle) -> None:
prefix = ">>>>" * max(1, payload.depth) processed = self._process_inline(payload.text)
text = self._render_inline(payload.text) indent = ">>>>" * max(1, payload.depth)
if text: self._wrap_emit(processed, style, initial_indent=indent, subsequent_indent=indent, hyphenate=self.hyphenate)
def render(target_style: BlockStyle) -> str: self.output.append("")
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: def _render_list_item(self, payload: ListItemPayload, style: BlockStyle) -> None:
indent_text = payload.indent.replace("\t", " ") base_indent = payload.indent.replace("\t", " ")
spacing = payload.spacing if payload.spacing else " " marker_indent = " " * self.list_marker_indent
initial_prefix = f"{indent_text}{payload.marker}{spacing}" marker = payload.marker
subsequent_prefix = f"{indent_text}{' ' * len(payload.marker)}{spacing}" spacing = " " * self.list_text_spacing
text = self._render_inline(payload.text) initial = f"{base_indent}{marker_indent}{marker}{spacing}"
if text: subsequent = f"{base_indent}{marker_indent}{' ' * len(marker)}{spacing}"
def render(target_style: BlockStyle) -> str: processed = self._process_inline(payload.text)
lines = self._wrap_text( self._wrap_emit(processed, style, initial_indent=initial, subsequent_indent=subsequent, hyphenate=self.hyphenate)
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_custom_block(self, payload: AsciiArtPayload, style: BlockStyle) -> None:
def render(target_style: BlockStyle) -> str: render = partial(self._layout_ascii_pieces, payload.pieces)
lines = self._layout_ascii_pieces(payload.pieces, target_style) self._emit_block(render(style), stylable=True, render_fn=render, style=style)
return "\n".join(lines)
block = render(style) def _render_horizontal_rule(self, _payload: object, style: BlockStyle) -> None:
if block: margin_left, _, available = self._margins(style)
self._emit_block(block, stylable=True, render_fn=render, style=style) self._emit_block([" " * margin_left + "-" * available], stylable=False)
self._ensure_newlines(2)
def _render_inline(self, text: str) -> str:
if not text:
return ""
# Inline transformations -------------------------------------------------
def _process_inline(self, text: str) -> str:
code_segments: List[str] = [] code_segments: List[str] = []
emphasis_segments: List[str] = []
def stash_code(match: re.Match[str]) -> str: def stash_code(match):
placeholder = f"\u0000CODE{len(code_segments)}\u0000" segment = match.group(0)
code_segments.append(match.group(1)) code_segments.append(segment[1:-1])
return placeholder return f"\u0000CODE{len(code_segments) - 1}\u0000"
def stash_emphasis(content: str) -> str: text = CODE_STASH_RE.sub(stash_code, text)
placeholder = f"\u0000EMP{len(emphasis_segments)}\u0000" text = STRIKETHROUGH_RE.sub(lambda m: f"~~{m.group(1)}~~", text) # keep strikethrough literal for now
emphasis_segments.append(content) text = BOLD_RE.sub(lambda m: self._apply_emphasis_spacing(m.string, m.start(), m.end(), f"`!{m.group(1)}`!"), text)
return placeholder 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 = re.sub(r"`([^`]+)`", stash_code, 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 = re.sub( text = IMAGE_RE.sub(self._replace_image, text)
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}`=")
for idx, code in enumerate(code_segments):
text = text.replace(f"\u0000CODE{idx}\u0000", f"`={code}`=")
return text 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 @staticmethod
def _apply_alignment(line: str, style: BlockStyle, available: int) -> str: def _replace_link(match) -> 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() label = match.group(1).strip()
url = match.group(2).strip() url = match.group(2).strip()
if not label: if not label:
@ -407,85 +101,16 @@ class MicronRenderer:
return f"`[{label}`" return f"`[{label}`"
return f"`[{label}`{url}]" return f"`[{label}`{url}]"
def _replace_image(self, match: re.Match[str]) -> str: @staticmethod
def _replace_image(match) -> str:
alt = match.group(1).strip() alt = match.group(1).strip()
url = match.group(2).strip() url = match.group(2).strip()
if not alt: return f"`[{alt or url}`{url}]"
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: 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: try: