modularized
This commit is contained in:
parent
4e754528c0
commit
de6e99c24a
5 changed files with 1416 additions and 1217 deletions
|
|
@ -2,8 +2,8 @@
|
||||||
h1_font: big
|
h1_font: big
|
||||||
h2_font: small
|
h2_font: small
|
||||||
h3_font: caps
|
h3_font: caps
|
||||||
margin_left: 8
|
margin_left: 12
|
||||||
margin_right: 8
|
margin_right: 12
|
||||||
paragraph_spacing: 2
|
paragraph_spacing: 2
|
||||||
hyphenate: true
|
hyphenate: true
|
||||||
---
|
---
|
||||||
|
|
|
||||||
542
markdown_parser.py
Normal file
542
markdown_parser.py
Normal file
|
|
@ -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*<p\b([^>]*)>(.*?)</p>\s*$", re.IGNORECASE)
|
||||||
|
PARA_OPEN_RE = re.compile(r"^\s*<p\b([^>]*)>\s*$", re.IGNORECASE)
|
||||||
|
PARA_CLOSE_RE = re.compile(r"^\s*</p>\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
|
||||||
87
md_types.py
Normal file
87
md_types.py
Normal file
|
|
@ -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
|
||||||
762
text_renderer.py
Normal file
762
text_renderer.py
Normal file
|
|
@ -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"(?<!\*)\*(?!\*)(.*?)(?<!\*)\*(?!\*)"),
|
||||||
|
lambda m, src: self._replace_spaced_emphasis(src, m, transform="preserve"),
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
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"(?<!_)_(?!_)(.*?)(?<!_)_(?!_)"),
|
||||||
|
lambda m: self._apply_emphasis_spacing(
|
||||||
|
m.string,
|
||||||
|
m.start(),
|
||||||
|
m.end(),
|
||||||
|
self._stylize_delimited(m.group(1), "_", transform="preserve", word_repeat=3),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
text = re.sub(r"(?<!\!)\[([^\]]+)\]\(([^)]+)\)", self._handle_link, text)
|
||||||
|
text = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", self._handle_image, text)
|
||||||
|
|
||||||
|
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()
|
||||||
|
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))
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue