feat: initial CTStruct framework

- CTNode: 트리 기본 단위 (전위/후위/BFS 순회, 탐색, 분리)
- CTTree: 트리 컨테이너 (빌더 API, TOC, filter/map/merge)
- NodeType: 구조/블록/인라인/메타 노드 타입 Enum
- MarkdownParser: Markdown → CTTree 파서
- MarkdownRenderer: CTTree → Markdown 렌더러
- JSONRenderer: CTTree → JSON 직렬화
- 27개 단위/통합 테스트 전체 통과
This commit is contained in:
2026-05-30 20:40:28 +09:00
commit 6e3cd19b55
38 changed files with 1695 additions and 0 deletions

10
ctstruct/__init__.py Normal file
View File

@@ -0,0 +1,10 @@
"""
CTStruct - 텍스트 양식을 구조화하는 트리 자료형 라이브러리
"""
from .node import CTNode
from .tree import CTTree
from .types import NodeType, ListStyle, Alignment
__version__ = "0.1.0"
__all__ = ["CTNode", "CTTree", "NodeType", "ListStyle", "Alignment"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

191
ctstruct/node.py Normal file
View File

@@ -0,0 +1,191 @@
"""
ctstruct/node.py
CTNode - 트리의 기본 단위 노드
"""
from __future__ import annotations
import uuid
from typing import Any, Iterator, Optional
from dataclasses import dataclass, field
from .types import NodeType
@dataclass
class CTNode:
"""
CTStruct 트리의 기본 노드.
Attributes:
node_type : 노드의 의미적 타입
content : 노드가 직접 보유하는 텍스트 값 (없으면 None)
attributes : 노드 메타정보 (level, lang, href, …)
node_id : 고유 식별자 (자동 생성)
parent : 부모 노드 (루트는 None)
children : 자식 노드 목록
"""
node_type : NodeType
content : Optional[str] = None
attributes : dict[str, Any] = field(default_factory=dict)
node_id : str = field(default_factory=lambda: uuid.uuid4().hex[:8])
parent : Optional["CTNode"] = field(default=None, repr=False, compare=False)
children : list["CTNode"] = field(default_factory=list)
# ── 자식 관리 ────────────────────────────────────────────
def append(self, child: "CTNode") -> "CTNode":
"""자식 노드를 추가하고 self 반환 (체이닝 지원)."""
child.parent = self
self.children.append(child)
return self
def prepend(self, child: "CTNode") -> "CTNode":
"""자식 노드를 맨 앞에 삽입."""
child.parent = self
self.children.insert(0, child)
return self
def insert(self, index: int, child: "CTNode") -> "CTNode":
"""지정 위치에 자식 노드 삽입."""
child.parent = self
self.children.insert(index, child)
return self
def remove(self, child: "CTNode") -> None:
"""자식 노드를 제거."""
self.children.remove(child)
child.parent = None
def detach(self) -> "CTNode":
"""자신을 부모에서 분리하고 self 반환."""
if self.parent:
self.parent.remove(self)
return self
# ── 탐색 ─────────────────────────────────────────────────
@property
def depth(self) -> int:
"""루트로부터의 깊이 (루트 = 0)."""
return 0 if self.parent is None else self.parent.depth + 1
@property
def is_leaf(self) -> bool:
return len(self.children) == 0
@property
def is_root(self) -> bool:
return self.parent is None
@property
def root(self) -> "CTNode":
"""트리 최상위 루트 노드 반환."""
return self if self.is_root else self.parent.root # type: ignore[union-attr]
@property
def siblings(self) -> list["CTNode"]:
"""같은 부모를 가진 형제 노드 목록."""
if self.parent is None:
return []
return [n for n in self.parent.children if n is not self]
def ancestors(self) -> Iterator["CTNode"]:
"""부모 → 루트 방향 이터레이터."""
node = self.parent
while node is not None:
yield node
node = node.parent
def walk(self, order: str = "pre") -> Iterator["CTNode"]:
"""
트리 전체 순회.
Args:
order: "pre" (전위) | "post" (후위) | "bfs" (너비 우선)
"""
if order == "pre":
yield self
for child in self.children:
yield from child.walk("pre")
elif order == "post":
for child in self.children:
yield from child.walk("post")
yield self
elif order == "bfs":
queue = [self]
while queue:
node = queue.pop(0)
yield node
queue.extend(node.children)
else:
raise ValueError(f"Unknown traversal order: {order!r}")
def find(self, node_type: NodeType) -> list["CTNode"]:
"""특정 타입의 모든 하위 노드 반환."""
return [n for n in self.walk() if n.node_type == node_type]
def find_one(self, node_type: NodeType) -> Optional["CTNode"]:
"""특정 타입의 첫 번째 하위 노드 반환."""
for n in self.walk():
if n.node_type == node_type:
return n
return None
def find_by_id(self, node_id: str) -> Optional["CTNode"]:
"""node_id로 노드 탐색."""
for n in self.walk():
if n.node_id == node_id:
return n
return None
# ── 속성 접근 헬퍼 ───────────────────────────────────────
def attr(self, key: str, default: Any = None) -> Any:
return self.attributes.get(key, default)
def set_attr(self, key: str, value: Any) -> "CTNode":
self.attributes[key] = value
return self
# ── 표현 ─────────────────────────────────────────────────
def text_content(self) -> str:
"""
자신과 모든 하위 노드의 텍스트를 이어 붙여 반환.
렌더러 없이 순수 텍스트 추출에 사용.
"""
parts: list[str] = []
for node in self.walk("pre"):
if node.content:
parts.append(node.content)
return "".join(parts)
def pretty(self, indent: int = 2) -> str:
"""트리 구조를 사람이 읽기 좋은 형태로 출력."""
pad = " " * (self.depth * indent)
tag = self.node_type.name
preview = repr(self.content[:20] + "") if self.content and len(self.content) > 20 else repr(self.content)
attrs = ""
if self.attributes:
attrs = " " + " ".join(f"{k}={v!r}" for k, v in self.attributes.items())
line = f"{pad}[{self.node_id}] {tag}{attrs}: {preview}"
child_lines = "\n".join(c.pretty(indent) for c in self.children)
return f"{line}\n{child_lines}" if child_lines else line
def __repr__(self) -> str:
return (
f"CTNode(id={self.node_id!r}, type={self.node_type.name}, "
f"children={len(self.children)}, content={self.content!r})"
)
def __len__(self) -> int:
"""자식 수 반환."""
return len(self.children)
def __iter__(self) -> Iterator["CTNode"]:
return iter(self.children)
def __getitem__(self, index: int) -> "CTNode":
return self.children[index]

View File

@@ -0,0 +1,4 @@
from .base import BaseParser
from .markdown import MarkdownParser
__all__ = ["BaseParser", "MarkdownParser"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

31
ctstruct/parsers/base.py Normal file
View File

@@ -0,0 +1,31 @@
"""
ctstruct/parsers/base.py
파서 추상 베이스 클래스
"""
from abc import ABC, abstractmethod
from ..tree import CTTree
class BaseParser(ABC):
"""
모든 텍스트 파서의 공통 인터페이스.
구현 클래스는 parse() 메서드만 오버라이드하면 됩니다.
"""
@abstractmethod
def parse(self, text: str) -> CTTree:
"""
텍스트를 파싱하여 CTTree 반환.
Args:
text: 파싱할 원문 텍스트
Returns:
구조화된 CTTree 객체
"""
...
def __call__(self, text: str) -> CTTree:
return self.parse(text)

View File

@@ -0,0 +1,172 @@
"""
ctstruct/parsers/markdown.py
Markdown → CTTree 파서
"""
from __future__ import annotations
import re
from ..node import CTNode
from ..tree import CTTree
from ..types import NodeType, ListStyle
from .base import BaseParser
class MarkdownParser(BaseParser):
"""
Markdown 텍스트를 CTTree 로 파싱.
지원 요소:
- ATX 헤딩 (# ~ ######)
- 문단
- 순서없는 목록 (-, *, +)
- 순서있는 목록 (1. 2. …)
- 코드 블록 (``` … ```)
- 인용구 (>)
- 구분선 (--- / ***)
- frontmatter (--- … ---)
"""
# ── 정규식 패턴 ──────────────────────────────────────────
_RE_HEADING = re.compile(r'^(#{1,6})\s+(.+)$')
_RE_UL_ITEM = re.compile(r'^(\s*)[*\-+]\s+(.+)$')
_RE_OL_ITEM = re.compile(r'^(\s*)\d+\.\s+(.+)$')
_RE_CODE_FENCE = re.compile(r'^```(\w*)$')
_RE_BLOCKQUOTE = re.compile(r'^>\s?(.*)$')
_RE_DIVIDER = re.compile(r'^(\-{3,}|\*{3,}|_{3,})$')
_RE_FRONTMATTER = re.compile(r'^---\s*$')
def parse(self, text: str) -> CTTree:
tree = CTTree()
lines = text.splitlines()
i = 0
# 1) frontmatter
metadata, i = self._parse_frontmatter(lines, i)
if metadata:
tree.root.set_attr("metadata", metadata)
# 2) 본문 파싱
current_section: CTNode | None = None
while i < len(lines):
line = lines[i]
# 빈 줄 → 스킵
if not line.strip():
i += 1
continue
# 코드 블록
m = self._RE_CODE_FENCE.match(line)
if m:
lang = m.group(1)
code_lines, i = self._collect_code_block(lines, i + 1)
node = CTNode(NodeType.CODE_BLOCK, "\n".join(code_lines), {"language": lang})
self._attach(tree, current_section, node)
continue
# 헤딩
m = self._RE_HEADING.match(line)
if m:
level = len(m.group(1))
text_content = m.group(2).strip()
heading = CTNode(NodeType.HEADING, text_content, {"level": level})
if level == 1:
# 새 섹션 시작
current_section = CTNode(NodeType.SECTION)
current_section.append(heading)
tree.root.append(current_section)
else:
self._attach(tree, current_section, heading)
i += 1
continue
# 구분선
if self._RE_DIVIDER.match(line):
node = CTNode(NodeType.DIVIDER)
self._attach(tree, current_section, node)
i += 1
continue
# 인용구
m = self._RE_BLOCKQUOTE.match(line)
if m:
quote_lines = [m.group(1)]
i += 1
while i < len(lines) and self._RE_BLOCKQUOTE.match(lines[i]):
quote_lines.append(self._RE_BLOCKQUOTE.match(lines[i]).group(1)) # type: ignore
i += 1
node = CTNode(NodeType.BLOCKQUOTE, "\n".join(quote_lines))
self._attach(tree, current_section, node)
continue
# 목록
if self._RE_UL_ITEM.match(line) or self._RE_OL_ITEM.match(line):
lst_node, i = self._parse_list(lines, i)
self._attach(tree, current_section, lst_node)
continue
# 문단
para_lines = [line]
i += 1
while i < len(lines) and lines[i].strip() and not self._is_block_start(lines[i]):
para_lines.append(lines[i])
i += 1
node = CTNode(NodeType.PARAGRAPH, " ".join(para_lines))
self._attach(tree, current_section, node)
return tree
# ── 헬퍼 ─────────────────────────────────────────────────
@staticmethod
def _attach(tree: CTTree, section: CTNode | None, node: CTNode) -> None:
if section is not None:
section.append(node)
else:
tree.root.append(node)
def _parse_frontmatter(self, lines: list[str], i: int) -> tuple[dict, int]:
if i >= len(lines) or not self._RE_FRONTMATTER.match(lines[i]):
return {}, i
i += 1
meta: dict = {}
while i < len(lines) and not self._RE_FRONTMATTER.match(lines[i]):
if ":" in lines[i]:
k, _, v = lines[i].partition(":")
meta[k.strip()] = v.strip()
i += 1
return meta, i + 1
def _collect_code_block(self, lines: list[str], i: int) -> tuple[list[str], int]:
code: list[str] = []
while i < len(lines):
if lines[i].strip() == "```":
return code, i + 1
code.append(lines[i])
i += 1
return code, i
def _parse_list(self, lines: list[str], i: int) -> tuple[CTNode, int]:
is_ordered = bool(self._RE_OL_ITEM.match(lines[i]))
style = ListStyle.ORDERED if is_ordered else ListStyle.UNORDERED
lst = CTNode(NodeType.LIST, attributes={"style": style.value})
pattern = self._RE_OL_ITEM if is_ordered else self._RE_UL_ITEM
while i < len(lines):
m = pattern.match(lines[i])
if not m:
break
lst.append(CTNode(NodeType.LIST_ITEM, m.group(2)))
i += 1
return lst, i
def _is_block_start(self, line: str) -> bool:
return bool(
self._RE_HEADING.match(line)
or self._RE_CODE_FENCE.match(line)
or self._RE_BLOCKQUOTE.match(line)
or self._RE_DIVIDER.match(line)
or self._RE_UL_ITEM.match(line)
or self._RE_OL_ITEM.match(line)
)

View File

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

Binary file not shown.

View File

@@ -0,0 +1,31 @@
"""
ctstruct/renderers/base.py
렌더러 추상 베이스 클래스
"""
from abc import ABC, abstractmethod
from ..tree import CTTree
class BaseRenderer(ABC):
"""
모든 렌더러의 공통 인터페이스.
구현 클래스는 render() 메서드를 오버라이드합니다.
"""
@abstractmethod
def render(self, tree: CTTree) -> str:
"""
CTTree 를 대상 포맷 텍스트로 변환.
Args:
tree: 렌더링할 CTTree
Returns:
변환된 텍스트 문자열
"""
...
def __call__(self, tree: CTTree) -> str:
return self.render(tree)

View File

@@ -0,0 +1,25 @@
"""
ctstruct/renderers/json_renderer.py
CTTree → JSON 렌더러
"""
from __future__ import annotations
import json
from ..tree import CTTree
from .base import BaseRenderer
class JSONRenderer(BaseRenderer):
"""CTTree 를 JSON 문자열로 직렬화."""
def __init__(self, indent: int = 2, ensure_ascii: bool = False) -> None:
self.indent = indent
self.ensure_ascii = ensure_ascii
def render(self, tree: CTTree) -> str:
return json.dumps(
tree.to_dict(),
indent=self.indent,
ensure_ascii=self.ensure_ascii,
)

View File

@@ -0,0 +1,88 @@
"""
ctstruct/renderers/markdown.py
CTTree → Markdown 렌더러
"""
from __future__ import annotations
from ..node import CTNode
from ..tree import CTTree
from ..types import NodeType
from .base import BaseRenderer
class MarkdownRenderer(BaseRenderer):
"""CTTree 를 Markdown 텍스트로 렌더링."""
def render(self, tree: CTTree) -> str:
lines: list[str] = []
# frontmatter
meta = tree.root.attr("metadata")
if meta:
lines.append("---")
for k, v in meta.items():
lines.append(f"{k}: {v}")
lines.append("---")
lines.append("")
for child in tree.root:
lines.extend(self._render_node(child))
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def _render_node(self, node: CTNode) -> list[str]:
t = node.node_type
if t == NodeType.SECTION:
out: list[str] = []
for c in node:
out.extend(self._render_node(c))
out.append("")
return out
if t == NodeType.HEADING:
level = node.attr("level", 1)
return [f"{'#' * level} {node.content or ''}"]
if t == NodeType.PARAGRAPH:
return [node.content or ""]
if t == NodeType.CODE_BLOCK:
lang = node.attr("language", "")
return [f"```{lang}", node.content or "", "```"]
if t == NodeType.BLOCKQUOTE:
return [f"> {line}" for line in (node.content or "").splitlines()]
if t == NodeType.DIVIDER:
return ["---"]
if t == NodeType.LIST:
out = []
style = node.attr("style", "unordered")
for idx, item in enumerate(node, 1):
prefix = f"{idx}." if style == "ordered" else "-"
out.append(f"{prefix} {item.content or ''}")
return out
if t == NodeType.TABLE:
return self._render_table(node)
if t == NodeType.METADATA:
return [] # 메타 노드는 frontmatter로 처리됨
# 그 외 노드는 텍스트 추출
return [node.text_content()]
def _render_table(self, table: CTNode) -> list[str]:
rows = list(table)
if not rows:
return []
out: list[str] = []
for i, row in enumerate(rows):
cells = [c.content or "" for c in row]
out.append("| " + " | ".join(cells) + " |")
if i == 0:
out.append("| " + " | ".join(["---"] * len(cells)) + " |")
return out

197
ctstruct/tree.py Normal file
View File

@@ -0,0 +1,197 @@
"""
ctstruct/tree.py
CTTree - 트리 전체를 관리하는 컨테이너
"""
from __future__ import annotations
from typing import Any, Callable, Iterator, Optional
from .node import CTNode
from .types import NodeType
class CTTree:
"""
CTStruct 트리 컨테이너.
루트 노드를 통해 전체 트리를 관리하며,
파싱·렌더링·변환 파이프라인의 중심 객체.
Examples:
>>> tree = CTTree()
>>> sec = tree.add(NodeType.SECTION)
>>> sec.append(CTNode(NodeType.HEADING, "제목", {"level": 1}))
>>> sec.append(CTNode(NodeType.PARAGRAPH, "본문 내용입니다."))
>>> print(tree.pretty())
"""
def __init__(self, title: str = "", metadata: Optional[dict[str, Any]] = None) -> None:
self.root = CTNode(NodeType.ROOT)
if title:
self.root.set_attr("title", title)
if metadata:
meta_node = CTNode(NodeType.METADATA, attributes=metadata)
self.root.append(meta_node)
# ── 빌더 API ─────────────────────────────────────────────
def add(self, node_type: NodeType,
content: Optional[str] = None,
**attrs: Any) -> CTNode:
"""루트에 직접 자식 노드를 추가하고 반환."""
node = CTNode(node_type, content, dict(attrs))
self.root.append(node)
return node
@staticmethod
def make_heading(text: str, level: int = 1) -> CTNode:
return CTNode(NodeType.HEADING, text, {"level": level})
@staticmethod
def make_paragraph(text: str) -> CTNode:
return CTNode(NodeType.PARAGRAPH, text)
@staticmethod
def make_code_block(code: str, language: str = "") -> CTNode:
return CTNode(NodeType.CODE_BLOCK, code, {"language": language})
@staticmethod
def make_list(items: list[str], ordered: bool = False) -> CTNode:
from .types import ListStyle
style = ListStyle.ORDERED if ordered else ListStyle.UNORDERED
lst = CTNode(NodeType.LIST, attributes={"style": style.value})
for item in items:
lst.append(CTNode(NodeType.LIST_ITEM, item))
return lst
@staticmethod
def make_table(headers: list[str], rows: list[list[str]]) -> CTNode:
table = CTNode(NodeType.TABLE)
header_row = CTNode(NodeType.TABLE_ROW, attributes={"is_header": True})
for h in headers:
header_row.append(CTNode(NodeType.TABLE_CELL, h, {"is_header": True}))
table.append(header_row)
for row in rows:
tr = CTNode(NodeType.TABLE_ROW)
for cell in row:
tr.append(CTNode(NodeType.TABLE_CELL, cell))
table.append(tr)
return table
# ── 탐색 ─────────────────────────────────────────────────
def find(self, node_type: NodeType) -> list[CTNode]:
"""트리 전체에서 특정 타입 노드 검색."""
return self.root.find(node_type)
def find_by_id(self, node_id: str) -> Optional[CTNode]:
return self.root.find_by_id(node_id)
def walk(self, order: str = "pre") -> Iterator[CTNode]:
return self.root.walk(order)
def headings(self) -> list[CTNode]:
return self.find(NodeType.HEADING)
def toc(self) -> list[dict[str, Any]]:
"""
문서 목차(TOC) 반환.
Returns:
[{"level": int, "text": str, "node_id": str}, ...]
"""
return [
{
"level": h.attr("level", 1),
"text": h.content or "",
"node_id": h.node_id,
}
for h in self.headings()
]
# ── 변환 ─────────────────────────────────────────────────
def filter(self, predicate: Callable[[CTNode], bool]) -> "CTTree":
"""
조건에 맞는 노드만 남긴 **새로운** 트리 반환.
원본 트리는 불변.
"""
import copy
new_tree = copy.deepcopy(self)
nodes_to_remove = [
n for n in new_tree.walk()
if not predicate(n) and not n.is_root
]
for n in nodes_to_remove:
if n.parent:
n.parent.remove(n)
return new_tree
def map(self, transform: Callable[[CTNode], None]) -> "CTTree":
"""
모든 노드에 transform 함수를 적용한 **새로운** 트리 반환.
"""
import copy
new_tree = copy.deepcopy(self)
for node in new_tree.walk():
transform(node)
return new_tree
def merge(self, other: "CTTree") -> "CTTree":
"""다른 트리의 루트 자식들을 현재 트리에 병합 (in-place)."""
import copy
for child in other.root.children:
self.root.append(copy.deepcopy(child))
return self
# ── 직렬화 ───────────────────────────────────────────────
def to_dict(self) -> dict[str, Any]:
"""트리 전체를 Python dict 로 직렬화."""
def _serialize(node: CTNode) -> dict:
return {
"id": node.node_id,
"type": node.node_type.name,
"content": node.content,
"attributes": node.attributes,
"children": [_serialize(c) for c in node.children],
}
return _serialize(self.root)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "CTTree":
"""dict 에서 트리 복원."""
tree = cls()
def _deserialize(d: dict) -> CTNode:
node = CTNode(
node_type=NodeType[d["type"]],
content=d.get("content"),
attributes=d.get("attributes", {}),
node_id=d.get("id", ""),
)
for child_data in d.get("children", []):
node.append(_deserialize(child_data))
return node
tree.root = _deserialize(data)
return tree
# ── 표현 ─────────────────────────────────────────────────
@property
def node_count(self) -> int:
return sum(1 for _ in self.walk())
@property
def depth(self) -> int:
return max((n.depth for n in self.walk()), default=0)
def pretty(self) -> str:
return self.root.pretty()
def __repr__(self) -> str:
return f"CTTree(nodes={self.node_count}, depth={self.depth})"
def __len__(self) -> int:
return len(self.root.children)

55
ctstruct/types.py Normal file
View File

@@ -0,0 +1,55 @@
"""
ctstruct/types.py
노드 타입 정의 모듈
"""
from enum import Enum, auto
class NodeType(Enum):
"""CTStruct 트리 노드 타입 정의."""
# ── 구조 노드 ────────────────────────────────────────────
ROOT = auto() # 트리의 최상위 루트
DOCUMENT = auto() # 문서 단위
SECTION = auto() # 섹션 (헤딩 + 하위 내용 묶음)
# ── 블록 노드 ────────────────────────────────────────────
HEADING = auto() # 제목 (level 1~6)
PARAGRAPH = auto() # 문단
LIST = auto() # 목록 컨테이너 (ordered / unordered)
LIST_ITEM = auto() # 목록 항목
TABLE = auto() # 표 컨테이너
TABLE_ROW = auto() # 표 행
TABLE_CELL = auto() # 표 셀
CODE_BLOCK = auto() # 코드 블록
BLOCKQUOTE = auto() # 인용구
DIVIDER = auto() # 구분선
# ── 인라인 노드 ──────────────────────────────────────────
TEXT = auto() # 순수 텍스트
BOLD = auto() # 굵게
ITALIC = auto() # 기울임
CODE = auto() # 인라인 코드
LINK = auto() # 하이퍼링크
IMAGE = auto() # 이미지
# ── 메타 노드 ────────────────────────────────────────────
METADATA = auto() # 문서 메타데이터 (frontmatter 등)
ANNOTATION = auto() # 주석 / 노트
CUSTOM = auto() # 사용자 정의 확장 노드
class ListStyle(Enum):
"""목록 스타일."""
UNORDERED = "unordered"
ORDERED = "ordered"
TASK = "task"
class Alignment(Enum):
"""표 셀 정렬."""
LEFT = "left"
CENTER = "center"
RIGHT = "right"
NONE = "none"