commit 3d889ec1177cce55b49117ef1579427728ac11c7 Author: randogoth Date: Sun Oct 19 22:58:10 2025 +0300 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..505a3b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/README.md b/README.md new file mode 100644 index 0000000..8427c4c --- /dev/null +++ b/README.md @@ -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 (H1–H3) 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 `

` 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 H1–H3 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 +

+Centered paragraph with custom margins. +

+ +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. diff --git a/loremipsum.md b/loremipsum.md new file mode 100644 index 0000000..d37ee95 --- /dev/null +++ b/loremipsum.md @@ -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. diff --git a/loremipsum.txt b/loremipsum.txt new file mode 100644 index 0000000..93aadb5 --- /dev/null +++ b/loremipsum.txt @@ -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 diff --git a/loremipsum_60.txt b/loremipsum_60.txt new file mode 100644 index 0000000..0664af1 --- /dev/null +++ b/loremipsum_60.txt @@ -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 diff --git a/main.py b/main.py new file mode 100644 index 0000000..a7a2d7f --- /dev/null +++ b/main.py @@ -0,0 +1,6 @@ +def main(): + print("Hello from md2amb!") + + +if __name__ == "__main__": + main() diff --git a/markdown.md b/markdown.md new file mode 100644 index 0000000..21a566e --- /dev/null +++ b/markdown.md @@ -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") \ No newline at end of file diff --git a/md2amb.py b/md2amb.py new file mode 100644 index 0000000..e69de29 diff --git a/md2txt.py b/md2txt.py new file mode 100644 index 0000000..8630535 --- /dev/null +++ b/md2txt.py @@ -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*]*)>(.*?)

\s*$", re.IGNORECASE) +PARA_OPEN_RE = re.compile(r"^\s*]*)>\s*$", re.IGNORECASE) +PARA_CLOSE_RE = re.compile(r"^\s*

\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"(? 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"(? 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()) diff --git a/output.txt b/output.txt new file mode 100644 index 0000000..8a59f29 --- /dev/null +++ b/output.txt @@ -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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3d06b88 --- /dev/null +++ b/pyproject.toml @@ -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", +] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9689b2f --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pyfiglet>=0.8.0 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..e6974c8 --- /dev/null +++ b/uv.lock @@ -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" }, +]