ADR-004: 요소 단위 API — Run / Paragraph / Cell / Section

공개

2026년 4월 29일

맥락

v3 의 ADR-003 으로 다중 문서가 깔끔히 정착했고, 각 Document 에 7개 컬렉션 (fields, bookmarks, hyperlinks, images, paragraphs, styles, tables) 이 자리잡았 습니다. 그러나 컬렉션이 반환하는 개별 element 들은 여전히 빈약합니다:

  • doc.paragraphs[0]Paragraph — 현재 text, index 정도만
  • doc.tables[0]Tablecell(r, c) 정도만
  • doc.tables[0].cell(1, 1)Celltext, border, fill setter 가 약속만 있음 (migration v1→v2 가이드 참조)
  • Run (글자 단위 형식 단위) 클래스 자체가 없음

결과적으로 사용자가 “이 단락의 정렬을 바꾸고 글자 색만 바꾸고 싶다” 같은 작업을 할 때 여전히 doc.actions.X.run() raw 로 내려가야 합니다. v3 의 doc.X 일관성이 element 레벨에서 깨지는 셈입니다.

xlwings 와 비교:

# xlwings — element 까지 풀 API
sheet.range("A1:B5").value = [[1,2],[3,4]]
sheet.range("A1").color = (255, 200, 200)
sheet.range("A1").font.bold = True

hwpapi 가 도달해야 할 비슷한 수준:

# 목표 (v3.x — 이 ADR)
para = doc.paragraphs[0]
para.text = "새 본문"
para.parashape.align = "center"
for run in para.runs:
    run.charshape.bold = True

cell = doc.tables[0].cell(1, 1)
cell.text = "Q1"
cell.fill = "#FFE0E0"
cell.border = {"top": "double", "bottom": "solid"}

결정

4 종 element 클래스를 정식 정의 + 각자 자기 책임 범위 명확화. 모든 element 는 ADR-003 의 doc-activate 패턴을 그대로 따름 — element 의 메소드 진입 시 owning doc 자동 활성화.

Element 매트릭스

Element 컬렉션 진입 책임
Paragraph doc.paragraphs[i] 단락 텍스트, ParaShape, runs 컬렉션
Run paragraph.runs[i] 한 글자형식 구간의 text, CharShape
Table doc.tables[i] rows/cols, cell(r,c), 표 단위 BorderFill
Cell table.cell(r, c) text, fill, border, charshape, parashape
Section (보류) doc.sections[i] 페이지 설정, 머리말/꼬리말 (v3.1)

표면 (각 element 의 핵심 surface)

class Paragraph:
    index           # 단락 번호 (0-based)
    text            # r/w property
    parashape       # ParaShape (r/w)
    charshape       # 단락 시작 글자의 CharShape (r/w convenience)
    runs            # RunCollection
    insert_text(s)  # 이 단락 끝에 추가
    delete()        # 이 단락 제거
    activate()      # 커서를 이 단락으로

class Run:
    paragraph       # owning Paragraph
    text            # r/w
    charshape       # CharShape (r/w)
    start, end      # 단락 안의 글자 위치 범위

class Table:
    index, rows, cols
    cell(r, c) → Cell
    iter_cells()    # row-major
    delete()
    border_fill     # 표 전체 BorderFill
    activate()

class Cell:
    table           # owning Table
    row, col, address (e.g. "B2")
    text            # r/w
    charshape / parashape   # r/w
    fill            # str | Color | None — 셀 배경
    border          # dict 형 setter ({"top": "solid", ...})
    width / height  # r/w (HWPUNIT)
    activate()      # 이 셀로 커서 이동

doc-activate 패턴 적용

모든 element 는 owning doc 의 reference 를 갖고, 자기 메소드/setter 진입 시 doc.activate() 를 호출합니다 (ADR-003 의 _DocActions 와 동일 패턴):

class Paragraph:
    def __init__(self, doc, index):
        self._doc = doc
        self._index = index

    @property
    def text(self):
        self._doc.activate()
        # ... GetTextFile + 범위 추출
        return ...

    @text.setter
    def text(self, value):
        self._doc.activate()
        # ... select range + insert_text

다중 문서 환경에서 for p in doc1.paragraphs: p.textfor p in doc2.paragraphs: p.text 가 자동 doc-switching 되면서 안전하게 동작.

CharShape / ParaShape — 기존 ParameterSet 재사용

v2 에서 이미 hwpapi.low.parametersets.CharShape / ParaShape 가 있음. element 의 .charshape / .parashape 는 이들의 인스턴스를 반환/할당. 사용자는 익숙한 dict-like 접근:

cs = run.charshape      # CharShape 인스턴스
cs.bold = True
cs.height = 1400
run.charshape = cs      # setter — 적용

# 또는 dict 한방
run.charshape = {"bold": True, "height": 1400}

사용자 멘탈 모델

App (process)
└── Document (per-doc)
    ├── 컬렉션 (fields, bookmarks, ...)
    ├── paragraphs : Collection[Paragraph]
    │   └── runs   : Collection[Run]
    └── tables : Collection[Table]
        └── cell(r, c) : Cell

