feat: add xhtmlmp renderer

This commit is contained in:
randogoth 2026-09-26 12:41:09 +03:00
parent 3ed0b5807c
commit 3af44fb0ac
14 changed files with 524 additions and 7 deletions

View file

@ -19,6 +19,7 @@ from .plugins import (
register_parser,
)
from .renderers import wml as _wml # noqa: F401 # registers the WML renderer plugin
from .renderers import xhtmlmp as _xhtmlmp # noqa: F401 # registers the XHTML-MP renderer plugin
from .renderers.wml.renderer import DeckOutput
@ -211,8 +212,21 @@ def _cli_overrides(args: argparse.Namespace) -> Dict[str, Any]:
return {dest: getattr(args, dest, None) for dest in _CONFIG_DESTS}
def write_output(output: DeckOutput, path: Optional[Path]) -> None:
"""Write the deck. WML is XML served over HTTP, so lines end with LF."""
def write_output(output: "DeckOutput | str", path: Optional[Path]) -> None:
"""Write the renderer's output. WML/XHTML-MP are XML served over HTTP,
so lines end with LF.
A plain string (as XHTML-MP produces -- it has no deck/card topology
to report) is written as-is; a DeckOutput may fan out to several files
in --deck-per-card mode.
"""
if isinstance(output, str):
if path is None:
sys.stdout.write(output)
return
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(output, encoding="utf-8")
return
multi = len(output.documents) > 1 or any(doc.name for doc in output.documents)
if multi:
if path is None:

View file

@ -1,5 +1,6 @@
"""Bundled renderer implementations."""
from .wml import WMLRenderer
from .xhtmlmp import XhtmlMpRenderer
__all__ = ["WMLRenderer"]
__all__ = ["WMLRenderer", "XhtmlMpRenderer"]

View file

@ -0,0 +1,24 @@
"""XHTML-MP renderer, registered under the name `xhtmlmp`."""
from typing import Any
from ...plugins import register_renderer
from .inline import escape_xhtml, process_inline
from .renderer import XHTML_PROLOG, XhtmlMpRenderer
def _xhtmlmp_renderer_factory(**options: Any) -> XhtmlMpRenderer:
return XhtmlMpRenderer(**options)
try:
register_renderer("xhtmlmp", _xhtmlmp_renderer_factory)
except ValueError: # pragma: no cover - already registered on re-import
pass
__all__ = [
"XHTML_PROLOG",
"XhtmlMpRenderer",
"escape_xhtml",
"process_inline",
]

View file

