feat: slim JSON, EmojiMarkupRenderer, configurable delimiters

- JSONRenderer(slim=True): null·기본값·빈 attributes 필드 제거
- EmojiMarkupRenderer: CTTree → 이모지 마크업 텍스트 왕복 변환 지원
  - title_mark / section_marks 커스터마이징 가능
  - names_per_line 옵션으로 줄당 이름 수 제한
- EmojiMarkupParser: 구분자를 생성자 파라미터로 주입 가능
  - section_marks 길이 제한 없음 (임의 깊이 섹션 지원)
  - 긴 마커 우선 매칭으로 prefix 충돌 방지
- 테스트 39개 추가 (커스텀 구분자·렌더러·slim 검증)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gm
2026-06-01 18:30:13 +09:00
parent daa590117d
commit a68a79411f
5 changed files with 356 additions and 55 deletions

View File

@@ -3,11 +3,16 @@ ctstruct/parsers/emoji_markup.py
이모지 마크업 포맷 → CTTree 파서
지원 포맷:
제목 → 문서 제목 (root 속성)
🔹1단계 섹션 → SECTION (level=1)
🔸2단계 섹션 → SECTION (level=2)
(카테고리) 이름 → 카테고리 태그가 붙은 LIST + LIST_ITEM들
이름1 이름2 → 카테고리 없는 LIST + LIST_ITEM들 (줄 연속 가능)
<title_mark>제목 → 문서 제목 (root 속성)
<section_marks[0]>1단계 → SECTION (level=1)
<section_marks[1]>2단계 → SECTION (level=2)
(카테고리) 이름 → 카테고리 태그가 붙은 LIST + LIST_ITEM들
이름1 이름2 → 카테고리 없는 LIST + LIST_ITEM들 (줄 연속 가능)
기본 구분자:
title_mark = ""
section_marks = ["🔹", "🔸"]
"""
from __future__ import annotations
@@ -21,36 +26,55 @@ from .base import BaseParser
class EmojiMarkupParser(BaseParser):
"""
✅ / 🔹 / 🔸 이모지로 계층을 표현하는 텍스트 포맷 파서.
임의 구분자로 계층을 표현하는 텍스트 포맷 파서.
명단은 공백으로 구분된 이름 목록이며, 빈 줄 또는 새로운 섹션/카테고리
마커가 나올 때까지 같은 그룹으로 묶입니다.
구분자는 이모지, 특수문자, 문자열 등 어떤 값도 사용할 수 있습니다.
Args:
title_mark: 문서 제목 줄 앞에 붙는 마커 (기본 "")
section_marks: 섹션 레벨별 마커 목록 (기본 ["🔹", "🔸"])
인덱스 0 → level 1, 인덱스 1 → level 2, …
더 긴 마커가 먼저 매칭되도록 내부에서 길이 내림차순 정렬됩니다.
Examples:
>>> parser = EmojiMarkupParser()
>>> tree = parser.parse(text)
>>> tree.pretty()
# 기본 이모지 구분자
parser = EmojiMarkupParser()
# 마크다운 스타일 구분자
parser = EmojiMarkupParser(title_mark="# ", section_marks=["## ", "### "])
# 커스텀 구분자
parser = EmojiMarkupParser(title_mark="[제목]", section_marks=["[1]", "[2]", "[3]"])
"""
_TITLE = ""
_SEC1 = "🔹"
_SEC2 = "🔸"
_RE_CAT = re.compile(r'^\(([^)]+)\)\s*(.*)')
_RE_CAT = re.compile(r'^\(([^)]+)\)\s*(.*)', re.DOTALL)
def __init__(
self,
title_mark: str = "",
section_marks: list[str] | None = None,
) -> None:
self.title_mark = title_mark
self.section_marks = section_marks if section_marks is not None else ["🔹", "🔸"]
# 긴 마커를 먼저 검사해 prefix 충돌 방지 (예: "##" vs "#")
self._sorted_marks: list[tuple[str, int]] = sorted(
((m, i + 1) for i, m in enumerate(self.section_marks)),
key=lambda x: len(x[0]),
reverse=True,
)
def parse(self, text: str) -> CTTree:
tree = CTTree()
# 현재 컨텍스트 추적
sec1: CTNode | None = None
sec2: CTNode | None = None
# 각 레벨별 현재 활성 섹션 노드 (인덱스 = level-1)
section_stack: list[CTNode | None] = [None] * len(self.section_marks)
# 아직 트리에 붙이지 않은 이름 버퍼
pending_names: list[str] = []
pending_category: str | None = None
pending_parent: CTNode | None = None # 이름 그룹이 붙을 부모 노드
pending_names: list[str] = []
pending_category: str | None = None
pending_parent: CTNode | None = None
def flush() -> None:
"""버퍼에 쌓인 이름을 LIST로 만들어 부모에 붙인다."""
nonlocal pending_names, pending_category, pending_parent
if not pending_names:
return
@@ -60,9 +84,16 @@ class EmojiMarkupParser(BaseParser):
lst.append(CTNode(NodeType.LIST_ITEM, name))
target = pending_parent if pending_parent is not None else tree.root
target.append(lst)
pending_names = []
pending_names.clear()
pending_category = None
def nearest_parent(level: int) -> CTNode:
"""level 보다 얕은 가장 가까운 활성 섹션, 없으면 root."""
for i in range(level - 2, -1, -1):
if section_stack[i] is not None:
return section_stack[i] # type: ignore[return-value]
return tree.root
for raw in text.splitlines():
line = raw.strip()
@@ -70,31 +101,31 @@ class EmojiMarkupParser(BaseParser):
flush()
continue
# ✅ 문서 제목
if line.startswith(self._TITLE):
# 제목 마커
if line.startswith(self.title_mark):
flush()
tree.root.set_attr("title", line[len(self._TITLE):].strip())
tree.root.set_attr("title", line[len(self.title_mark):].strip())
continue
# 🔹 1단계 섹션
if line.startswith(self._SEC1):
flush()
sec1 = CTNode(NodeType.SECTION, line[len(self._SEC1):].strip(), {"level": 1})
sec2 = None
pending_parent = sec1
tree.root.append(sec1)
# 섹션 마커 (긴 것 우선 매칭)
matched = False
for mark, level in self._sorted_marks:
if line.startswith(mark):
flush()
content = line[len(mark):].strip()
node = CTNode(NodeType.SECTION, content, {"level": level})
# 현재 레벨 이하의 스택 초기화
for i in range(level - 1, len(section_stack)):
section_stack[i] = None
section_stack[level - 1] = node
pending_parent = node
nearest_parent(level).append(node)
matched = True
break
if matched:
continue
# 🔸 2단계 섹션
if line.startswith(self._SEC2):
flush()
sec2 = CTNode(NodeType.SECTION, line[len(self._SEC2):].strip(), {"level": 2})
pending_parent = sec2
parent = sec1 if sec1 is not None else tree.root
parent.append(sec2)
continue
# (카테고리) 이름들 — 새 카테고리이면 기존 버퍼를 먼저 flush
# 카테고리 마커 → 새 그룹 시작
m = self._RE_CAT.match(line)
if m:
flush()
@@ -104,7 +135,7 @@ class EmojiMarkupParser(BaseParser):
pending_names.extend(rest.split())
continue
# 이름 목록 줄 (카테고리 없이 연속)
# 이름 목록 (연속 줄은 같은 그룹으로 병합)
pending_names.extend(line.split())
flush()

View File

@@ -1,5 +1,6 @@
from .base import BaseRenderer
from .markdown import MarkdownRenderer
from .json_renderer import JSONRenderer
from .emoji_markup import EmojiMarkupRenderer
__all__ = ["BaseRenderer", "MarkdownRenderer", "JSONRenderer"]
__all__ = ["BaseRenderer", "MarkdownRenderer", "JSONRenderer", "EmojiMarkupRenderer"]

View File

@@ -0,0 +1,83 @@
"""
ctstruct/renderers/emoji_markup.py
CTTree → 이모지 마크업 텍스트 렌더러
"""
from __future__ import annotations
from ..node import CTNode
from ..tree import CTTree
from ..types import NodeType
from .base import BaseRenderer
class EmojiMarkupRenderer(BaseRenderer):
"""
CTTree 를 이모지 마크업 텍스트로 렌더링.
EmojiMarkupParser 와 같은 구분자를 사용하면 완전한 왕복 변환(roundtrip)이 보장됩니다.
Args:
title_mark: 문서 제목 앞에 붙는 마커 (기본 "")
section_marks: 섹션 레벨별 마커 목록 (기본 ["🔹", "🔸"])
인덱스 0 → level 1, 인덱스 1 → level 2, …
names_per_line: 한 줄에 출력할 최대 이름 수 (기본 0 = 제한 없음)
"""
def __init__(
self,
title_mark: str = "",
section_marks: list[str] | None = None,
names_per_line: int = 0,
) -> None:
self.title_mark = title_mark
self.section_marks = section_marks if section_marks is not None else ["🔹", "🔸"]
self.names_per_line = names_per_line
def render(self, tree: CTTree) -> str:
lines: list[str] = []
title = tree.root.attr("title")
if title:
lines.append(f"{self.title_mark}{title}")
lines.append("")
for child in tree.root.children:
lines.extend(self._render_node(child))
return "\n".join(lines).rstrip() + "\n"
# ── 내부 ─────────────────────────────────────────────────
def _render_node(self, node: CTNode) -> list[str]:
if node.node_type == NodeType.SECTION:
return self._render_section(node)
if node.node_type == NodeType.LIST:
return self._render_list(node)
return []
def _render_section(self, node: CTNode) -> list[str]:
level = node.attr("level", 1)
mark = self.section_marks[level - 1] if level <= len(self.section_marks) else ""
out = [f"{mark}{node.content or ''}"]
for child in node.children:
out.extend(self._render_node(child))
return out
def _render_list(self, node: CTNode) -> list[str]:
names = [item.content or "" for item in node.children
if item.node_type == NodeType.LIST_ITEM]
category = node.attr("category")
prefix = f"({category}) " if category else ""
if self.names_per_line > 0:
chunks = [names[i:i + self.names_per_line]
for i in range(0, len(names), self.names_per_line)]
lines = [prefix + " ".join(chunk) for chunk in chunks]
# prefix는 첫 줄에만
if len(lines) > 1:
lines = [lines[0]] + [" ".join(chunk) for chunk in chunks[1:]]
else:
lines = [prefix + " ".join(names)] if names else []
return lines + [""] # 목록 뒤 빈 줄

View File

@@ -6,20 +6,53 @@ CTTree → JSON 렌더러
from __future__ import annotations
import json
from typing import Any
from ..tree import CTTree
from .base import BaseRenderer
class JSONRenderer(BaseRenderer):
"""CTTree 를 JSON 문자열로 직렬화."""
"""
CTTree 를 JSON 문자열로 직렬화.
def __init__(self, indent: int = 2, ensure_ascii: bool = False) -> None:
Args:
indent: 들여쓰기 공백 수 (기본 2)
ensure_ascii: ASCII 강제 인코딩 여부 (기본 False)
slim: True 이면 null·기본값 필드를 제거해 출력을 간소화
"""
def __init__(
self,
indent: int = 2,
ensure_ascii: bool = False,
slim: bool = False,
) -> None:
self.indent = indent
self.ensure_ascii = ensure_ascii
self.slim = slim
def render(self, tree: CTTree) -> str:
return json.dumps(
tree.to_dict(),
indent=self.indent,
ensure_ascii=self.ensure_ascii,
)
data = tree.to_dict()
if self.slim:
data = self._strip(data)
return json.dumps(data, indent=self.indent, ensure_ascii=self.ensure_ascii)
# ── slim 헬퍼 ─────────────────────────────────────────────
@staticmethod
def _strip(obj: Any) -> Any:
"""None·기본값 필드를 재귀적으로 제거."""
if isinstance(obj, list):
return [JSONRenderer._strip(v) for v in obj]
if isinstance(obj, dict):
result: dict[str, Any] = {}
for k, v in obj.items():
if v is None:
continue
if k == "data_type" and v == "unset":
continue
if k == "attributes" and v == {}:
continue
result[k] = JSONRenderer._strip(v)
return result
return obj