ascii art blocks
This commit is contained in:
parent
ad70e8dcd8
commit
b85530483a
5 changed files with 417 additions and 17 deletions
|
|
@ -41,20 +41,11 @@ populifer adeunt quicquam cum **ales** Ixion angulus placet reddere.
|
||||||
## Tractaque serpens arva quoque
|
## Tractaque serpens arva quoque
|
||||||
{:.center}
|
{:.center}
|
||||||
|
|
||||||
### Unordered
|
# Include
|
||||||
|
|
||||||
+ Create a list by starting a line with `+`, `-`, or `*`
|
#[d1 :left](dragon.txt) #[d1 :center :mirror](dragon.txt) #[d1 :right](dragon.txt)
|
||||||
- Marker character change forces new list start:
|
|
||||||
* Ac tristique libero volutpat at jesrh hjsrh sejr hhsjehv sjtrhg jrdth
|
|
||||||
+ Facilisis in pretium nisl aliquet
|
|
||||||
- Nulla volutpat aliquam velit
|
|
||||||
+ Very easy
|
|
||||||
|
|
||||||
### Ordered
|
![[markdown.md]]
|
||||||
|
|
||||||
1. Lorem ipsum dolor sit amet
|
|
||||||
2. Consectetur adipiscing elit
|
|
||||||
3. Integer molestie lorem at massa
|
|
||||||
|
|
||||||
Inprudens dum memor alma, casses dedi Sinuessa iam quid adicit
|
Inprudens dum memor alma, casses dedi Sinuessa iam quid adicit
|
||||||
`gate_truncate_wave`. [Geratur de](#salientia-iungit-contra-et), insolida
|
`gate_truncate_wave`. [Geratur de](#salientia-iungit-contra-et), insolida
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
from typing import Iterable, Iterator, List, Optional, Union
|
from pathlib import Path
|
||||||
|
from typing import Dict, Iterable, Iterator, List, Optional, Union
|
||||||
|
|
||||||
from md_types import (
|
from md_types import (
|
||||||
|
AsciiArtPayload,
|
||||||
|
AsciiArtPiece,
|
||||||
BlockEvent,
|
BlockEvent,
|
||||||
BlockKind,
|
BlockKind,
|
||||||
BlockQuotePayload,
|
BlockQuotePayload,
|
||||||
|
|
@ -27,6 +31,7 @@ PARA_OPEN_RE = re.compile(r"^\s*<p\b([^>]*)>\s*$", re.IGNORECASE)
|
||||||
PARA_CLOSE_RE = re.compile(r"^\s*</p>\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_LINE_RE = re.compile(r"^\{\s*:(.+)\}\s*$")
|
||||||
MMD_ATTR_TAIL_RE = re.compile(r"(.*?)\s*\{\s*:(.+?)\}\s*$")
|
MMD_ATTR_TAIL_RE = re.compile(r"(.*?)\s*\{\s*:(.+?)\}\s*$")
|
||||||
|
ASCII_SENTINEL_PREFIX = "\u0000ASCII:"
|
||||||
|
|
||||||
|
|
||||||
class MarkdownParser:
|
class MarkdownParser:
|
||||||
|
|
@ -36,6 +41,7 @@ class MarkdownParser:
|
||||||
self._paragraph_style_spec: Optional[StyleSpec] = None
|
self._paragraph_style_spec: Optional[StyleSpec] = None
|
||||||
self._pending_block_style_spec: Optional[StyleSpec] = None
|
self._pending_block_style_spec: Optional[StyleSpec] = None
|
||||||
self._last_stylable_block: bool = False
|
self._last_stylable_block: bool = False
|
||||||
|
self._ascii_cache: Dict[str, List[str]] = {}
|
||||||
|
|
||||||
def parse(self, lines: Iterable[str]) -> Iterator[Union[BlockEvent, StyleUpdateEvent]]:
|
def parse(self, lines: Iterable[str]) -> Iterator[Union[BlockEvent, StyleUpdateEvent]]:
|
||||||
self._reset_state()
|
self._reset_state()
|
||||||
|
|
@ -48,6 +54,23 @@ class MarkdownParser:
|
||||||
for raw_line in iterator:
|
for raw_line in iterator:
|
||||||
line = raw_line.rstrip("\n")
|
line = raw_line.rstrip("\n")
|
||||||
|
|
||||||
|
if line.startswith(ASCII_SENTINEL_PREFIX):
|
||||||
|
event = self._flush_paragraph(current_paragraph)
|
||||||
|
if event is not None:
|
||||||
|
yield event
|
||||||
|
current_paragraph = []
|
||||||
|
payload = self._build_ascii_payload(line)
|
||||||
|
style = self._combine_styles(self._current_style(), self._pending_block_style_spec)
|
||||||
|
self._pending_block_style_spec = None
|
||||||
|
self._last_stylable_block = True
|
||||||
|
yield BlockEvent(
|
||||||
|
kind=BlockKind.CUSTOM_BLOCK,
|
||||||
|
payload=payload,
|
||||||
|
style=style,
|
||||||
|
stylable=True,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
if in_code_block:
|
if in_code_block:
|
||||||
if line.strip().startswith("```"):
|
if line.strip().startswith("```"):
|
||||||
event = self._flush_code_block(code_lines)
|
event = self._flush_code_block(code_lines)
|
||||||
|
|
@ -297,6 +320,69 @@ class MarkdownParser:
|
||||||
stylable=False,
|
stylable=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _build_ascii_payload(self, sentinel_line: str) -> AsciiArtPayload:
|
||||||
|
entries = self._decode_ascii_sentinel(sentinel_line)
|
||||||
|
pieces: List[AsciiArtPiece] = []
|
||||||
|
for entry in entries:
|
||||||
|
lines = self._load_ascii_art_lines(entry["path"])
|
||||||
|
pieces.append(
|
||||||
|
AsciiArtPiece(
|
||||||
|
block_type=entry["type"],
|
||||||
|
name=entry["name"],
|
||||||
|
path=entry["path"],
|
||||||
|
lines=lines,
|
||||||
|
align=entry["align"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return AsciiArtPayload(pieces=pieces)
|
||||||
|
|
||||||
|
def _decode_ascii_sentinel(self, line: str) -> List[Dict[str, str]]:
|
||||||
|
payload = line[len(ASCII_SENTINEL_PREFIX) :]
|
||||||
|
try:
|
||||||
|
data = json.loads(payload)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ValueError(f"Invalid ASCII sentinel payload: {payload}") from exc
|
||||||
|
if "pieces" in data and isinstance(data["pieces"], list):
|
||||||
|
raw_pieces = data["pieces"]
|
||||||
|
else:
|
||||||
|
raw_pieces = [data]
|
||||||
|
pieces: List[Dict[str, str]] = []
|
||||||
|
for entry in raw_pieces:
|
||||||
|
for key in ("type", "name", "path"):
|
||||||
|
if key not in entry:
|
||||||
|
raise ValueError(f"ASCII sentinel missing '{key}' field: {payload}")
|
||||||
|
align = entry.get("align")
|
||||||
|
align_str = str(align) if align is not None else None
|
||||||
|
if align_str is not None:
|
||||||
|
align_lower = align_str.strip().lower()
|
||||||
|
if align_lower == "centre":
|
||||||
|
align_lower = "center"
|
||||||
|
if align_lower not in {"left", "center", "right"}:
|
||||||
|
align_lower = None
|
||||||
|
else:
|
||||||
|
align_lower = None
|
||||||
|
pieces.append(
|
||||||
|
{
|
||||||
|
"type": str(entry["type"]),
|
||||||
|
"name": str(entry["name"]),
|
||||||
|
"path": str(entry["path"]),
|
||||||
|
"align": align_lower,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return pieces
|
||||||
|
|
||||||
|
def _load_ascii_art_lines(self, path_str: str) -> List[str]:
|
||||||
|
cached = self._ascii_cache.get(path_str)
|
||||||
|
if cached is not None:
|
||||||
|
return list(cached)
|
||||||
|
path = Path(path_str)
|
||||||
|
if not path.exists():
|
||||||
|
raise FileNotFoundError(f"ASCII art file '{path}' was not found.")
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
lines = text.splitlines()
|
||||||
|
self._ascii_cache[path_str] = lines
|
||||||
|
return list(lines)
|
||||||
|
|
||||||
def _make_base_style(self) -> BlockStyle:
|
def _make_base_style(self) -> BlockStyle:
|
||||||
return BlockStyle(
|
return BlockStyle(
|
||||||
align=self._base_style.align,
|
align=self._base_style.align,
|
||||||
|
|
|
||||||
155
md2txt.py
155
md2txt.py
|
|
@ -5,10 +5,11 @@ Convert Markdown into 80-column DOS-compatible plain text.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable, List, Optional, Tuple
|
from typing import Iterable, List, Optional, Set, Tuple
|
||||||
|
|
||||||
from md_types import BlockStyle, FrontMatter
|
from md_types import BlockStyle, FrontMatter
|
||||||
from markdown_parser import MarkdownParser
|
from markdown_parser import MarkdownParser
|
||||||
|
|
@ -16,6 +17,14 @@ from text_renderer import TextRenderer
|
||||||
|
|
||||||
|
|
||||||
FRONTMATTER_PATTERN = re.compile(r"^---\s*$")
|
FRONTMATTER_PATTERN = re.compile(r"^---\s*$")
|
||||||
|
INCLUDE_WIKILINK_PATTERN = re.compile(r"^\s*!\[\[(.+?)\]\]\s*$")
|
||||||
|
INCLUDE_DIRECTIVE_PATTERN = re.compile(r"^\s*\{\s*\.include\s+(.+?)\s*\}\s*$")
|
||||||
|
ASCII_BLOCK_PATTERN = re.compile(
|
||||||
|
r"^\s*#\[(?P<label>[^\]]+)\]\((?P<target>[^)]+)\)\s*(?P<attr>\{\s*:[^}]+\s*\})?\s*$"
|
||||||
|
)
|
||||||
|
ASCII_INLINE_PATTERN = re.compile(r"#\[(?P<label>[^\]]+)\]\((?P<target>[^)]+)\)")
|
||||||
|
MMD_ATTR_TAIL_RE = re.compile(r"(.*?)\s*\{\s*:(.+?)\}\s*$")
|
||||||
|
ASCII_SENTINEL_PREFIX = "\u0000ASCII:"
|
||||||
|
|
||||||
|
|
||||||
def _parse_int(value: Optional[str], default: int = 0) -> int:
|
def _parse_int(value: Optional[str], default: int = 0) -> int:
|
||||||
|
|
@ -46,7 +55,10 @@ def convert_markdown(
|
||||||
*,
|
*,
|
||||||
width: int,
|
width: int,
|
||||||
frontmatter: FrontMatter,
|
frontmatter: FrontMatter,
|
||||||
|
base_path: Optional[Path] = None,
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
|
base_dir = (base_path or Path.cwd()).resolve()
|
||||||
|
expanded_lines = _expand_includes(list(lines), base_dir, set())
|
||||||
base_style = BlockStyle(
|
base_style = BlockStyle(
|
||||||
align="left",
|
align="left",
|
||||||
margin_left=max(0, frontmatter.margin_left),
|
margin_left=max(0, frontmatter.margin_left),
|
||||||
|
|
@ -54,11 +66,143 @@ def convert_markdown(
|
||||||
)
|
)
|
||||||
parser = MarkdownParser(base_style)
|
parser = MarkdownParser(base_style)
|
||||||
renderer = TextRenderer(width=width, frontmatter=frontmatter)
|
renderer = TextRenderer(width=width, frontmatter=frontmatter)
|
||||||
for event in parser.parse(lines):
|
for event in parser.parse(expanded_lines):
|
||||||
renderer.handle_event(event)
|
renderer.handle_event(event)
|
||||||
return renderer.finalize()
|
return renderer.finalize()
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_includes(lines: List[str], base_dir: Path, include_stack: Set[Path]) -> List[str]:
|
||||||
|
expanded: List[str] = []
|
||||||
|
for line in lines:
|
||||||
|
ascii_segments = _extract_ascii_segments(line, base_dir)
|
||||||
|
if ascii_segments is not None:
|
||||||
|
for sentinel_line, attr_line in ascii_segments:
|
||||||
|
expanded.append(sentinel_line)
|
||||||
|
if attr_line is not None:
|
||||||
|
expanded.append(attr_line)
|
||||||
|
continue
|
||||||
|
target = _extract_include_target(line)
|
||||||
|
if target is None:
|
||||||
|
expanded.append(line)
|
||||||
|
continue
|
||||||
|
target_path = (base_dir / target).resolve()
|
||||||
|
if target_path in include_stack:
|
||||||
|
raise RuntimeError(f"Circular include detected for '{target_path}'.")
|
||||||
|
if not target_path.exists():
|
||||||
|
raise FileNotFoundError(f"Included file '{target_path}' was not found.")
|
||||||
|
include_stack.add(target_path)
|
||||||
|
included_lines = read_lines(target_path)
|
||||||
|
_, include_body = parse_frontmatter(included_lines)
|
||||||
|
included_content = _expand_includes(include_body, target_path.parent, include_stack)
|
||||||
|
expanded.extend(included_content)
|
||||||
|
include_stack.remove(target_path)
|
||||||
|
return expanded
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_include_target(line: str) -> Optional[str]:
|
||||||
|
stripped = line.rstrip("\n")
|
||||||
|
match = INCLUDE_WIKILINK_PATTERN.match(stripped)
|
||||||
|
if match:
|
||||||
|
return _normalize_include_target(match.group(1))
|
||||||
|
match = INCLUDE_DIRECTIVE_PATTERN.match(stripped)
|
||||||
|
if match:
|
||||||
|
return _normalize_include_target(match.group(1))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_include_target(value: str) -> str:
|
||||||
|
trimmed = value.strip()
|
||||||
|
if len(trimmed) >= 2 and ((trimmed[0] == trimmed[-1]) and trimmed[0] in {"'", '"'}):
|
||||||
|
trimmed = trimmed[1:-1].strip()
|
||||||
|
return trimmed
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_ascii_segments(line: str, base_dir: Path) -> Optional[List[Tuple[str, Optional[str]]]]:
|
||||||
|
stripped_line = line.rstrip("\n")
|
||||||
|
block_match = ASCII_BLOCK_PATTERN.match(stripped_line)
|
||||||
|
if block_match:
|
||||||
|
label = block_match.group("label")
|
||||||
|
target = block_match.group("target")
|
||||||
|
attr_text = block_match.group("attr")
|
||||||
|
sentinel = _make_ascii_sentinel(label, target, base_dir)
|
||||||
|
attr_line = f"{attr_text}\n" if attr_text else None
|
||||||
|
return [(sentinel, attr_line)]
|
||||||
|
|
||||||
|
matches = list(ASCII_INLINE_PATTERN.finditer(stripped_line))
|
||||||
|
if not matches:
|
||||||
|
return None
|
||||||
|
|
||||||
|
pieces: List[dict] = []
|
||||||
|
last_end = 0
|
||||||
|
for match in matches:
|
||||||
|
prefix = stripped_line[last_end : match.start()]
|
||||||
|
if prefix.strip():
|
||||||
|
return None
|
||||||
|
label = match.group("label")
|
||||||
|
target = match.group("target")
|
||||||
|
block_type, block_name, align = _parse_ascii_label(label)
|
||||||
|
normalized_target = _normalize_include_target(target)
|
||||||
|
target_path = (base_dir / normalized_target).resolve()
|
||||||
|
if not target_path.exists():
|
||||||
|
raise FileNotFoundError(f"ASCII art file '{target_path}' was not found.")
|
||||||
|
pieces.append(
|
||||||
|
{
|
||||||
|
"type": block_type,
|
||||||
|
"name": block_name,
|
||||||
|
"path": str(target_path),
|
||||||
|
"align": align,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
last_end = match.end()
|
||||||
|
|
||||||
|
suffix = stripped_line[last_end:]
|
||||||
|
if suffix.strip():
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not pieces:
|
||||||
|
return None
|
||||||
|
|
||||||
|
sentinel = f"{ASCII_SENTINEL_PREFIX}{json.dumps({'pieces': pieces})}\n"
|
||||||
|
return [(sentinel, None)]
|
||||||
|
|
||||||
|
|
||||||
|
def _make_ascii_sentinel(label: str, target: str, base_dir: Path) -> str:
|
||||||
|
block_type, block_name, align = _parse_ascii_label(label)
|
||||||
|
normalized_target = _normalize_include_target(target)
|
||||||
|
target_path = (base_dir / normalized_target).resolve()
|
||||||
|
if not target_path.exists():
|
||||||
|
raise FileNotFoundError(f"ASCII art file '{target_path}' was not found.")
|
||||||
|
payload = {
|
||||||
|
"pieces": [
|
||||||
|
{
|
||||||
|
"type": block_type,
|
||||||
|
"name": block_name,
|
||||||
|
"path": str(target_path),
|
||||||
|
"align": align,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return f"{ASCII_SENTINEL_PREFIX}{json.dumps(payload)}\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ascii_label(label: str) -> Tuple[str, str, Optional[str]]:
|
||||||
|
tokens = label.strip().split()
|
||||||
|
non_colon: List[str] = []
|
||||||
|
align: Optional[str] = None
|
||||||
|
for token in tokens:
|
||||||
|
if token.startswith(":"):
|
||||||
|
tag = token[1:].strip().lower()
|
||||||
|
if tag in {"left", "right", "center", "centre"}:
|
||||||
|
align = "center" if tag in {"center", "centre"} else tag
|
||||||
|
# ignore other colon tags for now
|
||||||
|
else:
|
||||||
|
non_colon.append(token)
|
||||||
|
|
||||||
|
block_type = non_colon[0] if non_colon else "custom"
|
||||||
|
block_name = " ".join(non_colon[1:]) if len(non_colon) > 1 else ""
|
||||||
|
return block_type, block_name, align
|
||||||
|
|
||||||
|
|
||||||
def parse_frontmatter(lines: List[str]) -> Tuple[FrontMatter, List[str]]:
|
def parse_frontmatter(lines: List[str]) -> Tuple[FrontMatter, List[str]]:
|
||||||
if not lines or not FRONTMATTER_PATTERN.match(lines[0]):
|
if not lines or not FRONTMATTER_PATTERN.match(lines[0]):
|
||||||
return FrontMatter(), lines
|
return FrontMatter(), lines
|
||||||
|
|
@ -145,7 +289,12 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
lines = read_lines(args.input_path)
|
lines = read_lines(args.input_path)
|
||||||
frontmatter, content = parse_frontmatter(lines)
|
frontmatter, content = parse_frontmatter(lines)
|
||||||
converted_lines = convert_markdown(content, width=args.width, frontmatter=frontmatter)
|
converted_lines = convert_markdown(
|
||||||
|
content,
|
||||||
|
width=args.width,
|
||||||
|
frontmatter=frontmatter,
|
||||||
|
base_path=args.input_path.parent,
|
||||||
|
)
|
||||||
write_output(args.output, converted_lines)
|
write_output(args.output, converted_lines)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
|
||||||
15
md_types.py
15
md_types.py
|
|
@ -13,6 +13,7 @@ class BlockKind(Enum):
|
||||||
LIST_ITEM = "list_item"
|
LIST_ITEM = "list_item"
|
||||||
HORIZONTAL_RULE = "horizontal_rule"
|
HORIZONTAL_RULE = "horizontal_rule"
|
||||||
BLANK_LINE = "blank_line"
|
BLANK_LINE = "blank_line"
|
||||||
|
CUSTOM_BLOCK = "custom_block"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -80,6 +81,20 @@ class ListItemPayload:
|
||||||
ordered: bool
|
ordered: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AsciiArtPiece:
|
||||||
|
block_type: str
|
||||||
|
name: str
|
||||||
|
path: str
|
||||||
|
lines: List[str]
|
||||||
|
align: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AsciiArtPayload:
|
||||||
|
pieces: List[AsciiArtPiece]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class BlockEvent:
|
class BlockEvent:
|
||||||
kind: BlockKind
|
kind: BlockKind
|
||||||
|
|
|
||||||
161
text_renderer.py
161
text_renderer.py
|
|
@ -4,9 +4,11 @@ import re
|
||||||
import string
|
import string
|
||||||
import textwrap
|
import textwrap
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Callable, Dict, List, Optional
|
from typing import Callable, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from md_types import (
|
from md_types import (
|
||||||
|
AsciiArtPayload,
|
||||||
|
AsciiArtPiece,
|
||||||
BlockEvent,
|
BlockEvent,
|
||||||
BlockKind,
|
BlockKind,
|
||||||
BlockQuotePayload,
|
BlockQuotePayload,
|
||||||
|
|
@ -136,6 +138,8 @@ class TextRenderer:
|
||||||
self._render_horizontal_rule(event.style)
|
self._render_horizontal_rule(event.style)
|
||||||
elif event.kind is BlockKind.BLANK_LINE:
|
elif event.kind is BlockKind.BLANK_LINE:
|
||||||
self._render_blank_line()
|
self._render_blank_line()
|
||||||
|
elif event.kind is BlockKind.CUSTOM_BLOCK:
|
||||||
|
self._render_custom_block(event.payload, event.style)
|
||||||
else: # pragma: no cover - defensive default
|
else: # pragma: no cover - defensive default
|
||||||
self._last_stylable_block = None
|
self._last_stylable_block = None
|
||||||
|
|
||||||
|
|
@ -201,6 +205,161 @@ class TextRenderer:
|
||||||
if self.paragraph_spacing == 0:
|
if self.paragraph_spacing == 0:
|
||||||
self.output.append("")
|
self.output.append("")
|
||||||
|
|
||||||
|
def _render_custom_block(self, payload: AsciiArtPayload, style: BlockStyle) -> None:
|
||||||
|
|
||||||
|
def render(target_style: BlockStyle) -> List[str]:
|
||||||
|
return self._layout_ascii_pieces(payload.pieces, target_style)
|
||||||
|
|
||||||
|
lines = render(style)
|
||||||
|
self._emit_block(lines, stylable=True, render_fn=render, style=style)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
margin_left = min(max(style.margin_left, 0), self.width - 1)
|
||||||
|
margin_right = max(style.margin_right, 0)
|
||||||
|
available_width = max(1, self.width - margin_left - margin_right)
|
||||||
|
|
||||||
|
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:
|
||||||
|
# fall back to vertical stacking by aligning individually
|
||||||
|
result: List[str] = []
|
||||||
|
for piece in pieces:
|
||||||
|
result.extend(self._align_preformatted_lines(piece.lines, style, piece.align))
|
||||||
|
return result
|
||||||
|
|
||||||
|
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
|
||||||
|
result_lines: List[str] = []
|
||||||
|
for row in canvas:
|
||||||
|
content = "".join(row).rstrip()
|
||||||
|
result_lines.append(prefix + content)
|
||||||
|
return result_lines
|
||||||
|
|
||||||
|
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]] = {}
|
||||||
|
|
||||||
|
# Categorise pieces by alignment preference
|
||||||
|
left_indices: List[int] = []
|
||||||
|
center_indices: List[int] = []
|
||||||
|
right_indices: List[int] = []
|
||||||
|
|
||||||
|
for index, piece in enumerate(pieces):
|
||||||
|
align = (piece.align or "left").lower()
|
||||||
|
if align == "right":
|
||||||
|
right_indices.append(index)
|
||||||
|
elif align == "center":
|
||||||
|
center_indices.append(index)
|
||||||
|
else:
|
||||||
|
left_indices.append(index)
|
||||||
|
|
||||||
|
for index in left_indices:
|
||||||
|
width = min(widths[index], available_width)
|
||||||
|
pos = left_cursor
|
||||||
|
left_cursor = min(available_width, pos + width + gap)
|
||||||
|
placement_map[index] = (pos, width)
|
||||||
|
|
||||||
|
for index in reversed(right_indices):
|
||||||
|
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
|
||||||
|
for index in center_indices:
|
||||||
|
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 []
|
||||||
|
margin_left = min(max(style.margin_left, 0), self.width - 1)
|
||||||
|
margin_right = max(style.margin_right, 0)
|
||||||
|
available_width = max(1, self.width - margin_left - margin_right)
|
||||||
|
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]
|
||||||
|
|
||||||
def _emit_block(
|
def _emit_block(
|
||||||
self,
|
self,
|
||||||
lines: List[str],
|
lines: List[str],
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue