From 3d889ec1177cce55b49117ef1579427728ac11c7 Mon Sep 17 00:00:00 2001 From: randogoth Date: Sun, 19 Oct 2025 22:58:10 +0300 Subject: [PATCH] init --- .gitignore | 10 + .python-version | 1 + README.md | 69 ++++ loremipsum.md | 79 ++++ loremipsum.txt | 167 ++++++++ loremipsum_60.txt | 152 ++++++++ main.py | 6 + markdown.md | 84 ++++ md2amb.py | 0 md2txt.py | 958 ++++++++++++++++++++++++++++++++++++++++++++++ output.txt | 163 ++++++++ pyproject.toml | 9 + requirements.txt | 1 + uv.lock | 23 ++ 14 files changed, 1722 insertions(+) create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 README.md create mode 100644 loremipsum.md create mode 100644 loremipsum.txt create mode 100644 loremipsum_60.txt create mode 100644 main.py create mode 100644 markdown.md create mode 100644 md2amb.py create mode 100644 md2txt.py create mode 100644 output.txt create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 uv.lock 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" }, +]