각 레이어는 자기 책임만 — App 은 lifecycle, Document 는 doc-scoped ops, Element 는 element-scoped ops.

마이그레이션 단계

Phase 1 — Paragraph + Run

  • Paragraph.text getter/setter
  • Paragraph.parashape getter/setter
  • Paragraph.runs (RunCollection)
  • Run.text, Run.charshape getter/setter
  • 테스트: tests/test_paragraph.py, tests/test_run.py

Phase 2 — Table + Cell

  • Table.cell(r, c)Cell 반환
  • Cell.text, Cell.fill, Cell.border (border dict 형)
  • Cell.charshape, Cell.parashape
  • Cell.activate() 가 커서를 셀로
  • 테스트: tests/test_table.py, tests/test_cell.py

Phase 3 — RunCollection + iter_cells

  • paragraph.runs 의 iteration / 인덱싱
  • Table.iter_cells() row-major + filter
  • 테스트 추가

Phase 4 — Section (보류)

  • 페이지 설정, 머리말/꼬리말 — v3.1 또는 별도 ADR

사용 예

단락 / Run

doc = app.docs.open("report.hwp")

# 1단락 텍스트와 정렬
para = doc.paragraphs[0]
para.text = "1분기 보고"
para.parashape = {"align": "center", "line_spacing": 180}

# 안에 있는 모든 run 을 굵게
for run in para.runs:
    run.charshape = {"bold": True}

# 또는 한 단락 안의 일부만
para.runs[0].charshape = {"text_color": "#E74C3C"}

Table / Cell

doc.insert_table(rows=4, cols=4)
table = doc.tables[-1]                  # 방금 만든 표

table.cell(0, 0).text = "제품"
table.cell(0, 1).text = "Q1"
table.cell(0, 2).text = "Q2"
table.cell(0, 3).text = "Q3"

# 헤더 셀 일괄 서식
for c in range(4):
    cell = table.cell(0, c)
    cell.fill = "#34495E"
    cell.charshape = {"bold": True, "text_color": "#FFFFFF"}

# 첫 열만 굵은 테두리
for r in range(1, 4):
    table.cell(r, 0).border = {"right": "double"}

다중 문서 + element

src = app.docs.open("source.hwp")
dst = app.docs.add()

# doc-activate 자동 — element 가 owning doc 으로 자동 전환
for para in src.paragraphs:
    if para.text.startswith("□"):
        # 이 시점엔 src 활성, src.paragraphs[i].text 는 정상
        pass

# dst 작업 — 자동 전환
for row in [["A", "B"], ["C", "D"]]:
    dst.insert_table(rows=1, cols=2)
    for c, val in enumerate(row):
        dst.tables[-1].cell(0, c).text = val

결과

긍정

  • API 일관성doc.X, paragraph.X, cell.X 모두 같은 패턴
  • 다중 문서 안전 — element 도 doc-activate 자동
  • raw action 의존도 감소 — Cell 서식 등 일상 작업이 element 메소드로 가능
  • xlwings/openpyxl 사용자 친숙 — sheet.range, cell.font 등과 동형
  • 테스트 가능 — Mock 으로 element 단위 검증

부정 / 위험

  • 클래스 수 증가 — Paragraph/Run/Table/Cell 4개 신규. 컬렉션 포함 8개 (RunCollection, CellCollection 도) 이미 늘어남.
  • HWP 의 element 인덱싱 불안정 — 문서 편집 중 paragraph index 가 밀릴 수 있음. cell 도 표 행/열 추가 시 변동. 안정적 식별자가 필요한 경우 사용자가 reload 해야 함 (xlwings 도 동일).
  • CharShape/ParaShape 의 process-wide pset 문제 — element 마다 자기 pset 을 보유하지 않고 process 레벨 공유. doc-activate 와 결합해 안전하나, 동시성은 여전히 보장 안 됨 (Python GIL 가정).
  • Phase 1/2 구현 비용 — 각 element 에 대해 텍스트 범위 추적, selection 관리, charshape 적용/읽기 인프라 필요. 약 600~1000 LOC 추정.

미해결 질문

  1. Run 의 경계 정의 — HWP 에서 “동일 charshape 의 연속 글자” 가 run 의 자연스러운 정의이지만, 단락 안에서 이를 효율적으로 순회하는 API 가 있는지 검증 필요. 없으면 paragraph.text 를 캐릭터 단위로 스캔하며 charshape 비교로 구간 결정 (느림).
  2. Cell.border 의 dict 형 설계{"top": "solid"} 가 단일 변, {"all": "solid"} 가 4변 일괄, {"outer": "solid", "inner": "dot"} 같은 alias 까지 지원할지.
  3. Element 의 동일성 (==) — 같은 표의 같은 셀을 두 번 가져온 Cell 두 개가 == 인가? row/col 비교? raw COM 핸들 비교?
  4. Section / Header / Footer — 이번 ADR 범위에 포함 여부. 분리 (v3.1) 권장.
  5. iter 방식의 안정성for p in doc.paragraphs: 중에 단락이 추가/삭제되면? generator-based 으로 lazy 하게 갈지, snapshot 으로 고정할지.

참고

맨 위로