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:
10
ctstruct/__init__.py
Normal file
10
ctstruct/__init__.py
Normal 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"]
|
||||
BIN
ctstruct/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
ctstruct/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
ctstruct/__pycache__/node.cpython-313.pyc
Normal file
BIN
ctstruct/__pycache__/node.cpython-313.pyc
Normal file
Binary file not shown.
BIN
ctstruct/__pycache__/tree.cpython-313.pyc
Normal file
BIN
ctstruct/__pycache__/tree.cpython-313.pyc
Normal file
Binary file not shown.
BIN
ctstruct/__pycache__/types.cpython-313.pyc
Normal file
BIN
ctstruct/__pycache__/types.cpython-313.pyc
Normal file
Binary file not shown.
191
ctstruct/node.py
Normal file
191
ctstruct/node.py
Normal 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]
|
||||
4
ctstruct/parsers/__init__.py
Normal file
4
ctstruct/parsers/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .base import BaseParser
|
||||
from .markdown import MarkdownParser
|
||||
|
||||
__all__ = ["BaseParser", "MarkdownParser"]
|
||||
BIN
ctstruct/parsers/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
ctstruct/parsers/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
ctstruct/parsers/__pycache__/base.cpython-313.pyc
Normal file
BIN
ctstruct/parsers/__pycache__/base.cpython-313.pyc
Normal file
Binary file not shown.
BIN
ctstruct/parsers/__pycache__/markdown.cpython-313.pyc
Normal file
BIN
ctstruct/parsers/__pycache__/markdown.cpython-313.pyc
Normal file
Binary file not shown.
31
ctstruct/parsers/base.py
Normal file
31
ctstruct/parsers/base.py
Normal 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)
|
||||
172
ctstruct/parsers/markdown.py
Normal file
172
ctstruct/parsers/markdown.py
Normal 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)
|
||||
)
|
||||
5
ctstruct/renderers/__init__.py
Normal file
5
ctstruct/renderers/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from .base import BaseRenderer
|
||||
from .markdown import MarkdownRenderer
|
||||
from .json_renderer import JSONRenderer
|
||||
|
||||
__all__ = ["BaseRenderer", "MarkdownRenderer", "JSONRenderer"]
|
||||
BIN
ctstruct/renderers/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
ctstruct/renderers/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
ctstruct/renderers/__pycache__/base.cpython-313.pyc
Normal file
BIN
ctstruct/renderers/__pycache__/base.cpython-313.pyc
Normal file
Binary file not shown.
BIN
ctstruct/renderers/__pycache__/json_renderer.cpython-313.pyc
Normal file
BIN
ctstruct/renderers/__pycache__/json_renderer.cpython-313.pyc
Normal file
Binary file not shown.
BIN
ctstruct/renderers/__pycache__/markdown.cpython-313.pyc
Normal file
BIN
ctstruct/renderers/__pycache__/markdown.cpython-313.pyc
Normal file
Binary file not shown.
31
ctstruct/renderers/base.py
Normal file
31
ctstruct/renderers/base.py
Normal 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)
|
||||
25
ctstruct/renderers/json_renderer.py
Normal file
25
ctstruct/renderers/json_renderer.py
Normal 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,
|
||||
)
|
||||
88
ctstruct/renderers/markdown.py
Normal file
88
ctstruct/renderers/markdown.py
Normal 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
197
ctstruct/tree.py
Normal 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
55
ctstruct/types.py
Normal 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"
|
||||
Reference in New Issue
Block a user