gemini protocol gemtext support
This commit is contained in:
parent
ae26bb205b
commit
315142005d
10 changed files with 620 additions and 17 deletions
|
|
@ -13,12 +13,13 @@ This repository contains the `md2txt` command line tool and supporting libraries
|
||||||
|
|
||||||
## CLI
|
## 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` and `ama` renderers for Micron/Ancient Machine Book 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`, 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:
|
||||||
- FIGlet-rendered headings.
|
- FIGlet-rendered headings.
|
||||||
- Emphasis styles converted to spaced or delimited characters.
|
- Emphasis styles converted to spaced or delimited characters.
|
||||||
- Optional alignment and margin controls via HTML `<p>` attributes or MultiMarkdown attribute blocks (e.g. `{:.center margin=20px}`) applied as leading spaces.
|
- Optional alignment and margin controls via HTML `<p>` attributes or MultiMarkdown attribute blocks (e.g. `{:.center margin=20px}`) applied as leading spaces.
|
||||||
- ASCII art injection with `#[label :align](art.txt)` syntax, supporting multiple art pieces per line and optional `{: .right}` style annotations.
|
- ASCII art injection with `#[label :align](art.txt)` syntax, supporting multiple art pieces per line and optional `{: .right}` style annotations.
|
||||||
- Per-document toggles for code block wrapping, numbering, blockquote decoration, and list indentation spacing.
|
- Per-document toggles for code block wrapping, numbering, blockquote decoration, and list indentation spacing.
|
||||||
|
- Link references can be emitted per block by setting `links_per_block: true` in frontmatter; otherwise they collect at the end of the document. Gemini output uses `=>` lines while the text renderer keeps numbered footnotes.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
|
@ -28,6 +29,7 @@ md2txt input.md # write result to stdout
|
||||||
md2txt input.md --width 72 # override column width
|
md2txt input.md --width 72 # override column width
|
||||||
md2txt input.md --renderer micron # emit Micron-formatted output
|
md2txt input.md --renderer micron # emit Micron-formatted output
|
||||||
md2txt input.md --renderer ama # emit AMB/AMA markup
|
md2txt input.md --renderer ama # emit AMB/AMA markup
|
||||||
|
md2txt input.md --renderer gemini # emit Gemtext for Gemini capsules
|
||||||
md2txt input.md --renderer-option width=68 # pass KEY=VALUE to a renderer
|
md2txt input.md --renderer-option width=68 # pass KEY=VALUE to a renderer
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -60,6 +62,7 @@ hyphen_lang: en_US
|
||||||
| `h#_font: <font>` | Accepts any known figlet font name or one of the keywords `caps` (force uppercase) and `title` (title case) to define headings.
|
| `h#_font: <font>` | Accepts any known figlet font name or one of the keywords `caps` (force uppercase) and `title` (title case) to define headings.
|
||||||
| `hyphenate: true` | activates PyHyphen-based wrapping (optional `hyphen_lang` defaults to `en_US`) |
|
| `hyphenate: true` | activates PyHyphen-based wrapping (optional `hyphen_lang` defaults to `en_US`) |
|
||||||
| `header_spacing` | controls how many blank lines precede headings (default `2`). |
|
| `header_spacing` | controls how many blank lines precede headings (default `2`). |
|
||||||
|
| `links_per_block: true` | Emit link references directly below each block instead of collecting them at the end (supported by the text and Gemini renderers). |
|
||||||
| `list_marker_indent: <int>` | Extra spaces inserted before list markers. |
|
| `list_marker_indent: <int>` | Extra spaces inserted before list markers. |
|
||||||
| `list_text_spacing: <int>` | Spaces between the marker and the wrapped list text. |
|
| `list_text_spacing: <int>` | Spaces between the marker and the wrapped list text. |
|
||||||
| `margin_left`/`margin_right` | add leading/trailing spaces to body text |
|
| `margin_left`/`margin_right` | add leading/trailing spaces to body text |
|
||||||
|
|
|
||||||
117
examples/loremipsum.gmi
Normal file
117
examples/loremipsum.gmi
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
```
|
||||||
|
___ _ _ _ _
|
||||||
|
/ __| __ _| (_)___ _ _| |_(_)__ _
|
||||||
|
\__ \/ _` | | / -_) ' \ _| / _` |
|
||||||
|
|___/\__,_|_|_\___|_||_\__|_\__,_|
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Structa hinc vetitus factum
|
||||||
|
|
||||||
|
|
||||||
|
Lorem markdownum omnes tellus, in, Herculeis reciprocalPower neque promissa est
|
||||||
|
Latiae caput umero talia fidissima, nymphae. Foedera et sagittas tenetur adde
|
||||||
|
matri esse: talia manibus.
|
||||||
|
|
||||||
|
## Fuit video longique dedisse
|
||||||
|
|
||||||
|
|
||||||
|
Ille stupet, ultra incunabula agendum urbes mirer aper, metu. Aegides novissima
|
||||||
|
sunt turis quatiebant umbrosaque putares [1]. Metuunt eruiturque maximus velit,
|
||||||
|
ater Vesta vulnus rustica verba! Mei vincite nulla, in Iovem: fama exurunt
|
||||||
|
vernum!
|
||||||
|
|
||||||
|
=> #mille-parva [1] putares
|
||||||
|
|
||||||
|
|
||||||
|
De instat ubi induit fugientia inertes columbas tarda terga Parnasia sententia
|
||||||
|
omnia inmotusque, est et aetas sonus. Exhibuit coeperunt, canis. Suis minora,
|
||||||
|
tempore fateor neve moderamine erit. Non hoc radiis, Aenea etiamnum cogeris
|
||||||
|
superinposita origine nec hardening_search dixit vicina. Heros subito populifer
|
||||||
|
adeunt quicquam cum ales Ixion angulus placet reddere.
|
||||||
|
|
||||||
|
## Tractaque serpens arva quoque
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
<>=======()
|
||||||
|
(/\___ /|\\ ()==========<>_
|
||||||
|
\_/ | \\ //|\ ______/ \)
|
||||||
|
\_| \\ // | \_/
|
||||||
|
\|\/|\_ // /\/
|
||||||
|
(oo)\ \_// /
|
||||||
|
//_/\_\/ / |
|
||||||
|
@@/ |=\ \ |
|
||||||
|
\_=\_ \ |
|
||||||
|
\==\ \|\_ snd
|
||||||
|
__(\===\( )\
|
||||||
|
(((~) __(_/ |
|
||||||
|
(((~) \ /
|
||||||
|
______/ /
|
||||||
|
'------'
|
||||||
|
```
|
||||||
|
|
||||||
|
Inprudens dum memor alma, casses dedi Sinuessa iam quid adicit
|
||||||
|
gate_truncate_wave. Geratur de [2], insolida temptat open carentia; genitorem
|
||||||
|
solet, potens. Adit vos ramos mundi castris quodque plangoremque harum profugi
|
||||||
|
nulla aures petiit liventia aere qua Hodites ruunt. Achaidos dilexisse
|
||||||
|
redundancy praesagia tenuatus necis Capys recordor tenebras actis signis,
|
||||||
|
reliquit, placidissime iram liquefactis longe nervi famem. Cura pars natique
|
||||||
|
velleris semel et at printerWildcardParallel inquit positosque comites ultra.
|
||||||
|
|
||||||
|
=> #salientia-iungit-contra-et [2] Geratur de
|
||||||
|
|
||||||
|
|
||||||
|
## Quantaque augent
|
||||||
|
|
||||||
|
|
||||||
|
Acuti ignotis ignis, ostendit nostras terras nondum, petit utque utero [3]
|
||||||
|
sepulcrales, pluma reseratis Ismario. Tria silentia duae nymphe: certe parva
|
||||||
|
cernam et dixit. Verborum sedisti [1].
|
||||||
|
|
||||||
|
=> #structa-hinc-vetitus-factum [3] petit utque utero
|
||||||
|
=> #mille-parva [1] putares
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
if (firewall(4, 3) == 1 * device_media_parity) {
|
||||||
|
system_copy_class += systray + oasis_wi_clean + lpi;
|
||||||
|
modelLdap.search_secondary_socket *= bounce.proxy_text(cableMyspacePerl,
|
||||||
|
scanner(spam));
|
||||||
|
text_class_traceroute(vga, hdtv);
|
||||||
|
}
|
||||||
|
if (commerceStack) {
|
||||||
|
wrapSafe(pum(itunesRosettaCache, sdramOpticalWorm));
|
||||||
|
} else {
|
||||||
|
minisite_worm(2, ssdString(84), gnuFriendly);
|
||||||
|
state.qwerty(moduleJavascriptFloating + ugc, koffice,
|
||||||
|
mail_system.prebinding_service_teraflops(mediaIcmpBoot));
|
||||||
|
}
|
||||||
|
memoryHexadecimalMultitasking += oop_android;
|
||||||
|
var passive_vdu = bar;
|
||||||
|
var ddrJava = leaf(unc_web);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mille parva
|
||||||
|
|
||||||
|
|
||||||
|
Minetur et aurum! Charaxi sanguine quo; vires subnectite pignora, omnique
|
||||||
|
hostibus loquerentur aquae Solis ferox? Cum ne, putat utar ex ad aede mane.
|
||||||
|
Vitalesque geminas exstimulat, iam vivunt dat artes et quem! Iam violas molire
|
||||||
|
ipse, spoliabitur dente nervis regis, factos quae.
|
||||||
|
|
||||||
|
> Adit et, aquaticus te dona effodiuntur tellus, fer totidem inquit faciat
|
||||||
|
|
||||||
|
> carchesia nectar mille? Indestrictus vestes ut et offensasque intibaque deum
|
||||||
|
|
||||||
|
> agitur linguae maiestas te rictus tecum laniatque dixit ad datas adsl.
|
||||||
|
|
||||||
|
> Vino ut quod iaculi mutua armis uno est vos totidemque
|
||||||
|
|
||||||
|
> exhortor [4]. Iamdudum Romanae cepi linguisque
|
||||||
|
|
||||||
|
=> #tractaque-serpens-arva-quoque [4] exhortor
|
||||||
|
|
||||||
|
> velit accipe idem acutis hoc sparsumque dextra. Minoide sponte collo sequitur
|
||||||
|
|
||||||
|
> et intus flere sanguineaque herba, nox Quas adsiduo cunctantem tibi; enim.
|
||||||
|
|
@ -6,6 +6,7 @@ margin_left: 4
|
||||||
margin_right: 4
|
margin_right: 4
|
||||||
paragraph_spacing: 2
|
paragraph_spacing: 2
|
||||||
code_block_wrap: 4
|
code_block_wrap: 4
|
||||||
|
links_per_block: true
|
||||||
list_marker_indent: 2
|
list_marker_indent: 2
|
||||||
list_text_spacing: 1
|
list_text_spacing: 1
|
||||||
hyphenate: true
|
hyphenate: true
|
||||||
|
|
|
||||||
132
examples/markdown.gmi
Normal file
132
examples/markdown.gmi
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
```
|
||||||
|
_ _ _ _ _ _ ___ __
|
||||||
|
| |_ / | | || |___ __ _ __| (_)_ _ __ _ ( _ )__\ \
|
||||||
|
| ' \| | | __ / -_) _` / _` | | ' \/ _` | / _ \___| |
|
||||||
|
|_||_|_| |_||_\___\__,_\__,_|_|_||_\__, | \___/ | |
|
||||||
|
|___/ /_/
|
||||||
|
```
|
||||||
|
|
||||||
|
## h2 Heading
|
||||||
|
|
||||||
|
### h3 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
|
||||||
|
|
||||||
|
|
||||||
|
* Lorem ipsum dolor sit amet
|
||||||
|
* Consectetur adipiscing elit
|
||||||
|
* 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 [1]
|
||||||
|
|
||||||
|
link with title [2]
|
||||||
|
|
||||||
|
## Images
|
||||||
|
|
||||||
|
|
||||||
|
Minion [3] Stormtroopocat [4]
|
||||||
|
|
||||||
|
=> http://dev.nodeca.com [1] link text
|
||||||
|
=> http://nodeca.github.io/pica/demo/ "title text!" [2] link with title
|
||||||
|
=> https://octodex.github.com/images/minion.png [3] Minion
|
||||||
|
=> https://octodex.github.com/images/stormtroopocat.jpg "The Stormtroopocat" [4] Stormtroopocat
|
||||||
|
|
@ -22,8 +22,9 @@ from .plugins import (
|
||||||
)
|
)
|
||||||
from .renderers.text import TextRenderer
|
from .renderers.text import TextRenderer
|
||||||
|
|
||||||
from .renderers import micron # noqa: F401 # register micron renderer plugin
|
|
||||||
from .renderers import ama # noqa: F401 # register AMA renderer plugin
|
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
|
||||||
|
|
||||||
|
|
||||||
def _split_option(token: str) -> Tuple[str, str]:
|
def _split_option(token: str) -> Tuple[str, str]:
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,7 @@ def parse_frontmatter(lines: List[str]) -> Tuple[FrontMatter, List[str]]:
|
||||||
blockquote_bars = _parse_bool(frontmatter.get("blockquote_bars"), True)
|
blockquote_bars = _parse_bool(frontmatter.get("blockquote_bars"), True)
|
||||||
list_marker_indent = max(0, _parse_int(frontmatter.get("list_marker_indent"), 0))
|
list_marker_indent = max(0, _parse_int(frontmatter.get("list_marker_indent"), 0))
|
||||||
list_text_spacing = max(0, _parse_int(frontmatter.get("list_text_spacing"), 1))
|
list_text_spacing = max(0, _parse_int(frontmatter.get("list_text_spacing"), 1))
|
||||||
|
links_per_block = _parse_bool(frontmatter.get("links_per_block"), False)
|
||||||
fm = FrontMatter(
|
fm = FrontMatter(
|
||||||
h1_font=frontmatter.get("h1_font", "standard").strip() or "standard",
|
h1_font=frontmatter.get("h1_font", "standard").strip() or "standard",
|
||||||
h2_font=frontmatter.get("h2_font", "standard").strip() or "standard",
|
h2_font=frontmatter.get("h2_font", "standard").strip() or "standard",
|
||||||
|
|
@ -123,6 +124,7 @@ def parse_frontmatter(lines: List[str]) -> Tuple[FrontMatter, List[str]]:
|
||||||
blockquote_bars=blockquote_bars,
|
blockquote_bars=blockquote_bars,
|
||||||
list_marker_indent=list_marker_indent,
|
list_marker_indent=list_marker_indent,
|
||||||
list_text_spacing=list_text_spacing,
|
list_text_spacing=list_text_spacing,
|
||||||
|
links_per_block=links_per_block,
|
||||||
)
|
)
|
||||||
return fm, remaining
|
return fm, remaining
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ class FrontMatter:
|
||||||
blockquote_bars: bool = True
|
blockquote_bars: bool = True
|
||||||
list_marker_indent: int = 0
|
list_marker_indent: int = 0
|
||||||
list_text_spacing: int = 1
|
list_text_spacing: int = 1
|
||||||
|
links_per_block: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
"""Bundled renderer implementations."""
|
"""Bundled renderer implementations."""
|
||||||
|
|
||||||
from .ama import AmaRenderer
|
from .ama import AmaRenderer
|
||||||
|
from .gemini import GeminiRenderer
|
||||||
from .micron import MicronRenderer
|
from .micron import MicronRenderer
|
||||||
from .text import TextRenderer
|
from .text import TextRenderer
|
||||||
|
|
||||||
__all__ = ["AmaRenderer", "MicronRenderer", "TextRenderer"]
|
__all__ = ["AmaRenderer", "GeminiRenderer", "MicronRenderer", "TextRenderer"]
|
||||||
|
|
|
||||||
317
src/md2txt/renderers/gemini.py
Normal file
317
src/md2txt/renderers/gemini.py
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import textwrap
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from ..models import (
|
||||||
|
AsciiArtPayload,
|
||||||
|
BlockEvent,
|
||||||
|
BlockKind,
|
||||||
|
BlockQuotePayload,
|
||||||
|
BlockStyle,
|
||||||
|
CodeBlockPayload,
|
||||||
|
FrontMatter,
|
||||||
|
HeadingPayload,
|
||||||
|
ListItemPayload,
|
||||||
|
ParagraphPayload,
|
||||||
|
StyleUpdateEvent,
|
||||||
|
)
|
||||||
|
from ..plugins import register_renderer
|
||||||
|
from .text import (
|
||||||
|
BOLD_RE,
|
||||||
|
CODE_STASH_RE,
|
||||||
|
IMAGE_RE,
|
||||||
|
ITALIC_RE,
|
||||||
|
LINK_RE,
|
||||||
|
STRIKETHROUGH_RE,
|
||||||
|
TextRenderer,
|
||||||
|
UNDERLINE_EM_RE,
|
||||||
|
UNDERLINE_STRONG_RE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GeminiRenderer:
|
||||||
|
"""Render Markdown events as Gemtext suitable for the Gemini protocol."""
|
||||||
|
|
||||||
|
def __init__(self, frontmatter: FrontMatter, *, width: int = 80, preformatted_alt: str | None = None, **_: Any) -> None:
|
||||||
|
self.frontmatter = frontmatter
|
||||||
|
self._text_renderer = TextRenderer(width=width, frontmatter=frontmatter)
|
||||||
|
self.width = max(20, width)
|
||||||
|
self.preformatted_alt = preformatted_alt or ""
|
||||||
|
self.output: List[str] = []
|
||||||
|
self._in_list: bool = False
|
||||||
|
self.links_per_block = frontmatter.links_per_block
|
||||||
|
self._link_indices: Dict[str, int] = {}
|
||||||
|
self._link_catalog: Dict[int, Tuple[str, Optional[str]]] = {}
|
||||||
|
self._pending_links: List[int] = []
|
||||||
|
|
||||||
|
def handle_event(self, event: BlockEvent | StyleUpdateEvent) -> None:
|
||||||
|
if isinstance(event, StyleUpdateEvent):
|
||||||
|
# Gemtext has no notion of per-block styling; ignore updates.
|
||||||
|
return
|
||||||
|
handler = {
|
||||||
|
BlockKind.PARAGRAPH: self._render_paragraph,
|
||||||
|
BlockKind.HEADING: self._render_heading,
|
||||||
|
BlockKind.CODE_BLOCK: self._render_code_block,
|
||||||
|
BlockKind.BLOCKQUOTE: self._render_blockquote,
|
||||||
|
BlockKind.LIST_ITEM: self._render_list_item,
|
||||||
|
BlockKind.HORIZONTAL_RULE: self._render_horizontal_rule,
|
||||||
|
BlockKind.BLANK_LINE: self._render_blank_line,
|
||||||
|
BlockKind.CUSTOM_BLOCK: self._render_custom_block,
|
||||||
|
}.get(event.kind)
|
||||||
|
if handler is not None:
|
||||||
|
handler(event.payload, event.style)
|
||||||
|
|
||||||
|
def finalize(self) -> List[str]:
|
||||||
|
self._end_list()
|
||||||
|
self._flush_pending_links(trailing_blank=False)
|
||||||
|
return self.output
|
||||||
|
|
||||||
|
# Rendering helpers -------------------------------------------------
|
||||||
|
def _render_paragraph(self, payload: ParagraphPayload, _style: BlockStyle) -> None:
|
||||||
|
self._end_list()
|
||||||
|
text, links = self._process_inline(payload.text)
|
||||||
|
if text:
|
||||||
|
self._ensure_blank_line()
|
||||||
|
self.output.extend(self._wrap_text(text))
|
||||||
|
self._handle_links(links)
|
||||||
|
|
||||||
|
def _render_heading(self, payload: HeadingPayload, style: BlockStyle) -> None:
|
||||||
|
self._end_list()
|
||||||
|
text, links = self._process_inline(payload.text)
|
||||||
|
|
||||||
|
def render_heading(target_style: BlockStyle) -> List[str]:
|
||||||
|
figlet = self._text_renderer._render_figlet_heading(payload.level, text, target_style)
|
||||||
|
if figlet:
|
||||||
|
banner = [line.rstrip() for line in figlet]
|
||||||
|
fence_header = self._preformatted_header()
|
||||||
|
lines = [fence_header, *banner, "```"]
|
||||||
|
return lines
|
||||||
|
|
||||||
|
level = max(1, min(3, payload.level))
|
||||||
|
heading = text
|
||||||
|
if not heading:
|
||||||
|
return []
|
||||||
|
marker = "#" * level
|
||||||
|
wrapped = self._wrap_text(
|
||||||
|
heading,
|
||||||
|
initial=f"{marker} ",
|
||||||
|
subsequent=f"{marker} ",
|
||||||
|
)
|
||||||
|
if not wrapped or wrapped[-1] != "":
|
||||||
|
wrapped.append("")
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
lines = render_heading(style)
|
||||||
|
if not lines:
|
||||||
|
return
|
||||||
|
self._ensure_blank_line()
|
||||||
|
self.output.extend(lines)
|
||||||
|
self._handle_links(links)
|
||||||
|
|
||||||
|
def _render_code_block(self, payload: CodeBlockPayload, _style: BlockStyle) -> None:
|
||||||
|
self._end_list()
|
||||||
|
self._ensure_blank_line()
|
||||||
|
self.output.append(self._preformatted_header())
|
||||||
|
for line in payload.lines:
|
||||||
|
self.output.append(line.rstrip("\n"))
|
||||||
|
self.output.append("```")
|
||||||
|
|
||||||
|
def _render_blockquote(self, payload: BlockQuotePayload, _style: BlockStyle) -> None:
|
||||||
|
self._end_list()
|
||||||
|
text, links = self._process_inline(payload.text)
|
||||||
|
if text:
|
||||||
|
self._ensure_blank_line()
|
||||||
|
wrapped = self._wrap_text(text, initial="> ", subsequent="> ")
|
||||||
|
self.output.extend(wrapped)
|
||||||
|
self._handle_links(links)
|
||||||
|
|
||||||
|
def _render_list_item(self, payload: ListItemPayload, _style: BlockStyle) -> None:
|
||||||
|
indent = payload.indent.replace("\t", " ")
|
||||||
|
depth = max(0, len(indent) // 2)
|
||||||
|
bullet_prefix = " " * depth + "* "
|
||||||
|
continuation = " " * depth + " "
|
||||||
|
text, links = self._process_inline(payload.text)
|
||||||
|
self._begin_list()
|
||||||
|
if text:
|
||||||
|
self.output.extend(self._wrap_text(text, initial=bullet_prefix, subsequent=continuation))
|
||||||
|
self._handle_links(links, leading_blank=False, trailing_blank=False)
|
||||||
|
|
||||||
|
def _render_horizontal_rule(self, _payload: object, _style: BlockStyle) -> None:
|
||||||
|
self._end_list()
|
||||||
|
self._ensure_blank_line()
|
||||||
|
self.output.append("---")
|
||||||
|
|
||||||
|
def _render_blank_line(self, *_: object) -> None:
|
||||||
|
self._end_list()
|
||||||
|
self._ensure_blank_line(force=True)
|
||||||
|
|
||||||
|
def _render_custom_block(self, payload: AsciiArtPayload, _style: BlockStyle) -> None:
|
||||||
|
lines: List[str] = []
|
||||||
|
for index, piece in enumerate(payload.pieces):
|
||||||
|
lines.extend(line.rstrip("\n") for line in piece.lines)
|
||||||
|
if index + 1 < len(payload.pieces):
|
||||||
|
lines.append("")
|
||||||
|
if not lines:
|
||||||
|
return
|
||||||
|
self._end_list()
|
||||||
|
self._ensure_blank_line()
|
||||||
|
self.output.append(self._preformatted_header())
|
||||||
|
self.output.extend(lines)
|
||||||
|
self.output.append("```")
|
||||||
|
|
||||||
|
# Internal utilities ------------------------------------------------
|
||||||
|
def _begin_list(self) -> None:
|
||||||
|
if not self._in_list:
|
||||||
|
self._ensure_blank_line()
|
||||||
|
self._in_list = True
|
||||||
|
|
||||||
|
def _end_list(self) -> None:
|
||||||
|
if self._in_list:
|
||||||
|
if self.output and self.output[-1] != "":
|
||||||
|
self.output.append("")
|
||||||
|
self._in_list = False
|
||||||
|
|
||||||
|
def _ensure_blank_line(self, *, force: bool = False) -> None:
|
||||||
|
if not self.output:
|
||||||
|
return
|
||||||
|
if force or self.output[-1] != "":
|
||||||
|
self.output.append("")
|
||||||
|
|
||||||
|
def _wrap_text(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
initial: str = "",
|
||||||
|
subsequent: Optional[str] = None,
|
||||||
|
) -> List[str]:
|
||||||
|
normalized = self._normalise_whitespace(text)
|
||||||
|
if not normalized:
|
||||||
|
return [initial.rstrip()] if initial else []
|
||||||
|
wrapper = textwrap.TextWrapper(
|
||||||
|
width=self.width,
|
||||||
|
expand_tabs=False,
|
||||||
|
replace_whitespace=False,
|
||||||
|
drop_whitespace=False,
|
||||||
|
break_long_words=False,
|
||||||
|
break_on_hyphens=True,
|
||||||
|
initial_indent=initial,
|
||||||
|
subsequent_indent=subsequent if subsequent is not None else initial,
|
||||||
|
)
|
||||||
|
return [line.rstrip() for line in wrapper.wrap(normalized)]
|
||||||
|
|
||||||
|
def _normalise_whitespace(self, text: str) -> str:
|
||||||
|
return re.sub(r"\s+", " ", text).strip()
|
||||||
|
|
||||||
|
def _register_link(self, url: str, label: Optional[str]) -> int:
|
||||||
|
key = url
|
||||||
|
index = self._link_indices.get(key)
|
||||||
|
if index is None:
|
||||||
|
index = len(self._link_indices) + 1
|
||||||
|
self._link_indices[key] = index
|
||||||
|
self._link_catalog[index] = (url, label)
|
||||||
|
else:
|
||||||
|
stored_url, stored_label = self._link_catalog.get(index, (url, None))
|
||||||
|
if label and not stored_label:
|
||||||
|
self._link_catalog[index] = (stored_url, label)
|
||||||
|
return index
|
||||||
|
|
||||||
|
def _process_inline(self, text: str) -> Tuple[str, List[int]]:
|
||||||
|
code_segments: List[str] = []
|
||||||
|
indices: List[int] = []
|
||||||
|
|
||||||
|
def stash_code(match):
|
||||||
|
segment = match.group(0)[1:-1]
|
||||||
|
code_segments.append(segment)
|
||||||
|
return f"\u0000CODE{len(code_segments) - 1}\u0000"
|
||||||
|
|
||||||
|
def register_link(label: str, target: str) -> str:
|
||||||
|
url = target.strip()
|
||||||
|
label_clean = label.strip()
|
||||||
|
display = label_clean or url
|
||||||
|
link_label = label_clean if label_clean and label_clean != url else None
|
||||||
|
index = self._register_link(url, link_label)
|
||||||
|
if index not in indices:
|
||||||
|
indices.append(index)
|
||||||
|
return f"{display} [{index}]"
|
||||||
|
|
||||||
|
text = CODE_STASH_RE.sub(stash_code, text)
|
||||||
|
text = STRIKETHROUGH_RE.sub(lambda m: m.group(1), text)
|
||||||
|
text = BOLD_RE.sub(lambda m: m.group(1), text)
|
||||||
|
text = ITALIC_RE.sub(lambda m: m.group(1), text)
|
||||||
|
text = UNDERLINE_STRONG_RE.sub(lambda m: m.group(1), text)
|
||||||
|
text = UNDERLINE_EM_RE.sub(lambda m: m.group(1), text)
|
||||||
|
text = LINK_RE.sub(lambda m: register_link(m.group(1), m.group(2)), text)
|
||||||
|
text = IMAGE_RE.sub(lambda m: register_link(m.group(1), m.group(2)), text)
|
||||||
|
|
||||||
|
for idx, code in enumerate(code_segments):
|
||||||
|
text = text.replace(f"\u0000CODE{idx}\u0000", code)
|
||||||
|
|
||||||
|
return self._normalise_whitespace(text), indices
|
||||||
|
|
||||||
|
def _handle_links(
|
||||||
|
self,
|
||||||
|
indices: List[int],
|
||||||
|
*,
|
||||||
|
leading_blank: bool = True,
|
||||||
|
trailing_blank: bool = True,
|
||||||
|
) -> None:
|
||||||
|
if not indices:
|
||||||
|
return
|
||||||
|
if self.links_per_block:
|
||||||
|
self._emit_links(indices, leading_blank=leading_blank, trailing_blank=trailing_blank)
|
||||||
|
else:
|
||||||
|
for index in indices:
|
||||||
|
if index not in self._pending_links:
|
||||||
|
self._pending_links.append(index)
|
||||||
|
|
||||||
|
def _flush_pending_links(self, *, trailing_blank: bool) -> None:
|
||||||
|
if not self._pending_links:
|
||||||
|
return
|
||||||
|
self._emit_links(self._pending_links, leading_blank=True, trailing_blank=trailing_blank)
|
||||||
|
self._pending_links.clear()
|
||||||
|
|
||||||
|
def _emit_links(
|
||||||
|
self,
|
||||||
|
indices: List[int],
|
||||||
|
*,
|
||||||
|
leading_blank: bool = True,
|
||||||
|
trailing_blank: bool = True,
|
||||||
|
) -> None:
|
||||||
|
if not indices:
|
||||||
|
return
|
||||||
|
trailing_blanks: List[str] = []
|
||||||
|
while self.output and self.output[-1] == "":
|
||||||
|
trailing_blanks.append(self.output.pop())
|
||||||
|
trailing_blanks.reverse()
|
||||||
|
if leading_blank and self.output and self.output[-1] != "":
|
||||||
|
self.output.append("")
|
||||||
|
for index in indices:
|
||||||
|
url, label = self._link_catalog.get(index, ("", None))
|
||||||
|
label_text = f"[{index}]"
|
||||||
|
if label:
|
||||||
|
label_text = f"{label_text} {label}"
|
||||||
|
line = f"=> {url}" if not label_text else f"=> {url} {label_text}"
|
||||||
|
self.output.append(line)
|
||||||
|
if trailing_blank and not trailing_blanks and self.output and self.output[-1] != "":
|
||||||
|
self.output.append("")
|
||||||
|
if trailing_blanks:
|
||||||
|
self.output.extend(trailing_blanks)
|
||||||
|
|
||||||
|
def _preformatted_header(self) -> str:
|
||||||
|
if self.preformatted_alt:
|
||||||
|
return f"``` {self.preformatted_alt}"
|
||||||
|
return "```"
|
||||||
|
|
||||||
|
|
||||||
|
def _gemini_renderer_factory(*, frontmatter: FrontMatter, **options: Any) -> GeminiRenderer:
|
||||||
|
width = int(options.get("width", 80))
|
||||||
|
alt = options.get("preformatted_alt")
|
||||||
|
return GeminiRenderer(frontmatter, width=width, preformatted_alt=alt)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
register_renderer("gemini", _gemini_renderer_factory)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
@ -94,6 +94,7 @@ class TextRenderer:
|
||||||
)
|
)
|
||||||
self.list_marker_indent = max(0, frontmatter.list_marker_indent)
|
self.list_marker_indent = max(0, frontmatter.list_marker_indent)
|
||||||
self.list_text_spacing = max(0, frontmatter.list_text_spacing)
|
self.list_text_spacing = max(0, frontmatter.list_text_spacing)
|
||||||
|
self.links_per_block = frontmatter.links_per_block
|
||||||
self._base_style = BlockStyle(
|
self._base_style = BlockStyle(
|
||||||
align="left",
|
align="left",
|
||||||
margin_left=max(0, frontmatter.margin_left),
|
margin_left=max(0, frontmatter.margin_left),
|
||||||
|
|
@ -128,10 +129,30 @@ class TextRenderer:
|
||||||
self._apply_style_to_last_block(event.spec)
|
self._apply_style_to_last_block(event.spec)
|
||||||
|
|
||||||
def finalize(self) -> List[str]:
|
def finalize(self) -> List[str]:
|
||||||
if self.links:
|
if self.links_per_block:
|
||||||
|
self._flush_links_after_block(trailing_blank=False)
|
||||||
|
elif self.links:
|
||||||
|
self._emit_link_entries(self.links, trailing_blank=False)
|
||||||
|
self.links.clear()
|
||||||
|
self.link_indices.clear()
|
||||||
|
self._last_stylable_block = None
|
||||||
|
return self.output
|
||||||
|
|
||||||
|
def _handle_block_event(self, event: BlockEvent) -> None:
|
||||||
|
handler = self._handlers.get(event.kind)
|
||||||
|
if handler:
|
||||||
|
handler(event.payload, event.style)
|
||||||
|
if self.links_per_block:
|
||||||
|
self._flush_links_after_block()
|
||||||
|
else:
|
||||||
|
self._last_stylable_block = None
|
||||||
|
|
||||||
|
def _emit_link_entries(self, entries: List[tuple[int, str]], *, trailing_blank: bool) -> None:
|
||||||
|
if not entries:
|
||||||
|
return
|
||||||
if self.output and self.output[-1] != "":
|
if self.output and self.output[-1] != "":
|
||||||
self.output.append("")
|
self.output.append("")
|
||||||
for index, url in self.links:
|
for index, url in entries:
|
||||||
entry = f"[{index}] {url}"
|
entry = f"[{index}] {url}"
|
||||||
self.output.extend(
|
self.output.extend(
|
||||||
self._wrap_text(
|
self._wrap_text(
|
||||||
|
|
@ -141,15 +162,22 @@ class TextRenderer:
|
||||||
style=self._base_style,
|
style=self._base_style,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self._last_stylable_block = None
|
if trailing_blank and self.output and self.output[-1] != "":
|
||||||
return self.output
|
self.output.append("")
|
||||||
|
|
||||||
def _handle_block_event(self, event: BlockEvent) -> None:
|
def _flush_links_after_block(self, *, trailing_blank: bool = True) -> None:
|
||||||
handler = self._handlers.get(event.kind)
|
if not self.links:
|
||||||
if handler:
|
return
|
||||||
handler(event.payload, event.style)
|
trailing_blanks: List[str] = []
|
||||||
else:
|
while self.output and self.output[-1] == "":
|
||||||
self._last_stylable_block = None
|
trailing_blanks.append(self.output.pop())
|
||||||
|
trailing_blanks.reverse()
|
||||||
|
add_trailing_blank = trailing_blank and not trailing_blanks
|
||||||
|
self._emit_link_entries(self.links, trailing_blank=add_trailing_blank)
|
||||||
|
self.links.clear()
|
||||||
|
self.link_indices.clear()
|
||||||
|
if trailing_blanks:
|
||||||
|
self.output.extend(trailing_blanks)
|
||||||
|
|
||||||
def _render_paragraph(self, payload: ParagraphPayload, style: BlockStyle) -> None:
|
def _render_paragraph(self, payload: ParagraphPayload, style: BlockStyle) -> None:
|
||||||
processed = self._process_inline(payload.text)
|
processed = self._process_inline(payload.text)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue