improvements

This commit is contained in:
randogoth 2025-10-19 23:22:15 +03:00
parent 3d889ec117
commit b1f1d7b5f6
11 changed files with 305 additions and 514 deletions

View file

@ -17,6 +17,7 @@ This repository contains command line helpers for transforming Markdown into for
## Requirements
- Python 3.9+.
- Optional: `PyHyphen` (preferred) or `pyphen` for dictionary-driven hyphenation.
- Optional: `pyfiglet` for FIGlet headings (`pip install pyfiglet`). When unavailable, headings fall back to the H4-style renderer automatically.
## Usage
@ -38,6 +39,11 @@ You can set FIGlet fonts for H1H3 per document using YAML frontmatter:
h1_font: slant
h2_font: standard
h3_font: small
margin_left: 4
margin_right: 4
paragraph_spacing: 1
hyphenate: true
hyphen_lang: en_US
---
# Main Title
@ -45,6 +51,8 @@ h3_font: small
### Subsection
```
Font names also accept the special keywords `caps` (force uppercase) and `title` (title case) for any heading. `margin_left` and `margin_right` add leading/trailing spaces to body text, `paragraph_spacing` (or `lines_between_paragraphs`) inserts blank lines between paragraphs, and enabling `hyphenate` activates PyHyphen-based wrapping (optional `hyphen_lang` defaults to `en_US`).
If the rendered FIGlet text would exceed the configured width, the converter automatically falls back to the H4-style uppercase heading with a dashed underline.
## Styling via Attribute Blocks

View file

@ -1,19 +1,23 @@
---
h1_font: colossal
h2_font: big
h3_font: small
h1_font: big
h2_font: small
h3_font: caps
paragraph_spacing: 2
hyphenate: true
---
# Salientia
{:.center}
{:.right}
## Structa hinc vetitus factum
{:.center}
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
{:.center}
Ille stupet, ultra **incunabula agendum urbes** mirer **aper**, metu. Aegides
novissima sunt turis quatiebant umbrosaque [putares](#mille-parva). Metuunt
@ -25,6 +29,7 @@ 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.
{:.right}
## Tractaque serpens arva quoque

View file

@ -1,167 +0,0 @@
.d8888b. 888d8b 888 d8b
d88P Y88b 888Y8P 888 Y8P
Y88b. 888 888
"Y888b. 8888b. 888888 .d88b. 88888b. 888888888 8888b.
"Y88b. "88b888888d8P Y8b888 "88b888 888 "88b
"888.d88888888888888888888888 888888 888.d888888
Y88b d88P888 888888888Y8b. 888 888Y88b. 888888 888
"Y8888P" "Y888888888888 "Y8888 888 888 "Y888888"Y888888
_____ _ _ _ _
/ ____| | | | | | (_)
| (___ | |_ _ __ _ _ ___| |_ __ _ | |__ _ _ __ ___
\___ \| __| '__| | | |/ __| __/ _` | | '_ \| | '_ \ / __|
____) | |_| | | |_| | (__| || (_| | | | | | | | | | (__
|_____/ \__|_| \__,_|\___|\__\__,_| |_| |_|_|_| |_|\___|
_ _ _ __ _
| | (_) | / _| | |
__ _____| |_ _| |_ _ _ ___ | |_ __ _ ___| |_ _ _ _ __ ___
\ \ / / _ \ __| | __| | | / __| | _/ _` |/ __| __| | | | '_ ` _ \
\ V / __/ |_| | |_| |_| \__ \ | || (_| | (__| |_| |_| | | | | | |
\_/ \___|\__|_|\__|\__,_|___/ |_| \__,_|\___|\__|\__,_|_| |_| |_|
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.
______ _ _ _ _
| ____| (_) | (_) | |
| |__ _ _ _| |_ __ ___ __| | ___ ___
| __| | | | | __| \ \ / / |/ _` |/ _ \/ _ \
| | | |_| | | |_ \ V /| | (_| | __/ (_) |
|_| \__,_|_|\__| \_/ |_|\__,_|\___|\___/
_ _ _ _ _
| | (_) | | | (_)
| | ___ _ __ __ _ _ __ _ _ _ ___ __| | ___ __| |_ ___ ___ ___
| |/ _ \| '_ \ / _` | |/ _` | | | |/ _ \ / _` |/ _ \/ _` | / __/ __|/ _ \
| | (_) | | | | (_| | | (_| | |_| | __/ | (_| | __/ (_| | \__ \__ \ __/
|_|\___/|_| |_|\__, |_|\__, |\__,_|\___| \__,_|\___|\__,_|_|___/___/\___|
__/ | | |
|___/ |_|
Ille stupet, ultra I N C U N A B U L A A G E N D U M U R B E S mirer A P E
R, 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!
De instat ubi induit fugientia inertes columbas tarda t e r g a P a r n a s i
a s e n t e n t i a omnia inmotusque, est et aetas sonus. Exhibuit coeperunt,
canis. Suis minora, tempore fateor neve moderamine erit. Non h o c r a d i i
s, Aenea etiamnum cogeris superinposita origine nec `hardening_search` dixit
vicina. Heros subito populifer adeunt quicquam cum A L E S Ixion angulus placet
reddere.
_______ _
|__ __| | |
| |_ __ __ _ ___| |_ __ _ __ _ _ _ ___
| | '__/ _` |/ __| __/ _` |/ _` | | | |/ _ \
| | | | (_| | (__| || (_| | (_| | |_| | __/
|_|_| \__,_|\___|\__\__,_|\__, |\__,_|\___|
| |
|_|
___ ___ _ __ _ __ ___ _ __ ___ __ _ _ ____ ____ _
/ __|/ _ \ '__| '_ \ / _ \ '_ \/ __| / _` | '__\ \ / / _` |
\__ \ __/ | | |_) | __/ | | \__ \ | (_| | | \ V / (_| |
|___/\___|_| | .__/ \___|_| |_|___/ \__,_|_| \_/ \__,_|
| |
|_|
__ _ _ _ ___ __ _ _ _ ___
/ _` | | | |/ _ \ / _` | | | |/ _ \
| (_| | |_| | (_) | (_| | |_| | __/
\__, |\__,_|\___/ \__, |\__,_|\___|
| | | |
|_| |_|
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 C A P Y S R E C O R D O R tenebras actis
signis, reliquit, placidissime iram liquefactis longe nervi famem. Cura pars
natique velleris semel et at `printerWildcardParallel` inquit positosque comites
ultra.
____ _
/ __ \ | |
| | | |_ _ __ _ _ __ | |_ __ _ __ _ _ _ ___
| | | | | | |/ _` | '_ \| __/ _` |/ _` | | | |/ _ \
| |__| | |_| | (_| | | | | || (_| | (_| | |_| | __/
\___\_\\__,_|\__,_|_| |_|\__\__,_|\__, |\__,_|\___|
| |
|_|
_
| |
__ _ _ _ __ _ ___ _ __ | |_
/ _` | | | |/ _` |/ _ \ '_ \| __|
| (_| | |_| | (_| | __/ | | | |_
\__,_|\__,_|\__, |\___|_| |_|\__|
__/ |
|___/
Acuti ignotis ignis, ostendit nostras terras nondum, [petit utque utero](3)
sepulcrales, pluma reseratis Ismario. Tria silentia duae n y m p h e: certe
parva cernam et dixit. [Verborum sedisti](1).
01 | if (firewall(4, 3) == 1 * device_media_parity) {
02 | system_copy_class += systray + oasis_wi_clean + lpi;
03 | modelLdap.search_secondary_socket *= bounce.proxy_text(cableMyspacePerl,
04 | scanner(spam));
05 | text_class_traceroute(vga, hdtv);
06 | }
07 | if (commerceStack) {
08 | wrapSafe(pum(itunesRosettaCache, sdramOpticalWorm));
09 | } else {
10 | minisite_worm(2, ssdString(84), gnuFriendly);
11 | state.qwerty(moduleJavascriptFloating + ugc, koffice,
12 | mail_system.prebinding_service_teraflops(mediaIcmpBoot));
13 | }
14 | memoryHexadecimalMultitasking += oop_android;
15 | var passive_vdu = bar;
16 | var ddrJava = leaf(unc_web);
__ __ _ _ _
| \/ (_) | |___ _ __ __ _ _ ___ ____ _
| |\/| | | | / -_) | '_ \/ _` | '_\ V / _` |
|_| |_|_|_|_\___| | .__/\__,_|_| \_/\__,_|
|_|
M I N E T U R E T aurum! Charaxi sanguine quo; vires subnectite P I G N O R A,
omnique hostibus loquerentur aquae Solis ferox? C U M N E, 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 t e c u m l a n i a t q u e d i x i t
| ad datas `adsl`.
| Vino u t quod iaculi mutua armis uno est vos totidemque
| [exhortor](4). Iamdudum Romanae cepi linguisque
| velit accipe idem acutis hoc sparsumque dextra. Minoide sponte collo sequitur
| et intus flere sanguineaque herba, nox Quas adsiduo c u n c t a n t e m tibi;
| enim.
[1] #mille-parva
[2] #salientia-iungit-contra-et
[3] #structa-hinc-vetitus-factum
[4] #tractaque-serpens-arva-quoque

View file

@ -1,152 +0,0 @@
.d8888b. 888d8b 888 d8b
d88P Y88b 888Y8P 888 Y8P
Y88b. 888 888
"Y888b. 8888b. 888888 .d88b. 88888b. 888888888 8888b.
"Y88b. "88b888888d8P Y8b888 "88b888 888 "88b
"888.d88888888888888888888888 888888 888.d888888
Y88b d88P888 888888888Y8b. 888 888Y88b. 888888 888
"Y8888P" "Y888888888888 "Y8888 888 888 "Y888888"Y888888
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 I N C U N A B U L A A G E N D U M U
R B E S mirer A P E R , 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!
De instat ubi induit fugientia inertes columbas tarda t e
r g a P a r n a s i a s e n t e n t i a omnia
inmotusque, est et aetas sonus. Exhibuit coeperunt, canis.
Suis minora, tempore fateor neve moderamine erit. Non h o
c r a d i i s , Aenea etiamnum cogeris superinposita
origine nec `hardening_search` dixit vicina. Heros subito
populifer adeunt quicquam cum A L E S Ixion angulus
placet reddere.
_______ _
|__ __| | |
| |_ __ __ _ ___| |_ __ _ __ _ _ _ ___
| | '__/ _` |/ __| __/ _` |/ _` | | | |/ _ \
| | | | (_| | (__| || (_| | (_| | |_| | __/
|_|_| \__,_|\___|\__\__,_|\__, |\__,_|\___|
| |
|_|
___ ___ _ __ _ __ ___ _ __ ___ __ _ _ ____ ____ _
/ __|/ _ \ '__| '_ \ / _ \ '_ \/ __| / _` | '__\ \ / / _` |
\__ \ __/ | | |_) | __/ | | \__ \ | (_| | | \ V / (_| |
|___/\___|_| | .__/ \___|_| |_|___/ \__,_|_| \_/ \__,_|
| |
|_|
__ _ _ _ ___ __ _ _ _ ___
/ _` | | | |/ _ \ / _` | | | |/ _ \
| (_| | |_| | (_) | (_| | |_| | __/
\__, |\__,_|\___/ \__, |\__,_|\___|
| | | |
|_| |_|
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 C A P Y S
R E C O R D O R tenebras actis signis, reliquit,
placidissime iram liquefactis longe nervi famem. Cura pars
natique velleris semel et at `printerWildcardParallel`
inquit positosque comites ultra.
____ _
/ __ \ | |
| | | |_ _ __ _ _ __ | |_ __ _ __ _ _ _ ___
| | | | | | |/ _` | '_ \| __/ _` |/ _` | | | |/ _ \
| |__| | |_| | (_| | | | | || (_| | (_| | |_| | __/
\___\_\\__,_|\__,_|_| |_|\__\__,_|\__, |\__,_|\___|
| |
|_|
_
| |
__ _ _ _ __ _ ___ _ __ | |_
/ _` | | | |/ _` |/ _ \ '_ \| __|
| (_| | |_| | (_| | __/ | | | |_
\__,_|\__,_|\__, |\___|_| |_|\__|
__/ |
|___/
Acuti ignotis ignis, ostendit nostras terras nondum, [petit
utque utero](3) sepulcrales, pluma reseratis Ismario. Tria
silentia duae n y m p h e : certe parva cernam et dixit.
[Verborum sedisti](1).
01 | if (firewall(4, 3) == 1 * device_media_parity) {
02 | system_copy_class += systray + oasis_wi_clean + lpi;
03 | modelLdap.search_secondary_socket *= bounce.proxy_text(cableMyspacePerl,
04 | scanner(spam));
05 | text_class_traceroute(vga, hdtv);
06 | }
07 | if (commerceStack) {
08 | wrapSafe(pum(itunesRosettaCache, sdramOpticalWorm));
09 | } else {
10 | minisite_worm(2, ssdString(84), gnuFriendly);
11 | state.qwerty(moduleJavascriptFloating + ugc, koffice,
12 | mail_system.prebinding_service_teraflops(mediaIcmpBoot));
13 | }
14 | memoryHexadecimalMultitasking += oop_android;
15 | var passive_vdu = bar;
16 | var ddrJava = leaf(unc_web);
__ __ _ _ _
| \/ (_) | |___ _ __ __ _ _ ___ ____ _
| |\/| | | | / -_) | '_ \/ _` | '_\ V / _` |
|_| |_|_|_|_\___| | .__/\__,_|_| \_/\__,_|
|_|
M I N E T U R E T aurum! Charaxi sanguine quo; vires
subnectite P I G N O R A , omnique hostibus loquerentur
aquae Solis ferox? C U M N E , 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 t e c u m l a n i
| a t q u e d i x i t ad datas `adsl`.
| Vino u t quod iaculi mutua armis uno est vos
| totidemque
| [exhortor](4). Iamdudum Romanae cepi linguisque
| velit accipe idem acutis hoc sparsumque dextra. Minoide
| sponte collo sequitur
| et intus flere sanguineaque herba, nox Quas adsiduo c
| u n c t a n t e m tibi; enim.
[1] #mille-parva
[2] #salientia-iungit-contra-et
[3] #structa-hinc-vetitus-factum
[4] #tractaque-serpens-arva-quoque

View file

@ -1,6 +0,0 @@
def main():
print("Hello from md2amb!")
if __name__ == "__main__":
main()

291
md2txt.py
View file

@ -6,12 +6,38 @@ from __future__ import annotations
import argparse
import re
import string
import sys
import textwrap
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Dict, Iterable, List, Optional, Tuple
try: # pragma: no cover - optional dependency
from hyphen import Hyphenator as _Hyphenator # type: ignore
except ImportError: # pragma: no cover - handled at runtime
_Hyphenator = None # type: ignore[misc]
if _Hyphenator is not None: # pragma: no branch
Hyphenator = _Hyphenator # type: ignore[assignment]
else: # pragma: no cover - fallback path
try:
import pyphen
except ImportError: # pragma: no cover - handled at runtime
Hyphenator = None # type: ignore[misc, assignment]
else:
class _PyphenWrapper:
def __init__(self, lang: str) -> None:
self._dic = pyphen.Pyphen(lang=lang)
def hyphenate_word(self, word: str): # type: ignore[override]
inserted = self._dic.inserted(word)
if not inserted:
return []
return inserted.split("-")
Hyphenator = _PyphenWrapper # type: ignore[assignment]
try:
from pyfiglet import Figlet, FontNotFound
except ImportError: # pragma: no cover - emit a helpful error at runtime instead
@ -32,6 +58,29 @@ MMD_ATTR_LINE_RE = re.compile(r"^\{\s*:(.+)\}\s*$")
MMD_ATTR_TAIL_RE = re.compile(r"(.*?)\s*\{\s*:(.+?)\}\s*$")
def _parse_int(value: Optional[str], default: int = 0) -> int:
if value is None:
return default
match = re.search(r"-?\d+", value)
if not match:
return default
try:
return int(match.group())
except ValueError:
return default
def _parse_bool(value: Optional[str], default: bool = False) -> bool:
if value is None:
return default
lowered = value.strip().lower()
if lowered in {"true", "yes", "1", "on"}:
return True
if lowered in {"false", "no", "0", "off"}:
return False
return default
@dataclass
class BlockStyle:
align: str = "left"
@ -59,6 +108,11 @@ class FrontMatter:
h1_font: str = "standard"
h2_font: str = "standard"
h3_font: str = "standard"
margin_left: int = 0
margin_right: int = 0
paragraph_spacing: int = 0
hyphenate: bool = False
hyphen_lang: str = "en_US"
class MarkdownToTxtConverter:
@ -68,7 +122,22 @@ class MarkdownToTxtConverter:
self.links: List[Tuple[int, str]] = []
self.link_indices: Dict[str, int] = {}
self.figlets: Dict[str, Figlet] = {}
self._style_stack: List[BlockStyle] = [BlockStyle()]
self._base_margin_left = max(0, self.frontmatter.margin_left)
self._base_margin_right = max(0, self.frontmatter.margin_right)
self.paragraph_spacing = max(0, self.frontmatter.paragraph_spacing)
self.hyphenate = self.frontmatter.hyphenate
self.hyphen_lang = self.frontmatter.hyphen_lang or "en_US"
self.hyphenator: Optional[Hyphenator]
if self.hyphenate:
if Hyphenator is None:
raise RuntimeError("PyHyphen is required for hyphenation but is not installed.")
try:
self.hyphenator = Hyphenator(self.hyphen_lang)
except Exception as exc: # pragma: no cover - defensive
raise RuntimeError(f"Failed to initialise hyphenator for language '{self.hyphen_lang}': {exc}") from exc
else:
self.hyphenator = None
self._style_stack: List[BlockStyle] = [self._make_base_style()]
self._paragraph_style_spec: Optional[StyleSpec] = None
self._pending_block_style_spec: Optional[StyleSpec] = None
self._last_stylable_block: Optional[BlockRecord] = None
@ -77,7 +146,7 @@ class MarkdownToTxtConverter:
output: List[str] = []
self.links = []
self.link_indices = {}
self._style_stack = [BlockStyle()]
self._style_stack = [self._make_base_style()]
self._paragraph_style_spec = None
self._pending_block_style_spec = None
self._last_stylable_block = None
@ -199,6 +268,7 @@ class MarkdownToTxtConverter:
if not line.strip():
self._flush_paragraph(current_paragraph, output)
if self.paragraph_spacing == 0:
output.append("")
current_paragraph = []
continue
@ -216,7 +286,14 @@ class MarkdownToTxtConverter:
output.append("")
for index, url in self.links:
entry = f"[{index}] {url}"
output.extend(self._wrap_text(entry, initial_indent="", subsequent_indent="", style=BlockStyle()))
output.extend(
self._wrap_text(
entry,
initial_indent="",
subsequent_indent="",
style=self._make_base_style(),
)
)
self._last_stylable_block = None
return output
@ -230,10 +307,12 @@ class MarkdownToTxtConverter:
style = self._combine_styles(self._current_style(), combined_spec)
def render(target_style: BlockStyle) -> List[str]:
return self._wrap_text(processed, style=target_style)
return self._wrap_text(processed, style=target_style, hyphenate=self.hyphenate)
lines = render(style)
self._emit_block(output, lines, stylable=True, render_fn=render, style=style)
if self.paragraph_spacing > 0:
output.extend([ "" for _ in range(self.paragraph_spacing) ])
paragraph_lines.clear()
self._paragraph_style_spec = None
self._pending_block_style_spec = None
@ -264,6 +343,7 @@ class MarkdownToTxtConverter:
initial_indent=indent,
subsequent_indent=indent,
style=self._current_style(),
hyphenate=self.hyphenate,
)
self._emit_block(output, wrapped)
@ -327,6 +407,7 @@ class MarkdownToTxtConverter:
initial_indent=prefix,
subsequent_indent=" " * len(prefix),
style=self._current_style(),
hyphenate=self.hyphenate,
)
self._emit_block(output, wrapped)
@ -359,6 +440,13 @@ class MarkdownToTxtConverter:
self._last_stylable_block.length = len(new_lines)
self._last_stylable_block.style = new_style
def _make_base_style(self) -> BlockStyle:
return BlockStyle(
align="left",
margin_left=self._base_margin_left,
margin_right=self._base_margin_right,
)
def _current_style(self) -> BlockStyle:
return self._style_stack[-1]
@ -588,6 +676,10 @@ class MarkdownToTxtConverter:
return clean_text, spec
def _render_heading_lines(self, level: int, text: str, style: BlockStyle) -> List[str]:
font_name = getattr(self.frontmatter, f"h{level}_font", "standard")
style_key = font_name.lower()
if style_key in {"caps", "title"}:
return self._render_h4_plus(text, style, transform=style_key)
if level <= 3:
figlet_lines = self._figlet_render(level, text)
if figlet_lines:
@ -608,22 +700,23 @@ class MarkdownToTxtConverter:
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)
block_width = max((len(line.rstrip()) for line in lines), default=0)
block_width = min(block_width, available_width)
extra_space = max(0, available_width - block_width)
if style.align == "center":
align_offset = extra_space // 2
elif style.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)
result: List[str] = []
for line in lines:
if not line:
result.append("")
continue
trimmed = line.rstrip()
line_len = len(trimmed)
extra_space = max(0, available_width - line_len)
if style.align == "center":
extra_left = extra_space // 2
elif style.align == "right":
extra_left = extra_space
else:
extra_left = 0
max_indent = max(0, self.width - line_len)
indent = min(margin_left + extra_left, max_indent)
result.append(" " * indent + trimmed)
return result
@ -834,9 +927,14 @@ class MarkdownToTxtConverter:
return None
return [line.rstrip() for line in trimmed_lines]
def _render_h4_plus(self, text: str, style: BlockStyle) -> List[str]:
uppercase = text.upper()
wrapped = self._wrap_text(uppercase, style=style)
def _render_h4_plus(self, text: str, style: BlockStyle, transform: str = "caps") -> List[str]:
if transform == "title":
processed = self._to_title_case(text)
elif transform == "caps":
processed = text.upper()
else:
processed = text
wrapped = self._wrap_text(processed, style=style)
output: List[str] = []
for line in wrapped:
line = line.rstrip()
@ -850,18 +948,32 @@ class MarkdownToTxtConverter:
output.append("")
return output
def _to_title_case(self, value: str) -> str:
return string.capwords(value)
def _wrap_text(
self,
text: str,
initial_indent: str = "",
subsequent_indent: Optional[str] = None,
style: Optional[BlockStyle] = None,
hyphenate: bool = False,
) -> List[str]:
style = style or BlockStyle()
margin_left = min(max(style.margin_left, 0), self.width - 1)
available_width = max(1, self._effective_width(style))
subsequent = initial_indent if subsequent_indent is None else subsequent_indent
if hyphenate and self.hyphenator is not None:
return self._wrap_text_hyphenated(
text,
initial_indent,
subsequent,
style,
available_width,
)
wrapper = textwrap.TextWrapper(
width=available_width,
expand_tabs=False,
@ -891,6 +1003,135 @@ class MarkdownToTxtConverter:
result.append(" " * indent + line)
return result
def _wrap_text_hyphenated(
self,
text: str,
initial_indent: str,
subsequent_indent: str,
style: BlockStyle,
available_width: int,
) -> List[str]:
tokens = re.split(r"(\s+)", text)
lines: List[str] = []
current_indent = initial_indent
current_line = initial_indent
current_len = len(current_line)
width = available_width
for index, token in enumerate(tokens):
if token == "":
continue
if token.isspace():
if current_len + len(token) > width and current_len > len(current_indent):
lines.append(current_line.rstrip())
current_indent = subsequent_indent
current_line = subsequent_indent
current_len = len(current_line)
else:
current_line += token
current_len += len(token)
continue
segments = self._hyphenate_token(token) or [token]
while segments:
remaining = width - current_len
if remaining <= 1:
lines.append(current_line.rstrip())
current_indent = subsequent_indent
current_line = subsequent_indent
current_len = len(current_line)
continue
joined_length = sum(len(part) for part in segments)
if current_len + joined_length <= width:
current_line += "".join(segments)
current_len += joined_length
segments = []
break
split_index = None
running = 0
for idx in range(1, len(segments)):
running += len(segments[idx - 1])
needed = running + 1 # hyphen
if current_len + needed <= width:
split_index = idx
else:
break
if split_index is None:
# fallback: break the first segment
fragment = segments[0]
force_split = min(len(fragment), remaining - 1)
if force_split <= 0:
lines.append(current_line.rstrip())
current_indent = subsequent_indent
current_line = subsequent_indent
current_len = len(current_line)
continue
head = fragment[:force_split] + "-"
tail = fragment[force_split:]
current_line += head
lines.append(current_line.rstrip())
current_indent = subsequent_indent
current_line = subsequent_indent
current_len = len(current_line)
segments[0] = tail
if not tail:
segments.pop(0)
continue
consumed_segments = segments[:split_index]
current_line += "".join(consumed_segments) + "-"
current_len += sum(len(part) for part in consumed_segments) + 1
segments = segments[split_index:]
lines.append(current_line.rstrip())
current_indent = subsequent_indent
current_line = subsequent_indent
current_len = len(current_line)
if current_line.strip():
lines.append(current_line.rstrip())
if not lines:
lines.append(initial_indent.rstrip())
result: List[str] = []
margin_left = min(max(style.margin_left, 0), self.width - 1)
width = available_width
for line in lines:
stripped = line.rstrip()
line_len = len(stripped)
extra_space = max(0, width - line_len)
if style.align == "center":
extra_left = extra_space // 2
elif style.align == "right":
extra_left = extra_space
else:
extra_left = 0
max_indent = max(0, self.width - line_len)
indent = min(margin_left + extra_left, max_indent)
result.append(" " * indent + stripped)
return result
def _hyphenate_token(self, token: str) -> Optional[List[str]]:
if self.hyphenator is None:
return None
match = re.match(r"^([^A-Za-zÀ-ÖØ-öø-ÿ']*)([A-Za-zÀ-ÖØ-öø-ÿ']+)([^A-Za-zÀ-ÖØ-öø-ÿ']*)$", token)
if not match:
return None
leading, word, trailing = match.groups()
if len(word) <= 4:
return None
parts = self.hyphenator.hyphenate_word(word)
if not parts:
return None
if isinstance(parts, str):
segments = [segment for segment in parts.split("-") if segment]
else:
segments = [segment for segment in parts if segment]
if len(segments) < 2:
return None
segments[0] = leading + segments[0]
segments[-1] = segments[-1] + trailing
return segments
def _effective_width(self, style: BlockStyle) -> int:
margin_left = min(max(style.margin_left, 0), self.width - 1)
remaining = self.width - margin_left
@ -913,10 +1154,20 @@ def parse_frontmatter(lines: List[str]) -> Tuple[FrontMatter, List[str]]:
if idx >= len(lines):
return FrontMatter(), lines
remaining = lines[idx + 1 :] if idx + 1 < len(lines) else []
paragraph_spacing_value = frontmatter.get("paragraph_spacing")
if paragraph_spacing_value is None:
paragraph_spacing_value = frontmatter.get("lines_between_paragraphs")
if paragraph_spacing_value is None:
paragraph_spacing_value = frontmatter.get("paragraph_lines")
fm = FrontMatter(
h1_font=frontmatter.get("h1_font", "standard"),
h2_font=frontmatter.get("h2_font", "standard"),
h3_font=frontmatter.get("h3_font", "standard"),
h1_font=frontmatter.get("h1_font", "standard").strip() or "standard",
h2_font=frontmatter.get("h2_font", "standard").strip() or "standard",
h3_font=frontmatter.get("h3_font", "standard").strip() or "standard",
margin_left=_parse_int(frontmatter.get("margin_left"), 0),
margin_right=_parse_int(frontmatter.get("margin_right"), 0),
paragraph_spacing=max(0, _parse_int(paragraph_spacing_value, 0)),
hyphenate=_parse_bool(frontmatter.get("hyphenate"), False),
hyphen_lang=(frontmatter.get("hyphen_lang") or "en_US").strip() or "en_US",
)
return fm, remaining

View file

@ -1,163 +0,0 @@
_ _ _ _ _ _ ___ __
| |__ / | | | | | ___ __ _ __| (_)_ __ __ _ ( _ ) \ \
| '_ \| | | |_| |/ _ \/ _` |/ _` | | '_ \ / _` | / _ \ _____| |
| | | | | | _ | __/ (_| | (_| | | | | | (_| | | (_) |_____| |
|_| |_|_| |_| |_|\___|\__,_|\__,_|_|_| |_|\__, | \___/ | |
|___/ /_/
_ ____ _ _ _ _
| |__ |___ \ | | | | ___ __ _ __| (_)_ __ __ _
| '_ \ __) | | |_| |/ _ \/ _` |/ _` | | '_ \ / _` |
| | | |/ __/ | _ | __/ (_| | (_| | | | | | (_| |
|_| |_|_____| |_| |_|\___|\__,_|\__,_|_|_| |_|\__, |
|___/
_ _____ _ _ _ _
| |__ |___ / | | | | ___ __ _ __| (_)_ __ __ _
| '_ \ |_ \ | |_| |/ _ \/ _` |/ _` | | '_ \ / _` |
| | | |___) | | _ | __/ (_| | (_| | | | | | (_| |
|_| |_|____/ |_| |_|\___|\__,_|\__,_|_|_| |_|\__, |
|___/
H4 HEADING
----------
H5 HEADING
----------
H6 HEADING
----------
_ _ _ _ _ ____ _
| | | | ___ _ __(_)_______ _ __ | |_ __ _| | | _ \ _ _| | ___ ___
| |_| |/ _ \| '__| |_ / _ \| '_ \| __/ _` | | | |_) | | | | |/ _ \/ __|
| _ | (_) | | | |/ / (_) | | | | || (_| | | | _ <| |_| | | __/\__ \
|_| |_|\___/|_| |_/___\___/|_| |_|\__\__,_|_| |_| \_\\__,_|_|\___||___/
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
_____ _ _
| ____|_ __ ___ _ __ | |__ __ _ ___(_)___
| _| | '_ ` _ \| '_ \| '_ \ / _` / __| / __|
| |___| | | | | | |_) | | | | (_| \__ \ \__ \
|_____|_| |_| |_| .__/|_| |_|\__,_|___/_|___/
|_|
T H I S I S B O L D T E X T _T_H_I_S___I_S___B_O_L_D___T_E_X_T_ T h i s
i s i t a l i c t e x t _T_h_i_s___i_s___i_t_a_l_i_c___t_e_x_t_
-S-t-r-i-k-e-t-h-r-o-u-g-h-
____ _ _ _
| __ )| | ___ ___| | ____ _ _ _ ___ | |_ ___ ___
| _ \| |/ _ \ / __| |/ / _` | | | |/ _ \| __/ _ \/ __|
| |_) | | (_) | (__| < (_| | |_| | (_) | || __/\__ \
|____/|_|\___/ \___|_|\_\__, |\__,_|\___/ \__\___||___/
|_|
| Blockquotes can also be nested...
| | by using additional greater-than signs right next to each other...
| | | or with spaces between arrows.
_ _ _
| | (_)___| |_ ___
| | | / __| __/ __|
| |___| \__ \ |_\__ \
|_____|_|___/\__|___/
_ _ _ _
| | | |_ __ ___ _ __ __| | ___ _ __ ___ __| |
| | | | '_ \ / _ \| '__/ _` |/ _ \ '__/ _ \/ _` |
| |_| | | | | (_) | | | (_| | __/ | | __/ (_| |
\___/|_| |_|\___/|_| \__,_|\___|_| \___|\__,_|
+ 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
___ _ _
/ _ \ _ __ __| | ___ _ __ ___ __| |
| | | | '__/ _` |/ _ \ '__/ _ \/ _` |
| |_| | | | (_| | __/ | | __/ (_| |
\___/|_| \__,_|\___|_| \___|\__,_|
1. Lorem ipsum dolor sit amet
2. Consectetur adipiscing elit
3. Integer molestie lorem at massa
____ _
/ ___|___ __| | ___
| | / _ \ / _` |/ _ \
| |__| (_) | (_| | __/
\____\___/ \__,_|\___|
Inline `code`
Indented code
01 | // Some comments
02 | line 1 of code
03 | line 2 of code
04 | line 3 of code
Block code "fences"
01 | Sample text here...
Syntax highlighting
01 | var foo = function (bar) {
02 | return bar++;
03 | };
04 |
05 | console.log(foo(5));
_ _ _
| | (_)_ __ | | _____
| | | | '_ \| |/ / __|
| |___| | | | | <\__ \
|_____|_|_| |_|_|\_\___/
[link text](1)
[link with title](2)
___
|_ _|_ __ ___ __ _ __ _ ___ ___
| || '_ ` _ \ / _` |/ _` |/ _ \/ __|
| || | | | | | (_| | (_| | __/\__ \
|___|_| |_| |_|\__,_|\__, |\___||___/
|___/
[Image: Minion](3) [Image: Stormtroopocat](4)
[1] http://dev.nodeca.com
[2] http://nodeca.github.io/pica/demo/
[3] https://octodex.github.com/images/minion.png
[4] https://octodex.github.com/images/stormtroopocat.jpg

View file

@ -6,4 +6,5 @@ readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"pyfiglet>=0.8.0",
"pyphen>=0.17.2",
]

View file

@ -1 +1,2 @@
pyphen>=0.17.2
pyfiglet>=0.8.0

15
uv.lock generated
View file

@ -8,10 +8,14 @@ version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "pyfiglet" },
{ name = "pyphen" },
]
[package.metadata]
requires-dist = [{ name = "pyfiglet", specifier = ">=0.8.0" }]
requires-dist = [
{ name = "pyfiglet", specifier = ">=0.8.0" },
{ name = "pyphen", specifier = ">=0.17.2" },
]
[[package]]
name = "pyfiglet"
@ -21,3 +25,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/c8/e3/0a86276ad2c383ce0
wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/5c/fe9f95abd5eaedfa69f31e450f7e2768bef121dbdf25bcddee2cd3087a16/pyfiglet-1.0.4-py3-none-any.whl", hash = "sha256:65b57b7a8e1dff8a67dc8e940a117238661d5e14c3e49121032bd404d9b2b39f", size = 1806118, upload-time = "2025-08-15T18:32:45.556Z" },
]
[[package]]
name = "pyphen"
version = "0.17.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/69/56/e4d7e1bd70d997713649c5ce530b2d15a5fc2245a74ca820fc2d51d89d4d/pyphen-0.17.2.tar.gz", hash = "sha256:f60647a9c9b30ec6c59910097af82bc5dd2d36576b918e44148d8b07ef3b4aa3", size = 2079470, upload-time = "2025-01-20T13:18:36.296Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/1f/c2142d2edf833a90728e5cdeb10bdbdc094dde8dbac078cee0cf33f5e11b/pyphen-0.17.2-py3-none-any.whl", hash = "sha256:3a07fb017cb2341e1d9ff31b8634efb1ae4dc4b130468c7c39dd3d32e7c3affd", size = 2079358, upload-time = "2025-01-20T13:18:29.629Z" },
]