diff --git a/README.md b/README.md
index 93514f0..71a4dd2 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@ uv tool install --from git+https://codeberg.org/randogoth/md2txt/ md2txt
## CLI
-- `md2txt` – converts Markdown into plain text with extensive formatting support. It ships with the default `markdown` parser and `text` renderer plugins, registers optional `micron`, `ama`, and `gemini` renderers for Micron/Ancient Machine Book or Gemtext output, and exposes the core pipeline so you can add your own parser or renderer modules:
+- `md2txt` – converts Markdown into plain text with extensive formatting support. It ships with the default `markdown` parser and `text` renderer plugins, registers optional `micron`, `ama`, `gemini`, and `wml` renderers for Micron/Ancient Machine Book, Gemtext, or WML (WAP) output, and exposes the core pipeline so you can add your own parser or renderer modules:
- FIGlet-rendered headings.
- Emphasis styles converted to spaced or delimited characters.
- Optional alignment and margin controls via HTML `
` attributes or MultiMarkdown attribute blocks (e.g. `{:.center margin=20px}`) applied as leading spaces.
@@ -36,6 +36,7 @@ md2txt input.md --width 72 # override column width
md2txt input.md --renderer micron # emit Micron-formatted output for NomadNet
md2txt input.md --renderer ama # emit AMA markup for Ancient Machine Book
md2txt input.md --renderer gemini # emit Gemtext for Gemini capsules
+md2txt input.md --renderer wml # emit a single-card WML deck for WAP browsers
md2txt input.md --renderer-option width=68 # pass column width to a renderer
```
@@ -122,6 +123,40 @@ Inline or block-level ASCII art can be embedded directly in Markdown:
Generated text uses CRLF line endings to maintain DOS compatibility.
+## WML Decks
+
+The `wml` renderer converts Markdown into a [WML](https://en.wikipedia.org/wiki/Wireless_Markup_Language) deck for old WAP browsers. WML screens are idiomatically short and button/link-navigated rather than one long scrolling page, so unlike the other renderers, `wml` can split a document into multiple ``s — but only when you ask it to; a plain document with no options stays a single card.
+
+Two independent, both-optional ways to define card boundaries:
+
+- **Manual dividers** — insert a `{.card}` or `{.card Title}` line wherever you want a new card to start, regardless of heading structure:
+
+ ```markdown
+ Intro shown on the menu card...
+
+ {.card Weather}
+ Cold and clear...
+
+ {.card Trail Status}
+ - North Loop: open
+ ```
+
+- **Heading-level splitting** — opt in with `--renderer-option split_level=N` to start a new card at every heading of level `N` (e.g. `split_level=2` for the common one-H1-title-plus-H2-sections pattern). Ignored if the document contains any `{.card}` markers.
+
+When a document resolves to two or more cards, `wml` builds a "menu" card by default — the classic WAP-portal pattern: a list of button/link choices leading to each section, each with a Back control (`--renderer-option menu=false` switches to a linear book instead, chaining cards with Next/Back). A section whose content would still be too large for one card even after splitting is automatically paginated further (`--renderer-option max_card_bytes=N`, default 1400 — a nod to classic WAP 1.x per-deck cache limits; `0` disables it).
+
+| Renderer option | Default | Meaning |
+| --- | --- | --- |
+| `title` | *(none)* | Overall deck title, used on the menu card (or the single card, if never split). |
+| `split_level` | `0` (disabled) | Heading level that starts a new card. Ignored if `{.card}` markers are present. |
+| `max_card_bytes` | `1400` | Byte safety-net per card; `0` disables. |
+| `menu` | `true` | Hub-and-spoke menu card vs. linear Next/Back chaining (`menu=false`). |
+| `nav_next_label` | `More` | Label for forward navigation (linear mode / overflow pagination). |
+| `nav_prev_label` | `Prev` | Label for backward navigation between overflow pagination parts. |
+| `nav_back_label` | `Back` | Label for backward navigation from a section back to the menu/previous section. |
+
+WML 1.3 is the only supported DTD (fixed by design, not a flag). Since WML uses `$` for variable substitution, a literal `$` in your Markdown is escaped to `$$` in the output automatically. Images pass through as ` ` referencing the original path/URL — no WBMP conversion is performed.
+
## Plugin Architecture
The conversion pipeline lives in `src/md2txt/conversion/core.py` and is exposed via `run_conversion`. It is designed around lightweight factories:
@@ -149,7 +184,7 @@ register_renderer("ansi", my_renderer_factory)
Once registered (for example in a small module that imports `md2txt.cli`), the new plugins are available via `--parser my-markdown` or `--renderer ansi`. The CLI lists registered plugin names in `--help`, and the helper functions `available_parsers()` / `available_renderers()` return the sorted names if you need to build higher-level tooling.
-The shared preprocessing helpers—YAML frontmatter parsing, recursive include expansion, and ASCII art sentinels—also live in `src/md2txt/conversion/core.py`, allowing alternate front-ends to reuse exactly the same behaviour without duplicating code.
+The shared preprocessing helpers—YAML frontmatter parsing, recursive include expansion, ASCII art sentinels, and pipe-table sentinels—also live in `src/md2txt/conversion/core.py`, allowing alternate front-ends to reuse exactly the same behaviour without duplicating code.
---
diff --git a/examples/wml-manual.md b/examples/wml-manual.md
new file mode 100644
index 0000000..9efb7c4
--- /dev/null
+++ b/examples/wml-manual.md
@@ -0,0 +1,14 @@
+Live updates for the ridge trail network. Reception is spotty past the tree line, check before you go.
+
+{.card Weather}
+Cold and clear. Wind: **15 mph** gusting from the *northwest*. Permit fee: $5.
+
+{.card Trail Status}
+- North Loop: open
+- South Loop: closed
+- Summit Spur: open, ice above 2000m
+
+{.card Contact}
+Ranger station: [call dispatch](tel:+15555550123)
+
+
diff --git a/examples/wml.md b/examples/wml.md
new file mode 100644
index 0000000..92f2ae9
--- /dev/null
+++ b/examples/wml.md
@@ -0,0 +1,19 @@
+# Trail Conditions
+
+Live updates for the ridge trail network. Reception is spotty past the tree line, check before you go.
+
+## Weather
+
+Cold and clear. Wind: **15 mph** gusting from the *northwest*. Permit fee: $5.
+
+## Trail Status
+
+- North Loop: open
+- South Loop: closed
+- Summit Spur: open, ice above 2000m
+
+## Contact
+
+Ranger station: [call dispatch](tel:+15555550123)
+
+
diff --git a/examples/wml.wml b/examples/wml.wml
new file mode 100644
index 0000000..c28de82
--- /dev/null
+++ b/examples/wml.wml
@@ -0,0 +1,17 @@
+
+
+
+
+Trail Conditions
+Live updates for the ridge trail network. Reception is spotty past the tree line, check before you go.
+Weather
+Cold and clear. Wind: 15 mph gusting from the northwest . Permit fee: $$5.
+Trail Status
+- North Loop: open
+- South Loop: closed
+- Summit Spur: open, ice above 2000m
+Contact
+Ranger station: call dispatch
+
+
+
diff --git a/src/md2txt/cli.py b/src/md2txt/cli.py
index b8c9d3e..0951d78 100644
--- a/src/md2txt/cli.py
+++ b/src/md2txt/cli.py
@@ -25,6 +25,7 @@ from .renderers.text import TextRenderer
from .renderers import ama # noqa: F401 # register AMA renderer plugin
from .renderers import gemini # noqa: F401 # register Gemini renderer plugin
from .renderers import micron # noqa: F401 # register Micron renderer plugin
+from .renderers import wml # noqa: F401 # register WML renderer plugin
def _split_option(token: str) -> Tuple[str, str]:
diff --git a/src/md2txt/conversion/core.py b/src/md2txt/conversion/core.py
index f283bff..9656673 100644
--- a/src/md2txt/conversion/core.py
+++ b/src/md2txt/conversion/core.py
@@ -17,6 +17,8 @@ ASCII_BLOCK_PATTERN = re.compile(
ASCII_INLINE_PATTERN = re.compile(r"#\[(?P[^\]]+)\]\((?P[^)]+)\)")
MMD_ATTR_TAIL_RE = re.compile(r"(.*?)\s*\{\s*:(.+?)\}\s*$")
ASCII_SENTINEL_PREFIX = "\u0000ASCII:"
+TABLE_SENTINEL_PREFIX = "\u0000TABLE:"
+SEPARATOR_CELL_PATTERN = re.compile(r"^:?-{1,}:?$")
Event = BlockEvent | StyleUpdateEvent
RendererOutput = TypeVar("RendererOutput")
@@ -269,6 +271,62 @@ def _parse_ascii_label(label: str) -> Tuple[str, str, Optional[str]]:
return block_type, block_name, align
+def _split_pipe_row(line: str) -> List[str]:
+ trimmed = line.strip()
+ if trimmed.startswith("|"):
+ trimmed = trimmed[1:]
+ if trimmed.endswith("|"):
+ trimmed = trimmed[:-1]
+ return [cell.strip() for cell in trimmed.split("|")]
+
+
+def _is_table_separator_row(line: str, expected_cells: int) -> bool:
+ stripped = line.strip()
+ if not stripped:
+ return False
+ cells = _split_pipe_row(stripped)
+ if len(cells) != expected_cells:
+ return False
+ return all(SEPARATOR_CELL_PATTERN.match(cell) for cell in cells)
+
+
+def expand_tables(lines: List[str]) -> List[str]:
+ """Collapse GFM-style pipe tables into single JSON sentinel lines.
+
+ Mirrors the ASCII-art sentinel trick above: the parser only ever sees one
+ physical line per table, so no lookahead/pushback is needed in its
+ line-by-line loop. Runs after `expand_includes` so tables inside included
+ files are also detected.
+ """
+ expanded: List[str] = []
+ index = 0
+ total = len(lines)
+ while index < total:
+ line = lines[index]
+ stripped = line.rstrip("\n")
+ if stripped.startswith(ASCII_SENTINEL_PREFIX) or stripped.startswith(TABLE_SENTINEL_PREFIX):
+ expanded.append(line)
+ index += 1
+ continue
+ if "|" in stripped and stripped.strip() and index + 1 < total:
+ header_cells = _split_pipe_row(stripped)
+ if header_cells and _is_table_separator_row(lines[index + 1].rstrip("\n"), len(header_cells)):
+ rows = [header_cells]
+ index += 2 # header + separator row
+ while index < total:
+ row_line = lines[index].rstrip("\n")
+ if not row_line.strip() or "|" not in row_line:
+ break
+ rows.append(_split_pipe_row(row_line))
+ index += 1
+ sentinel = f"{TABLE_SENTINEL_PREFIX}{json.dumps({'rows': rows})}\n"
+ expanded.append(sentinel)
+ continue
+ expanded.append(line)
+ index += 1
+ return expanded
+
+
def run_pipeline(
lines: Iterable[str],
*,
@@ -278,6 +336,7 @@ def run_pipeline(
) -> RendererOutput:
base_dir = (base_path or Path.cwd()).resolve()
expanded_lines = expand_includes(list(lines), base_dir, set())
+ expanded_lines = expand_tables(expanded_lines)
for event in parser.parse(expanded_lines):
renderer.handle_event(event)
return renderer.finalize()
diff --git a/src/md2txt/models.py b/src/md2txt/models.py
index ab09ef4..4df1d72 100644
--- a/src/md2txt/models.py
+++ b/src/md2txt/models.py
@@ -12,8 +12,10 @@ class BlockKind(Enum):
BLOCKQUOTE = "blockquote"
LIST_ITEM = "list_item"
HORIZONTAL_RULE = "horizontal_rule"
+ TABLE = "table"
BLANK_LINE = "blank_line"
CUSTOM_BLOCK = "custom_block"
+ CARD_BREAK = "card_break"
@dataclass
@@ -96,6 +98,23 @@ class AsciiArtPayload:
pieces: List[AsciiArtPiece]
+@dataclass
+class TablePayload:
+ rows: List[List[str]] # rows[0] is the header row
+
+
+@dataclass
+class CardBreakPayload:
+ """Marks an explicit, renderer-agnostic 'start a new page/card here' hint.
+
+ Emitted for the `{.card}` / `{.card Title}` directive. Formats without a
+ notion of pages (plain text, Gemtext, Micron, AMA) simply ignore this
+ event kind via their existing unhandled-BlockKind no-op path.
+ """
+
+ title: Optional[str] = None
+
+
@dataclass
class BlockEvent:
kind: BlockKind
diff --git a/src/md2txt/parsers/markdown.py b/src/md2txt/parsers/markdown.py
index 23b4972..6993925 100644
--- a/src/md2txt/parsers/markdown.py
+++ b/src/md2txt/parsers/markdown.py
@@ -5,7 +5,7 @@ import re
from pathlib import Path
from typing import Dict, Iterable, Iterator, List, Optional, Union
-from ..conversion.core import ASCII_SENTINEL_PREFIX
+from ..conversion.core import ASCII_SENTINEL_PREFIX, TABLE_SENTINEL_PREFIX
from ..models import (
AsciiArtPayload,
AsciiArtPiece,
@@ -13,12 +13,14 @@ from ..models import (
BlockKind,
BlockQuotePayload,
BlockStyle,
+ CardBreakPayload,
CodeBlockPayload,
HeadingPayload,
ListItemPayload,
ParagraphPayload,
StyleSpec,
StyleUpdateEvent,
+ TablePayload,
)
@@ -27,6 +29,7 @@ ORDERED_LIST_PATTERN = re.compile(r"^(\s*)(\d+\.)(\s+)(.*)$")
UNORDERED_LIST_PATTERN = re.compile(r"^(\s*)([*+-])(\s+)(.*)$")
BLOCKQUOTE_PATTERN = re.compile(r"^\s{0,3}>(.*)$")
HORIZONTAL_RULE_PATTERN = re.compile(r"^\s*([-*_])(?:\s*\1){2,}\s*$")
+CARD_BREAK_PATTERN = re.compile(r"^\s*\{\s*\.card(?:\s+(?P.+?))?\s*\}\s*$")
INLINE_PARA_RE = re.compile(r"^\s*]*)>(.*?)
\s*$", re.IGNORECASE)
PARA_OPEN_RE = re.compile(r"^\s*]*)>\s*$", re.IGNORECASE)
PARA_CLOSE_RE = re.compile(r"^\s*
\s*$", re.IGNORECASE)
@@ -71,6 +74,23 @@ class MarkdownParser:
)
continue
+ if line.startswith(TABLE_SENTINEL_PREFIX):
+ event = self._flush_paragraph(current_paragraph)
+ if event is not None:
+ yield event
+ current_paragraph = []
+ payload = self._build_table_payload(line)
+ style = self._combine_styles(self._current_style(), self._pending_block_style_spec)
+ self._pending_block_style_spec = None
+ self._last_stylable_block = True
+ yield BlockEvent(
+ kind=BlockKind.TABLE,
+ payload=payload,
+ style=style,
+ stylable=True,
+ )
+ continue
+
if in_code_block:
if line.strip().startswith("```"):
event = self._flush_code_block(code_lines)
@@ -97,6 +117,22 @@ class MarkdownParser:
yield event
indented_code_lines = []
+ card_break_match = CARD_BREAK_PATTERN.match(line)
+ if card_break_match:
+ event = self._flush_paragraph(current_paragraph)
+ if event is not None:
+ yield event
+ current_paragraph = []
+ title = card_break_match.group("title")
+ self._last_stylable_block = False
+ yield BlockEvent(
+ kind=BlockKind.CARD_BREAK,
+ payload=CardBreakPayload(title=title.strip() if title else None),
+ style=self._clone_style(),
+ stylable=False,
+ )
+ continue
+
inline_para = INLINE_PARA_RE.match(line.strip())
if inline_para:
event = self._flush_paragraph(current_paragraph)
@@ -336,6 +372,15 @@ class MarkdownParser:
)
return AsciiArtPayload(pieces=pieces)
+ def _build_table_payload(self, sentinel_line: str) -> TablePayload:
+ payload = sentinel_line[len(TABLE_SENTINEL_PREFIX) :]
+ try:
+ data = json.loads(payload)
+ except json.JSONDecodeError as exc:
+ raise ValueError(f"Invalid table sentinel payload: {payload}") from exc
+ rows = data.get("rows", [])
+ return TablePayload(rows=[[str(cell) for cell in row] for row in rows])
+
def _decode_ascii_sentinel(self, line: str) -> List[Dict[str, str]]:
payload = line[len(ASCII_SENTINEL_PREFIX) :]
try:
diff --git a/src/md2txt/renderers/wml.py b/src/md2txt/renderers/wml.py
new file mode 100644
index 0000000..12e24a6
--- /dev/null
+++ b/src/md2txt/renderers/wml.py
@@ -0,0 +1,429 @@
+from __future__ import annotations
+
+import sys
+from dataclasses import dataclass
+from typing import Any, List, Optional, Tuple
+
+from ..models import (
+ BlockEvent,
+ BlockKind,
+ FrontMatter,
+ StyleUpdateEvent,
+)
+from ..plugins import register_renderer
+from .text import (
+ BOLD_RE,
+ CODE_STASH_RE,
+ IMAGE_RE,
+ ITALIC_RE,
+ LINK_RE,
+ STRIKETHROUGH_RE,
+ UNDERLINE_EM_RE,
+ UNDERLINE_STRONG_RE,
+)
+
+
+WML_PROLOG = (
+ '\n'
+ '\n'
+)
+
+# A conservative nod to classic WAP 1.x per-deck cache limits; purely a
+# safety net for a section that's still too big after §-based splitting,
+# not the primary splitting mechanism. Reserves headroom for the nav
+# markup each continuation card also carries.
+DEFAULT_MAX_CARD_BYTES = 1400
+_NAV_HEADROOM_BYTES = 160
+
+
+def escape_wml(text: str) -> str:
+ """XML-escape text for WML element content or a double-quoted attribute
+ value, plus WML's own quirk: a literal '$' must become '$$' since WML
+ uses '$' for variable substitution. Applied once, up front, to the whole
+ raw string before any inline markup is turned into tags (see
+ `WMLRenderer._process_inline`), so link/image URLs captured out of the
+ already-escaped text are correctly escaped for attribute use too.
+ """
+ text = text.replace("&", "&")
+ text = text.replace("<", "<").replace(">", ">").replace('"', """)
+ return text.replace("$", "$$")
+
+
+def _coerce_bool(value: Any, default: bool) -> bool:
+ if value is None:
+ return default
+ if isinstance(value, bool):
+ return value
+ return str(value).strip().lower() not in {"false", "no", "0", "off"}
+
+
+@dataclass
+class _Section:
+ title: Optional[str]
+ events: List[BlockEvent]
+
+
+@dataclass
+class _Card:
+ id: str
+ title: Optional[str]
+ fragments: List[str]
+ back_label: Optional[str] = None
+ next_id: Optional[str] = None
+ next_label: Optional[str] = None
+
+
+class WMLRenderer:
+ """Render Markdown events as a WML deck for WAP browsers.
+
+ Unlike TextRenderer/MicronRenderer/AmaRenderer, which emit output
+ incrementally per `handle_event`, this renderer buffers the whole event
+ stream and does the real work in `finalize()` — card boundaries, the
+ menu card's link list, and forward/backward nav targets all depend on
+ the full document's structure being known up front. It still satisfies
+ the same Renderer protocol (`handle_event`/`finalize`), so it plugs into
+ `run_pipeline` unchanged; `finalize()` just returns a single WML string
+ instead of a list of lines.
+ """
+
+ def __init__(self, frontmatter: FrontMatter, **options: Any) -> None:
+ self.frontmatter = frontmatter
+ self._events: List[BlockEvent] = []
+ # 0 = disabled. Heading-based splitting is opt-in, not a default —
+ # a document with headings but no explicit split_level stays one card.
+ self.split_level = int(options.get("split_level", 0) or 0)
+ self.max_card_bytes = int(options.get("max_card_bytes", DEFAULT_MAX_CARD_BYTES) or 0)
+ self.menu = _coerce_bool(options.get("menu"), True)
+ self.nav_next_label = str(options.get("nav_next_label", "More"))
+ self.nav_prev_label = str(options.get("nav_prev_label", "Prev"))
+ self.nav_back_label = str(options.get("nav_back_label", "Back"))
+ title = options.get("title")
+ self.title: Optional[str] = str(title) if title else None
+
+ # -- Renderer protocol ---------------------------------------------------
+
+ def handle_event(self, event: "BlockEvent | StyleUpdateEvent") -> None:
+ if isinstance(event, BlockEvent):
+ self._events.append(event)
+ # StyleUpdateEvent: WML has no per-block alignment/style stack to
+ # apply this to, by design; ignored like other structural mismatches.
+
+ def finalize(self) -> str:
+ preamble_events, sections = self._split_sections(self._events)
+ if len(sections) < 2:
+ all_events = list(preamble_events)
+ if sections:
+ all_events.extend(sections[0].events)
+ fragments = self._render_events(all_events)
+ cards = self._pack_section(
+ "card1",
+ self.title,
+ fragments,
+ entry_back_label=None,
+ exit_next_id=None,
+ exit_next_label=None,
+ )
+ elif self.menu:
+ cards = self._build_hub_and_spoke(preamble_events, sections)
+ else:
+ cards = self._build_linear(preamble_events, sections)
+ body = "\n".join(self._render_card(card) for card in cards)
+ return f"{WML_PROLOG}\n{body}\n \n"
+
+ # -- Splitting into sections ----------------------------------------------
+
+ def _split_sections(self, events: List[BlockEvent]) -> Tuple[List[BlockEvent], List[_Section]]:
+ card_break_indices = [i for i, e in enumerate(events) if e.kind == BlockKind.CARD_BREAK]
+ if card_break_indices:
+ return self._split_by_card_breaks(events, card_break_indices)
+ if self.split_level > 0:
+ heading_indices = [
+ i
+ for i, e in enumerate(events)
+ if e.kind == BlockKind.HEADING and e.payload.level == self.split_level
+ ]
+ if heading_indices:
+ return self._split_by_heading(events, heading_indices)
+ return list(events), []
+
+ def _split_by_card_breaks(
+ self, events: List[BlockEvent], indices: List[int]
+ ) -> Tuple[List[BlockEvent], List[_Section]]:
+ preamble = events[: indices[0]]
+ boundaries = indices + [len(events)]
+ sections: List[_Section] = []
+ for idx in range(len(indices)):
+ start = indices[idx] + 1 # skip the {.card} marker event itself
+ end = boundaries[idx + 1]
+ section_events = events[start:end]
+ marker_title = events[indices[idx]].payload.title
+ title = marker_title or self._first_heading_text(section_events)
+ sections.append(_Section(title=title, events=section_events))
+ return preamble, sections
+
+ def _split_by_heading(
+ self, events: List[BlockEvent], indices: List[int]
+ ) -> Tuple[List[BlockEvent], List[_Section]]:
+ preamble = events[: indices[0]]
+ boundaries = indices + [len(events)]
+ sections: List[_Section] = []
+ for idx in range(len(indices)):
+ start = indices[idx] # keep the heading event, it still renders in-body
+ end = boundaries[idx + 1]
+ section_events = events[start:end]
+ title = events[indices[idx]].payload.text
+ sections.append(_Section(title=title, events=section_events))
+ return preamble, sections
+
+ @staticmethod
+ def _first_heading_text(events: List[BlockEvent]) -> Optional[str]:
+ for event in events:
+ if event.kind == BlockKind.HEADING:
+ return event.payload.text
+ return None
+
+ # -- Card/menu/navigation topology ---------------------------------------
+
+ def _build_hub_and_spoke(
+ self, preamble_events: List[BlockEvent], sections: List[_Section]
+ ) -> List[_Card]:
+ menu_fragments = self._render_events(preamble_events, big_first_heading=True)
+ for i, section in enumerate(sections):
+ label = escape_wml(section.title or "Untitled")
+ menu_fragments.append(f'{label}
')
+ menu_title = self.title or self._first_heading_text(preamble_events)
+ cards: List[_Card] = [
+ _Card(id="menu", title=menu_title, fragments=menu_fragments)
+ ]
+ for i, section in enumerate(sections):
+ fragments = self._render_events(section.events, big_first_heading=True)
+ cards.extend(
+ self._pack_section(
+ f"card{i + 1}",
+ section.title or "Untitled",
+ fragments,
+ entry_back_label=self.nav_back_label,
+ exit_next_id=None,
+ exit_next_label=None,
+ )
+ )
+ return cards
+
+ def _build_linear(
+ self, preamble_events: List[BlockEvent], sections: List[_Section]
+ ) -> List[_Card]:
+ cards: List[_Card] = []
+ total = len(sections)
+ for i, section in enumerate(sections):
+ events = section.events
+ is_first = i == 0
+ if is_first:
+ events = list(preamble_events) + list(events)
+ fragments = self._render_events(events, big_first_heading=True)
+ # Card 1 also carries the preamble, so prefer the deck-level
+ # title / preamble's own heading over this section's heading.
+ card_title = (
+ (self.title or self._first_heading_text(preamble_events) or section.title or "Untitled")
+ if is_first
+ else (section.title or "Untitled")
+ )
+ is_last = i == total - 1
+ next_id = f"card{i + 2}" if not is_last else None
+ cards.extend(
+ self._pack_section(
+ f"card{i + 1}",
+ card_title,
+ fragments,
+ entry_back_label=None if is_first else self.nav_back_label,
+ exit_next_id=next_id,
+ exit_next_label=self.nav_next_label if next_id else None,
+ )
+ )
+ return cards
+
+ def _pack_section(
+ self,
+ base_id: str,
+ title: Optional[str],
+ fragments: List[str],
+ *,
+ entry_back_label: Optional[str],
+ exit_next_id: Optional[str],
+ exit_next_label: Optional[str],
+ ) -> List[_Card]:
+ """Split one section's rendered fragments into one or more physical
+ s if they'd exceed max_card_bytes (0 disables). This is the
+ secondary byte-overflow safety net, not the primary splitting
+ mechanism (that's §-based or manual {.card} splitting upstream).
+ """
+ chunks = self._greedy_pack(fragments, base_id)
+ cards: List[_Card] = []
+ last = len(chunks) - 1
+ for i, chunk in enumerate(chunks):
+ part_id = base_id if i == 0 else f"{base_id}-p{i + 1}"
+ is_first = i == 0
+ is_last = i == last
+ back_label = entry_back_label if is_first else self.nav_prev_label
+ next_id = f"{base_id}-p{i + 2}" if not is_last else exit_next_id
+ next_label = self.nav_next_label if not is_last else exit_next_label
+ cards.append(
+ _Card(
+ id=part_id,
+ title=title if is_first else None,
+ fragments=chunk,
+ back_label=back_label,
+ next_id=next_id,
+ next_label=next_label,
+ )
+ )
+ return cards
+
+ def _greedy_pack(self, fragments: List[str], base_id: str) -> List[List[str]]:
+ if self.max_card_bytes <= 0:
+ return [fragments]
+ budget = max(1, self.max_card_bytes - _NAV_HEADROOM_BYTES)
+ chunks: List[List[str]] = []
+ current: List[str] = []
+ current_size = 0
+ for frag in fragments:
+ frag_size = len(frag.encode("utf-8"))
+ if frag_size > budget:
+ if current:
+ chunks.append(current)
+ current, current_size = [], 0
+ chunks.append([frag])
+ print(
+ f"wml: warning: a single block in '{base_id}' is {frag_size} bytes, "
+ f"exceeding max_card_bytes ({self.max_card_bytes}); emitting it as its own card",
+ file=sys.stderr,
+ )
+ continue
+ if current and current_size + frag_size > budget:
+ chunks.append(current)
+ current, current_size = [], 0
+ current.append(frag)
+ current_size += frag_size
+ if current:
+ chunks.append(current)
+ return chunks or [[]]
+
+ def _render_card(self, card: _Card) -> str:
+ attrs = f' title="{escape_wml(card.title)}"' if card.title else ""
+ parts = [f'']
+ if card.back_label:
+ parts.append(f' ')
+ if card.next_id:
+ label = escape_wml(card.next_label or self.nav_next_label)
+ parts.append(f' ')
+ parts.extend(card.fragments)
+ parts.append(" ")
+ return "\n".join(parts)
+
+ # -- Block markup mapping -------------------------------------------------
+
+ def _render_events(self, events: List[BlockEvent], *, big_first_heading: bool = True) -> List[str]:
+ fragments: List[str] = []
+ seen_heading = not big_first_heading
+ list_counter = 0
+ for event in events:
+ kind = event.kind
+ if kind == BlockKind.PARAGRAPH:
+ fragments.append(f"{self._process_inline(event.payload.text)}
")
+ list_counter = 0
+ elif kind == BlockKind.HEADING:
+ text = self._process_inline(event.payload.text)
+ if not seen_heading:
+ fragments.append(f"{text}
")
+ seen_heading = True
+ else:
+ fragments.append(f"{text}
")
+ list_counter = 0
+ elif kind == BlockKind.LIST_ITEM:
+ text = self._process_inline(event.payload.text)
+ if event.payload.ordered:
+ list_counter += 1
+ fragments.append(f"{list_counter}. {text}
")
+ else:
+ list_counter = 0
+ fragments.append(f"- {text}
")
+ elif kind == BlockKind.CODE_BLOCK:
+ lines = [escape_wml(line) for line in event.payload.lines]
+ fragments.append(f"{' '.join(lines)}
")
+ list_counter = 0
+ elif kind == BlockKind.BLOCKQUOTE:
+ fragments.append(f"{self._process_inline(event.payload.text)}
")
+ list_counter = 0
+ elif kind == BlockKind.HORIZONTAL_RULE:
+ fragments.append("------------
")
+ list_counter = 0
+ elif kind == BlockKind.TABLE:
+ fragments.append(self._render_table(event.payload.rows))
+ list_counter = 0
+ # BLANK_LINE, CARD_BREAK, CUSTOM_BLOCK: no WML output of their own.
+ return fragments
+
+ def _render_table(self, rows: List[List[str]]) -> str:
+ 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))
+ td_parts = []
+ for cell in cells:
+ content = self._process_inline(cell)
+ if row_index == 0:
+ content = f"{content} "
+ td_parts.append(f"{content} ")
+ tr_parts.append(f"{''.join(td_parts)} ")
+ return f''
+
+ # -- Inline markup mapping -------------------------------------------------
+
+ def _process_inline(self, text: str) -> str:
+ # Escape once, up front; the markdown syntax markers below aren't
+ # XML-significant characters, so inline tag insertion on top of the
+ # already-escaped text is always safe (see `escape_wml`'s docstring).
+ escaped = escape_wml(text)
+
+ code_segments: List[str] = []
+
+ def stash_code(match) -> str:
+ code_segments.append(match.group(0)[1:-1])
+ return f"\u0000CODE{len(code_segments) - 1}\u0000"
+
+ escaped = CODE_STASH_RE.sub(stash_code, escaped)
+ escaped = STRIKETHROUGH_RE.sub(lambda m: m.group(1), escaped) # no WML equivalent
+ escaped = BOLD_RE.sub(lambda m: f"{m.group(1)} ", escaped)
+ escaped = UNDERLINE_STRONG_RE.sub(lambda m: f"{m.group(1)} ", escaped)
+ escaped = ITALIC_RE.sub(lambda m: f"{m.group(1)} ", escaped)
+ escaped = UNDERLINE_EM_RE.sub(lambda m: f"{m.group(1)} ", escaped)
+ escaped = LINK_RE.sub(self._replace_link, escaped)
+ escaped = IMAGE_RE.sub(self._replace_image, escaped)
+
+ for idx, code in enumerate(code_segments):
+ escaped = escaped.replace(f"\u0000CODE{idx}\u0000", code)
+ return escaped
+
+ @staticmethod
+ def _replace_link(match) -> str:
+ label = match.group(1).strip()
+ url = match.group(2).strip()
+ return f'{label} '
+
+ @staticmethod
+ def _replace_image(match) -> str:
+ alt = match.group(1).strip()
+ src = match.group(2).strip()
+ return f' '
+
+
+def _wml_renderer_factory(*, frontmatter: FrontMatter, **options: Any) -> WMLRenderer:
+ return WMLRenderer(frontmatter, **options)
+
+
+try:
+ register_renderer("wml", _wml_renderer_factory)
+except ValueError:
+ pass