feat: add xhtmlmp renderer
This commit is contained in:
parent
3ed0b5807c
commit
3af44fb0ac
14 changed files with 524 additions and 7 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""Bundled renderer implementations."""
|
||||
|
||||
from .wml import WMLRenderer
|
||||
from .xhtmlmp import XhtmlMpRenderer
|
||||
|
||||
__all__ = ["WMLRenderer"]
|
||||
__all__ = ["WMLRenderer", "XhtmlMpRenderer"]
|
||||
|
|
|
|||
24
src/wapdown/renderers/xhtmlmp/__init__.py
Normal file
24
src/wapdown/renderers/xhtmlmp/__init__.py
Normal 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",
|
||||
]
|
||||
95
src/wapdown/renderers/xhtmlmp/inline.py
Normal file
95
src/wapdown/renderers/xhtmlmp/inline.py
Normal 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("&", "&")
|
||||
return text.replace("<", "<").replace(">", ">").replace('"', """)
|
||||
|
||||
|
||||
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: `` 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
|
||||
157
src/wapdown/renderers/xhtmlmp/renderer.py
Normal file
157
src/wapdown/renderers/xhtmlmp/renderer.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue