From b1f1d7b5f6963705c2a6ce3326b2d056229ecfca Mon Sep 17 00:00:00 2001 From: randogoth Date: Sun, 19 Oct 2025 23:22:15 +0300 Subject: [PATCH] improvements --- README.md | 8 + loremipsum.md => examples/loremipsum.md | 13 +- markdown.md => examples/markdown.md | 0 loremipsum.txt | 167 -------------- loremipsum_60.txt | 152 ------------ main.py | 6 - md2txt.py | 293 ++++++++++++++++++++++-- output.txt | 163 ------------- pyproject.toml | 1 + requirements.txt | 1 + uv.lock | 15 +- 11 files changed, 305 insertions(+), 514 deletions(-) rename loremipsum.md => examples/loremipsum.md (96%) rename markdown.md => examples/markdown.md (100%) delete mode 100644 loremipsum.txt delete mode 100644 loremipsum_60.txt delete mode 100644 main.py delete mode 100644 output.txt diff --git a/README.md b/README.md index 8427c4c..d22824f 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ This repository contains command line helpers for transforming Markdown into for ## Requirements - Python 3.9+. +- Optional: `PyHyphen` (preferred) or `pyphen` for dictionary-driven hyphenation. - Optional: `pyfiglet` for FIGlet headings (`pip install pyfiglet`). When unavailable, headings fall back to the H4-style renderer automatically. ## Usage @@ -38,6 +39,11 @@ You can set FIGlet fonts for H1–H3 per document using YAML frontmatter: h1_font: slant h2_font: standard h3_font: small +margin_left: 4 +margin_right: 4 +paragraph_spacing: 1 +hyphenate: true +hyphen_lang: en_US --- # Main Title @@ -45,6 +51,8 @@ h3_font: small ### Subsection ``` +Font names also accept the special keywords `caps` (force uppercase) and `title` (title case) for any heading. `margin_left` and `margin_right` add leading/trailing spaces to body text, `paragraph_spacing` (or `lines_between_paragraphs`) inserts blank lines between paragraphs, and enabling `hyphenate` activates PyHyphen-based wrapping (optional `hyphen_lang` defaults to `en_US`). + If the rendered FIGlet text would exceed the configured width, the converter automatically falls back to the H4-style uppercase heading with a dashed underline. ## Styling via Attribute Blocks diff --git a/loremipsum.md b/examples/loremipsum.md similarity index 96% rename from loremipsum.md rename to examples/loremipsum.md index d37ee95..63d5ec9 100644 --- a/loremipsum.md +++ b/examples/loremipsum.md @@ -1,19 +1,23 @@ --- -h1_font: colossal -h2_font: big -h3_font: small +h1_font: big +h2_font: small +h3_font: caps +paragraph_spacing: 2 +hyphenate: true --- # Salientia -{:.center} +{:.right} ## Structa hinc vetitus factum +{:.center} Lorem markdownum omnes tellus, in, Herculeis `reciprocalPower` neque promissa est Latiae caput umero talia fidissima, nymphae. Foedera et sagittas tenetur adde matri esse: talia manibus. ## Fuit video longique dedisse +{:.center} Ille stupet, ultra **incunabula agendum urbes** mirer **aper**, metu. Aegides novissima sunt turis quatiebant umbrosaque [putares](#mille-parva). Metuunt @@ -25,6 +29,7 @@ omnia inmotusque, est et aetas sonus. Exhibuit coeperunt, canis. Suis minora, tempore fateor neve moderamine erit. Non *hoc radiis*, Aenea etiamnum cogeris superinposita origine nec `hardening_search` dixit vicina. Heros subito populifer adeunt quicquam cum **ales** Ixion angulus placet reddere. +{:.right} ## Tractaque serpens arva quoque diff --git a/markdown.md b/examples/markdown.md similarity index 100% rename from markdown.md rename to examples/markdown.md diff --git a/loremipsum.txt b/loremipsum.txt deleted file mode 100644 index 93aadb5..0000000 --- a/loremipsum.txt +++ /dev/null @@ -1,167 +0,0 @@ - - .d8888b. 888d8b 888 d8b - d88P Y88b 888Y8P 888 Y8P - Y88b. 888 888 - "Y888b. 8888b. 888888 .d88b. 88888b. 888888888 8888b. - "Y88b. "88b888888d8P Y8b888 "88b888 888 "88b - "888.d88888888888888888888888 888888 888.d888888 - Y88b d88P888 888888888Y8b. 888 888Y88b. 888888 888 - "Y8888P" "Y888888888888 "Y8888 888 888 "Y888888"Y888888 - - - - - - _____ _ _ _ _ - / ____| | | | | | (_) -| (___ | |_ _ __ _ _ ___| |_ __ _ | |__ _ _ __ ___ - \___ \| __| '__| | | |/ __| __/ _` | | '_ \| | '_ \ / __| - ____) | |_| | | |_| | (__| || (_| | | | | | | | | | (__ -|_____/ \__|_| \__,_|\___|\__\__,_| |_| |_|_|_| |_|\___| - - - _ _ _ __ _ - | | (_) | / _| | | -__ _____| |_ _| |_ _ _ ___ | |_ __ _ ___| |_ _ _ _ __ ___ -\ \ / / _ \ __| | __| | | / __| | _/ _` |/ __| __| | | | '_ ` _ \ - \ V / __/ |_| | |_| |_| \__ \ | || (_| | (__| |_| |_| | | | | | | - \_/ \___|\__|_|\__|\__,_|___/ |_| \__,_|\___|\__|\__,_|_| |_| |_| - - - - -Lorem markdownum omnes tellus, in, Herculeis `reciprocalPower` neque promissa -est Latiae caput umero talia fidissima, nymphae. Foedera et sagittas tenetur -adde matri esse: talia manibus. - - ______ _ _ _ _ -| ____| (_) | (_) | | -| |__ _ _ _| |_ __ ___ __| | ___ ___ -| __| | | | | __| \ \ / / |/ _` |/ _ \/ _ \ -| | | |_| | | |_ \ V /| | (_| | __/ (_) | -|_| \__,_|_|\__| \_/ |_|\__,_|\___|\___/ - - - _ _ _ _ _ -| | (_) | | | (_) -| | ___ _ __ __ _ _ __ _ _ _ ___ __| | ___ __| |_ ___ ___ ___ -| |/ _ \| '_ \ / _` | |/ _` | | | |/ _ \ / _` |/ _ \/ _` | / __/ __|/ _ \ -| | (_) | | | | (_| | | (_| | |_| | __/ | (_| | __/ (_| | \__ \__ \ __/ -|_|\___/|_| |_|\__, |_|\__, |\__,_|\___| \__,_|\___|\__,_|_|___/___/\___| - __/ | | | - |___/ |_| - - -Ille stupet, ultra I N C U N A B U L A A G E N D U M U R B E S mirer A P E -R, metu. Aegides novissima sunt turis quatiebant umbrosaque [putares](1). -Metuunt eruiturque maximus velit, ater Vesta vulnus rustica verba! Mei vincite -nulla, in Iovem: fama exurunt vernum! - -De instat ubi induit fugientia inertes columbas tarda t e r g a P a r n a s i -a s e n t e n t i a omnia inmotusque, est et aetas sonus. Exhibuit coeperunt, -canis. Suis minora, tempore fateor neve moderamine erit. Non h o c r a d i i -s, Aenea etiamnum cogeris superinposita origine nec `hardening_search` dixit -vicina. Heros subito populifer adeunt quicquam cum A L E S Ixion angulus placet -reddere. - - _______ _ -|__ __| | | - | |_ __ __ _ ___| |_ __ _ __ _ _ _ ___ - | | '__/ _` |/ __| __/ _` |/ _` | | | |/ _ \ - | | | | (_| | (__| || (_| | (_| | |_| | __/ - |_|_| \__,_|\___|\__\__,_|\__, |\__,_|\___| - | | - |_| - - - ___ ___ _ __ _ __ ___ _ __ ___ __ _ _ ____ ____ _ -/ __|/ _ \ '__| '_ \ / _ \ '_ \/ __| / _` | '__\ \ / / _` | -\__ \ __/ | | |_) | __/ | | \__ \ | (_| | | \ V / (_| | -|___/\___|_| | .__/ \___|_| |_|___/ \__,_|_| \_/ \__,_| - | | - |_| - - - __ _ _ _ ___ __ _ _ _ ___ - / _` | | | |/ _ \ / _` | | | |/ _ \ -| (_| | |_| | (_) | (_| | |_| | __/ - \__, |\__,_|\___/ \__, |\__,_|\___| - | | | | - |_| |_| - - -Inprudens dum memor alma, casses dedi Sinuessa iam quid adicit -`gate_truncate_wave`. [Geratur de](2), insolida temptat `open` carentia; -genitorem solet, potens. Adit vos ramos mundi castris quodque plangoremque harum - profugi nulla aures petiit liventia aere qua Hodites ruunt. Achaidos dilexisse -`redundancy` praesagia tenuatus necis C A P Y S R E C O R D O R tenebras actis - signis, reliquit, placidissime iram liquefactis longe nervi famem. Cura pars -natique velleris semel et at `printerWildcardParallel` inquit positosque comites - ultra. - - ____ _ - / __ \ | | -| | | |_ _ __ _ _ __ | |_ __ _ __ _ _ _ ___ -| | | | | | |/ _` | '_ \| __/ _` |/ _` | | | |/ _ \ -| |__| | |_| | (_| | | | | || (_| | (_| | |_| | __/ - \___\_\\__,_|\__,_|_| |_|\__\__,_|\__, |\__,_|\___| - | | - |_| - _ - | | - __ _ _ _ __ _ ___ _ __ | |_ - / _` | | | |/ _` |/ _ \ '_ \| __| -| (_| | |_| | (_| | __/ | | | |_ - \__,_|\__,_|\__, |\___|_| |_|\__| - __/ | - |___/ - - -Acuti ignotis ignis, ostendit nostras terras nondum, [petit utque utero](3) -sepulcrales, pluma reseratis Ismario. Tria silentia duae n y m p h e: certe -parva cernam et dixit. [Verborum sedisti](1). - - 01 | if (firewall(4, 3) == 1 * device_media_parity) { - 02 | system_copy_class += systray + oasis_wi_clean + lpi; - 03 | modelLdap.search_secondary_socket *= bounce.proxy_text(cableMyspacePerl, - 04 | scanner(spam)); - 05 | text_class_traceroute(vga, hdtv); - 06 | } - 07 | if (commerceStack) { - 08 | wrapSafe(pum(itunesRosettaCache, sdramOpticalWorm)); - 09 | } else { - 10 | minisite_worm(2, ssdString(84), gnuFriendly); - 11 | state.qwerty(moduleJavascriptFloating + ugc, koffice, - 12 | mail_system.prebinding_service_teraflops(mediaIcmpBoot)); - 13 | } - 14 | memoryHexadecimalMultitasking += oop_android; - 15 | var passive_vdu = bar; - 16 | var ddrJava = leaf(unc_web); - - - __ __ _ _ _ -| \/ (_) | |___ _ __ __ _ _ ___ ____ _ -| |\/| | | | / -_) | '_ \/ _` | '_\ V / _` | -|_| |_|_|_|_\___| | .__/\__,_|_| \_/\__,_| - |_| - - -M I N E T U R E T aurum! Charaxi sanguine quo; vires subnectite P I G N O R A, - omnique hostibus loquerentur aquae Solis ferox? C U M N E, putat utar ex ad -aede mane. Vitalesque geminas exstimulat, iam vivunt dat artes et quem! Iam -violas molire ipse, spoliabitur dente nervis regis, factos quae. - - | Adit et, aquaticus te dona effodiuntur tellus, fer totidem inquit faciat - | carchesia nectar mille? Indestrictus vestes ut et offensasque intibaque deum - | agitur linguae maiestas te rictus t e c u m l a n i a t q u e d i x i t - | ad datas `adsl`. - | Vino u t quod iaculi mutua armis uno est vos totidemque - | [exhortor](4). Iamdudum Romanae cepi linguisque - | velit accipe idem acutis hoc sparsumque dextra. Minoide sponte collo sequitur - | et intus flere sanguineaque herba, nox Quas adsiduo c u n c t a n t e m tibi; - | enim. - -[1] #mille-parva -[2] #salientia-iungit-contra-et -[3] #structa-hinc-vetitus-factum -[4] #tractaque-serpens-arva-quoque diff --git a/loremipsum_60.txt b/loremipsum_60.txt deleted file mode 100644 index 0664af1..0000000 --- a/loremipsum_60.txt +++ /dev/null @@ -1,152 +0,0 @@ - - .d8888b. 888d8b 888 d8b -d88P Y88b 888Y8P 888 Y8P -Y88b. 888 888 - "Y888b. 8888b. 888888 .d88b. 88888b. 888888888 8888b. - "Y88b. "88b888888d8P Y8b888 "88b888 888 "88b - "888.d88888888888888888888888 888888 888.d888888 -Y88b d88P888 888888888Y8b. 888 888Y88b. 888888 888 - "Y8888P" "Y888888888888 "Y8888 888 888 "Y888888"Y888888 - - - - - -STRUCTA HINC VETITUS FACTUM ---------------------------- - - -Lorem markdownum omnes tellus, in, Herculeis -`reciprocalPower` neque promissa est Latiae caput umero -talia fidissima, nymphae. Foedera et sagittas tenetur adde -matri esse: talia manibus. - -FUIT VIDEO LONGIQUE DEDISSE ---------------------------- - - -Ille stupet, ultra I N C U N A B U L A A G E N D U M U - R B E S mirer A P E R , metu. Aegides novissima sunt -turis quatiebant umbrosaque [putares](1). Metuunt eruiturque - maximus velit, ater Vesta vulnus rustica verba! Mei vincite - nulla, in Iovem: fama exurunt vernum! - -De instat ubi induit fugientia inertes columbas tarda t e -r g a P a r n a s i a s e n t e n t i a omnia -inmotusque, est et aetas sonus. Exhibuit coeperunt, canis. -Suis minora, tempore fateor neve moderamine erit. Non h o -c r a d i i s , Aenea etiamnum cogeris superinposita -origine nec `hardening_search` dixit vicina. Heros subito -populifer adeunt quicquam cum A L E S Ixion angulus -placet reddere. - - _______ _ -|__ __| | | - | |_ __ __ _ ___| |_ __ _ __ _ _ _ ___ - | | '__/ _` |/ __| __/ _` |/ _` | | | |/ _ \ - | | | | (_| | (__| || (_| | (_| | |_| | __/ - |_|_| \__,_|\___|\__\__,_|\__, |\__,_|\___| - | | - |_| - - - ___ ___ _ __ _ __ ___ _ __ ___ __ _ _ ____ ____ _ -/ __|/ _ \ '__| '_ \ / _ \ '_ \/ __| / _` | '__\ \ / / _` | -\__ \ __/ | | |_) | __/ | | \__ \ | (_| | | \ V / (_| | -|___/\___|_| | .__/ \___|_| |_|___/ \__,_|_| \_/ \__,_| - | | - |_| - - - __ _ _ _ ___ __ _ _ _ ___ - / _` | | | |/ _ \ / _` | | | |/ _ \ -| (_| | |_| | (_) | (_| | |_| | __/ - \__, |\__,_|\___/ \__, |\__,_|\___| - | | | | - |_| |_| - - -Inprudens dum memor alma, casses dedi Sinuessa iam quid -adicit `gate_truncate_wave`. [Geratur de](2), insolida -temptat `open` carentia; genitorem solet, potens. Adit vos -ramos mundi castris quodque plangoremque harum profugi nulla - aures petiit liventia aere qua Hodites ruunt. Achaidos -dilexisse `redundancy` praesagia tenuatus necis C A P Y S - R E C O R D O R tenebras actis signis, reliquit, -placidissime iram liquefactis longe nervi famem. Cura pars -natique velleris semel et at `printerWildcardParallel` -inquit positosque comites ultra. - - ____ _ - / __ \ | | -| | | |_ _ __ _ _ __ | |_ __ _ __ _ _ _ ___ -| | | | | | |/ _` | '_ \| __/ _` |/ _` | | | |/ _ \ -| |__| | |_| | (_| | | | | || (_| | (_| | |_| | __/ - \___\_\\__,_|\__,_|_| |_|\__\__,_|\__, |\__,_|\___| - | | - |_| - _ - | | - __ _ _ _ __ _ ___ _ __ | |_ - / _` | | | |/ _` |/ _ \ '_ \| __| -| (_| | |_| | (_| | __/ | | | |_ - \__,_|\__,_|\__, |\___|_| |_|\__| - __/ | - |___/ - - -Acuti ignotis ignis, ostendit nostras terras nondum, [petit -utque utero](3) sepulcrales, pluma reseratis Ismario. Tria -silentia duae n y m p h e : certe parva cernam et dixit. -[Verborum sedisti](1). - -01 | if (firewall(4, 3) == 1 * device_media_parity) { -02 | system_copy_class += systray + oasis_wi_clean + lpi; -03 | modelLdap.search_secondary_socket *= bounce.proxy_text(cableMyspacePerl, -04 | scanner(spam)); -05 | text_class_traceroute(vga, hdtv); -06 | } -07 | if (commerceStack) { -08 | wrapSafe(pum(itunesRosettaCache, sdramOpticalWorm)); -09 | } else { -10 | minisite_worm(2, ssdString(84), gnuFriendly); -11 | state.qwerty(moduleJavascriptFloating + ugc, koffice, -12 | mail_system.prebinding_service_teraflops(mediaIcmpBoot)); -13 | } -14 | memoryHexadecimalMultitasking += oop_android; -15 | var passive_vdu = bar; -16 | var ddrJava = leaf(unc_web); - - - __ __ _ _ _ -| \/ (_) | |___ _ __ __ _ _ ___ ____ _ -| |\/| | | | / -_) | '_ \/ _` | '_\ V / _` | -|_| |_|_|_|_\___| | .__/\__,_|_| \_/\__,_| - |_| - - - M I N E T U R E T aurum! Charaxi sanguine quo; vires -subnectite P I G N O R A , omnique hostibus loquerentur -aquae Solis ferox? C U M N E , putat utar ex ad aede -mane. Vitalesque geminas exstimulat, iam vivunt dat artes et - quem! Iam violas molire ipse, spoliabitur dente nervis -regis, factos quae. - -| Adit et, aquaticus te dona effodiuntur tellus, fer -| totidem inquit faciat -| carchesia nectar mille? Indestrictus vestes ut et -| offensasque intibaque deum -| agitur linguae maiestas te rictus t e c u m l a n i -| a t q u e d i x i t ad datas `adsl`. -| Vino u t quod iaculi mutua armis uno est vos -| totidemque -| [exhortor](4). Iamdudum Romanae cepi linguisque -| velit accipe idem acutis hoc sparsumque dextra. Minoide -| sponte collo sequitur -| et intus flere sanguineaque herba, nox Quas adsiduo c -| u n c t a n t e m tibi; enim. - -[1] #mille-parva -[2] #salientia-iungit-contra-et -[3] #structa-hinc-vetitus-factum -[4] #tractaque-serpens-arva-quoque diff --git a/main.py b/main.py deleted file mode 100644 index a7a2d7f..0000000 --- a/main.py +++ /dev/null @@ -1,6 +0,0 @@ -def main(): - print("Hello from md2amb!") - - -if __name__ == "__main__": - main() diff --git a/md2txt.py b/md2txt.py index 8630535..279873f 100644 --- a/md2txt.py +++ b/md2txt.py @@ -6,12 +6,38 @@ from __future__ import annotations import argparse import re +import string import sys import textwrap from dataclasses import dataclass from pathlib import Path from typing import Callable, Dict, Iterable, List, Optional, Tuple +try: # pragma: no cover - optional dependency + from hyphen import Hyphenator as _Hyphenator # type: ignore +except ImportError: # pragma: no cover - handled at runtime + _Hyphenator = None # type: ignore[misc] + +if _Hyphenator is not None: # pragma: no branch + Hyphenator = _Hyphenator # type: ignore[assignment] +else: # pragma: no cover - fallback path + try: + import pyphen + except ImportError: # pragma: no cover - handled at runtime + Hyphenator = None # type: ignore[misc, assignment] + else: + class _PyphenWrapper: + def __init__(self, lang: str) -> None: + self._dic = pyphen.Pyphen(lang=lang) + + def hyphenate_word(self, word: str): # type: ignore[override] + inserted = self._dic.inserted(word) + if not inserted: + return [] + return inserted.split("-") + + Hyphenator = _PyphenWrapper # type: ignore[assignment] + try: from pyfiglet import Figlet, FontNotFound except ImportError: # pragma: no cover - emit a helpful error at runtime instead @@ -32,6 +58,29 @@ MMD_ATTR_LINE_RE = re.compile(r"^\{\s*:(.+)\}\s*$") MMD_ATTR_TAIL_RE = re.compile(r"(.*?)\s*\{\s*:(.+?)\}\s*$") +def _parse_int(value: Optional[str], default: int = 0) -> int: + if value is None: + return default + match = re.search(r"-?\d+", value) + if not match: + return default + try: + return int(match.group()) + except ValueError: + return default + + +def _parse_bool(value: Optional[str], default: bool = False) -> bool: + if value is None: + return default + lowered = value.strip().lower() + if lowered in {"true", "yes", "1", "on"}: + return True + if lowered in {"false", "no", "0", "off"}: + return False + return default + + @dataclass class BlockStyle: align: str = "left" @@ -59,6 +108,11 @@ class FrontMatter: h1_font: str = "standard" h2_font: str = "standard" h3_font: str = "standard" + margin_left: int = 0 + margin_right: int = 0 + paragraph_spacing: int = 0 + hyphenate: bool = False + hyphen_lang: str = "en_US" class MarkdownToTxtConverter: @@ -68,7 +122,22 @@ class MarkdownToTxtConverter: self.links: List[Tuple[int, str]] = [] self.link_indices: Dict[str, int] = {} self.figlets: Dict[str, Figlet] = {} - self._style_stack: List[BlockStyle] = [BlockStyle()] + self._base_margin_left = max(0, self.frontmatter.margin_left) + self._base_margin_right = max(0, self.frontmatter.margin_right) + self.paragraph_spacing = max(0, self.frontmatter.paragraph_spacing) + self.hyphenate = self.frontmatter.hyphenate + self.hyphen_lang = self.frontmatter.hyphen_lang or "en_US" + self.hyphenator: Optional[Hyphenator] + if self.hyphenate: + if Hyphenator is None: + raise RuntimeError("PyHyphen is required for hyphenation but is not installed.") + try: + self.hyphenator = Hyphenator(self.hyphen_lang) + except Exception as exc: # pragma: no cover - defensive + raise RuntimeError(f"Failed to initialise hyphenator for language '{self.hyphen_lang}': {exc}") from exc + else: + self.hyphenator = None + self._style_stack: List[BlockStyle] = [self._make_base_style()] self._paragraph_style_spec: Optional[StyleSpec] = None self._pending_block_style_spec: Optional[StyleSpec] = None self._last_stylable_block: Optional[BlockRecord] = None @@ -77,7 +146,7 @@ class MarkdownToTxtConverter: output: List[str] = [] self.links = [] self.link_indices = {} - self._style_stack = [BlockStyle()] + self._style_stack = [self._make_base_style()] self._paragraph_style_spec = None self._pending_block_style_spec = None self._last_stylable_block = None @@ -199,7 +268,8 @@ class MarkdownToTxtConverter: if not line.strip(): self._flush_paragraph(current_paragraph, output) - output.append("") + if self.paragraph_spacing == 0: + output.append("") current_paragraph = [] continue @@ -216,7 +286,14 @@ class MarkdownToTxtConverter: output.append("") for index, url in self.links: entry = f"[{index}] {url}" - output.extend(self._wrap_text(entry, initial_indent="", subsequent_indent="", style=BlockStyle())) + output.extend( + self._wrap_text( + entry, + initial_indent="", + subsequent_indent="", + style=self._make_base_style(), + ) + ) self._last_stylable_block = None return output @@ -230,10 +307,12 @@ class MarkdownToTxtConverter: style = self._combine_styles(self._current_style(), combined_spec) def render(target_style: BlockStyle) -> List[str]: - return self._wrap_text(processed, style=target_style) + return self._wrap_text(processed, style=target_style, hyphenate=self.hyphenate) lines = render(style) self._emit_block(output, lines, stylable=True, render_fn=render, style=style) + if self.paragraph_spacing > 0: + output.extend([ "" for _ in range(self.paragraph_spacing) ]) paragraph_lines.clear() self._paragraph_style_spec = None self._pending_block_style_spec = None @@ -264,6 +343,7 @@ class MarkdownToTxtConverter: initial_indent=indent, subsequent_indent=indent, style=self._current_style(), + hyphenate=self.hyphenate, ) self._emit_block(output, wrapped) @@ -327,6 +407,7 @@ class MarkdownToTxtConverter: initial_indent=prefix, subsequent_indent=" " * len(prefix), style=self._current_style(), + hyphenate=self.hyphenate, ) self._emit_block(output, wrapped) @@ -359,6 +440,13 @@ class MarkdownToTxtConverter: self._last_stylable_block.length = len(new_lines) self._last_stylable_block.style = new_style + def _make_base_style(self) -> BlockStyle: + return BlockStyle( + align="left", + margin_left=self._base_margin_left, + margin_right=self._base_margin_right, + ) + def _current_style(self) -> BlockStyle: return self._style_stack[-1] @@ -588,6 +676,10 @@ class MarkdownToTxtConverter: return clean_text, spec def _render_heading_lines(self, level: int, text: str, style: BlockStyle) -> List[str]: + font_name = getattr(self.frontmatter, f"h{level}_font", "standard") + style_key = font_name.lower() + if style_key in {"caps", "title"}: + return self._render_h4_plus(text, style, transform=style_key) if level <= 3: figlet_lines = self._figlet_render(level, text) if figlet_lines: @@ -608,22 +700,23 @@ class MarkdownToTxtConverter: margin_left = min(max(style.margin_left, 0), self.width - 1) margin_right = max(style.margin_right, 0) available_width = max(1, self.width - margin_left - margin_right) + block_width = max((len(line.rstrip()) for line in lines), default=0) + block_width = min(block_width, available_width) + extra_space = max(0, available_width - block_width) + if style.align == "center": + align_offset = extra_space // 2 + elif style.align == "right": + align_offset = extra_space + else: + align_offset = 0 + max_indent = max(0, self.width - block_width) + indent = min(margin_left + align_offset, max_indent) result: List[str] = [] for line in lines: if not line: result.append("") continue trimmed = line.rstrip() - line_len = len(trimmed) - extra_space = max(0, available_width - line_len) - if style.align == "center": - extra_left = extra_space // 2 - elif style.align == "right": - extra_left = extra_space - else: - extra_left = 0 - max_indent = max(0, self.width - line_len) - indent = min(margin_left + extra_left, max_indent) result.append(" " * indent + trimmed) return result @@ -834,9 +927,14 @@ class MarkdownToTxtConverter: return None return [line.rstrip() for line in trimmed_lines] - def _render_h4_plus(self, text: str, style: BlockStyle) -> List[str]: - uppercase = text.upper() - wrapped = self._wrap_text(uppercase, style=style) + def _render_h4_plus(self, text: str, style: BlockStyle, transform: str = "caps") -> List[str]: + if transform == "title": + processed = self._to_title_case(text) + elif transform == "caps": + processed = text.upper() + else: + processed = text + wrapped = self._wrap_text(processed, style=style) output: List[str] = [] for line in wrapped: line = line.rstrip() @@ -850,18 +948,32 @@ class MarkdownToTxtConverter: output.append("") return output + def _to_title_case(self, value: str) -> str: + return string.capwords(value) + def _wrap_text( self, text: str, initial_indent: str = "", subsequent_indent: Optional[str] = None, style: Optional[BlockStyle] = None, + hyphenate: bool = False, ) -> List[str]: style = style or BlockStyle() margin_left = min(max(style.margin_left, 0), self.width - 1) available_width = max(1, self._effective_width(style)) subsequent = initial_indent if subsequent_indent is None else subsequent_indent + + if hyphenate and self.hyphenator is not None: + return self._wrap_text_hyphenated( + text, + initial_indent, + subsequent, + style, + available_width, + ) + wrapper = textwrap.TextWrapper( width=available_width, expand_tabs=False, @@ -891,6 +1003,135 @@ class MarkdownToTxtConverter: result.append(" " * indent + line) return result + def _wrap_text_hyphenated( + self, + text: str, + initial_indent: str, + subsequent_indent: str, + style: BlockStyle, + available_width: int, + ) -> List[str]: + tokens = re.split(r"(\s+)", text) + lines: List[str] = [] + current_indent = initial_indent + current_line = initial_indent + current_len = len(current_line) + width = available_width + for index, token in enumerate(tokens): + if token == "": + continue + if token.isspace(): + if current_len + len(token) > width and current_len > len(current_indent): + lines.append(current_line.rstrip()) + current_indent = subsequent_indent + current_line = subsequent_indent + current_len = len(current_line) + else: + current_line += token + current_len += len(token) + continue + segments = self._hyphenate_token(token) or [token] + while segments: + remaining = width - current_len + if remaining <= 1: + lines.append(current_line.rstrip()) + current_indent = subsequent_indent + current_line = subsequent_indent + current_len = len(current_line) + continue + + joined_length = sum(len(part) for part in segments) + if current_len + joined_length <= width: + current_line += "".join(segments) + current_len += joined_length + segments = [] + break + + split_index = None + running = 0 + for idx in range(1, len(segments)): + running += len(segments[idx - 1]) + needed = running + 1 # hyphen + if current_len + needed <= width: + split_index = idx + else: + break + + if split_index is None: + # fallback: break the first segment + fragment = segments[0] + force_split = min(len(fragment), remaining - 1) + if force_split <= 0: + lines.append(current_line.rstrip()) + current_indent = subsequent_indent + current_line = subsequent_indent + current_len = len(current_line) + continue + head = fragment[:force_split] + "-" + tail = fragment[force_split:] + current_line += head + lines.append(current_line.rstrip()) + current_indent = subsequent_indent + current_line = subsequent_indent + current_len = len(current_line) + segments[0] = tail + if not tail: + segments.pop(0) + continue + + consumed_segments = segments[:split_index] + current_line += "".join(consumed_segments) + "-" + current_len += sum(len(part) for part in consumed_segments) + 1 + segments = segments[split_index:] + lines.append(current_line.rstrip()) + current_indent = subsequent_indent + current_line = subsequent_indent + current_len = len(current_line) + if current_line.strip(): + lines.append(current_line.rstrip()) + if not lines: + lines.append(initial_indent.rstrip()) + + result: List[str] = [] + margin_left = min(max(style.margin_left, 0), self.width - 1) + width = available_width + for line in lines: + stripped = line.rstrip() + line_len = len(stripped) + extra_space = max(0, width - line_len) + if style.align == "center": + extra_left = extra_space // 2 + elif style.align == "right": + extra_left = extra_space + else: + extra_left = 0 + max_indent = max(0, self.width - line_len) + indent = min(margin_left + extra_left, max_indent) + result.append(" " * indent + stripped) + return result + + def _hyphenate_token(self, token: str) -> Optional[List[str]]: + if self.hyphenator is None: + return None + match = re.match(r"^([^A-Za-zÀ-ÖØ-öø-ÿ'’]*)([A-Za-zÀ-ÖØ-öø-ÿ'’]+)([^A-Za-zÀ-ÖØ-öø-ÿ'’]*)$", token) + if not match: + return None + leading, word, trailing = match.groups() + if len(word) <= 4: + return None + parts = self.hyphenator.hyphenate_word(word) + if not parts: + return None + if isinstance(parts, str): + segments = [segment for segment in parts.split("-") if segment] + else: + segments = [segment for segment in parts if segment] + if len(segments) < 2: + return None + segments[0] = leading + segments[0] + segments[-1] = segments[-1] + trailing + return segments + def _effective_width(self, style: BlockStyle) -> int: margin_left = min(max(style.margin_left, 0), self.width - 1) remaining = self.width - margin_left @@ -913,10 +1154,20 @@ def parse_frontmatter(lines: List[str]) -> Tuple[FrontMatter, List[str]]: if idx >= len(lines): return FrontMatter(), lines remaining = lines[idx + 1 :] if idx + 1 < len(lines) else [] + paragraph_spacing_value = frontmatter.get("paragraph_spacing") + if paragraph_spacing_value is None: + paragraph_spacing_value = frontmatter.get("lines_between_paragraphs") + if paragraph_spacing_value is None: + paragraph_spacing_value = frontmatter.get("paragraph_lines") fm = FrontMatter( - h1_font=frontmatter.get("h1_font", "standard"), - h2_font=frontmatter.get("h2_font", "standard"), - h3_font=frontmatter.get("h3_font", "standard"), + h1_font=frontmatter.get("h1_font", "standard").strip() or "standard", + h2_font=frontmatter.get("h2_font", "standard").strip() or "standard", + h3_font=frontmatter.get("h3_font", "standard").strip() or "standard", + margin_left=_parse_int(frontmatter.get("margin_left"), 0), + margin_right=_parse_int(frontmatter.get("margin_right"), 0), + paragraph_spacing=max(0, _parse_int(paragraph_spacing_value, 0)), + hyphenate=_parse_bool(frontmatter.get("hyphenate"), False), + hyphen_lang=(frontmatter.get("hyphen_lang") or "en_US").strip() or "en_US", ) return fm, remaining diff --git a/output.txt b/output.txt deleted file mode 100644 index 8a59f29..0000000 --- a/output.txt +++ /dev/null @@ -1,163 +0,0 @@ - _ _ _ _ _ _ ___ __ -| |__ / | | | | | ___ __ _ __| (_)_ __ __ _ ( _ ) \ \ -| '_ \| | | |_| |/ _ \/ _` |/ _` | | '_ \ / _` | / _ \ _____| | -| | | | | | _ | __/ (_| | (_| | | | | | (_| | | (_) |_____| | -|_| |_|_| |_| |_|\___|\__,_|\__,_|_|_| |_|\__, | \___/ | | - |___/ /_/ - - _ ____ _ _ _ _ -| |__ |___ \ | | | | ___ __ _ __| (_)_ __ __ _ -| '_ \ __) | | |_| |/ _ \/ _` |/ _` | | '_ \ / _` | -| | | |/ __/ | _ | __/ (_| | (_| | | | | | (_| | -|_| |_|_____| |_| |_|\___|\__,_|\__,_|_|_| |_|\__, | - |___/ - - _ _____ _ _ _ _ -| |__ |___ / | | | | ___ __ _ __| (_)_ __ __ _ -| '_ \ |_ \ | |_| |/ _ \/ _` |/ _` | | '_ \ / _` | -| | | |___) | | _ | __/ (_| | (_| | | | | | (_| | -|_| |_|____/ |_| |_|\___|\__,_|\__,_|_|_| |_|\__, | - |___/ - -H4 HEADING ----------- - -H5 HEADING ----------- - -H6 HEADING ----------- - - - _ _ _ _ _ ____ _ -| | | | ___ _ __(_)_______ _ __ | |_ __ _| | | _ \ _ _| | ___ ___ -| |_| |/ _ \| '__| |_ / _ \| '_ \| __/ _` | | | |_) | | | | |/ _ \/ __| -| _ | (_) | | | |/ / (_) | | | | || (_| | | | _ <| |_| | | __/\__ \ -|_| |_|\___/|_| |_/___\___/|_| |_|\__\__,_|_| |_| \_\\__,_|_|\___||___/ - - - --------------------------------------------------------------------------------- - --------------------------------------------------------------------------------- - --------------------------------------------------------------------------------- - - _____ _ _ -| ____|_ __ ___ _ __ | |__ __ _ ___(_)___ -| _| | '_ ` _ \| '_ \| '_ \ / _` / __| / __| -| |___| | | | | | |_) | | | | (_| \__ \ \__ \ -|_____|_| |_| |_| .__/|_| |_|\__,_|___/_|___/ - |_| - - -T H I S I S B O L D T E X T _T_H_I_S___I_S___B_O_L_D___T_E_X_T_ T h i s -i s i t a l i c t e x t _T_h_i_s___i_s___i_t_a_l_i_c___t_e_x_t_ --S-t-r-i-k-e-t-h-r-o-u-g-h- - - ____ _ _ _ -| __ )| | ___ ___| | ____ _ _ _ ___ | |_ ___ ___ -| _ \| |/ _ \ / __| |/ / _` | | | |/ _ \| __/ _ \/ __| -| |_) | | (_) | (__| < (_| | |_| | (_) | || __/\__ \ -|____/|_|\___/ \___|_|\_\__, |\__,_|\___/ \__\___||___/ - |_| - - - | Blockquotes can also be nested... - | | by using additional greater-than signs right next to each other... - | | | or with spaces between arrows. - - _ _ _ -| | (_)___| |_ ___ -| | | / __| __/ __| -| |___| \__ \ |_\__ \ -|_____|_|___/\__|___/ - - - - _ _ _ _ -| | | |_ __ ___ _ __ __| | ___ _ __ ___ __| | -| | | | '_ \ / _ \| '__/ _` |/ _ \ '__/ _ \/ _` | -| |_| | | | | (_) | | | (_| | __/ | | __/ (_| | - \___/|_| |_|\___/|_| \__,_|\___|_| \___|\__,_| - - - -+ Create a list by starting a line with `+`, `-`, or `*` -- Marker character change forces new list start: -* Ac tristique libero volutpat at -+ Facilisis in pretium nisl aliquet -- Nulla volutpat aliquam velit -+ Very easy - - ___ _ _ - / _ \ _ __ __| | ___ _ __ ___ __| | -| | | | '__/ _` |/ _ \ '__/ _ \/ _` | -| |_| | | | (_| | __/ | | __/ (_| | - \___/|_| \__,_|\___|_| \___|\__,_| - - - -1. Lorem ipsum dolor sit amet -2. Consectetur adipiscing elit -3. Integer molestie lorem at massa - - ____ _ - / ___|___ __| | ___ -| | / _ \ / _` |/ _ \ -| |__| (_) | (_| | __/ - \____\___/ \__,_|\___| - - - -Inline `code` - -Indented code - -01 | // Some comments -02 | line 1 of code -03 | line 2 of code -04 | line 3 of code - - - -Block code "fences" - -01 | Sample text here... - - -Syntax highlighting - -01 | var foo = function (bar) { -02 | return bar++; -03 | }; -04 | -05 | console.log(foo(5)); - - - _ _ _ -| | (_)_ __ | | _____ -| | | | '_ \| |/ / __| -| |___| | | | | <\__ \ -|_____|_|_| |_|_|\_\___/ - - - -[link text](1) - -[link with title](2) - - ___ -|_ _|_ __ ___ __ _ __ _ ___ ___ - | || '_ ` _ \ / _` |/ _` |/ _ \/ __| - | || | | | | | (_| | (_| | __/\__ \ -|___|_| |_| |_|\__,_|\__, |\___||___/ - |___/ - - -[Image: Minion](3) [Image: Stormtroopocat](4) - -[1] http://dev.nodeca.com -[2] http://nodeca.github.io/pica/demo/ -[3] https://octodex.github.com/images/minion.png -[4] https://octodex.github.com/images/stormtroopocat.jpg diff --git a/pyproject.toml b/pyproject.toml index 3d06b88..e813fa1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,4 +6,5 @@ readme = "README.md" requires-python = ">=3.13" dependencies = [ "pyfiglet>=0.8.0", + "pyphen>=0.17.2", ] diff --git a/requirements.txt b/requirements.txt index 9689b2f..da649e0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ +pyphen>=0.17.2 pyfiglet>=0.8.0 diff --git a/uv.lock b/uv.lock index e6974c8..3b1bd70 100644 --- a/uv.lock +++ b/uv.lock @@ -8,10 +8,14 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "pyfiglet" }, + { name = "pyphen" }, ] [package.metadata] -requires-dist = [{ name = "pyfiglet", specifier = ">=0.8.0" }] +requires-dist = [ + { name = "pyfiglet", specifier = ">=0.8.0" }, + { name = "pyphen", specifier = ">=0.17.2" }, +] [[package]] name = "pyfiglet" @@ -21,3 +25,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/c8/e3/0a86276ad2c383ce0 wheels = [ { url = "https://files.pythonhosted.org/packages/9f/5c/fe9f95abd5eaedfa69f31e450f7e2768bef121dbdf25bcddee2cd3087a16/pyfiglet-1.0.4-py3-none-any.whl", hash = "sha256:65b57b7a8e1dff8a67dc8e940a117238661d5e14c3e49121032bd404d9b2b39f", size = 1806118, upload-time = "2025-08-15T18:32:45.556Z" }, ] + +[[package]] +name = "pyphen" +version = "0.17.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/56/e4d7e1bd70d997713649c5ce530b2d15a5fc2245a74ca820fc2d51d89d4d/pyphen-0.17.2.tar.gz", hash = "sha256:f60647a9c9b30ec6c59910097af82bc5dd2d36576b918e44148d8b07ef3b4aa3", size = 2079470, upload-time = "2025-01-20T13:18:36.296Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/1f/c2142d2edf833a90728e5cdeb10bdbdc094dde8dbac078cee0cf33f5e11b/pyphen-0.17.2-py3-none-any.whl", hash = "sha256:3a07fb017cb2341e1d9ff31b8634efb1ae4dc4b130468c7c39dd3d32e7c3affd", size = 2079358, upload-time = "2025-01-20T13:18:29.629Z" }, +]