From de6e99c24a4a007048f872ebf53b631cb23d8802 Mon Sep 17 00:00:00 2001 From: randogoth Date: Mon, 20 Oct 2025 11:40:17 +0300 Subject: [PATCH] modularized --- examples/loremipsum.md | 4 +- markdown_parser.py | 542 ++++++++++++++++++ md2txt.py | 1238 +--------------------------------------- md_types.py | 87 +++ text_renderer.py | 762 +++++++++++++++++++++++++ 5 files changed, 1416 insertions(+), 1217 deletions(-) create mode 100644 markdown_parser.py create mode 100644 md_types.py create mode 100644 text_renderer.py diff --git a/examples/loremipsum.md b/examples/loremipsum.md index d1769af..b892d9b 100644 --- a/examples/loremipsum.md +++ b/examples/loremipsum.md @@ -2,8 +2,8 @@ h1_font: big h2_font: small h3_font: caps -margin_left: 8 -margin_right: 8 +margin_left: 12 +margin_right: 12 paragraph_spacing: 2 hyphenate: true --- diff --git a/markdown_parser.py b/markdown_parser.py new file mode 100644 index 0000000..6ed08be --- /dev/null +++ b/markdown_parser.py @@ -0,0 +1,542 @@ +from __future__ import annotations + +import re +from typing import Iterable, Iterator, List, Optional, Union + +from md_types import ( + BlockEvent, + BlockKind, + BlockQuotePayload, + BlockStyle, + CodeBlockPayload, + HeadingPayload, + ListItemPayload, + ParagraphPayload, + StyleSpec, + StyleUpdateEvent, +) + + +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*$") + + +class MarkdownParser: + def __init__(self, base_style: BlockStyle) -> None: + self._base_style = base_style + 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: bool = False + + def parse(self, lines: Iterable[str]) -> Iterator[Union[BlockEvent, StyleUpdateEvent]]: + self._reset_state() + 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("```"): + event = self._flush_code_block(code_lines) + if event is not None: + yield event + 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(): + event = self._flush_code_block(indented_code_lines) + if event is not None: + yield event + indented_code_lines = [] + else: + event = self._flush_code_block(indented_code_lines) + if event is not None: + yield event + indented_code_lines = [] + + inline_para = INLINE_PARA_RE.match(line.strip()) + if inline_para: + event = self._flush_paragraph(current_paragraph) + if event is not None: + yield event + 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) + event = self._flush_paragraph(current_paragraph) + if event is not None: + yield event + 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: + event = self._flush_paragraph(current_paragraph) + if event is not None: + yield event + 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: + yield StyleUpdateEvent(spec) + else: + self._pending_block_style_spec = self._merge_specs(self._pending_block_style_spec, spec) + continue + + if line.strip().startswith("```"): + event = self._flush_paragraph(current_paragraph) + if event is not None: + yield event + current_paragraph = [] + in_code_block = True + code_lines = [] + continue + + if line.startswith(" "): + event = self._flush_paragraph(current_paragraph) + if event is not None: + yield event + current_paragraph = [] + indented_code_lines = [line[4:]] + continue + + heading_match = HEADING_PATTERN.match(line) + if heading_match: + event = self._flush_paragraph(current_paragraph) + if event is not None: + yield event + 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) + self._pending_block_style_spec = None + self._last_stylable_block = True + yield BlockEvent( + kind=BlockKind.HEADING, + payload=HeadingPayload(level=level, text=heading_text), + style=style, + stylable=True, + ) + continue + + if HORIZONTAL_RULE_PATTERN.match(line): + event = self._flush_paragraph(current_paragraph) + if event is not None: + yield event + current_paragraph = [] + self._last_stylable_block = False + yield BlockEvent( + kind=BlockKind.HORIZONTAL_RULE, + payload=None, + style=self._clone_style(), + stylable=False, + ) + continue + + if BLOCKQUOTE_PATTERN.match(line): + event = self._flush_paragraph(current_paragraph) + if event is not None: + yield event + current_paragraph = [] + quote_event = self._parse_blockquote(line) + if quote_event is not None: + yield quote_event + continue + + if UNORDERED_LIST_PATTERN.match(line) or ORDERED_LIST_PATTERN.match(line): + event = self._flush_paragraph(current_paragraph) + if event is not None: + yield event + current_paragraph = [] + list_event = self._parse_list_line(line) + if list_event is not None: + yield list_event + continue + + if not line.strip(): + event = self._flush_paragraph(current_paragraph) + if event is not None: + yield event + yield BlockEvent( + kind=BlockKind.BLANK_LINE, + payload=None, + style=self._clone_style(), + stylable=False, + ) + current_paragraph = [] + continue + + current_paragraph.append(line) + + event = self._flush_paragraph(current_paragraph) + if event is not None: + yield event + if in_code_block: + final_event = self._flush_code_block(code_lines) + if final_event is not None: + yield final_event + if indented_code_lines: + final_event = self._flush_code_block(indented_code_lines) + if final_event is not None: + yield final_event + + def _reset_state(self) -> None: + self._style_stack = [self._make_base_style()] + self._paragraph_style_spec = None + self._pending_block_style_spec = None + self._last_stylable_block = False + + def _flush_paragraph(self, paragraph_lines: List[str]) -> Optional[BlockEvent]: + if not paragraph_lines: + return None + text = " ".join(line.strip() for line in paragraph_lines) + combined_spec = self._merge_specs(self._pending_block_style_spec, self._paragraph_style_spec) + style = self._combine_styles(self._current_style(), combined_spec) + + paragraph_lines.clear() + self._paragraph_style_spec = None + self._pending_block_style_spec = None + self._last_stylable_block = True + return BlockEvent( + kind=BlockKind.PARAGRAPH, + payload=ParagraphPayload(text=text), + style=style, + stylable=True, + ) + + def _flush_code_block(self, code_lines: List[str]) -> Optional[BlockEvent]: + if not code_lines: + return None + lines = code_lines.copy() + code_lines.clear() + self._last_stylable_block = False + return BlockEvent( + kind=BlockKind.CODE_BLOCK, + payload=CodeBlockPayload(lines=lines), + style=self._clone_style(), + stylable=False, + ) + + def _parse_blockquote(self, line: str) -> Optional[BlockEvent]: + content = line + depth = 0 + while content.lstrip().startswith(">"): + depth += 1 + content = content.lstrip()[1:] + text = content.lstrip() + self._last_stylable_block = False + return BlockEvent( + kind=BlockKind.BLOCKQUOTE, + payload=BlockQuotePayload(depth=max(1, depth), text=text), + style=self._clone_style(), + stylable=False, + ) + + def _parse_list_line(self, line: str) -> Optional[BlockEvent]: + ordered = ORDERED_LIST_PATTERN.match(line) + unordered = UNORDERED_LIST_PATTERN.match(line) + if ordered: + indent, marker, spacing, rest = ordered.groups() + ordered_flag = True + elif unordered: + indent, marker, spacing, rest = unordered.groups() + ordered_flag = False + else: + return None + self._last_stylable_block = False + return BlockEvent( + kind=BlockKind.LIST_ITEM, + payload=ListItemPayload( + indent=indent, + marker=marker, + spacing=spacing, + text=rest, + ordered=ordered_flag, + ), + style=self._clone_style(), + stylable=False, + ) + + def _make_base_style(self) -> BlockStyle: + return BlockStyle( + align=self._base_style.align, + margin_left=self._base_style.margin_left, + margin_right=self._base_style.margin_right, + ) + + def _current_style(self) -> BlockStyle: + return self._style_stack[-1] + + def _clone_style(self) -> BlockStyle: + style = self._current_style() + return BlockStyle( + align=style.align, + margin_left=style.margin_left, + margin_right=style.margin_right, + ) + + 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 = {name.lower(): value.strip().strip("\"'") for name, value in attr_pattern.findall(attributes)} + + 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): + 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): + 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 diff --git a/md2txt.py b/md2txt.py index 858e42a..772b500 100644 --- a/md2txt.py +++ b/md2txt.py @@ -6,56 +6,16 @@ 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 +from typing import 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 - Figlet = None # type: ignore[assignment] - FontNotFound = ValueError # type: ignore[assignment] +from md_types import BlockStyle, FrontMatter +from markdown_parser import MarkdownParser +from text_renderer import TextRenderer 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*$") def _parse_int(value: Optional[str], default: int = 0) -> int: @@ -81,1180 +41,28 @@ def _parse_bool(value: Optional[str], default: bool = False) -> bool: return default -@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 = "small" - h2_font: str = "caps" - h3_font: str = "title" - margin_left: int = 2 - margin_right: int = 2 - paragraph_spacing: int = 2 - hyphenate: bool = False - hyphen_lang: str = "en_US" - figlet_fallback: bool = False - header_spacing: int = 2 - - -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._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.figlet_fallback = self.frontmatter.figlet_fallback - self.header_spacing = max(0, self.frontmatter.header_spacing) - 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 - - def convert(self, lines: Iterable[str]) -> List[str]: - output: List[str] = [] - self.links = [] - self.link_indices = {} - self._style_stack = [self._make_base_style()] - 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() - if output and self.header_spacing > 0: - self._ensure_header_spacing(output) - 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) - if self.paragraph_spacing == 0: - 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=self._make_base_style(), - ) - ) - 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, 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 - - 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] = [] - style = self._current_style() - margin_indent = " " * max(0, style.margin_left) - for idx, line in enumerate(code_lines, start=1): - formatted.append(f"{margin_indent}{idx:0{width}d} | {line}") - formatted.append(margin_indent if margin_indent else "") - 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(), - hyphenate=self.hyphenate, - ) - 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(), - hyphenate=self.hyphenate, - ) - 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 _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] - - 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]: - 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._render_figlet_heading(level, text, style) - if figlet_lines is not None: - return figlet_lines + [""] - return self._render_h4_plus(text, style) - - def _render_figlet_heading(self, level: int, text: str, style: BlockStyle) -> Optional[List[str]]: - if Figlet is None: - return None - font_name = getattr(self.frontmatter, f"h{level}_font", "standard") - cache_key = (font_name, "wide") - figlet = self.figlets.get(cache_key) - if figlet is None: - try: - figlet = Figlet(font=font_name, width=max(self.width, 100000)) - except (FontNotFound, TypeError): - return None - self.figlets[cache_key] = figlet - - words = text.split() - if not words: - return [] - - available_width = self._effective_width(style) - justify = self._figlet_justify(style.align) - - render_key = (font_name, available_width, justify) - render_figlet = self.figlets.get(render_key) - if render_figlet is None: - try: - render_figlet = Figlet(font=font_name, width=available_width, justify=justify) - except (FontNotFound, TypeError): - return None - self.figlets[render_key] = render_figlet - - rendered = render_figlet.renderText(text).rstrip("\n").splitlines() - if not rendered: - return [] - - lines = [line.rstrip("\n") for line in rendered] - overflow_detected = any(len(line.rstrip()) > available_width for line in lines) - if overflow_detected and self.figlet_fallback: - return None - - margin_left = min(max(style.margin_left, 0), self.width - 1) - indent_str = " " * margin_left - return [indent_str + line.rstrip() for line in lines] - - def _figlet_justify(self, align: str) -> str: - if align == "center": - return "center" - if align == "right": - return "right" - return "left" - - 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] = [] - block: List[str] = [] - - def flush_block() -> None: - if not block: - return - trimmed = [entry.rstrip("\n").rstrip() for entry in block] - block_width = max((len(line) for line in trimmed), default=0) - 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) - indent_str = " " * indent - for line in trimmed: - result.append(indent_str + line) - block.clear() - - for line in lines: - if line.strip(): - block.append(line) - else: - flush_block() - result.append("") - flush_block() - return result - - def _ensure_header_spacing(self, output: List[str]) -> None: - if self.header_spacing <= 0: - return - existing = 0 - for line in reversed(output): - if line.strip() == "": - existing += 1 - if existing >= self.header_spacing: - return - else: - break - output.extend([""] * (self.header_spacing - existing)) - - 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, allow_overflow: bool = False) -> 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: - return None - if not allow_overflow and max(len(line.rstrip()) for line in trimmed_lines) > self.width: - return None - return [line.rstrip() for line in trimmed_lines] - - def _align_figlet_block(self, lines: List[str], style: BlockStyle) -> List[str]: - 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) - - raw = [line.rstrip() for line in lines] - non_empty = [line for line in raw if line.strip()] - if non_empty: - leading = min(len(line) - len(line.lstrip(" ")) for line in non_empty) - else: - leading = 0 - stripped = [line[leading:] if line.strip() else "" for line in raw] - block_width = max((len(line) for line in stripped if line.strip()), default=0) - 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) - indent_str = " " * indent - - aligned: List[str] = [] - for line in stripped: - if not line.strip(): - aligned.append("") - continue - aligned.append(indent_str + line) - return aligned - - 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() - 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 _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, - 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 _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 - margin_right = min(max(style.margin_right, 0), max(0, remaining - 1)) - return max(1, self.width - margin_left - margin_right) +def convert_markdown( + lines: Iterable[str], + *, + width: int, + frontmatter: FrontMatter, +) -> List[str]: + base_style = BlockStyle( + align="left", + margin_left=max(0, frontmatter.margin_left), + margin_right=max(0, frontmatter.margin_right), + ) + parser = MarkdownParser(base_style) + renderer = TextRenderer(width=width, frontmatter=frontmatter) + for event in parser.parse(lines): + renderer.handle_event(event) + return renderer.finalize() 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] = {} + frontmatter: dict[str, str] = {} idx = 1 while idx < len(lines): if FRONTMATTER_PATTERN.match(lines[idx]): @@ -1313,11 +121,11 @@ def main(argv: Optional[List[str]] = None) -> int: 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) + converted_lines = convert_markdown(content, width=args.width, frontmatter=frontmatter) write_output(args.output, converted_lines) return 0 if __name__ == "__main__": raise SystemExit(main()) + diff --git a/md_types.py b/md_types.py new file mode 100644 index 0000000..4535d2e --- /dev/null +++ b/md_types.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import List, Optional + + +class BlockKind(Enum): + PARAGRAPH = "paragraph" + HEADING = "heading" + CODE_BLOCK = "code_block" + BLOCKQUOTE = "blockquote" + LIST_ITEM = "list_item" + HORIZONTAL_RULE = "horizontal_rule" + BLANK_LINE = "blank_line" + + +@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 FrontMatter: + h1_font: str = "small" + h2_font: str = "caps" + h3_font: str = "title" + margin_left: int = 2 + margin_right: int = 2 + paragraph_spacing: int = 2 + hyphenate: bool = False + hyphen_lang: str = "en_US" + figlet_fallback: bool = False + header_spacing: int = 2 + + +@dataclass +class ParagraphPayload: + text: str + + +@dataclass +class HeadingPayload: + level: int + text: str + + +@dataclass +class CodeBlockPayload: + lines: List[str] + + +@dataclass +class BlockQuotePayload: + depth: int + text: str + + +@dataclass +class ListItemPayload: + indent: str + marker: str + spacing: str + text: str + ordered: bool + + +@dataclass +class BlockEvent: + kind: BlockKind + payload: object + style: BlockStyle + stylable: bool = False + + +@dataclass +class StyleUpdateEvent: + spec: StyleSpec diff --git a/text_renderer.py b/text_renderer.py new file mode 100644 index 0000000..b5d9363 --- /dev/null +++ b/text_renderer.py @@ -0,0 +1,762 @@ +from __future__ import annotations + +import re +import string +import textwrap +from dataclasses import dataclass +from typing import Callable, Dict, List, Optional + +from md_types import ( + BlockEvent, + BlockKind, + BlockQuotePayload, + BlockStyle, + CodeBlockPayload, + FrontMatter, + HeadingPayload, + ListItemPayload, + ParagraphPayload, + StyleSpec, + StyleUpdateEvent, +) + +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 + Figlet = None # type: ignore[assignment] + FontNotFound = ValueError # type: ignore[assignment] + + +@dataclass +class BlockRecord: + start: int + length: int + render: Callable[[BlockStyle], List[str]] + style: BlockStyle + + +class TextRenderer: + def __init__(self, width: int, frontmatter: FrontMatter) -> None: + self.width = width + self.frontmatter = frontmatter + self.links: List[tuple[int, str]] = [] + self.link_indices: Dict[str, int] = {} + self.figlets: Dict[tuple, Figlet] = {} + self.output: List[str] = [] + self.paragraph_spacing = max(0, frontmatter.paragraph_spacing) + self.hyphenate = frontmatter.hyphenate + self.hyphen_lang = frontmatter.hyphen_lang or "en_US" + self.figlet_fallback = frontmatter.figlet_fallback + self.header_spacing = max(0, frontmatter.header_spacing) + self._base_style = BlockStyle( + align="left", + margin_left=max(0, frontmatter.margin_left), + margin_right=max(0, frontmatter.margin_right), + ) + self._last_stylable_block: Optional[BlockRecord] = None + 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 + + def handle_event(self, event: BlockEvent | StyleUpdateEvent) -> None: + if isinstance(event, BlockEvent): + self._handle_block_event(event) + else: + self._apply_style_to_last_block(event.spec) + + def finalize(self) -> List[str]: + if self.links: + if self.output and self.output[-1] != "": + self.output.append("") + for index, url in self.links: + entry = f"[{index}] {url}" + self.output.extend( + self._wrap_text( + entry, + initial_indent="", + subsequent_indent="", + style=self._base_style, + ) + ) + self._last_stylable_block = None + return self.output + + def _handle_block_event(self, event: BlockEvent) -> None: + if event.kind is BlockKind.PARAGRAPH: + self._render_paragraph(event.payload, event.style) + elif event.kind is BlockKind.HEADING: + self._render_heading(event.payload, event.style) + elif event.kind is BlockKind.CODE_BLOCK: + self._render_code_block(event.payload, event.style) + elif event.kind is BlockKind.BLOCKQUOTE: + self._render_blockquote(event.payload, event.style) + elif event.kind is BlockKind.LIST_ITEM: + self._render_list_item(event.payload, event.style) + elif event.kind is BlockKind.HORIZONTAL_RULE: + self._render_horizontal_rule(event.style) + elif event.kind is BlockKind.BLANK_LINE: + self._render_blank_line() + else: # pragma: no cover - defensive default + self._last_stylable_block = None + + def _render_paragraph(self, payload: ParagraphPayload, style: BlockStyle) -> None: + processed = self._process_inline(payload.text) + + def render(target_style: BlockStyle) -> List[str]: + return self._wrap_text(processed, style=target_style, hyphenate=self.hyphenate) + + lines = render(style) + self._emit_block(lines, stylable=True, render_fn=render, style=style) + if self.paragraph_spacing > 0: + self.output.extend([""] * self.paragraph_spacing) + + def _render_heading(self, payload: HeadingPayload, style: BlockStyle) -> None: + self._ensure_header_spacing() + + def render(target_style: BlockStyle) -> List[str]: + return self._render_heading_lines(payload.level, payload.text, target_style) + + lines = render(style) + self._emit_block(lines, stylable=True, render_fn=render, style=style) + + def _render_code_block(self, payload: CodeBlockPayload, style: BlockStyle) -> None: + lines = self._format_code_block(payload.lines, style) + self._emit_block(lines, stylable=False) + + def _render_blockquote(self, payload: BlockQuotePayload, style: BlockStyle) -> None: + processed = self._process_inline(payload.text) + indent_unit = " | " + indent = indent_unit * max(1, payload.depth) + wrapped = self._wrap_text( + processed, + initial_indent=indent, + subsequent_indent=indent, + style=style, + hyphenate=self.hyphenate, + ) + self._emit_block(wrapped, stylable=False) + + def _render_list_item(self, payload: ListItemPayload, style: BlockStyle) -> None: + prefix = f"{payload.indent}{payload.marker}{payload.spacing}" + processed = self._process_inline(payload.text) + wrapped = self._wrap_text( + processed, + initial_indent=prefix, + subsequent_indent=" " * len(prefix), + style=style, + hyphenate=self.hyphenate, + ) + self._emit_block(wrapped, stylable=False) + + def _render_horizontal_rule(self, style: BlockStyle) -> None: + line = self._render_horizontal_rule_line(style) + self._emit_block([line], stylable=False) + + def _render_blank_line(self) -> None: + if self.paragraph_spacing == 0: + self.output.append("") + + def _emit_block( + self, + lines: List[str], + *, + stylable: bool, + render_fn: Optional[Callable[[BlockStyle], List[str]]] = None, + style: Optional[BlockStyle] = None, + ) -> None: + if not lines: + return + start = len(self.output) + self.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, 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 + self.output[start:end] = new_lines + self._last_stylable_block.length = len(new_lines) + self._last_stylable_block.style = new_style + + 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 _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._render_figlet_heading(level, text, style) + if figlet_lines is not None: + return figlet_lines + [""] + return self._render_h4_plus(text, style) + + def _render_figlet_heading(self, level: int, text: str, style: BlockStyle) -> Optional[List[str]]: + if Figlet is None: + return None + font_name = getattr(self.frontmatter, f"h{level}_font", "standard") + cache_key = (font_name, "wide") + figlet = self.figlets.get(cache_key) + if figlet is None: + try: + figlet = Figlet(font=font_name, width=max(self.width, 100000)) + except (FontNotFound, TypeError): + return None + self.figlets[cache_key] = figlet + + if not text.split(): + return [] + + available_width = self._effective_width(style) + justify = self._figlet_justify(style.align) + + render_key = (font_name, available_width, justify) + render_figlet = self.figlets.get(render_key) + if render_figlet is None: + try: + render_figlet = Figlet(font=font_name, width=available_width, justify=justify) + except (FontNotFound, TypeError): + return None + self.figlets[render_key] = render_figlet + + rendered = render_figlet.renderText(text).rstrip("\n").splitlines() + if not rendered: + return [] + + lines = [line.rstrip("\n") for line in rendered] + overflow_detected = any(len(line.rstrip()) > available_width for line in lines) + if overflow_detected and self.figlet_fallback: + return None + + margin_left = min(max(style.margin_left, 0), self.width - 1) + indent_str = " " * margin_left + return [indent_str + line.rstrip() for line in lines] + + def _figlet_justify(self, align: str) -> str: + if align == "center": + return "center" + if align == "right": + return "right" + return "left" + + 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() + 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 _to_title_case(self, value: str) -> str: + return string.capwords(value) + + def _render_horizontal_rule_line(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 _format_code_block(self, lines: List[str], style: BlockStyle) -> List[str]: + if not lines: + return [] + width = max(2, len(str(len(lines)))) + margin_indent = " " * max(0, style.margin_left) + formatted: List[str] = [] + for idx, line in enumerate(lines, start=1): + formatted.append(f"{margin_indent}{idx:0{width}d} | {line}") + formatted.append(margin_indent if margin_indent else "") + return formatted + + 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: + 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 _stylize_letters(self, content: str, transform: str = "preserve") -> str: + if not content: + return "" + result: List[str] = [] + previous_alnum = False + pending_delimiter: str = "" + + def apply_transform(char: str) -> str: + if transform == "upper": + return char.upper() + if transform == "lower": + return char.lower() + return char + + for char in content: + processed = apply_transform(char) + if processed.isspace(): + if result and result[-1].isalnum(): + pending_delimiter = " " + previous_alnum = False + continue + if processed.isalnum(): + if previous_alnum: + result.append(" ") + elif pending_delimiter: + result.append(pending_delimiter) + pending_delimiter = "" + elif result: + result.append(" ") + result.append(processed) + previous_alnum = True + else: + if result and result[-1] == " ": + result.pop() + if pending_delimiter: + result.append(pending_delimiter.strip()) + pending_delimiter = "" + result.append(processed) + previous_alnum = False + stylized = "".join(result) + return stylized.strip() + + def _stylize_delimited( + self, + content: str, + delimiter: str, + transform: str = "preserve", + word_repeat: int = 2, + ) -> str: + def apply_transform(char: str) -> str: + if transform == "upper": + return char.upper() + if transform == "lower": + return char.lower() + return char + + output: List[str] = [] + open_sequence = False + pending_gap = False + + for char in content: + if char.isspace(): + if open_sequence: + pending_gap = True + continue + + processed = apply_transform(char) + if not open_sequence: + output.append(delimiter) + open_sequence = True + else: + repeat = word_repeat if pending_gap else 1 + output.append(delimiter * repeat) + output.append(processed) + pending_gap = False + + if not open_sequence: + return delimiter * 2 + + output.append(delimiter) + return "".join(output) + + def _handle_link(self, match: re.Match[str]) -> str: + text, target = match.groups() + url, _title = self._split_link_target(target) + index = self._register_link(url) + return f"[{text}]({index})" + + def _handle_image(self, match: re.Match[str]) -> str: + alt_text, target = match.groups() + url, _title = self._split_link_target(target) + display_text = alt_text or "Image" + index = self._register_link(url) + return f"[Image: {display_text}]({index})" + + def _split_link_target(self, value: str) -> tuple[str, Optional[str]]: + value = value.strip() + if not value: + return "", None + if " " not in value: + return value.strip(), None + url, remainder = value.split(" ", 1) + remainder = remainder.strip() + if remainder.startswith('"') and remainder.endswith('"'): + return url.strip(), remainder.strip('"') + return url.strip(), remainder + + def _register_link(self, url: str) -> int: + if url in self.link_indices: + return self.link_indices[url] + index = len(self.links) + 1 + self.links.append((index, url)) + self.link_indices[url] = index + return index + + def _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, + 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 _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: + 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 + margin_right = min(max(style.margin_right, 0), max(0, remaining - 1)) + return max(1, self.width - margin_left - margin_right) + + def _ensure_header_spacing(self) -> None: + if self.header_spacing <= 0: + return + existing = 0 + for line in reversed(self.output): + if line.strip() == "": + existing += 1 + if existing >= self.header_spacing: + return + else: + break + self.output.extend([""] * (self.header_spacing - existing)) +