Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@

## [Unreleased]

### 추가

- `doc.text.blocks()`가 본문 문단과 표 칸 문단(칸 안 표 포함)을 문서 순서대로, 위치로 정해지는 id(`s0p16`,
`s0p16t0r3c0q0`)와 사람이 읽는 위치(`표1 4행 1열`)와 함께 내놓는다. 병합 칸은 대표 칸에서 한 번만 나오고, id는
저장·재열기 뒤에도 같다(#666).
- `doc.text.outline(max_chars=8000)`이 글자가 있는 덩어리만 `{"id", "where", "text"}` 목록으로, 글자 예산 안에서
돌려준다. 탭·줄 바꿈은 공백, 제어 문자는 지우고, 긴 덩어리는 앞부분만 남긴다. 예산이 다하면 `next_after`로 다음
쪽을 읽고, `to_text()`는 한 줄에 한 항목인 프롬프트 형태다. 작은 모델에 문서 전체 대신 짧은 목록을 넘기는 용도다(#668).
- `doc.text.apply_changes({id: 새 글})`이 여러 덩어리를 한 번에 바꾸고 id마다 `changed`·`unchanged`·`unknown_ids`·
`rewritten`을 보고한다. 한 런 안의 바꿈은 런과 컨트롤을 지키고, 칸이 품는 줄보다 많이 필요할 것으로 추정되는 칸은
`overflow`로 미리 알린다(form-fit 측정기의 추정, 렌더 아님)(#667).

### 고침

- 줄 캐시(`write_line_caches`)가 머리에 정의되지 않은 글머리표·번호를 가리키는 문단 모양(예: 글머리표 idRef 0)의
Expand Down
24 changes: 24 additions & 0 deletions docs/ai-assistants.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,30 @@ environment where PyPI is unavailable:
confirm the requested change is present.
```

## 작은 모델·로컬 LLM — 짧은 목록으로 읽고 id로 바꾸기

27B급 로컬 모델은 문서 구조를 통째로 읽으면 문맥이 넘치고, 긴 인수를 담은 도구 호출이
깨지기 쉽습니다. 모델에게는 id 붙은 짧은 목록만 보여 주고, 바꿀 글만 `{id: 새 글}`로
받아 코드가 문서에 넣는 방식이 안정적입니다.

```python
from hwpx import HwpxDocument

doc = HwpxDocument.open("학습지.hwpx")
page = doc.text.outline(max_chars=8000) # [{"id", "where", "text"}, ...]
prompt = page.to_text() # "[s0p3t0r1c1q0] 표1 2행 2열: ..." 한 줄씩
# page.complete 가 False 면 doc.text.outline(after=page.next_after) 로 다음 쪽

changes = {"s0p3t0r1c1q0": "바뀐 문항 내용"} # 모델이 JSON 으로 돌려준 것
report = doc.text.apply_changes(changes)
report.unknown_ids # 문서에 없는 id
report.overflow # 칸이 넘칠 것으로 추정되는 곳 — 글을 줄이게 다시 물어보기
doc.save_to_path("학습지-변형.hwpx")
```

id는 위치로 정해져 저장·재열기 뒤에도 같습니다. 문단·표·칸을 넣거나 지우면 그 뒤의
id가 바뀌므로, 목록을 읽은 뒤 구조를 바꿨다면 목록을 다시 읽습니다.

## 최소 예제

wheel 설치 뒤 새 문서를 만들어 돌려주는 가장 짧은 코드입니다.
Expand Down
6 changes: 6 additions & 0 deletions docs/architecture/module-ownership.json
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,12 @@
"disposition": "core",
"approvedBy": "owner-authorized issue565 additive implementation",
"rationale": "QName-aware bounded byte-span selection of text after a table within its own paragraph; reusable format behavior. The caller supplies the predicate, so this module contains no genre, institution, workflow or caption policy. Secure XML parsing and existing OPC markup tokenization validate namespace scopes without reserializing source bytes."
},
{
"path": "src/hwpx/_document/text_blocks.py",
"disposition": "core",
"approvedBy": "text-blocks-for-small-models",
"rationale": "doc.text.blocks/outline/apply_changes: walks body and table-cell paragraphs (nested tables included) in document order with positional ids, lists the non-empty ones within a character budget, and writes {id: text} back through the paragraph's own run-preserving setter, estimating cell overflow with the form-fit measurer. Genre-neutral format behavior with no workflow, prompt or model policy; the owner of doc.text keeps its namespace module short by delegating here like the other _document owners."
}
],
"removed": [
Expand Down
3 changes: 2 additions & 1 deletion docs/editor-surface-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ support-matrix.md` · `docs/coverage-ledger.json`)과 OWPML 스키마 ·

<!-- AUTO-GENERATED:BEGIN (scripts/editor_surface_inventory.py) -->

자동 생성 시점 교차 확인: 원장(요소 축) 132건 render-verified(요소 345개 중) · 캐파빌리티 영역(기능 축) 41개 등록됨.
자동 생성 시점 교차 확인: 원장(요소 축) 132건 render-verified(요소 345개 중) · 캐파빌리티 영역(기능 축) 42개 등록됨.

### 서식

Expand Down Expand Up @@ -122,6 +122,7 @@ support-matrix.md` · `docs/coverage-ledger.json`)과 OWPML 스키마 ·

| 기능 | 엔진 상태 | 근거 | 실한컴 검증 |
|---|---|---|---|
| id 기반 글 읽기·일괄 바꿈(text blocks) | 저작 api | 지원 매트릭스 「id 기반 글 읽기·일괄 바꿈(text blocks)」(`Parse·Edit`) · capabilities 영역 `text-blocks` · 위치 `doc.text` | 미실측 |
| 메일머지(placeholder 템플릿 배치 생성) | 저작 api | 지원 매트릭스 「메일머지(placeholder 템플릿 배치 생성)」(`Edit·Render-verified`) · capabilities 영역 `mail-merge` · 위치 모듈 — `hwpx.tools.mail_merge` | Render-verified |
| 문서 끼워 넣기(문서 병합) | 저작 api | 지원 매트릭스 「문서 끼워 넣기(문서 병합)」(`Create·Render-verified`) · capabilities 영역 `document-merge` · 위치 모듈 — `hwpx.tools.document_merge` | Render-verified |
| 편집 계획 실행(edit plan) | 저작 api | 지원 매트릭스 「편집 계획 실행(edit plan)」(`Preserve·Edit·Render-verified`) · capabilities 영역 `edit-plan` · 위치 모듈 — `hwpx.plan` | Render-verified |
Expand Down
4 changes: 3 additions & 1 deletion docs/error-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ except HwpxError as exc:
| 형태 | `style-not-found` | `VISUAL_COMPLETE_FAILED` |
| 쓰임 | 예외 분기 | **발행된 영수증 스키마의 필드값** |
| 관리 | major 경계 | 영수증 스키마 버전 |
| 개수 | 196 | 11 |
| 개수 | 199 | 11 |

통합하지 않는 이유: quality 코드는 `hwpx.mutation-report/v1` 과
`VisualCompleteReport` 에 이미 실려 나간 값이다. 이름을 바꾸면 영수증을 읽는
Expand Down Expand Up @@ -371,10 +371,12 @@ except HwpxError as exc:

| 코드 | 뜻 |
|---|---|
| `text-block-unknown` | outline 의 after 에 준 글 덩어리 id 가 문서에 없다. |
| `text-highlight-color-invalid` | 형광펜 색이 #RRGGBB 6자리 16진 형식이 아니다. |
| `text-highlight-match-crosses-markup` | 찾은 구간이 인라인 마크업을 가로질러 안전하게 감쌀 수 없다. |
| `text-highlight-match-empty` | 형광펜으로 감쌀 문자열이 비어 있다. |
| `text-highlight-match-not-found` | 문단에서 형광펜으로 감쌀 문자열을 찾지 못했다. |
| `text-outline-budget` | outline 의 글자·항목 예산이 1보다 작다. |
| `text-search-empty` | 바꿀 대상 문자열이 비어 있다. |

### `track-*`
Expand Down
2 changes: 2 additions & 0 deletions docs/support-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
| 글꼴 등록 | Create·Render-verified | `doc.styles.ensure_font`(6.8 트레인㉚ 등재, 코드는 2026-08-04 감사 §2 갭#1 이후 사이클에서 구현됐으나 캐파빌리티 미등재 상태였다). 실한컴 렌더 검증: `docs/openrate/report-v5.json` authored-fontface 스트라텀(lang 7블록 전부·substFont on/off 조합), macOS Hancom GUI 오라클 15/15 render_checked·0 render_failed — 실제로 참조된 fontRef까지 확인(선언만 하고 고아로 남기지 않음) |
| 표 탐색 기반 채움(라벨 매칭 네비게이션) | Edit·Render-verified | `doc.tables.fill_by_path`/`.find_cell_by_label`(6.8 트레인㉚ 등재, 코드는 이전 사이클부터 존재). "양식 채움(byte-splice)" 영역과는 다른 메커니즘(byte-splice가 아니라 라벨 매칭 기반 구조적 셀 채움) — 혼동 주의. 실한컴 렌더 검증: `docs/openrate/report-v14.json` authored-tablenavfill 스트라텀(단일-홉 right/down 4건 + multi-hop `합계 > down > right` 1건), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-08 측정). **원장 라우팅은 없음** — 기존 `hp:tc`/`hp:t` 텍스트를 치환만 할 뿐 새 요소를 안 만든다(document-merge·mail-merge·find-replace와 같은 이유로 CAPABILITY_KEYWORDS 등록 요소 0개) — 등급 갱신은 이 산문과 report-v14.json 직접 인용으로만 |
| 찾아바꾸기 | Edit·Render-verified | `doc.text.replace`/`.find_runs`(6.8 트레인㉚ 등재, 코드는 이전 사이클부터 존재). 실한컴 렌더 검증: `docs/openrate/report-v14.json` authored-findreplace 스트라텀(무제한 치환 + `char_pr_id_ref`로 좁힌 스타일 한정 치환 교대), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-08 측정). **원장 라우팅은 없음** — 기존 `hp:t` 텍스트만 치환할 뿐 새 요소를 안 만든다(document-merge·mail-merge·table-navigation-fill과 같은 이유) — 등급 갱신은 이 산문과 report-v14.json 직접 인용으로만 |
| id 기반 글 읽기·일괄 바꿈(text blocks) | Parse·Edit | `doc.text.blocks`·`.outline`·`.apply_changes`: 본문 문단과 표 칸 문단(칸 안 표 포함)을 문서 순서대로 위치형 id(`s0p16t0r3c0q0`)와 함께 순회하고, 글자 예산 안의 짧은 목록으로 읽고, `{id: 새 글}`을 한 번에 적용한다. 병합 칸은 대표 칸에서 한 번만 나오고 id는 저장·재열기 뒤에도 같다(저장소 픽스처 1,210개·93,688덩어리 실측). 한 런 안의 바꿈은 런·컨트롤을 지키고, 칸 넘침은 form-fit 측정기로 추정해 보고한다 — 렌더가 아니라 추정이므로 Render-verified가 아니다. 기존 `hp:t` 텍스트만 바꾸고 새 요소를 만들지 않아 원장 라우팅은 없다(찾아바꾸기와 같은 이유) |
| 하이퍼링크·책갈피 | Create·Render-verified | `doc.refs.add_hyperlink`/`.add_bookmark`(6.8 트레인㉚에서 "네이티브 목차(TOC)/상호참조" 영역에서 분리 — 그 영역의 Render-verified 근거는 TOC 구조·페이지 정합뿐이라 하이퍼링크·책갈피를 한 번도 독립적으로 커버한 적이 없었다, 편집기 표면 인벤토리 트레인㉙ 발견). **6.9 트레인㉜**: `docs/openrate/report-v12.json` authored-hyperlink-bookmark 스트라텀(북마크 생성 후 외부 URL/문서 내 `#앵커` 하이퍼링크 교대), macOS Hancom GUI 오라클 15/15 render_checked·0 render_failed(2026-08-07 측정) — 이 영역의 첫 독립 실한컴 판정. `hp:bookmark`는 요소 직접 등록(`by-openrate-corpus`), `hp:fieldBegin`(하이퍼링크 자신의 컨트롤)은 CLICKHERE/TOC/CROSSREF와 공유하는 저수준 요소라 여전히 요소 등록 없음 — 영역 등급만 실측으로 갱신, 원장 오염 경로는 그대로 차단. 읽기: `doc.refs.bookmarks`가 한/글의 두 저장 형태(`hp:bookmark`·`hp:fieldBegin type="BOOKMARK"`)를 함께 나열한다(#579) |
| 메일머지(placeholder 템플릿 배치 생성) | Edit·Render-verified | `hwpx.tools.mail_merge.merge_template_rows`(6.8 트레인㉛ 등재, 코드는 이전 사이클부터 존재 — 편집기 표면 인벤토리 트레인㉙·계층 판정 트레인㉚이 찾을 때까지 캐파빌리티·매트릭스 어디에도 등재가 없었다). `{{field}}`/`${field}`/`<<field>>` 세 플레이스홀더 문법을 지원하는 템플릿 1개 + 행 데이터(csv/json/시퀀스)로 문서 N개를 배치 생성 — 기존 `hp:t` 텍스트를 치환하는 것이라 새 요소를 만들지 않는다(Create가 아니라 Edit). `strict`/`fit_policy`/`value_sanitizer` 등 운영 옵션 다수. 실한컴 렌더 검증: `docs/openrate/report-v13.json` authored-mailmerge 스트라텀(세 플레이스홀더 문법 각각 겨냥하는 전용 템플릿으로 회전), macOS Hancom GUI 오라클 10/10 render_checked·0 render_failed(2026-08-08 측정) — 이 영역의 첫 독립 실한컴 판정 |
| 메일머지 표시 필드 | Create(experimental) | `HwpxOxmlParagraph.add_mail_merge_field(name, cached_text=)`(`oxml/field_marks.py`, P4). 팀장 실한컴 GUI 프로브 gold(`tests/fixtures/gui_probes/mailmerge_display_fields.hwpx`, "도구 > 메일 머지 > 메일 머지 표시 달기"로 필드 2개 삽입) 역설계 — 위 「메일머지(placeholder 템플릿 배치 생성)」가 **기존 `hp:t` 텍스트를 치환**하는 Edit인 것과 달리 이쪽은 `hp:fieldBegin type="MAILMERGE"` 자체를 새로 만드는 Create다(그래서 별도 영역). DATE/PATH와 같은 단일 run 안 `ctrl(begin)`/`t`/`ctrl(end)` 배치에 더해 **끝에 빈 `hp:t`가 하나 더** 붙고(gold 2/2), `hp:fieldBegin`에 `metaTag` 속성이 **없다**(DATE/교정 부호/PATH gold는 셋 다 `metaTag=""`) — 필드 종류마다 실측이 갈리는 유일한 속성. `hp:parameters`(`cnt="5"`): `Fiexde=1`(**오타 철자가 실물·한컴 공식 문서 공통의 정본이라 정정 금지** — `trackchageConfig`·memo의 `NOMAL`과 같은 부류) · `Prop=8`(DATE/PATH와 같은 캐시 필드류 공유값) · `Command`/`FieldValue`가 둘 다 필드 이름 그대로 · `FieldType="USER_DEFINE"`. 캐시 텍스트 기본값 `{{이름}}`은 `hwpx.tools.mail_merge`가 인식하는 플레이스홀더 문법이라 이 필드로 저작한 템플릿이 그 배치 생성기에 그대로 물린다. gold의 두 필드는 `id`가 다른데 `fieldid`는 같은 값을 공유하나, 표본이 문서 하나뿐이라 채번 규칙을 역산할 근거가 없어 DATE/PATH와 같은 독립 난수를 쓴다. 실한컴 렌더 검증: `docs/openrate/report-v22.json` authored-mailmerge-field 스트라텀(단일/2필드/커스텀 캐시/**한글 필드명(경계)**/표 셀 안 필드, 5건), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-18 측정) |
Expand Down Expand Up @@ -107,6 +108,7 @@
| 글꼴 등록 | `doc.styles` |
| 표 탐색 기반 채움(라벨 매칭 네비게이션) | `doc.tables` |
| 찾아바꾸기 | `doc.text` |
| id 기반 글 읽기·일괄 바꿈(text blocks) | `doc.text` |
| 하이퍼링크·책갈피 | `doc.refs` |
| 메일머지(placeholder 템플릿 배치 생성) | 모듈 — `hwpx.tools.mail_merge` |
| 메일머지 표시 필드 | `doc.fields` |
Expand Down
1 change: 1 addition & 0 deletions scripts/editor_surface_inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@
"font-registration": "서식",
"table-navigation-fill": "표",
"find-replace": "서식",
"text-blocks": "자동화",
"hyperlink-bookmark": "참조",
# 6.8 트레인㉛ — 트레인㉚이 찾은 mail_merge 측정 갭 등재.
"mail-merge": "자동화",
Expand Down
62 changes: 62 additions & 0 deletions src/hwpx/_document/ns/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
if TYPE_CHECKING:
from ...objects.highlight import Highlight
from ...oxml import HwpxOxmlParagraph, HwpxOxmlRun
from ..text_blocks import TextBlock, TextChangeReport, TextOutline

__all__ = ["TextNamespace"]

Expand Down Expand Up @@ -154,6 +155,67 @@ def find_runs(
)
]

# -- id 붙은 글 덩어리 ---------------------------------------------------

def blocks(self) -> Iterator["TextBlock"]:
"""본문 문단과 표 칸 문단(칸 안 표 포함)을 문서 순서대로, 안정적인 id와 함께 내놓는다.

id는 위치로 정해진다: ``s0p16`` (0번 구역의 16번 본문 문단, 번호는 0부터), ``s0p16t0r3c0q0`` (그 문단의 0번 표,
3행 0열 칸의 0번 문단), 칸 안 표는 ``…q0t0r1c1q0`` 처럼 이어진다. 행·열은 칸의 주소라
병합 칸은 대표 칸에서 한 번만 나온다. 저장·재열기 뒤에도 같고, 앞쪽에 문단·표·칸을
넣거나 지울 때만 바뀐다. 머리말·꼬리말·각주·메모·글상자는 포함하지 않는다.
"""

from ..text_blocks import iter_text_blocks

return iter_text_blocks(self._doc)

def outline(
self,
*,
max_chars: int = 8000,
max_items: int | None = None,
max_block_chars: int = 200,
after: str | None = None,
) -> "TextOutline":
"""글자가 있는 덩어리를 ``{"id", "where", "text"}`` 목록으로, 글자 예산 안에서 돌려준다.

빈 문단은 빼고, 탭·줄 바꿈은 공백으로, 제어 문자는 지운다. *max_block_chars* 보다 긴
덩어리는 앞부분만 남기고 ``…`` 로 끝낸다(``"truncated": True``). *max_chars* 는 각 항목의
id·위치·글자를 센다. 예산이나 *max_items* 가 다하면 항목 단위로 멈추고 ``complete`` 가
``False``, ``next_after`` 가 마지막 id다 — 다음 쪽은 ``after=next_after`` 로 읽는다. 한 쪽에는 예산보다 큰 항목이라도 적어도 하나가 들어간다.
``to_text()`` 는 한 줄에 한 항목인 가장 짧은 프롬프트 형태다.
"""

from ..text_blocks import outline

return outline(
self._doc,
max_chars=max_chars,
max_items=max_items,
max_block_chars=max_block_chars,
after=after,
)

def apply_changes(
self,
changes: "dict[str, str]",
*,
check_overflow: bool = True,
) -> "TextChangeReport":
"""``{id: 새 글}`` 을 한 번에 적용하고 id마다 결과를 보고한다.

모든 id를 먼저 찾은 뒤 쓴다. 없는 id는 ``unknown_ids`` 로 보고하고 나머지는 적용한다.
한 런 안에서 끝나는 바꿈은 런과 컨트롤을 모두 지키고, 서식이 다른 런에 걸치거나 탭·줄
바꿈을 넣는 바꿈은 문단 첫 런의 서식으로 쓴다(``rewritten``). *check_overflow* 면 바뀐 칸을
전후로 재어, 칸이 품는 줄보다 많이(그리고 전보다 많이) 필요할 것으로 추정되는 칸을
``overflow`` 에 넣는다 — 렌더가 아니라 form-fit 측정기의 추정이다.
"""

from ..text_blocks import apply_text_changes

return apply_text_changes(self._doc, changes, check_overflow=check_overflow)

# -- 치환 --------------------------------------------------------------

def replace(
Expand Down
Loading
Loading