@ -0,0 +1,95 @@
"""Inline Markdown -> XHTML-MP markup mapping, plus XML escaping.
Mirrors wml/inline.py's stash-then-substitute structure and reuses its
Markdown-parsing regexes (a URL like `/a_b_c` must survive intact, which is
what the stash step protects against), but the two output grammars differ
enough that the escaping and link/code handling are not shared:
- XHTML-MP has no '$' variable-substitution quirk.
- <a> may contain inline markup, unlike WML's `(#PCDATA | br | img)*`, so
a link label is run through the same emphasis pass as ordinary text
instead of having its markers stripped.
- <code> exists, so a code span becomes an element instead of plain text.
"""
from __future__ import annotations
from typing import List
from ..wml.inline import (
BOLD_RE,
CODE_STASH_RE,
IMAGE_RE,
ITALIC_RE,
LINK_RE,
STRIKETHROUGH_RE,
UNDERLINE_EM_RE,
UNDERLINE_STRONG_RE,
)
_STASH = "\u0000{kind}{index}\u0000"
_CODE = "CODE"
_TAG = "TAG"
def escape_xhtml(text: str) -> str:
"""XML-escape text for XHTML-MP element content or a double-quoted
attribute value. Applied once, up front, to the whole raw string before
any inline markup is turned into tags, matching wml/inline.py's
ordering, so link/image URLs captured out of the already-escaped text
are correctly escaped for attribute use too.
"""
text = text.replace("&", "&amp;")
return text.replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
def process_inline(text: str) -> str:
"""Convert one run of inline Markdown into XHTML-MP markup.
Links and images are resolved *before* the emphasis patterns run and
stashed behind sentinels, for the same reason as in wml/inline.py: a
path like `/a_b_c` or `/a*b*c` would otherwise be shredded into <em>
tags by the emphasis regexes before LINK_RE ever saw it.
"""
escaped = escape_xhtml(text)
code_segments: List[str] = []
tag_segments: List[str] = []
def stash_code(match):
code_segments.append(match.group(0)[1:-1])
return _STASH.format(kind=_CODE, index=len(code_segments) - 1)
def stash(markup: str) -> str:
tag_segments.append(markup)
return _STASH.format(kind=_TAG, index=len(tag_segments) - 1)
def stash_image(match):
alt = match.group(1).strip()
src = match.group(2).strip()
return stash(f'<img src="{src}" alt="{alt}"/>')
def stash_link(match):
label = match.group(1).strip()
url = match.group(2).strip()
return stash(f'<a href="{url}">{_apply_emphasis(label)}</a>')
escaped = CODE_STASH_RE.sub(stash_code, escaped)
escaped = IMAGE_RE.sub(stash_image, escaped) # before LINK_RE: `![x](y)` is not a link
escaped = LINK_RE.sub(stash_link, escaped)
escaped = _apply_emphasis(escaped)
for index, markup in enumerate(tag_segments):
escaped = escaped.replace(_STASH.format(kind=_TAG, index=index), markup)
for index, code in enumerate(code_segments):
escaped = escaped.replace(_STASH.format(kind=_CODE, index=index), f"<code>{code}</code>")
return escaped
def _apply_emphasis(text: str) -> str:
text = STRIKETHROUGH_RE.sub(lambda m: m.group(1), text) # no XHTML-MP equivalent
text = BOLD_RE.sub(lambda m: f"<strong>{m.group(1)}</strong>", text)
text = UNDERLINE_STRONG_RE.sub(lambda m: f"<strong>{m.group(1)}</strong>", text)
text = ITALIC_RE.sub(lambda m: f"<em>{m.group(1)}</em>", text)
text = UNDERLINE_EM_RE.sub(lambda m: f"<em>{m.group(1)}</em>", text)
return text

View file

