improvements

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

293
md2txt.py
View file

@ -6,12 +6,38 @@ from __future__ import annotations
import argparse
import re
import string
import sys
import textwrap
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Dict, Iterable, List, Optional, Tuple
try: # pragma: no cover - optional dependency
from hyphen import Hyphenator as _Hyphenator # type: ignore
except ImportError: # pragma: no cover - handled at runtime
_Hyphenator = None # type: ignore[misc]
if _Hyphenator is not None: # pragma: no branch
Hyphenator = _Hyphenator # type: ignore[assignment]
else: # pragma: no cover - fallback path
try:
import pyphen
except ImportError: # pragma: no cover - handled at runtime
Hyphenator = None # type: ignore[misc, assignment]
else:
class _PyphenWrapper:
def __init__(self, lang: str) -> None:
self._dic = pyphen.Pyphen(lang=lang)
def hyphenate_word(self, word: str): # type: ignore[override]
inserted = self._dic.inserted(word)
if not inserted:
return []
return inserted.split("-")
Hyphenator = _PyphenWrapper # type: ignore[assignment]
try:
from pyfiglet import Figlet, FontNotFound
except ImportError: # pragma: no cover - emit a helpful error at runtime instead
@ -32,6 +58,29 @@ MMD_ATTR_LINE_RE = re.compile(r"^\{\s*:(.+)\}\s*$")
MMD_ATTR_TAIL_RE = re.compile(r"(.*?)\s*\{\s*:(.+?)\}\s*$")
def _parse_int(value: Optional[str], default: int = 0) -> int:
if value is None:
return default
match = re.search(r"-?\d+", value)
if not match:
return default
try:
return int(match.group())
except ValueError:
return default
def _parse_bool(value: Optional[str], default: bool = False) -> bool:
if value is None:
return default
lowered = value.strip().lower()
if lowered in {"true", "yes", "1", "on"}:
return True
if lowered in {"false", "no", "0", "off"}:
return False
return default
@dataclass
class BlockStyle:
align: str = "left"
@ -59,6 +108,11 @@ class FrontMatter:
h1_font: str = "standard"
h2_font: str = "standard"
h3_font: str = "standard"
margin_left: int = 0
margin_right: int = 0
paragraph_spacing: int = 0
hyphenate: bool = False
hyphen_lang: str = "en_US"
class MarkdownToTxtConverter:
@ -68,7 +122,22 @@ class MarkdownToTxtConverter:
self.links: List[Tuple[int, str]] = []
self.link_indices: Dict[str, int] = {}
self.figlets: Dict[str, Figlet] = {}
self._style_stack: List[BlockStyle] = [BlockStyle()]
self._base_margin_left = max(0, self.frontmatter.margin_left)
self._base_margin_right = max(0, self.frontmatter.margin_right)
self.paragraph_spacing = max(0, self.frontmatter.paragraph_spacing)
self.hyphenate = self.frontmatter.hyphenate
self.hyphen_lang = self.frontmatter.hyphen_lang or "en_US"
self.hyphenator: Optional[Hyphenator]
if self.hyphenate:
if Hyphenator is None:
raise RuntimeError("PyHyphen is required for hyphenation but is not installed.")
try:
self.hyphenator = Hyphenator(self.hyphen_lang)
except Exception as exc: # pragma: no cover - defensive
raise RuntimeError(f"Failed to initialise hyphenator for language '{self.hyphen_lang}': {exc}") from exc
else:
self.hyphenator = None
self._style_stack: List[BlockStyle] = [self._make_base_style()]
self._paragraph_style_spec: Optional[StyleSpec] = None
self._pending_block_style_spec: Optional[StyleSpec] = None
self._last_stylable_block: Optional[BlockRecord] = None
@ -77,7 +146,7 @@ class MarkdownToTxtConverter:
output: List[str] = []
self.links = []
self.link_indices = {}
self._style_stack = [BlockStyle()]
self._style_stack = [self._make_base_style()]
self._paragraph_style_spec = None
self._pending_block_style_spec = None
self._last_stylable_block = None
@ -199,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