md2txt/markdown_parser.py
2025-10-20 11:40:17 +03:00

542 lines
20 KiB
Python

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