This commit is contained in:
randogoth 2025-10-19 22:58:10 +03:00
commit 3d889ec117
14 changed files with 1722 additions and 0 deletions

10
.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv

1
.python-version Normal file
View file

@ -0,0 +1 @@
3.13

69
README.md Normal file
View file

@ -0,0 +1,69 @@
# md2amb utilities
This repository contains command line helpers for transforming Markdown into formats that work well on retro hardware or constrained text viewers.
## Tools
- `md2amb.py` converts Markdown into Amber-screen formatted text (see script for details).
- `md2txt.py` converts Markdown into 80-column, DOS-compatible plain text with extensive formatting support:
- FIGlet-rendered headings (H1H3) driven by optional YAML frontmatter (`h1_font`, `h2_font`, `h3_font`).
- H4+ headings rendered in uppercase with dashed underlines.
- Emphasis styles converted to spaced or delimited characters, e.g. `**bold**``B O L D`, `__strong__``_s_t_r_o_n_g_`, `~~strike~~``~s~t~r~i~k~e~`.
- Blockquotes prefixed by `|    `, lists preserved, inline code untouched.
- Code blocks numbered (`01 | line`), with both fenced and indented fences supported.
- Links transformed into `[label](n)` references, with footnote-style URL list at the end.
- Optional alignment and margin controls via HTML `<p>` attributes or MultiMarkdown attribute blocks (e.g. `{:.center margin=20px}`) applied as leading spaces.
## Requirements
- Python 3.9+.
- Optional: `pyfiglet` for FIGlet headings (`pip install pyfiglet`). When unavailable, headings fall back to the H4-style renderer automatically.
## Usage
```bash
python md2txt.py input.md -o output.txt # convert to DOS-friendly text
python md2txt.py input.md # write result to stdout
python md2txt.py input.md --width 72 # override column width
```
Both scripts accept `--help` for the full option list.
## FIGlet Fonts via Frontmatter
You can set FIGlet fonts for H1H3 per document using YAML frontmatter:
```markdown
---
h1_font: slant
h2_font: standard
h3_font: small
---
# Main Title
## Section Heading
### Subsection
```
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
You can influence alignment and margins in the source Markdown using either HTML paragraphs or MultiMarkdown attribute blocks:
```markdown
<p align="center" style="margin: 10px 20px;">
Centered paragraph with custom margins.
</p>
Paragraph styled via MMD attributes.
{: .right margin=12px }
## Centered Heading {: .center margin=8px }
```
Margins are interpreted as spaces; values using `px` are rounded to the nearest integer, and `margin: 0 auto;` centers content.
## Output Line Endings
Generated text uses CRLF line endings to maintain DOS compatibility.

79
loremipsum.md Normal file
View file

@ -0,0 +1,79 @@
---
h1_font: colossal
h2_font: big
h3_font: small
---
# Salientia
{:.center}
## 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](#mille-parva). 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 *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
Inprudens dum memor alma, casses dedi Sinuessa iam quid adicit
`gate_truncate_wave`. [Geratur de](#salientia-iungit-contra-et), 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.
## Quantaque augent
Acuti ignotis ignis, ostendit nostras terras nondum, [petit utque
utero](#structa-hinc-vetitus-factum) sepulcrales, pluma reseratis Ismario. Tria
silentia duae *nymphe*: certe parva cernam et dixit. [Verborum
sedisti](#mille-parva).
```
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](#tractaque-serpens-arva-quoque). Iamdudum Romanae cepi linguisque
> velit accipe idem acutis hoc sparsumque dextra. Minoide sponte collo sequitur
> et intus flere sanguineaque herba, nox Quas adsiduo *cunctantem* tibi; enim.

167
loremipsum.txt Normal file
View file

@ -0,0 +1,167 @@
.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

152
loremipsum_60.txt Normal file
View file

@ -0,0 +1,152 @@
.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

6
main.py Normal file
View file

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

84
markdown.md Normal file
View file

@ -0,0 +1,84 @@
# h1 Heading 8-)
## h2 Heading
### h3 Heading
#### h4 Heading
##### h5 Heading
###### h6 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
1. Lorem ipsum dolor sit amet
2. Consectetur adipiscing elit
3. 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
``` js
var foo = function (bar) {
return bar++;
};
console.log(foo(5));
```
## Links
[link text](http://dev.nodeca.com)
[link with title](http://nodeca.github.io/pica/demo/ "title text!")
## Images
![Minion](https://octodex.github.com/images/minion.png)
![Stormtroopocat](https://octodex.github.com/images/stormtroopocat.jpg "The Stormtroopocat")

0
md2amb.py Normal file
View file

958
md2txt.py Normal file
View file

@ -0,0 +1,958 @@
#!/usr/bin/env python3
"""
Convert Markdown into 80-column DOS-compatible plain text.
"""
from __future__ import annotations
import argparse
import re
import sys
import textwrap
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Dict, Iterable, List, Optional, Tuple
try:
from pyfiglet import Figlet, FontNotFound
except ImportError: # pragma: no cover - emit a helpful error at runtime instead
Figlet = None # type: ignore[assignment]
FontNotFound = ValueError # type: ignore[assignment]
FRONTMATTER_PATTERN = re.compile(r"^---\s*$")
HEADING_PATTERN = re.compile(r"^(#{1,6})\s+(.*)$")
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*$")
INLINE_PARA_RE = re.compile(r"^\s*<p\b([^>]*)>(.*?)</p>\s*$", re.IGNORECASE)
PARA_OPEN_RE = re.compile(r"^\s*<p\b([^>]*)>\s*$", re.IGNORECASE)
PARA_CLOSE_RE = re.compile(r"^\s*</p>\s*$", re.IGNORECASE)
MMD_ATTR_LINE_RE = re.compile(r"^\{\s*:(.+)\}\s*$")
MMD_ATTR_TAIL_RE = re.compile(r"(.*?)\s*\{\s*:(.+?)\}\s*$")
@dataclass
class BlockStyle:
align: str = "left"
margin_left: int = 0
margin_right: int = 0
@dataclass
class StyleSpec:
align: Optional[str] = None
margin_left: Optional[int] = None
margin_right: Optional[int] = None
@dataclass
class BlockRecord:
start: int
length: int
render: Callable[[BlockStyle], List[str]]
style: BlockStyle
@dataclass
class FrontMatter:
h1_font: str = "standard"
h2_font: str = "standard"
h3_font: str = "standard"
class MarkdownToTxtConverter:
def __init__(self, width: int = 80, frontmatter: Optional[FrontMatter] = None) -> None:
self.width = width
self.frontmatter = frontmatter or FrontMatter()
self.links: List[Tuple[int, str]] = []
self.link_indices: Dict[str, int] = {}
self.figlets: Dict[str, Figlet] = {}
self._style_stack: List[BlockStyle] = [BlockStyle()]
self._paragraph_style_spec: Optional[StyleSpec] = None
self._pending_block_style_spec: Optional[StyleSpec] = None
self._last_stylable_block: Optional[BlockRecord] = None
def convert(self, lines: Iterable[str]) -> List[str]:
output: List[str] = []
self.links = []
self.link_indices = {}
self._style_stack = [BlockStyle()]
self._paragraph_style_spec = None
self._pending_block_style_spec = None
self._last_stylable_block = None
iterator = iter(lines)
in_code_block = False
code_lines: List[str] = []
indented_code_lines: List[str] = []
current_paragraph: List[str] = []
for raw_line in iterator:
line = raw_line.rstrip("\n")
if in_code_block:
if line.strip().startswith("```"):
self._emit_block(output, self._flush_code_block(code_lines))
code_lines = []
in_code_block = False
else:
code_lines.append(line)
continue
if indented_code_lines:
if line.startswith(" "):
indented_code_lines.append(line[4:])
continue
if not line.strip():
self._emit_block(output, self._flush_code_block(indented_code_lines))
indented_code_lines = []
else:
self._emit_block(output, self._flush_code_block(indented_code_lines))
indented_code_lines = []
inline_para = INLINE_PARA_RE.match(line.strip())
if inline_para:
self._flush_paragraph(current_paragraph, output)
current_paragraph = []
spec = self._style_spec_from_html_attributes(inline_para.group(1) or "")
self._push_style(spec)
content = inline_para.group(2)
if content:
current_paragraph.append(content)
self._flush_paragraph(current_paragraph, output)
current_paragraph = []
self._pop_style()
continue
open_para = PARA_OPEN_RE.match(line)
if open_para:
spec = self._style_spec_from_html_attributes(open_para.group(1) or "")
self._push_style(spec)
continue
close_para = PARA_CLOSE_RE.match(line)
if close_para:
self._flush_paragraph(current_paragraph, output)
current_paragraph = []
self._paragraph_style_spec = None
self._pop_style()
continue
stripped = line.strip()
attr_match = MMD_ATTR_LINE_RE.match(stripped)
if attr_match:
spec = self._parse_style_spec_from_tokens(attr_match.group(1))
if spec:
if current_paragraph:
self._paragraph_style_spec = self._merge_specs(self._paragraph_style_spec, spec)
elif self._last_stylable_block is not None:
self._apply_style_to_last_block(output, spec)
else:
self._pending_block_style_spec = self._merge_specs(self._pending_block_style_spec, spec)
continue
if line.strip().startswith("```"):
self._flush_paragraph(current_paragraph, output)
current_paragraph = []
in_code_block = True
code_lines = []
continue
if line.startswith(" "):
self._flush_paragraph(current_paragraph, output)
current_paragraph = []
indented_code_lines = [line[4:]]
continue
heading_match = HEADING_PATTERN.match(line)
if heading_match:
self._flush_paragraph(current_paragraph, output)
current_paragraph = []
level = len(heading_match.group(1))
heading_text = heading_match.group(2).strip()
heading_text, inline_spec = self._extract_trailing_attr(heading_text)
combined_spec = self._merge_specs(self._pending_block_style_spec, inline_spec)
style = self._combine_styles(self._current_style(), combined_spec)
render = lambda s: self._render_heading_lines(level, heading_text, s)
self._emit_block(output, render(style), stylable=True, render_fn=render, style=style)
self._pending_block_style_spec = None
continue
if HORIZONTAL_RULE_PATTERN.match(line):
self._flush_paragraph(current_paragraph, output)
current_paragraph = []
self._emit_block(output, [self._render_horizontal_rule(self._current_style())])
continue
if BLOCKQUOTE_PATTERN.match(line):
self._flush_paragraph(current_paragraph, output)
current_paragraph = []
self._handle_blockquote(line, output)
continue
if UNORDERED_LIST_PATTERN.match(line) or ORDERED_LIST_PATTERN.match(line):
self._flush_paragraph(current_paragraph, output)
current_paragraph = []
self._handle_list_line(line, output)
continue
if not line.strip():
self._flush_paragraph(current_paragraph, output)
output.append("")
current_paragraph = []
continue
current_paragraph.append(line)
self._flush_paragraph(current_paragraph, output)
if in_code_block:
self._emit_block(output, self._flush_code_block(code_lines))
if indented_code_lines:
self._emit_block(output, self._flush_code_block(indented_code_lines))
if self.links:
if output and output[-1] != "":
output.append("")
for index, url in self.links:
entry = f"[{index}] {url}"
output.extend(self._wrap_text(entry, initial_indent="", subsequent_indent="", style=BlockStyle()))
self._last_stylable_block = None
return output
def _flush_paragraph(self, paragraph_lines: List[str], output: List[str]) -> None:
if not paragraph_lines:
return
text = " ".join(line.strip() for line in paragraph_lines)
processed = self._process_inline(text)
combined_spec = self._merge_specs(self._pending_block_style_spec, self._paragraph_style_spec)
style = self._combine_styles(self._current_style(), combined_spec)
def render(target_style: BlockStyle) -> List[str]:
return self._wrap_text(processed, style=target_style)
lines = render(style)
self._emit_block(output, lines, stylable=True, render_fn=render, style=style)
paragraph_lines.clear()
self._paragraph_style_spec = None
self._pending_block_style_spec = None
def _flush_code_block(self, code_lines: List[str]) -> List[str]:
if not code_lines:
return []
width = max(2, len(str(len(code_lines))))
formatted: List[str] = []
for idx, line in enumerate(code_lines, start=1):
formatted.append(f" {idx:0{width}d} | {line}")
formatted.append("")
return formatted
def _handle_blockquote(self, line: str, output: List[str]) -> None:
# Normalize a single blockquote line before wrapping.
depth = 0
content = line
while content.lstrip().startswith(">"):
depth += 1
content = content.lstrip()[1:]
content = content.lstrip()
processed = self._process_inline(content)
indent_unit = " | "
indent = indent_unit * max(1, depth)
wrapped = self._wrap_text(
processed,
initial_indent=indent,
subsequent_indent=indent,
style=self._current_style(),
)
self._emit_block(output, wrapped)
def _apply_pattern(
self,
text: str,
pattern: re.Pattern[str],
handler: Callable[[re.Match[str], str], str],
) -> str:
result: List[str] = []
last = 0
for match in pattern.finditer(text):
result.append(text[last:match.start()])
replacement = handler(match, text)
result.append(replacement)
last = match.end()
result.append(text[last:])
return "".join(result)
def _replace_spaced_emphasis(
self,
source: str,
match: re.Match[str],
*,
transform: str,
) -> str:
stylized = self._stylize_letters(match.group(1), transform=transform)
if not stylized:
return stylized
return self._apply_emphasis_spacing(source, match.start(), match.end(), stylized)
def _apply_emphasis_spacing(self, source: str, start: int, end: int, stylized: str) -> str:
if not stylized:
return stylized
prefix = ""
suffix = ""
if start > 0:
before_char = source[start - 1]
if before_char.isalnum():
prefix = " "
if end < len(source):
after_char = source[end]
if after_char.isalnum():
suffix = " "
return f"{prefix}{stylized}{suffix}"
def _handle_list_line(self, line: str, output: List[str]) -> None:
ordered = ORDERED_LIST_PATTERN.match(line)
unordered = UNORDERED_LIST_PATTERN.match(line)
if ordered:
indent, marker, spacing, rest = ordered.groups()
elif unordered:
indent, marker, spacing, rest = unordered.groups()
else: # fall back to raw line
self._emit_block(output, [line])
return
prefix = f"{indent}{marker}{spacing}"
processed = self._process_inline(rest)
wrapped = self._wrap_text(
processed,
initial_indent=prefix,
subsequent_indent=" " * len(prefix),
style=self._current_style(),
)
self._emit_block(output, wrapped)
def _emit_block(
self,
output: List[str],
lines: List[str],
*,
stylable: bool = False,
render_fn: Optional[Callable[[BlockStyle], List[str]]] = None,
style: Optional[BlockStyle] = None,
) -> None:
if not lines:
return
start = len(output)
output.extend(lines)
if stylable and render_fn is not None and style is not None:
self._last_stylable_block = BlockRecord(start, len(lines), render_fn, style)
else:
self._last_stylable_block = None
def _apply_style_to_last_block(self, output: List[str], spec: StyleSpec) -> None:
if self._last_stylable_block is None:
return
new_style = self._combine_styles(self._last_stylable_block.style, spec)
new_lines = self._last_stylable_block.render(new_style)
start = self._last_stylable_block.start
end = start + self._last_stylable_block.length
output[start:end] = new_lines
self._last_stylable_block.length = len(new_lines)
self._last_stylable_block.style = new_style
def _current_style(self) -> BlockStyle:
return self._style_stack[-1]
def _push_style(self, spec: Optional[StyleSpec]) -> None:
base = self._current_style()
self._style_stack.append(self._combine_styles(base, spec))
def _pop_style(self) -> None:
if len(self._style_stack) > 1:
self._style_stack.pop()
def _combine_styles(self, base: BlockStyle, spec: Optional[StyleSpec]) -> BlockStyle:
if spec is None:
return BlockStyle(align=base.align, margin_left=base.margin_left, margin_right=base.margin_right)
return BlockStyle(
align=spec.align or base.align,
margin_left=spec.margin_left if spec.margin_left is not None else base.margin_left,
margin_right=spec.margin_right if spec.margin_right is not None else base.margin_right,
)
def _merge_specs(self, first: Optional[StyleSpec], second: Optional[StyleSpec]) -> Optional[StyleSpec]:
if first is None and second is None:
return None
if first is None:
return second
if second is None:
return first
return StyleSpec(
align=second.align or first.align,
margin_left=second.margin_left if second.margin_left is not None else first.margin_left,
margin_right=second.margin_right if second.margin_right is not None else first.margin_right,
)
def _style_spec_from_html_attributes(self, attributes: str) -> Optional[StyleSpec]:
if not attributes:
return None
attr_pattern = re.compile(r"([\w:-]+)\s*=\s*(\".*?\"|'.*?'|\S+)")
attr_map: Dict[str, str] = {}
for name, value in attr_pattern.findall(attributes):
attr_map[name.lower()] = value.strip().strip("\"'")
spec: Optional[StyleSpec] = None
align_value = attr_map.get("align")
if align_value:
normalized = self._normalize_align(align_value)
if normalized:
spec = self._merge_specs(spec, StyleSpec(align=normalized))
style_value = attr_map.get("style")
if style_value:
css_spec = self._style_spec_from_css(style_value)
spec = self._merge_specs(spec, css_spec)
return spec
def _style_spec_from_css(self, css: str) -> Optional[StyleSpec]:
spec = StyleSpec()
changed = False
for declaration in css.split(";"):
if ":" not in declaration:
continue
name, value = declaration.split(":", 1)
name = name.strip().lower()
value = value.strip()
if not value:
continue
if name == "text-align":
normalized = self._normalize_align(value)
if normalized:
spec.align = normalized
changed = True
elif name == "margin":
left, right, auto_center = self._parse_css_margin_shorthand(value)
if left is not None:
spec.margin_left = left
changed = True
if right is not None:
spec.margin_right = right
changed = True
if auto_center:
spec.align = "center"
changed = True
elif name == "margin-left":
parsed = self._parse_space_value(value)
if parsed is not None:
spec.margin_left = parsed
changed = True
elif value.lower() == "auto":
spec.align = spec.align or "center"
changed = True
elif name == "margin-right":
parsed = self._parse_space_value(value)
if parsed is not None:
spec.margin_right = parsed
changed = True
elif value.lower() == "auto":
spec.align = spec.align or "center"
changed = True
return spec if changed else None
def _parse_style_spec_from_tokens(self, token_str: str) -> Optional[StyleSpec]:
tokens = re.split(r"\s+", token_str.strip())
spec = StyleSpec()
changed = False
for token in tokens:
token = token.strip()
if not token:
continue
if token.startswith("."):
align = self._class_to_align(token[1:])
if align:
spec.align = align
changed = True
continue
if "=" in token:
key, value = token.split("=", 1)
key = key.strip().lower().lstrip(".")
value = value.strip().strip("\"'")
if key in {"align", "text-align"}:
normalized = self._normalize_align(value)
if normalized:
spec.align = normalized
changed = True
elif key in {"margin", "margin-left", "margin-right"}:
if key == "margin":
left, right, auto_center = self._parse_css_margin_shorthand(value)
if left is not None:
spec.margin_left = left
changed = True
if right is not None:
spec.margin_right = right
changed = True
if auto_center:
spec.align = "center"
changed = True
elif key == "margin-left":
parsed = self._parse_space_value(value)
if parsed is not None:
spec.margin_left = parsed
changed = True
elif value.lower() == "auto":
spec.align = spec.align or "center"
changed = True
elif key == "margin-right":
parsed = self._parse_space_value(value)
if parsed is not None:
spec.margin_right = parsed
changed = True
elif value.lower() == "auto":
spec.align = spec.align or "center"
changed = True
continue
align = self._normalize_align(token)
if align:
spec.align = align
changed = True
return spec if changed else None
def _parse_css_margin_shorthand(self, value: str) -> Tuple[Optional[int], Optional[int], bool]:
parts = [part for part in re.split(r"\s+", value.strip()) if part]
if not parts:
return None, None, False
values: List[Optional[int]] = []
autos: List[bool] = []
for part in parts:
if part.lower() == "auto":
values.append(None)
autos.append(True)
else:
parsed = self._parse_space_value(part)
values.append(parsed)
autos.append(False)
left_auto = False
right_auto = False
if len(values) == 1:
left = right = values[0]
left_auto = right_auto = autos[0]
elif len(values) == 2:
left = right = values[1]
left_auto = right_auto = autos[1]
elif len(values) == 3:
left = right = values[1]
left_auto = right_auto = autos[1]
else:
right = values[1]
left = values[3]
right_auto = autos[1]
left_auto = autos[3]
auto_center = left_auto and right_auto
return left, right, auto_center
def _parse_space_value(self, value: str) -> Optional[int]:
match = re.match(r"(-?\d+(?:\.\d+)?)", value.strip())
if not match:
return None
number = float(match.group(1))
return max(0, int(round(number)))
def _normalize_align(self, value: str) -> Optional[str]:
normalized = value.strip().lower()
mapping = {
"centre": "center",
"center": "center",
"left": "left",
"right": "right",
}
return mapping.get(normalized)
def _class_to_align(self, class_name: str) -> Optional[str]:
name = class_name.strip().lower().lstrip(".")
if name in {"center", "text-center", "align-center"}:
return "center"
if name in {"left", "text-left", "align-left"}:
return "left"
if name in {"right", "text-right", "align-right"}:
return "right"
return None
def _extract_trailing_attr(self, text: str) -> Tuple[str, Optional[StyleSpec]]:
match = MMD_ATTR_TAIL_RE.match(text)
if not match:
return text, None
clean_text = match.group(1).rstrip()
spec = self._parse_style_spec_from_tokens(match.group(2))
return clean_text, spec
def _render_heading_lines(self, level: int, text: str, style: BlockStyle) -> List[str]:
if level <= 3:
figlet_lines = self._figlet_render(level, text)
if figlet_lines:
max_figlet_width = max((len(line.rstrip()) for line in figlet_lines), default=0)
if max_figlet_width <= self._effective_width(style):
styled = self._apply_style_to_lines(figlet_lines, style)
return styled + [""]
return self._render_h4_plus(text, style)
def _render_horizontal_rule(self, style: BlockStyle) -> str:
margin_left = min(max(style.margin_left, 0), self.width - 1)
available_width = max(1, self._effective_width(style))
return " " * margin_left + "-" * available_width
def _apply_style_to_lines(self, lines: List[str], style: BlockStyle) -> List[str]:
if not lines:
return []
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)
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
def _process_inline(self, text: str) -> str:
code_segments: List[str] = []
def stash_code(match: re.Match[str]) -> str:
code_segments.append(match.group(0))
return f"\u0000CODE{len(code_segments) - 1}\u0000"
text = re.sub(r"`[^`]*`", stash_code, text)
text = self._apply_pattern(
text,
re.compile(r"~~(.*?)~~"),
lambda m, src: self._stylize_delimited(m.group(1), "-", transform="preserve"),
)
text = self._apply_pattern(
text,
re.compile(r"\*\*(.*?)\*\*"),
lambda m, src: self._replace_spaced_emphasis(src, m, transform="upper"),
)
text = self._apply_pattern(
text,
re.compile(r"(?<!\*)\*(?!\*)(.*?)(?<!\*)\*(?!\*)"),
lambda m, src: self._replace_spaced_emphasis(src, m, transform="preserve"),
)
emphasis_segments: List[str] = []
def apply_with_placeholder(
current: str,
pattern: re.Pattern[str],
handler: Callable[[re.Match[str]], str],
) -> str:
def replacer(match: re.Match[str]) -> str:
replacement = handler(match)
placeholder = f"\u0000EMP{len(emphasis_segments)}\u0000"
emphasis_segments.append(replacement)
return placeholder
return pattern.sub(replacer, current)
text = apply_with_placeholder(
text,
re.compile(r"__(.*?)__"),
lambda m: self._apply_emphasis_spacing(
m.string,
m.start(),
m.end(),
self._stylize_delimited(m.group(1), "_", transform="upper", word_repeat=3),
),
)
text = apply_with_placeholder(
text,
re.compile(r"(?<!_)_(?!_)(.*?)(?<!_)_(?!_)"),
lambda m: self._apply_emphasis_spacing(
m.string,
m.start(),
m.end(),
self._stylize_delimited(m.group(1), "_", transform="preserve", word_repeat=3),
),
)
text = re.sub(r"(?<!\!)\[([^\]]+)\]\(([^)]+)\)", self._handle_link, text)
text = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", self._handle_image, text)
for index, replacement in enumerate(emphasis_segments):
placeholder = f"\u0000EMP{index}\u0000"
text = text.replace(placeholder, replacement)
for index, code in enumerate(code_segments):
placeholder = f"\u0000CODE{index}\u0000"
text = text.replace(placeholder, code)
return text
def _stylize_letters(self, content: str, transform: str = "preserve") -> str:
if not content:
return ""
result: List[str] = []
previous_alnum = False
pending_delimiter: str = ""
def apply_transform(char: str) -> str:
if transform == "upper":
return char.upper()
if transform == "lower":
return char.lower()
return char
for char in content:
processed = apply_transform(char)
if processed.isspace():
if result and result[-1].isalnum():
pending_delimiter = " "
previous_alnum = False
continue
if processed.isalnum():
if previous_alnum:
result.append(" ")
elif pending_delimiter:
result.append(pending_delimiter)
pending_delimiter = ""
elif result:
result.append(" ")
result.append(processed)
previous_alnum = True
else:
if result and result[-1] == " ":
result.pop()
if pending_delimiter:
result.append(pending_delimiter.strip())
pending_delimiter = ""
result.append(processed)
previous_alnum = False
stylized = "".join(result)
return stylized.strip()
def _stylize_delimited(
self,
content: str,
delimiter: str,
transform: str = "preserve",
word_repeat: int = 2,
) -> str:
def apply_transform(char: str) -> str:
if transform == "upper":
return char.upper()
if transform == "lower":
return char.lower()
return char
output: List[str] = []
open_sequence = False
pending_gap = False
for char in content:
if char.isspace():
if open_sequence:
pending_gap = True
continue
processed = apply_transform(char)
if not open_sequence:
output.append(delimiter)
open_sequence = True
else:
repeat = word_repeat if pending_gap else 1
output.append(delimiter * repeat)
output.append(processed)
pending_gap = False
if not open_sequence:
return delimiter * 2
output.append(delimiter)
return "".join(output)
def _handle_link(self, match: re.Match[str]) -> str:
text, target = match.groups()
url, _title = self._split_link_target(target)
index = self._register_link(url)
return f"[{text}]({index})"
def _handle_image(self, match: re.Match[str]) -> str:
alt_text, target = match.groups()
url, _title = self._split_link_target(target)
display_text = alt_text or "Image"
index = self._register_link(url)
return f"[Image: {display_text}]({index})"
def _split_link_target(self, value: str) -> Tuple[str, Optional[str]]:
value = value.strip()
if not value:
return "", None
if " " not in value:
return value.strip(), None
url, remainder = value.split(" ", 1)
remainder = remainder.strip()
if remainder.startswith('"') and remainder.endswith('"'):
return url.strip(), remainder.strip('"')
return url.strip(), remainder
def _register_link(self, url: str) -> int:
if url in self.link_indices:
return self.link_indices[url]
index = len(self.links) + 1
self.links.append((index, url))
self.link_indices[url] = index
return index
def _figlet_render(self, level: int, text: str) -> Optional[List[str]]:
if Figlet is None:
return None
font_name = getattr(self.frontmatter, f"h{level}_font", "standard")
figlet = self.figlets.get(font_name)
if figlet is None:
try:
figlet = Figlet(font=font_name)
except (FontNotFound, TypeError):
return None
self.figlets[font_name] = figlet
rendered = figlet.renderText(text).rstrip("\n")
lines = rendered.splitlines()
trimmed_lines = [line.rstrip("\n") for line in lines]
if not trimmed_lines or max(len(line.rstrip()) for line in trimmed_lines) > self.width:
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)
output: List[str] = []
for line in wrapped:
line = line.rstrip()
if not line.strip():
output.append("")
continue
leading = len(line) - len(line.lstrip(" "))
underline = " " * leading + "-" * len(line.lstrip(" "))
output.append(line)
output.append(underline)
output.append("")
return output
def _wrap_text(
self,
text: str,
initial_indent: str = "",
subsequent_indent: Optional[str] = None,
style: Optional[BlockStyle] = None,
) -> 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
wrapper = textwrap.TextWrapper(
width=available_width,
expand_tabs=False,
replace_whitespace=False,
drop_whitespace=False,
break_on_hyphens=False,
break_long_words=True,
initial_indent=initial_indent,
subsequent_indent=subsequent,
)
wrapped = wrapper.wrap(text)
if not wrapped:
wrapped = [initial_indent.rstrip()]
result: List[str] = []
for line in wrapped:
line = line.rstrip()
line_len = len(line)
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 + line)
return result
def _effective_width(self, style: BlockStyle) -> int:
margin_left = min(max(style.margin_left, 0), self.width - 1)
remaining = self.width - margin_left
margin_right = min(max(style.margin_right, 0), max(0, remaining - 1))
return max(1, self.width - margin_left - margin_right)
def parse_frontmatter(lines: List[str]) -> Tuple[FrontMatter, List[str]]:
if not lines or not FRONTMATTER_PATTERN.match(lines[0]):
return FrontMatter(), lines
frontmatter: Dict[str, str] = {}
idx = 1
while idx < len(lines):
if FRONTMATTER_PATTERN.match(lines[idx]):
break
if ":" in lines[idx]:
key, value = lines[idx].split(":", 1)
frontmatter[key.strip()] = value.strip()
idx += 1
if idx >= len(lines):
return FrontMatter(), lines
remaining = lines[idx + 1 :] if idx + 1 < len(lines) else []
fm = FrontMatter(
h1_font=frontmatter.get("h1_font", "standard"),
h2_font=frontmatter.get("h2_font", "standard"),
h3_font=frontmatter.get("h3_font", "standard"),
)
return fm, remaining
def read_lines(path: Path) -> List[str]:
with path.open("r", encoding="utf-8") as handle:
return handle.readlines()
def write_output(path: Optional[Path], lines: List[str]) -> None:
content = "\r\n".join(lines)
if path is None:
sys.stdout.write(content + "\r\n")
return
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content + "\r\n", encoding="utf-8")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Convert Markdown files to 80-column DOS-compatible text.")
parser.add_argument("input_path", type=Path, help="Path to the Markdown input file.")
parser.add_argument("-o", "--output", type=Path, help="Optional path to write the resulting text file.")
parser.add_argument("--width", type=int, default=80, help="Maximum column width (default: 80).")
return parser
def main(argv: Optional[List[str]] = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
lines = read_lines(args.input_path)
frontmatter, content = parse_frontmatter(lines)
converter = MarkdownToTxtConverter(width=args.width, frontmatter=frontmatter)
converted_lines = converter.convert(content)
write_output(args.output, converted_lines)
return 0
if __name__ == "__main__":
raise SystemExit(main())

163
output.txt Normal file
View file

@ -0,0 +1,163 @@
_ _ _ _ _ _ ___ __
| |__ / | | | | | ___ __ _ __| (_)_ __ __ _ ( _ ) \ \
| '_ \| | | |_| |/ _ \/ _` |/ _` | | '_ \ / _` | / _ \ _____| |
| | | | | | _ | __/ (_| | (_| | | | | | (_| | | (_) |_____| |
|_| |_|_| |_| |_|\___|\__,_|\__,_|_|_| |_|\__, | \___/ | |
|___/ /_/
_ ____ _ _ _ _
| |__ |___ \ | | | | ___ __ _ __| (_)_ __ __ _
| '_ \ __) | | |_| |/ _ \/ _` |/ _` | | '_ \ / _` |
| | | |/ __/ | _ | __/ (_| | (_| | | | | | (_| |
|_| |_|_____| |_| |_|\___|\__,_|\__,_|_|_| |_|\__, |
|___/
_ _____ _ _ _ _
| |__ |___ / | | | | ___ __ _ __| (_)_ __ __ _
| '_ \ |_ \ | |_| |/ _ \/ _` |/ _` | | '_ \ / _` |
| | | |___) | | _ | __/ (_| | (_| | | | | | (_| |
|_| |_|____/ |_| |_|\___|\__,_|\__,_|_|_| |_|\__, |
|___/
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

9
pyproject.toml Normal file
View file

@ -0,0 +1,9 @@
[project]
name = "md2amb"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"pyfiglet>=0.8.0",
]

1
requirements.txt Normal file
View file

@ -0,0 +1 @@
pyfiglet>=0.8.0

23
uv.lock generated Normal file
View file

@ -0,0 +1,23 @@
version = 1
revision = 3
requires-python = ">=3.13"
[[package]]
name = "md2amb"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "pyfiglet" },
]
[package.metadata]
requires-dist = [{ name = "pyfiglet", specifier = ">=0.8.0" }]
[[package]]
name = "pyfiglet"
version = "1.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c8/e3/0a86276ad2c383ce08d76110a8eec2fe22e7051c4b8ba3fa163a0b08c428/pyfiglet-1.0.4.tar.gz", hash = "sha256:db9c9940ed1bf3048deff534ed52ff2dafbbc2cd7610b17bb5eca1df6d4278ef", size = 1560615, upload-time = "2025-08-15T18:32:47.302Z" }
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" },
]