md2txt/text_renderer.py

1012 lines
37 KiB
Python
Raw Normal View History

2025-10-20 11:40:17 +03:00
from __future__ import annotations
import re
import string
import textwrap
from dataclasses import dataclass
2025-10-20 15:49:27 +03:00
from functools import partial
2025-10-20 14:14:07 +03:00
from typing import Callable, Dict, List, Optional, Tuple
2025-10-20 11:40:17 +03:00
from md_types import (
2025-10-20 14:14:07 +03:00
AsciiArtPayload,
AsciiArtPiece,
2025-10-20 11:40:17 +03:00
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]
2025-10-20 15:49:27 +03:00
CODE_STASH_RE = re.compile(r"`[^`]*`")
STRIKETHROUGH_RE = re.compile(r"~~(.*?)~~")
BOLD_RE = re.compile(r"\*\*(.*?)\*\*")
ITALIC_RE = re.compile(r"(?<!\*)\*(?!\*)(.*?)(?<!\*)\*(?!\*)")
UNDERLINE_STRONG_RE = re.compile(r"__(.*?)__")
UNDERLINE_EM_RE = re.compile(r"(?<!_)_(?!_)(.*?)(?<!_)_(?!_)")
IMAGE_RE = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)")
LINK_RE = re.compile(r"(?<!\!)\[([^\]]+)\]\(([^)]+)\)")
2025-10-20 11:40:17 +03:00
@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)
2025-10-20 12:09:25 +03:00
self.wrap_code_blocks = frontmatter.wrap_code_blocks
self.code_block_line_numbers = frontmatter.code_block_line_numbers
self.blockquote_bars = frontmatter.blockquote_bars
self.wrap_code_blocks_indent = (
max(0, frontmatter.code_block_wrap_indent) if self.wrap_code_blocks else 0
)
2025-10-20 12:16:20 +03:00
self.list_marker_indent = max(0, frontmatter.list_marker_indent)
self.list_text_spacing = max(0, frontmatter.list_text_spacing)
2025-10-20 11:40:17 +03:00
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
2025-10-20 15:49:27 +03:00
self._handlers: Dict[BlockKind, Callable[[object, BlockStyle], None]] = {
BlockKind.PARAGRAPH: self._render_paragraph,
BlockKind.HEADING: self._render_heading,
BlockKind.CODE_BLOCK: self._render_code_block,
BlockKind.BLOCKQUOTE: self._render_blockquote,
BlockKind.LIST_ITEM: self._render_list_item,
BlockKind.HORIZONTAL_RULE: self._render_horizontal_rule,
BlockKind.BLANK_LINE: self._render_blank_line,
BlockKind.CUSTOM_BLOCK: self._render_custom_block,
}
2025-10-20 11:40:17 +03:00
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:
2025-10-20 15:49:27 +03:00
handler = self._handlers.get(event.kind)
if handler:
handler(event.payload, event.style)
else:
2025-10-20 11:40:17 +03:00
self._last_stylable_block = None
def _render_paragraph(self, payload: ParagraphPayload, style: BlockStyle) -> None:
processed = self._process_inline(payload.text)
2025-10-20 15:49:27 +03:00
self._wrap_emit(processed, style, stylable=True, hyphenate=self.hyphenate)
2025-10-20 11:40:17 +03:00
if self.paragraph_spacing > 0:
self.output.extend([""] * self.paragraph_spacing)
def _render_heading(self, payload: HeadingPayload, style: BlockStyle) -> None:
self._ensure_header_spacing()
2025-10-20 15:49:27 +03:00
self._emit_render(partial(self._render_heading_lines, payload.level, payload.text), style, stylable=True)
2025-10-20 11:40:17 +03:00
def _render_code_block(self, payload: CodeBlockPayload, style: BlockStyle) -> None:
2025-10-20 15:49:27 +03:00
self._emit_render(partial(self._format_code_block, payload.lines), style)
2025-10-20 11:40:17 +03:00
def _render_blockquote(self, payload: BlockQuotePayload, style: BlockStyle) -> None:
processed = self._process_inline(payload.text)
2025-10-20 12:09:25 +03:00
indent_unit = " | " if self.blockquote_bars else " "
2025-10-20 11:40:17 +03:00
indent = indent_unit * max(1, payload.depth)
2025-10-20 15:49:27 +03:00
self._wrap_emit(
2025-10-20 11:40:17 +03:00
processed,
2025-10-20 15:49:27 +03:00
style,
2025-10-20 11:40:17 +03:00
initial_indent=indent,
subsequent_indent=indent,
hyphenate=self.hyphenate,
)
def _render_list_item(self, payload: ListItemPayload, style: BlockStyle) -> None:
2025-10-20 12:16:20 +03:00
base_indent = payload.indent.replace("\t", " ")
marker_indent = " " * self.list_marker_indent
marker = payload.marker
text_spacing = " " * self.list_text_spacing
initial_indent = f"{base_indent}{marker_indent}{marker}{text_spacing}"
subsequent_indent = f"{base_indent}{marker_indent}{' ' * len(marker)}{text_spacing}"
2025-10-20 11:40:17 +03:00
processed = self._process_inline(payload.text)
2025-10-20 15:49:27 +03:00
self._wrap_emit(
2025-10-20 11:40:17 +03:00
processed,
2025-10-20 15:49:27 +03:00
style,
2025-10-20 12:16:20 +03:00
initial_indent=initial_indent,
subsequent_indent=subsequent_indent,
2025-10-20 11:40:17 +03:00
hyphenate=self.hyphenate,
)
2025-10-20 15:49:27 +03:00
def _render_horizontal_rule(self, _payload: object, style: BlockStyle) -> None:
2025-10-20 11:40:17 +03:00
line = self._render_horizontal_rule_line(style)
self._emit_block([line], stylable=False)
2025-10-20 15:49:27 +03:00
def _render_blank_line(self, *_: object) -> None:
2025-10-20 11:40:17 +03:00
if self.paragraph_spacing == 0:
self.output.append("")
2025-10-20 14:14:07 +03:00
def _render_custom_block(self, payload: AsciiArtPayload, style: BlockStyle) -> None:
2025-10-20 15:49:27 +03:00
self._emit_render(partial(self._layout_ascii_pieces, payload.pieces), style, stylable=True)
2025-10-20 14:14:07 +03:00
def _layout_ascii_pieces(self, pieces: List[AsciiArtPiece], style: BlockStyle) -> List[str]:
if not pieces:
return []
if len(pieces) == 1:
piece = pieces[0]
return self._align_preformatted_lines(piece.lines, style, piece.align)
2025-10-20 15:49:27 +03:00
margin_left, _, available_width = self._margins(style)
2025-10-20 14:14:07 +03:00
heights = [len(piece.lines) for piece in pieces]
max_height = max(heights) if heights else 0
widths = [self._ascii_piece_width(piece.lines) for piece in pieces]
positions = self._compute_ascii_positions(pieces, widths, available_width)
if positions is None:
2025-10-20 15:49:27 +03:00
return [
aligned_line
for piece in pieces
for aligned_line in self._align_preformatted_lines(piece.lines, style, piece.align)
]
2025-10-20 14:14:07 +03:00
canvas = [list(" " * available_width) for _ in range(max_height)]
for piece, pos, width in positions:
lines = piece.lines
for row_index in range(max_height):
if row_index >= len(lines):
continue
line = lines[row_index]
for col_index, char in enumerate(line):
target_index = pos + col_index
if target_index >= available_width:
break
if char == " ":
continue
canvas[row_index][target_index] = char
prefix = " " * margin_left
2025-10-20 15:49:27 +03:00
return [prefix + "".join(row).rstrip() for row in canvas]
2025-10-20 14:14:07 +03:00
def _compute_ascii_positions(
self,
pieces: List[AsciiArtPiece],
widths: List[int],
available_width: int,
) -> Optional[List[Tuple[AsciiArtPiece, int, int]]]:
if available_width <= 0:
return None
gap = 4
left_cursor = 0
right_cursor = available_width
placement_map: Dict[int, Tuple[int, int]] = {}
2025-10-20 15:49:27 +03:00
groups: Dict[str, List[int]] = {"left": [], "center": [], "right": []}
2025-10-20 14:14:07 +03:00
for index, piece in enumerate(pieces):
2025-10-20 15:49:27 +03:00
groups.get((piece.align or "left").lower(), groups["left"]).append(index)
2025-10-20 14:14:07 +03:00
2025-10-20 15:49:27 +03:00
for index in groups["left"]:
2025-10-20 14:14:07 +03:00
width = min(widths[index], available_width)
pos = left_cursor
left_cursor = min(available_width, pos + width + gap)
placement_map[index] = (pos, width)
2025-10-20 15:49:27 +03:00
for index in reversed(groups["right"]):
2025-10-20 14:14:07 +03:00
width = min(widths[index], available_width)
pos = max(0, right_cursor - width)
right_cursor = max(0, pos - gap)
placement_map[index] = (pos, width)
center_used = False
2025-10-20 15:49:27 +03:00
for index in groups["center"]:
2025-10-20 14:14:07 +03:00
width = min(widths[index], available_width)
pos = max(0, (available_width - width) // 2)
if center_used:
# shift to current left cursor if already used center
pos = left_cursor
left_cursor = min(available_width, pos + width + gap)
else:
center_used = True
placement_map[index] = (pos, width)
# Detect overlap; if found, abort to fallback
for i in range(len(pieces)):
if i not in placement_map:
continue
pos_i, width_i = placement_map[i]
end_i = pos_i + width_i
for j in range(i + 1, len(pieces)):
if j not in placement_map:
continue
pos_j, width_j = placement_map[j]
if pos_i <= pos_j < end_i or pos_j <= pos_i < pos_j + width_j:
return None
ordered_positions: List[Tuple[AsciiArtPiece, int, int]] = []
for index, piece in enumerate(pieces):
if index not in placement_map:
continue
pos, width = placement_map[index]
ordered_positions.append((piece, pos, width))
return ordered_positions
def _ascii_piece_width(self, lines: List[str]) -> int:
return max((len(line.rstrip("\n")) for line in lines), default=0)
def _align_preformatted_lines(
self,
lines: List[str],
style: BlockStyle,
explicit_align: Optional[str] = None,
) -> List[str]:
if not lines:
return []
2025-10-20 15:49:27 +03:00
margin_left, _, available_width = self._margins(style)
2025-10-20 14:14:07 +03:00
processed = [line.rstrip("\n") for line in lines]
block_width = max((len(line) for line in processed), default=0)
extra_space = max(0, available_width - block_width)
align = (explicit_align or style.align or "left").lower()
if align == "center" or align == "centre":
align_offset = extra_space // 2
elif 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
return [indent_str + line for line in processed]
2025-10-20 15:49:27 +03:00
def _margins(self, style: BlockStyle) -> Tuple[int, int, int]:
margin_left = max(0, min(style.margin_left, self.width - 1))
remaining = self.width - margin_left
margin_right = max(0, min(style.margin_right, max(0, remaining - 1)))
available = max(1, self.width - margin_left - margin_right)
return margin_left, margin_right, available
def _wrap_emit(
self,
text: str,
style: BlockStyle,
*,
initial_indent: str = "",
subsequent_indent: Optional[str] = None,
hyphenate: bool,
stylable: bool = False,
) -> None:
if not text:
return
self._emit_render(
lambda target_style: self._wrap_text(
text,
initial_indent=initial_indent,
subsequent_indent=subsequent_indent if subsequent_indent is not None else initial_indent,
style=target_style,
hyphenate=hyphenate,
),
style,
stylable=stylable,
)
def _emit_render(
self,
render_fn: Callable[[BlockStyle], List[str]],
style: BlockStyle,
*,
stylable: bool = False,
) -> None:
self._emit_block(
render_fn(style),
stylable=stylable,
render_fn=render_fn if stylable else None,
style=style if stylable else None,
)
2025-10-20 11:40:17 +03:00
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
2025-10-20 15:49:27 +03:00
margin_left, _, _ = self._margins(style)
2025-10-20 11:40:17 +03:00
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:
2025-10-20 15:49:27 +03:00
margin_left, _, available_width = self._margins(style)
2025-10-20 11:40:17 +03:00
return " " * margin_left + "-" * available_width
def _format_code_block(self, lines: List[str], style: BlockStyle) -> List[str]:
if not lines:
return []
2025-10-20 15:49:27 +03:00
margin_left, _, available = self._margins(style)
indent = " " * margin_left
numbered = self.code_block_line_numbers
wrapped = self.wrap_code_blocks
if numbered:
width = max(2, len(str(len(lines))))
lead_fmt = f"{indent}{{:0{width}d}} | "
cont_prefix = f"{indent}{' ' * width} | "
content_width = max(1, available - (len(lead_fmt.format(0)) - len(indent)))
else:
base = indent + " "
lead_fmt = None
cont_prefix = base
content_width = max(1, available - 3)
2025-10-20 11:40:17 +03:00
2025-10-20 15:49:27 +03:00
def lead(idx: int) -> str:
return lead_fmt.format(idx) if numbered else cont_prefix
2025-10-20 12:09:25 +03:00
formatted: List[str] = []
for idx, line in enumerate(lines, start=1):
2025-10-20 15:49:27 +03:00
prefix = lead(idx)
segments = (
self._wrap_code_line_segments(line, content_width)
if wrapped and line
else ([line] if line else [])
)
if not segments:
formatted.append(prefix)
2025-10-20 12:09:25 +03:00
continue
2025-10-20 15:49:27 +03:00
formatted.append(prefix + segments[0])
formatted.extend(cont_prefix + segment for segment in segments[1:])
formatted.append(indent if indent else "")
2025-10-20 12:09:25 +03:00
return formatted
def _wrap_code_line_segments(self, line: str, content_width: int) -> List[str]:
if not line:
return []
segments: List[str] = []
remaining = line
current_indent = 0
max_indent_allowed = max(0, content_width - 1)
while remaining:
available = max(1, content_width - current_indent)
segment, remaining = self._split_code_segment(remaining, available)
segments.append(" " * current_indent + segment)
leading = self._leading_space_count(segment)
total_indent = current_indent + leading
desired_indent = total_indent + self.wrap_code_blocks_indent
current_indent = min(max_indent_allowed, desired_indent)
return segments
def _split_code_segment(self, text: str, max_width: int) -> tuple[str, str]:
if max_width <= 0:
max_width = 1
if len(text) <= max_width:
return text, ""
slice_text = text[:max_width]
break_pos = -1
for idx, char in enumerate(slice_text):
if char in {" ", "\t"}:
break_pos = idx
if break_pos <= 0:
segment = slice_text
remainder = text[len(segment) :]
else:
segment = text[:break_pos]
remainder = text[break_pos:]
if not segment:
segment = slice_text
remainder = text[len(segment) :]
return segment, remainder
def _leading_space_count(self, text: str) -> int:
count = 0
for char in text:
if char == " ":
count += 1
elif char == "\t":
count += 1
else:
break
return count
2025-10-20 11:40:17 +03:00
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"
2025-10-20 15:49:27 +03:00
text = CODE_STASH_RE.sub(stash_code, text)
2025-10-20 11:40:17 +03:00
2025-10-20 15:49:27 +03:00
text = self._apply_pattern(text, STRIKETHROUGH_RE, lambda m, src: self._stylize_delimited(m.group(1), "-", transform="preserve"))
text = self._apply_pattern(text, BOLD_RE, lambda m, src: self._replace_spaced_emphasis(src, m, transform="upper"))
text = self._apply_pattern(text, ITALIC_RE, lambda m, src: self._replace_spaced_emphasis(src, m, transform="preserve"))
2025-10-20 11:40:17 +03:00
emphasis_segments: List[str] = []
def apply_with_placeholder(
current: str,
pattern: re.Pattern[str],
handler: Callable[[re.Match[str]], str],
) -> 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,
2025-10-20 15:49:27 +03:00
UNDERLINE_STRONG_RE,
2025-10-20 11:40:17 +03:00
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,
2025-10-20 15:49:27 +03:00
UNDERLINE_EM_RE,
2025-10-20 11:40:17 +03:00
lambda m: self._apply_emphasis_spacing(
m.string,
m.start(),
m.end(),
self._stylize_delimited(m.group(1), "_", transform="preserve", word_repeat=3),
),
)
2025-10-20 15:49:27 +03:00
text = LINK_RE.sub(self._handle_link, text)
text = IMAGE_RE.sub(self._handle_image, text)
2025-10-20 11:40:17 +03:00
for index, replacement in enumerate(emphasis_segments):
placeholder = f"\u0000EMP{index}\u0000"
text = text.replace(placeholder, replacement)
for index, code in enumerate(code_segments):
placeholder = f"\u0000CODE{index}\u0000"
text = text.replace(placeholder, code)
return text
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 _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()
2025-10-20 15:49:27 +03:00
margin_left, _, available_width = self._margins(style)
2025-10-20 11:40:17 +03:00
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] = []
2025-10-20 15:49:27 +03:00
margin_left, _, width = self._margins(style)
2025-10-20 11:40:17 +03:00
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:
2025-10-20 15:49:27 +03:00
return self._margins(style)[2]
2025-10-20 11:40:17 +03:00
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))