@ -0,0 +1,157 @@
"""The XHTML-MP renderer."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, List, Optional
from ...config import DeckConfig
from ...models import BlockEvent, BlockKind, StyleUpdateEvent
from .inline import escape_xhtml, process_inline
XHTML_PROLOG = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.0//EN" '
'"http://www.wapforum.org/DTD/xhtml-mobile10.dtd">\n'
)
@dataclass
class _ListFrame:
"""One open <ul>/<ol>, mid-render.
`items` holds complete `<li>...</li>` strings. A deeper list is spliced
into the last item *before* its closing tag when it closes, because
XHTML nests a sub-list inside its parent <li>, not after it -- unlike
WML, which has no list element to nest at all.
"""
depth: int
ordered: bool
items: List[str] = field(default_factory=list)
class XhtmlMpRenderer:
"""Render Markdown events as an XHTML-MP page for WAP 2.0 browsers and,
served as text/html, modern browsers too.
Unlike WMLRenderer, there is no deck-size budget or card topology to
compute, so output streams incrementally per event rather than being
buffered and resolved in `finalize()`.
"""
def __init__(self, config: Optional[DeckConfig] = None, **options: Any) -> None:
self.config = config or DeckConfig.resolve(overrides=options)
self._body: List[str] = []
self._list_stack: List[_ListFrame] = []
# -- Renderer protocol ---------------------------------------------------
def handle_event(self, event: "BlockEvent | StyleUpdateEvent") -> None:
if isinstance(event, StyleUpdateEvent):
# XHTML-MP has no per-block style stack to apply this to, by
# design; ignored like other structural mismatches.
return
handler = {
BlockKind.PARAGRAPH: self._render_paragraph,
BlockKind.HEADING: self._render_heading,
BlockKind.LIST_ITEM: self._render_list_item,
BlockKind.CODE_BLOCK: self._render_code_block,
BlockKind.BLOCKQUOTE: self._render_blockquote,
BlockKind.TABLE: self._render_table,
BlockKind.HORIZONTAL_RULE: self._render_horizontal_rule,
BlockKind.CARD_BREAK: self._close_lists,
# BLANK_LINE: a blank line between list items is loose Markdown
# formatting, not a break in the list, so it leaves any open
# list open -- matching the WML renderer's own convention of
# not resetting list state on a blank line.
}.get(event.kind)
if handler is not None:
handler(event.payload)
def finalize(self) -> str:
self._close_lists()
title = escape_xhtml(self.config.title) if self.config.title else ""
head = f"<head><title>{title}</title></head>"
body = "".join(self._body)
return (
f"{XHTML_PROLOG}"
'<html xmlns="http://www.w3.org/1999/xhtml">\n'
f"{head}\n"
f"<body>{body}</body>\n"
"</html>\n"
)
# -- List nesting ---------------------------------------------------------
def _render_list_item(self, payload) -> None:
indent = payload.indent.replace("\t", " ")
depth = max(0, len(indent) // 2)
text = self._inline(payload.text)
self._open_list(depth, payload.ordered)
self._list_stack[-1].items.append(f"<li>{text}</li>")
def _open_list(self, depth: int, ordered: bool) -> None:
while self._list_stack and (
self._list_stack[-1].depth > depth
or (self._list_stack[-1].depth == depth and self._list_stack[-1].ordered != ordered)
):
self._close_one_list()
if not self._list_stack or self._list_stack[-1].depth < depth:
self._list_stack.append(_ListFrame(depth, ordered))
def _close_one_list(self) -> None:
frame = self._list_stack.pop()
tag = "ol" if frame.ordered else "ul"
wrapped = f"<{tag}>{''.join(frame.items)}</{tag}>"
if self._list_stack:
parent_items = self._list_stack[-1].items
parent_items[-1] = parent_items[-1][: -len("</li>")] + wrapped + "</li>"
else:
self._body.append(wrapped)
def _close_lists(self, *_: object) -> None:
while self._list_stack:
self._close_one_list()
# -- Block markup mapping -------------------------------------------------
def _render_paragraph(self, payload) -> None:
self._close_lists()
self._body.append(f"<p>{self._inline(payload.text)}</p>")
def _render_heading(self, payload) -> None:
self._close_lists()
level = max(1, min(6, payload.level))
text = self._inline(payload.text)
self._body.append(f"<h{level}>{text}</h{level}>")
def _render_code_block(self, payload) -> None:
self._close_lists()
lines = "\n".join(escape_xhtml(line.rstrip("\n")) for line in payload.lines)
self._body.append(f"<pre>{lines}</pre>")
def _render_blockquote(self, payload) -> None:
self._close_lists()
self._body.append(f"<blockquote><p>{self._inline(payload.text)}</p></blockquote>")
def _render_table(self, payload) -> None:
self._close_lists()
rows = payload.rows
if not rows:
return
columns = max(len(row) for row in rows)
tr_parts: List[str] = []
for row_index, row in enumerate(rows):
cells = list(row) + [""] * (columns - len(row))
cell_tag = "th" if row_index == 0 else "td"
tds = "".join(f"<{cell_tag}>{self._inline(cell)}</{cell_tag}>" for cell in cells)
tr_parts.append(f"<tr>{tds}</tr>")
self._body.append(f"<table>{''.join(tr_parts)}</table>")
def _render_horizontal_rule(self, _payload) -> None:
self._close_lists()
self._body.append("<hr/>")
def _inline(self, text: str) -> str:
return process_inline(text)