Intent 스키마
AI 가 낼 수 있는 편집 명령의 전체 계약(v0) — 필드·제약·거부 규칙.
원문: docs/INTENT-SCHEMA.md · 이 페이지는 빌드할 때 그 파일에서 생성됩니다.
이슈 008 산출물. 새 프로토콜이 아니라, 지금
crates/hwp-mcp/src/lib.rs의Intent표면을 전수 조사해 명문화한 문서다. "코어 하나, 셸 셋"의 계약은 이 Intent JSON이며, 외부 소비자(business_plan_k·에르메스·웹 SDK)가 붙기 전에 표면·버전·에러 계약을 고정한다.정본(定本)은 코드다. 이 문서의 모든 예제는 스냅샷 테스트
crates/hwp-mcp/tests/schema_v0.rs가 실제로deserialize_intent로 파싱하고 op-bus로 디스패치한다(문서↔코드 드리프트 방지). 예제를 바꾸면 그 테스트도 함께 바꿔야 한다.
1. 요청 형식 (wire format)
Intent는 내부 태그(internally tagged) JSON 오브젝트다. 판별 필드는 "intent"이고,
나머지 필드는 같은 레벨에 평평하게 온다.
{ "intent": "SetImageSize", "section": 0, "index": 1, "width": 12000, "height": 9000 }
- 파싱 진입점:
hwp_mcp::deserialize_intent(&serde_json::Value) -> Result<Intent, String>. - 엔드투엔드(파싱→디스패치):
hwp_mcp::apply_intent_json(&mut Session, &Value) -> Result<Outcome, String>. - 디코더는
#[serde(tag = "intent", deny_unknown_fields)]— 알 수 없는intent태그나 알 수 없는/오타 필드는 조용히 무시하지 않고 하드 에러로 거부한다. 에이전트가 오타를 "성공"으로 오인하는 것이 최악이기 때문(레드팀 R11). Option<T>필드는 생략 시None(예:SetCharFmt의italic을 빼면 이태릭 미변경).bool/String/정수 등 비-Option 필드는 필수(누락 시missing field에러).- 튜플 필드
cell: Option<(usize, usize)>는 JSON 배열[row, col]로 온다(또는 생략/null).
두 개의 전송 레인 (참고)
Intent 표면은 하나지만 이를 나르는 전송은 둘이다. 이 문서는 (A) Intent JSON 표면을 동결한다.
| 레인 | 위치 | 형태 | intent_version |
|---|---|---|---|
| (A) 타입드 Intent | hwp-mcp Intent/apply_intent | 이 문서의 JSON(내부 태그) | 적용됨(엔벨로프) |
(B) MCP tools/* | hwp-mcp handle/call_tool | JSON-RPC 2.0 tool 호출(툴별 arguments) | 미적용(§5 참고) |
- Tauri 데스크톱 셸(
hwp-viewer/src/lib.rs)은 커맨드별invoke로Intentvariant를 Rust에서 직접 생성한다(JSON 엔벨로프를 거치지 않음). 그래서Intent에 붙인Deserialize파생은 셸에 순수 가산(加算)이며 기존 앱 플로우를 바꾸지 않는다. - MCP
tools/*레인은 별도 표면(13개 툴:open_document/get_context/apply_content/export_hwpx/extract_text/render_page/page_count/undo/redo/propose_content/commit_proposal/find_text/replace_text)이며 셀 서식 Intent를 포함하지 않는다. 이 중복은 이슈 012(hwp-session 파사드)에서 해소 예정.
2. intent_version 엔벨로프
요청 오브젝트는 판별 필드와 같은 레벨에 선택적 intent_version 정수를 실을 수 있다.
{ "intent_version": 0, "intent": "Undo" }
- 현재 지원 버전:
0(hwp_mcp::INTENT_VERSION상수). - 없으면
0으로 간주 — 기존 호출(필드 미포함)은 그대로 동작(하위호환). deserialize_intent는intent_version을 먼저 검사하고 제거한 뒤 태그 본문을 디코딩한다. (제거하지 않으면deny_unknown_fields가 unknown field로 거부하므로.)- 지원 범위(
0..=0) 밖이면 명시적 에러:unsupported intent_version 1 (this build supports 0..=0). - 정수가 아니면 에러:
intent_version must be a non-negative integer.
3. 호환성 정책 (v0에서 동결)
- unknown Intent/필드 = 명시적 거부. 보존·무시가 아니라 에러(§1,
deny_unknown_fields). - 필드 추가는 optional(
Option<T>)로만. 기존 요청(그 필드 미포함)이 계속 파싱돼야 함. - 의미 변경·필드 삭제·필수화는
intent_version범프로만. v0 계약을 깨는 변경 금지. - 단위 불변. 각 필드의 단위(HWPUNIT/px/mm/pt/비율)는 §6 표에 고정. 단위 슬립은 조용히 클릭선택/이동/리사이즈를 죽인다(공통 계약 §4.5).
deny_unknown_fields ↔ serde(flatten) 함정
deny_unknown_fields는 serde(flatten)과 충돌한다. 본 스키마는 flatten을 쓰지 않는다.
intent_version은 flatten 필드가 아니라 deserialize_intent에서 수동으로 벗겨낸 뒤
태그 본문을 디코딩하므로 충돌이 없다.
4. 에러 계약
에러는 형태가 둘로 갈린다.
| 채널 | 형태 | 코드 |
|---|---|---|
(A) deserialize_intent/apply_intent(_json) | Err(String) (평문) | 없음(문자열) |
(B) MCP tools/call 실패 | result{ content:[{text}], isError:true } (평문) | 없음 |
| (B) MCP 프로토콜 에러 | JSON-RPC error{ code, message } | 실방출 -32601(method not found)뿐 |
즉 **대부분의 실패는 "코드 없는 평문 문자열"**이다. wasm HwpDoc 레인은 이 문자열로 code 를 붙인다 —
invalid target: 머리말 → invalid_target, invalid intent: 머리말 · serde 의 unknown variant/unknown field →
bad_intent, … out of range → out_of_range. 대표 에러 문자열:
| 상황 | 대표 문자열(부분 일치) |
|---|---|
| 엔벨로프가 오브젝트가 아님 | intent envelope must be a JSON object |
intent_version 타입 오류 | intent_version must be a non-negative integer |
intent_version 범위 밖 | unsupported intent_version {n} (this build supports 0..=0) |
| 알 수 없는 Intent 태그 | unknown variant \Foo`, expected one of ...` (serde) |
| 알 수 없는/오타 필드 | unknown field \bar`, expected ...` (serde) |
| 판별 태그 누락 | missing field \intent`` (serde) |
| 필수 필드 누락 | missing field \section`` (serde) |
| 문서 미개봉 상태에서 편집/조회 | no document open (call open_document first) |
| 블록 인덱스 범위 밖 | SetTableCell: block index {i} out of range 등 op별 |
| 주소는 맞는데 대상 종류가 다름(#483) | invalid target: SetTableProps: block {i} is not a table 등 |
| 바꿀 값이 없음 · 허용 밖 값 · 함께 줄 수 없는 필드(#483) | invalid intent: … |
| 구조 문단 in-place 편집 | paragraph N has structural content and cannot be edited in place |
| 대기 제안 없이 Commit | 대기 중인 제안이 없습니다 (propose first) |
| 오래된/다른 Proposal v1 커밋 | stale Proposal v1 (...) |
| rhwp 미빌드에서 렌더/캐럿 | render needs a build with --features rhwp / hit_test needs ... / caret_rect needs ... |
5. 위험 표시 필드 (경로/자유문자열) — 013 경로 감금 대상
에이전트가 자유 문자열로 주는 파일 경로 필드. 이슈 013(헤드리스 서비스 컨테이너)에서
WORKSPACE_ROOT canonicalize-후-거부의 감금 대상이 된다. 컨테이너/볼륨 마운트 환경에서
path traversal(호스트 파일 노출) 벡터이므로 여기 나열된 필드만 감금하면 충분하다.
| 표면 | 필드 | 위험 |
|---|---|---|
Intent Open | path | ⚠️ 임의 읽기 경로 |
Intent Export | path | ⚠️ 임의 쓰기 경로(atomic_write) |
MCP open_document | path | ⚠️ 임의 읽기 경로 |
MCP export_hwpx | path | ⚠️ 임의 쓰기 경로 |
그 외 자유 문자열(ApplyContent.json, Propose.json)은 경로가 아니라 AI 콘텐츠 JSON이며,
문서 텍스트를 LLM 컨텍스트에 넣는 프롬프트 인젝션(R5)은 이슈 010/013 소관.
6. Intent 레퍼런스
필드표 범례 — 타입: JSON 타입. 단위/값: 의미 단위 또는 허용 값. 필수: ●=필수,
○=선택(생략 시 None/무변경). 예제는 schema_v0.rs의 정본과 동일.
6.1 수명주기 / 조회
Open — 문서 열기 (HWPX/HWP5/DOCX/PDF 감지)
{ "intent": "Open", "path": "corpus/hwpx/FormattingShowcase.hwpx" }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
path | string | ⚠️ 파일 경로(§5) | ● |
실패: 읽기 실패 read {path}: ..., 미인식 포맷 unrecognized format (not HWP/HWPX/DOCX/PDF).
PageCount — 현재 문서 페이지 수
{ "intent": "PageCount" }
필드 없음. 실패: no document open ....
Render — 현재 페이지 SVG (rhwp 빌드 필요)
{ "intent": "Render", "page": 0 }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
page | integer | 0-based 페이지 | ● |
실패: rhwp 미빌드 render needs a build with --features rhwp; 편집된 문서는 SVG 렌더 거부
(HTML 미리보기로 표시).
ApplyContent — AI 콘텐츠 JSON 적용(1 undo 단위)
{ "intent": "ApplyContent", "json": "{\"blocks\":[{\"type\":\"paragraph\",\"runs\":[{\"text\":\"에이전트 추가\"}]}]}" }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
json | string | AiContent JSON(문자열로 인코딩) | ● |
Export — HWPX 직렬화 저장(atomic write)
{ "intent": "Export", "path": "/tmp/out.hwpx" }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
path | string | ⚠️ 출력 경로(§5) | ● |
Undo / Redo — 마지막 편집 취소/재실행
{ "intent": "Undo" }
{ "intent": "Redo" }
필드 없음. 되돌릴/재실행할 것이 없으면 그레이스풀 no-op(에러 아님).
ExtractText — 읽기 순서 평문 추출
{ "intent": "ExtractText" }
필드 없음.
6.2 제안(propose) 루프
Propose — AI 콘텐츠를 미리보기 제안으로 검증(문서 미변경)
{ "intent": "Propose", "json": "{\"blocks\":[{\"type\":\"heading\",\"text\":\"제안\",\"align\":\"center\"}]}" }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
json | string | AiContent JSON | ● |
Commit — 대기 중 제안을 1 undo 단위로 적용
{ "intent": "Commit" }
필드 없음. 실패: 대기 중인 제안이 없습니다 (propose first).
Propose/Commit은 기존 AiContent 호출자를 위한 호환 레인이다. 내부에서는 즉시 아래
Proposal v1 계약으로 컴파일되고 live revision을 다시 확인하므로, 오래된 pending ops를 그대로
적용하지 않는다.
ApplyEditScript — anchored 편집 DSL의 즉시 컴파일
{
"intent": "ApplyEditScript",
"json": "{\"edits\":[{\"op\":\"set_paragraph\",\"section\":0,\"block\":0,\"text\":\"제안\"}]}"
}
json은 typed EditScript로 즉시 parse·정규화한 뒤 live 또는 Proposal scratch에서 typed op로
컴파일한다. Proposal 밖에서 직접 호출하면 한 undo 단위이며, AI preview는 반드시 아래
ProposeIntents 안에 넣는다. 알 수 없는 command/필드는 parse 단계에서 거부한다.
ProposeIntents — canonical Proposal v1 scratch preview
{
"intent": "ProposeIntents",
"intents": [
{ "intent": "SetParagraphText", "section": 0, "block": 0, "text": "제안" }
]
}
- 각 내부 Intent를 이 문서의
deny_unknown_fieldsdecoder로 다시 읽고 기본값을 채운 typed 형태로 정규화한다. lifecycle/query/undo/redo/proposal 중첩은 preview 전에 거부한다. ApplyContent의 JSON 문자열도 즉시 typedAiContent로 파싱·재직렬화한 뒤 scratch에서 op로 컴파일한다. 공백·키 순서·생략 가능한 기본값이 Proposal identity를 바꾸지 않는다.- scratch clone에 전체 batch를 적용해 live bytes/revision/undo depth를 바꾸지 않는다.
- 결과는
proposal_id,session_id,document_id,base_revision, 정규화 Intents, 영향 주소/쪽, capability snapshot, risks/warnings, cross-surface digest와 content-freeverification보고서를 갖는 Proposal v1이다. 보고서 계약은 PRECOMMIT-VERIFICATION-V1.md를 따른다.
CommitProposal — revision-bound atomic commit
{
"intent": "CommitProposal",
"proposal_id": "proposal-v1:fnv1a64:…",
"expected_revision": 7
}
proposal_id와 expected/live/base revision, session/document identity가 모두 일치할 때만 scratch에서
검증된 snapshot을 한 undo 단위로 교체한다. reopen, 외부 편집, undo, redo, 다른 commit 뒤에는
stale Proposal v1로 거부하며, verification.commit_allowed가 false인 proposal도 거부한다.
실패한 proposal은 재사용할 수 없다.
DiscardProposal — 대기 제안 폐기
{ "intent": "DiscardProposal" }
필드 없음.
6.3 찾기 / 바꾸기
Find — 편집 가능한 단순 문단에서 검색(읽기 전용)
{ "intent": "Find", "query": "문서", "case_sensitive": false, "whole_word": false }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
query | string | 검색어 | ● |
case_sensitive | bool | 대소문자 구분 | ● |
whole_word | bool | 온전한 단어 | ● |
Replace — 찾아 바꾸기(1 undo 단위)
{ "intent": "Replace", "query": "문서", "replacement": "파일", "case_sensitive": false, "whole_word": false, "all": true }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
query | string | 검색어 | ● |
replacement | string | 대체 텍스트 | ● |
case_sensitive | bool | 대소문자 구분 | ● |
whole_word | bool | 온전한 단어 | ● |
all | bool | true=전체, false=첫 매치만 | ● |
6.4 WYSIWYG 캐럿 지오메트리
⚠️ 단위: 모든 hit/caret 좌표는 페이지 공간 px(미스케일). 프런트가 SVG를 확대하면 같은 배율로 스케일해야 한다(공통 계약 §4.5). 레거시
HitTest/CaretRect는 rhwp 라이브 NodeId,*Cell/*Body쌍은 우리place_doc지오메트리를 정본으로 쓴다.
HitTest — 클릭 좌표를 편집 대상으로 매핑
{ "intent": "HitTest", "page": 0, "x": 120.0, "y": 90.0 }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
page | integer | 0-based 페이지 | ● |
x | number | 페이지 px | ● |
y | number | 페이지 px | ● |
CaretRect — 모델 대상(NodeId+offset)을 캐럿 사각형으로
{ "intent": "CaretRect", "page": 0, "node": 7, "offset": 3 }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
page | integer | 0-based 페이지 | ● |
node | integer | NodeId(u64) | ● |
offset | integer | 문단 내 문자(char) 인덱스 | ● |
HitTestCell — 클릭 좌표를 셀 텍스트 캐럿 대상으로 매핑 (issue 053)
{ "intent": "HitTestCell", "page": 0, "x": 120.0, "y": 90.0 }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
page | integer | 0-based 페이지 | ● |
x | number | 페이지 px | ● |
y | number | 페이지 px | ● |
HitTest의 in_cell → node:null 갭(docs/CARET-GAP.md §2)을 닫는 셀 주소형 쌍둥이. 지오메트리는
rhwp 글리프 박스가 아니라 자체 렌더 place_doc(화면 SVG와 동일 좌표)에서 나오므로 rhwp 게이트가
없고 바이너리 .hwp에서도 답한다. 결과 {kind:"hitCell", hit} — hit은 {section, block, row, col, para, offset, para_len, caret:{page,x,top,height}} (row/col 모델-전역, para=셀 내 문단 서수 —
block_runs가 "\n"으로 join하는 순서, caret은 px). 셀 텍스트 밖 클릭은 hit:null(018).
CaretRectCell — 셀 주소(+문단/offset)를 캐럿 사각형으로 (issue 053)
{ "intent": "CaretRectCell", "section": 0, "block": 1, "row": 0, "col": 0, "para": 0, "offset": 1 }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
section | integer | 구역 인덱스 | ● |
block | integer | 표 블록 인덱스 | ● |
row | integer | 셀 행(모델-전역) | ● |
col | integer | 셀 열(모델-전역) | ● |
para | integer | 셀 내 문단 서수(0-based) — 셀 텍스트를 "\n"으로 나눈 에디터 세그먼트 공간(blockRuns join / SetTableCellRuns split과 동일; 모델 문단 내 강제 줄바꿈도 경계) | ● |
offset | integer | 문단 내 문자(char) 인덱스("\n" 미포함) | ● |
path | {block,row,col}[] | 중첩 leaf CellPath (issue #48). 생략/null/길이 1 = 기존 053 평면 레인. 길이 ≥ 2만 path 레인 (A2) | ○ |
결과 {kind:"caretCell", caret} — caret은 {page, x, top, height}(px + 소유 fragment의 0-based
페이지 — 분할 표는 셀의 top row를 그린 페이지). past-end offset은 문단 끝으로 클램프되어
사각형을 돌려준다(CaretRect 계약과 동일 — null을 "문단 끝"으로 읽지 말 것). 주소 해소 불가(없는
표/셀/문단)는 caret:null(018). 읽기 전용 — undo 단위/리비전 범프 없음.
HitTestBody — 클릭 좌표를 본문 문단 캐럿 대상으로 매핑
{ "intent": "HitTestBody", "page": 0, "x": 120.0, "y": 90.0 }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
page | integer | 0-based 페이지 | ● |
x | number | 페이지 px | ● |
y | number | 페이지 px | ● |
결과 {kind:"hitBody", hit} — hit은 {section, block, offset, para_len, caret:{page,x,y,w,h}}. 지오메트리는 화면과 같은 PlacedGlyph/LineSeg를 사용하며 SVG <text>
markup이나 rhwp 좌표에 의존하지 않는다. 클릭은 편집 가능한 top-level simple paragraph의 실제 배치
band 안에서만 해소되고, 여백·표·그림·구조 문단은 hit:null이다(018). wrap/page 경계의 같은
주소에는 두 시각 위치가 있을 수 있으므로 hit.caret은 클릭한 queried-page upstream 줄에 남는
visual affinity를 쓴다. 반면 아래 CaretRectBody는 주소의 canonical downstream 위치를 반환한다.
CaretRectBody — 본문 주소(+offset)를 zero-width 캐럿으로
{ "intent": "CaretRectBody", "page": 0, "section": 0, "block": 0, "offset": 1 }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
page | integer | 캐럿이 소유해야 할 0-based 페이지 | ● |
section | integer | 구역 인덱스 | ● |
block | integer | top-level 문단 블록 인덱스 | ● |
offset | integer | Unicode scalar 기준 문단 내 문자 인덱스 | ● |
결과 {kind:"caretBody", caret} — caret은 {page,x,y,w:0,h}. w:0은 문서 삽입 경계이며
호스트가 실제 device-pixel stroke 폭을 정한다. past-end는 문단 끝으로 클램프한다. 한 문단이 여러
페이지에 나뉘면 offset을 소유한 페이지에서만 rect를 돌려주며, 다른 page나 알 수 없는 주소는
caret:null이다. 두 Body Intent 모두 읽기 전용이고 AI 편집 화이트리스트에는 없다.
InsertText — 캐럿 위치에 문자 삽입(1 undo 단위)
{ "intent": "InsertText", "node": 7, "offset": 0, "text": "끼움" }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
node | integer | NodeId | ● |
offset | integer | 문단 내 char 인덱스 | ● |
text | string | 삽입 텍스트 | ● |
실패: 구조 문단 ... structural content and cannot be edited in place; 범위 밖 offset 에러.
DeleteBack — offset 직전 1문자 삭제(Backspace, 1 undo 단위)
{ "intent": "DeleteBack", "node": 7, "offset": 1 }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
node | integer | NodeId | ● |
offset | integer | 문단 내 char 인덱스(0=no-op) | ● |
6.5 이미지 오버레이 (라이브 이미지 필요)
⚠️ 단위:
width/height는 HWPUNIT.
SetImageSize — 이미지 리사이즈(1 undo 단위)
{ "intent": "SetImageSize", "section": 0, "index": 2, "width": 12000, "height": 9000 }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
section | integer | 구역 인덱스 | ● |
index | integer | 블록 인덱스 | ● |
width | integer | HWPUNIT | ● |
height | integer | HWPUNIT | ● |
MoveImage — 이미지 블록 이동(DeleteBlock+InsertImageAt, 1 undo 단위)
{ "intent": "MoveImage", "section": 0, "from": 2, "to": 0, "width": 12000, "height": 9000 }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
section | integer | 구역 인덱스 | ● |
from | integer | 원본 블록 인덱스 | ● |
to | integer | 대상 블록 인덱스 | ● |
width | integer | HWPUNIT(크기 보존) | ● |
height | integer | HWPUNIT(크기 보존) | ● |
InsertImage — 이미지 삽입(드롭/업로드, bytes 기반, 1 undo 단위)
웹은 파일 경로가 없다 — 이미지는 base64 바이트로 전달된다(
data:접두어 없이). 형식(png/jpg)은 매직바이트로 자동 감지하며(호출자가 확장자를 넘기지 않는다), PNG/JPEG 가 아니거나 크기 상한 (24 MiB, decoded)을 넘으면 정직하게 거부한다(조용한 무시 금지). BinData 로 임베드되어 HWPX 내보내기에 포함된다(왕복).InsertImageAtop 를 재사용하므로 레이아웃 로직은 무접촉.
{ "intent": "InsertImage", "section": 0, "block": null,
"data_b64": "iVBORw0KGgo…", "width": 34016, "height": 25512 }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
section | integer | 구역 인덱스 | ● |
block | integer|null | 앵커 블록: Some(b)=블록 b 뒤(구역 끝으로 clamp), null=구역 끝 | ○(없으면 구역 끝) |
data_b64 | string | base64 이미지 바이트(PNG/JPEG, data: 접두어 제외) | ● |
width | integer | HWPUNIT(표시 폭) | ● |
height | integer | HWPUNIT(표시 높이) | ● |
6.6 블록 / 표 구조
MoveBlock — 블록 이동(1 undo 단위)
{ "intent": "MoveBlock", "section": 0, "from": 0, "to": 1 }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
section | integer | 구역 인덱스 | ● |
from | integer | 원본 블록 인덱스 | ● |
to | integer | 대상 블록 인덱스(==len=끝) | ● |
TableInsertRows — 빈 본문 행 삽입(1 undo 단위)
{ "intent": "TableInsertRows", "section": 0, "index": 1, "at": 2, "count": 1, "cols": 3 }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
section | integer | 구역 인덱스 | ● |
index | integer | 표 블록 인덱스 | ● |
at | integer | 논리 행 위치(==rows=append) | ● |
count | integer | 삽입 행 수(>0) | ● |
cols | integer | 행당 셀 수(>0) | ● |
#457: 새 칸은 바로 위 행(
at == 0이면 첫 행) 칸의 모양 · 높이를 따르고, HWPX 내보내기는 원본 칸 · 표 제목을 그대로 둔다.at을 가로지르는 세로 병합은 새 행까지 늘어나고 그 열에는 새 칸을 만들지 않는다(겹침 방지). 위 행에서 끝나기만 하는 병합은 늘리지 않는다 — 행 복제는TableAppendRow.
SetTableCell — 셀 텍스트 교체(단일 평문 run, 1 undo 단위)
{ "intent": "SetTableCell", "section": 0, "index": 1, "row": 0, "col": 0, "text": "셀 값" }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
section | integer | 구역 인덱스 | ● |
index | integer | 표 블록 인덱스 | ● |
row | integer | 행 | ● |
col | integer | 열 | ● |
text | string | 셀 텍스트(빈 문자열=비움) | ● |
⚠️ 서식 보존 편집은 §6.7
SetTableCellRuns를 써라. 평문SetTableCell은 run을 하나로 접는다(공통 계약 §4.7).
TableAppendRow — 마지막 행의 열 구성을 복제해 빈 행 1개 추가(merge-safe, 1 undo 단위)
{ "intent": "TableAppendRow", "section": 0, "index": 1 }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
section | integer | 구역 인덱스 | ● |
index | integer | 표 블록 인덱스 | ● |
#457: 마지막 행이 기준 행이다 — 그 행의 칸을 본떠(열 구성 · 테두리 · 음영 · 칸 모양 · 높이, 글은 비움) 새 행을 만들고, 마지막 행을 덮는 세로 병합(마지막 행에서 끝나는 묶음 제목 칸 등)은
rowSpan을 늘려 새 행을 덮는다(기본 동작, 옵션 없음). HWPX 내보내기는 원본 칸<hp:tc>· 표 제목 · 바깥 속성을 바이트 그대로 두고 새 칸은 기준 칸<hp:tc>를 복제한다.TableDeleteRows로 붙인 행을 지우면 원본 표 XML 로 돌아온다.
TableDeleteRows · TableInsertCols · TableDeleteCols — 행 지우기 · 열 넣기 · 열 지우기(1 undo 단위, #442)
{ "intent": "TableDeleteRows", "section": 0, "index": 1, "at": 2, "count": 1 }
{ "intent": "TableInsertCols", "section": 0, "index": 1, "at": 1, "count": 1 }
{ "intent": "TableDeleteCols", "section": 0, "index": 1, "at": 1, "count": 1, "path": [{ "block": 3, "row": 0, "col": 0 }] }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
section | integer | 구역 인덱스 | ● |
index | integer | 표 블록 인덱스(path 가 있으면 그 칸 안 블록 인덱스) | ● |
at | integer | 첫 행/열(TableInsertCols 는 이 열 앞에, ==cols=끝) | ● |
count | integer | 행/열 수(>0) | ● |
path | CellStep[] | 칸 안 표: 표를 품은 칸까지의 내림 CellPath(DeleteNestedBlock 과 같은 주소) |
- 병합: 지우는 범위를 가로지르는 병합은 줄고(rowSpan/colSpan − 겹친 수), 범위 안에서 시작해 밖으로 이어지는
병합은 내용을 지키며
at으로 옮긴다. 범위 안에 통째 들어간 칸은 지운다. 넣는 경계에 걸친 병합은 넓어진다. - 너비: 열 넣기/지우기는 표 너비를 그대로 둔다 — 새 열은 이웃 열 너비를 받고, 모든 열이 비율로 다시 나뉜다.
- 거절: 범위 밖 ·
count0 · 행/열을 모두 지우기 · 열 256 초과. - HWPX 내보내기: 원본
<hp:tc>를 그대로 두고cellAddr/cellSpan/cellSz·rowCnt/colCnt· 표sz만 고친다(표 위치 · 칸 이름 · 세로 정렬 보존). 새 칸은 같은 행 이웃 칸을 본뜬다(이름은 비움).path로 고친 칸 안 표는 바깥 칸을 다시 쓰는 기존 경로를 탄다. - 사람용 편집 동사 — AI 프롬프트 허용목록(
DEFAULT_ALLOWED_INTENTS)에는 없다.
SetParagraphText — 단순 문단 텍스트 교체(char/para shape 보존, 1 undo 단위)
{ "intent": "SetParagraphText", "section": 0, "block": 0, "text": "바뀐 문단" }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
section | integer | 구역 인덱스 | ● |
block | integer | 블록 인덱스 | ● |
text | string | 문단 텍스트 | ● |
실패: 구조 문단이면 거부(UI는 채팅으로 폴백).
SetTableColWidths — 열 너비 비율(1 undo 단위)
{ "intent": "SetTableColWidths", "section": 0, "index": 1, "widths": [2, 1, 1] }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
section | integer | 구역 인덱스 | ● |
index | integer | 표 블록 인덱스 | ● |
widths | integer[] | 상대 비율(i32, 양수). len==표의 열 수 | ● |
SetTableRowHeights — 행 최소높이 오버라이드(1 undo 단위)
{ "intent": "SetTableRowHeights", "section": 0, "index": 1, "heights": [0, 0] }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
section | integer | 구역 인덱스 | ● |
index | integer | 표 블록 인덱스 | ● |
heights | integer[] | HWPUNIT 최소높이(0=content-sized). len==표의 행 수 | ● |
SetPageMargins — 구역 페이지 여백(1 undo 단위, 전체 재-flow)
{ "intent": "SetPageMargins", "section": 0, "left_mm": 20.0, "right_mm": 20.0, "top_mm": 20.0, "bottom_mm": 15.0 }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
section | integer | 구역 인덱스 | ● |
left_mm | number | mm | ● |
right_mm | number | mm | ● |
top_mm | number | mm | ● |
bottom_mm | number | mm | ● |
DeleteBlock — 블록 삭제(1 undo 단위)
{ "intent": "DeleteBlock", "section": 0, "index": 0 }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
section | integer | 구역 인덱스 | ● |
index | integer | 블록 인덱스 | ● |
표 블록의 담는 문단(#439). 파싱한 HWPX 표는 「표 + 담는 문단(
is_table_anchor)」 두 블록이다. 표 블록을 지우면 메모리에는 담는 문단 블록이 남지만, 저장 → 다시 열면 그 담는 문단도 사라진다(표와 함께 담는<hp:p>가 빠진다) — 다시 연 문서의 그 뒤 블록 순번은 1 당겨진다. 반대 방향(InsertTableAt은 1블록, 다시 열면 2블록)은 §InsertTableAt 「담는 문단」. 표를 같은 자리에서 바꾸는 묶음[DeleteBlock(표), InsertTableAt(같은 index)]은 새 표가 남은 담는 문단을 다시 쓰므로 메모리 = 다시 연 문서이고, 저장 → 다시 열기를 되풀이해도 블록 순번 · 쪽 수가 그대로다(crates/hwp-mcp/tests/table_replace_roundtrip_439.rs).
DeleteNestedBlock — 셀 안의 중첩 블록 삭제(1 undo 단위, 수동 UI 전용)
{ "intent": "DeleteNestedBlock", "section": 0, "path": [{"block": 1, "row": 3, "col": 1}], "index": 2 }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
section | integer | 구역 인덱스 | ● |
path | CellStep[] | 삭제 대상의 부모 셀까지 내려가는 CellPath(비어 있으면 거부) | ● |
index | integer | 부모 셀 blocks 안의 중첩 블록 인덱스 | ● |
DeleteBlock과 주소 공간을 분리한다. 중첩 표를 선택한 Backspace/Delete만 이 Intent를 사용하며,
최상위 바깥 표는 보존된다. AI 화이트리스트에는 넣지 않는다.
6.7 문자 / 문단 서식
⚠️ 단위:
size_pt는 포인트(pt). 색은"#RRGGBB".align∈left|center|right|justify|distribute.cell은[row, col]또는 생략/null.
SetCharFmt — 대상 run의 볼드/이태릭/크기/글꼴 패치(다른 속성 보존, 1 undo 단위)
{ "intent": "SetCharFmt", "section": 0, "block": 0, "cell": null, "bold": true, "italic": null, "size_pt": 14.0, "font": "맑은 고딕" }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
section | integer | 구역 인덱스 | ● |
block | integer | 블록 인덱스 | ● |
cell | [int,int]/null | [row,col]=셀, 생략/null=블록 문단 | ○ |
bold | bool/null | ○ | |
italic | bool/null | ○ | |
size_pt | number/null | pt(CharShape.height=round(pt*100)) | ○ |
font | string/null | 글꼴 패밀리(""=지움) | ○ |
SetRunCharFmt — char 범위 [start,end)의 볼드/이태릭 패치(1 undo 단위)
{ "intent": "SetRunCharFmt", "section": 0, "block": 0, "cell": null, "start": 0, "end": 2, "bold": true, "italic": false }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
section | integer | 구역 인덱스 | ● |
block | integer | 블록 인덱스 | ● |
cell | [int,int]/null | [row,col]=셀, 생략/null=블록 문단 | ○ |
start | integer | char 오프셋(바이트 아님) | ● |
end | integer | char 오프셋(반열림) | ● |
bold | bool/null | ○ | |
italic | bool/null | ○ |
SetTableCellRuns — 셀을 스타일 run으로 교체(WYSIWYG 커밋, 1 undo 단위)
{ "intent": "SetTableCellRuns", "section": 0, "index": 1, "row": 0, "col": 0, "runs": [{"text": "강조", "bold": true}, {"text": " 일반"}] }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
section | integer | 구역 인덱스 | ● |
index | integer | 표 블록 인덱스 | ● |
row | integer | 행 | ● |
col | integer | 열 | ● |
runs | RunSpec[] | 스타일 run 배열(아래) | ● |
SetParagraphRuns — 단순 문단을 스타일 run으로 교체(1 undo 단위)
{ "intent": "SetParagraphRuns", "section": 0, "block": 0, "runs": [{"text": "굵게", "bold": true}] }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
section | integer | 구역 인덱스 | ● |
block | integer | 블록 인덱스 | ● |
runs | RunSpec[] | 스타일 run 배열(아래) | ● |
RunSpec (중첩 오브젝트) — #[serde(default, deny_unknown_fields)]. 모든 필드 선택.
알 수 없는 run 키는 거부. 생략한 필드의 뜻은 아래 「생략 = 물려받기」.
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
text | string | run 텍스트(생략 시 "") | ○ |
bold | bool/null | 생략 · null = 물려받기 | ○ |
italic | bool/null | 생략 · null = 물려받기 | ○ |
underline | bool/null | 생략 · null = 물려받기 | ○ |
strike | bool/null | 생략 · null = 물려받기 | ○ |
size_pt | number/null | pt. 생략 · null = 물려받기 | ○ |
color | string/null | "#RRGGBB". 생략 · null = 검정(물려받지 않는다) | ○ |
highlight | string/null | "#RRGGBB"(형광/음영). 생략 = 물려받기, null = 음영 없음 | ○ |
font | string/null | 글꼴 패밀리(모든 계열에 쓴다). 생략 · null = 물려받기 | ○ |
생략 = 물려받기 (#481). SetTableCellRuns · SetParagraphRuns 에서 run 이 적지 않은 속성은 바꾸는 자리의
글자 모양을 그대로 쓴다. 「크기만」 · 「굵게만」 같은 요청이 나머지 속성을 바꾸지 않는다.
- 기준 run: 그 문단(구조 문단은 글자 구역) · 칸에서 글자가 있는 첫 run(공백이 아니고 크기가 있는 run)이다. 없으면 문단은 자기 첫 run → 이웃 문단(§6.9 「서식 상속」), 칸은 첫 문단의 첫 run 을 쓴다. 새 run 은 모두 같은 기준을 쓴다.
- 예외 — 글자 색: 생략하면 검정이다. 양식의 안내 글자(파랑 · 빨강)를 바꿔 쓸 때 그 색을 물려받지 않게 하려는 것이다.
원래 색을 이어 쓰려면
color를 적는다. - 끄기: 끄려면
false, 음영을 지우려면highlight: null을 적는다. run 의 전체 모양을 아는 생산자(편집기 ·blockRuns를 고쳐 되돌리는 호스트)는 끈 속성도 적는다.blockRuns가 돌려주는 run 은 이미 그렇게 생겼다. - 메모리 = 파일: 메모리(
blockRuns)의 값과 내보낸 파일을 다시 연 값이 같다. 파일은 기준 run 의 원래<hh:charPr>를 복제해 기준과 다른 속성만 고친다. 장평 · 자간 · 상대 크기 · 글자 위치 · 계열별 글꼴처럼RunSpec에 없는 속성은 원래 그대로다. 값이 기준과 같으면 원래 id 를 쓴다. - 넣기 Intent(
InsertParagraphAt·InsertTableAt·AppendRichParagraph…)에는 바꾸는 자리가 없다. 그래서 생략은 기본값(끔 · 없음)이고, 서식을 하나도 적지 않은 run 은 §6.9 이웃 규칙을 따른다.
6.8 셀 음영 / 범위 서식
⚠️ 색은
"#RRGGBB"또는null(지움).sel∈row|col|cell|all(그 외=cell로 취급).
SetTableCellShade — 셀 배경색 설정/해제(sel 기준, 1 undo 단위)
{ "intent": "SetTableCellShade", "section": 0, "index": 1, "sel": "cell", "row": 0, "col": 0, "shade": "#FFFF00" }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
section | integer | 구역 인덱스 | ● |
index | integer | 표 블록 인덱스 | ● |
sel | string | row|col|cell|all | ● |
row | integer | (row,col) 기준 | ● |
col | integer | (row,col) 기준 | ● |
shade | string/null | "#RRGGBB" 또는 null=지움 | ○ |
SetCellRangeShade — 사각형 [r0..=r1]×[c0..=c1] 셀 배경 일괄(1 undo 단위)
{ "intent": "SetCellRangeShade", "section": 0, "index": 1, "r0": 0, "c0": 0, "r1": 1, "c1": 2, "shade": "#EEEEEE" }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
section | integer | 구역 인덱스 | ● |
index | integer | 표 블록 인덱스 | ● |
r0,c0,r1,c1 | integer | 포함 사각형 경계 | ● |
shade | string/null | "#RRGGBB" 또는 null=지움 | ○ |
SetCellRangeFmt — 사각형 범위 셀의 문자 서식/정렬 일괄(1 undo 단위)
{ "intent": "SetCellRangeFmt", "section": 0, "index": 1, "r0": 0, "c0": 0, "r1": 1, "c1": 2, "bold": true, "italic": null, "size_pt": null, "font": null, "color": "#0000FF", "align": "center" }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
section | integer | 구역 인덱스 | ● |
index | integer | 표 블록 인덱스 | ● |
r0,c0,r1,c1 | integer | 포함 사각형 경계 | ● |
bold | bool/null | ○ | |
italic | bool/null | ○ | |
size_pt | number/null | pt | ○ |
font | string/null | 글꼴(""=지움) | ○ |
color | string/null | "#RRGGBB" | ○ |
align | string/null | left|center|right|justify|distribute | ○ |
6.9 구조 삽입 (이슈 051 — 챗 구조 편집; additive)
기존 op
InsertTableAt/InsertParagraphAt(hwp-ops)를 Intent 레인에 노출만 한 variant.index앵커는InsertImage.block(§6.5)의 선례를 따른다: 정수 = 그 블록 인덱스 위치에 삽입 (index == len이면 끝-append, 범위 밖이면 정직한 에러 — 클램프 없음),null/생략 = 구역 끝. 블록 수를 알 수 없는 셸(웹)이 끝-append를 쓸 수 있도록 디스패처가null을len으로 해석한다.
InsertTableAt — 리치 표를 블록 인덱스 위치에 삽입(1 undo 단위)
{ "intent": "InsertTableAt", "section": 0, "index": 1, "rows": [[{"text":"머리","bold":true},{"text":"칸"}],[{"text":"A2"},{"text":"B2"}]] }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
section | integer | 구역 인덱스 | ● |
index | integer|null | 블록 인덱스(==len=끝-append, 범위 밖=에러), null/생략=구역 끝 | ○(없으면 구역 끝) |
rows | CellSpec[][] | 행별 셀 그리드(아래 CellSpec; HTML-table 커버리지 — 각 논리 행은 덮이지 않은 셀만 나열) | ● |
border | object/null | A-3 추가. 모든 칸 변 + 표 외곽에 한 테두리: {"type":"solid"|"dash"|"dot"|"double"|"none","width_mm":0.12,"color":"#RRGGBB"}(모두 선택 — 기본 solid · 0.12 mm · 검정; 굵기는 HWP 선 굵기 0.1–5.0 mm 중 가장 가까운 값). 생략 = 문서 표 테두리(없으면 기본 실선) | ○ |
col_widths | number[]/null | A-3 추가. 열 너비 비율(열 수만큼, 모두 양수) → 구역 본문 폭에 맞춰 HWPUNIT 로 환산. 생략 = 같은 너비 | ○ |
header_row | bool/null | A-3 추가. 첫 행을 머리 행으로 — 다음 쪽으로 이어지면 반복(HWPX repeatHeader). 생략 = 켬, false = 끔 | ○ |
treat_as_char | bool/null | #441 추가(0.0.15). HWPX <hp:pos treatAsChar>. 생략/true = 글자처럼 취급(지금까지와 같음 — 한/글은 쪽에서 나누지 않는다), false = 문단 기준 표 — 한/글이 행 경계에서 쪽을 나눈다(pageBreak="CELL") | ○ |
서식 상속(A-3). 칸 글은 가장 가까운 본문 문단(InsertParagraphAt 서식 상속과 같은 이웃 규칙)의 글꼴 · 크기를
따른다. 강조(굵게 · 기울임 · 밑줄 · 취소선 · 음영)는 따르지 않는다 — 굵게는 CellSpec.bold 로만. 글자 색도 따르지 않는다 —
칸 글은 문서 기본 본문 색(검정)이다(#431, 0.0.14 — 0.0.13 은 이웃 색을 따랐다). 문단 모양(들여쓰기 등)은
상속하지 않는다(글머리표 문단의 내어쓰기가 칸 안에서 어긋나므로).
담는 문단(#440 · #439, 0.0.15). HWPX 에서 표는 문단(<hp:p>) 안의 개체다. 넣은 표를 담는 문단은 문서의 기본 문단 모양
(paraPr id="0"; 「다음 문단과 함께」 · 「쪽 나누기 앞」 이 켜져 있으면 그 둘을 끈 사본, HWPX 풀이 없는 문서는 줄 간격 160% 만 있는
모양)을 넣을 때 정해 갖는다 — 다른 편집과 무관하게 같다(0.0.14 까지는 구역의 마지막 paraPrIDRef 를 써서 뒤쪽 표를 고치면 바뀌었다).
메모리에서 넣은 표는 한 블록이다. 저장 → 다시 열면 파싱한 모든 표처럼 표 뒤에 담는 문단 블록(is_table_anchor, 글 없음)이
붙어 그 뒤 블록 순번이 1 밀린다 — 한/글 XML 에 빈 문단이 더 생기는 것은 아니고(담는 문단 하나), 쪽 수도 같다. 다시 연 문서의
주소는 다시 읽어 쓴다. 파싱한 표를 지울 때의 반대 방향 비대칭과 「표 통째 바꾸기」 묶음은 §DeleteBlock 주.
CellSpec (중첩 오브젝트) — #[serde(default, deny_unknown_fields)]. 모든 필드 선택
({} = 빈 일반 셀). 알 수 없는 셀 키는 거부.
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
text | string | 셀 텍스트(생략 시 ""). \n(또는 \r\n)은 칸 안 새 문단(#441, 0.0.15 — 전에는 한 <hp:t> 안 글자로 들어갔다) | ○ |
col_span | integer | 열 병합 폭(기본 1) | ○ |
row_span | integer | 행 병합 높이(기본 1) | ○ |
bold | bool | 기본 false | ○ |
shade | string/null | 셀 배경 "#RRGGBB"(기본 없음) | ○ |
실패: rich table needs at least one row(빈 rows), insert index {i} out of range ...(범위 밖),
col_widths: expected {n} positive numbers …, border.type … · border.color … · border.width_mm …(A-3 — 문서는 바뀌지 않는다).
SplitParagraph — 캐럿 위치에서 문단을 둘로 나눔(1 undo 단위)
{ "intent": "SplitParagraph", "section": 0, "block": 3, "at": 12 }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
section | integer | 구역 인덱스 | ● |
block | integer | 나눌 문단의 블록 인덱스 | ● |
at | integer | 문단 평문 기준 글자 오프셋(0=맨 앞, len=맨 끝) | ● |
머리는 정체성을 유지한다 — NodeId·원본 바이트 스팬·문단모양이 그대로다(그래야 무편집
왕복의 바이트 보존이 살아 있다). 꼬리만 block+1에 새로 삽입되며 para_shape/style을
상속하되 id/source는 비운다(NodeId 복제 금지, 합성 문단 취급 —InsertParagraphAt과 동일).
자른 자리의 런은 양쪽 모두 보존한다(한쪽이 비어도 — 이어 치는 글자가 그 스타일을 물려받게).
구조 문단(이미지/필드/복합)과 표 앵커 문단은 거부한다.
⚠️ InsertParagraphAt으로는 분리를 대신할 수 없다 — 그쪽은 ParaSpec에서 문단모양을 새로
합성하므로 정렬·들여쓰기·스타일이 날아간다.
MergeParagraph — 문단을 앞 문단에 붙임(1 undo 단위, SplitParagraph의 역연산)
{ "intent": "MergeParagraph", "section": 0, "block": 4 }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
section | integer | 구역 인덱스 | ● |
block | integer | 없어질 문단의 블록 인덱스(내용이 block-1로 이동) | ● |
살아남는 쪽(block-1)이 서식을 갖는다(한글 규약). block==0·비문단·구조 문단·표 앵커는 거부.
두 op 는 AI 화이트리스트에 없다(
DEFAULT_ALLOWED_INTENTS무변경). 캐럿 상대 오프셋은 모델이 알 수 없고, 구조 저작에는 이미InsertParagraphAt/DeleteBlock이 있다 — 수동 편집(캐럿) 전용으로 두는 편이 안전하다.
InsertParagraphAt — 리치 문단을 블록 인덱스 위치에 삽입(1 undo 단위)
{ "intent": "InsertParagraphAt", "section": 0, "index": 0, "runs": [{"text":"새 문단","bold":true}], "para": {"align":"center"} }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
section | integer | 구역 인덱스 | ● |
index | integer|null | 블록 인덱스(==len=끝-append, 범위 밖=에러), null/생략=구역 끝 | ○(없으면 구역 끝) |
runs | RunSpec[] | 스타일 run 배열(§6.7 RunSpec와 동일 wire) | ● |
para | ParaSpec | 문단 모양 오버라이드(아래; 생략={}=전부 상속) | ○(생략 시 상속) |
para_like | {section, block} | 이 본문 문단의 문단 모양 참조를 그대로 쓴다 — 내보내면 그 paraPrIDRef(새 hh:paraPr 없음, 번호 · 탭 · 테두리 포함). 기준은 넣기 전 문서의 블록. para 와 함께 주면 거부(#483) | ○ |
서식 상속(불릿 채움 수리) — runs가 서식을 하나도 지정하지 않으면(기본 RunSpec) 새 문단의
글자 모양은 삽입 지점의 이웃 본문 문단에서 물려받는다. para도 비어 있으면 문단 모양(들여쓰기·
정렬)을 같은 이웃에서 물려받는다. 이웃 탐색은 index-1에서 뒤로 → index에서 앞으로, 빈
스페이서 문단(양식이 줄간격용으로 끼워 둔 6~8pt 빈 줄)과 표 앵커 문단은 건너뛴다. 크기·정렬 등을
명시하면 그 값이 이긴다(기존 동작 — 바꾸는 Intent 의 생략 규칙은 §6.7 「생략 = 물려받기」). 이유: 예전에는 CharShape::default()(height 0 → 조판 10pt
폴백) + 기본 ParaShape(들여쓰기 0)로 합성돼, 12~14pt 개요 목록 한가운데 삽입하면 더 작은 글자가
불릿과 분리된 줄로 렌더됐다.
ParaSpec (중첩 오브젝트) — #[serde(default, deny_unknown_fields)]. 모든 필드 선택
(생략 = 이웃 문단 상속, 이웃이 없으면 문서 기본). 알 수 없는 키는 거부.
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
style | string | 문단 스타일 이름(예: "개요 1", "본문") | ○ |
align | string | left|center|right|justify|distribute|distribute_space | ○ |
line_spacing_pct | integer | 줄간격 %(예: 160) | ○ |
indent_pt | number | 첫 줄 들여쓰기 pt(음수=내어쓰기) | ○ |
margin_left_pt | number | pt | ○ |
margin_right_pt | number | pt | ○ |
space_before_pt | number | pt | ○ |
space_after_pt | number | pt | ○ |
6.10 AI 데이터 차트 삽입 (이슈 062-follow; additive)
AI가 데이터로부터 만드는 막대/원/선 차트를 블록 인덱스 위치에 삽입한다(1 undo 단위). 엔진이
chart스펙을 순수 Rust SVG(hwp_ops::chart_gen)로 그려Inline::Chart로 심는데, 이는 이슈 062의ChartRef/PaintOp::Image.svg렌더 채널을 그대로 재사용한다 — own-render SVG + HTML export가 새 배관 없이 그리고, place_doc↔NaiveLayout이 고정 박스를 락스텝으로 예약한다 (PDF는 062처럼 v1에서 예약 박스만; SVG→PDF 벡터는 후속).index앵커는 §6.9와 동일 (정수=그 블록,==len=끝-append,null/생략=구역 끝).
InsertChartAt — 데이터 차트(막대/원/선)를 블록 인덱스 위치에 삽입(1 undo 단위)
{ "intent": "InsertChartAt", "section": 0, "index": 1, "chart": { "type": "bar", "title": "연도별 매출", "categories": ["2024","2025","2026"], "series": [{ "name": "매출", "values": [10, 18, 30] }] } }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
section | integer | 구역 인덱스 | ● |
index | integer|null | 블록 인덱스(==len=끝-append, 범위 밖=에러), null/생략=구역 끝 | ○(없으면 구역 끝) |
chart | ChartSpec | 차트 스펙(아래) | ● |
ChartSpec (중첩 오브젝트) — #[serde(deny_unknown_fields)]. 알 수 없는 키는 거부(불변식 7).
와이어 필드는 type(Rust kind로 rename). title/width/height는 선택, categories/series는 필수.
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
type | string | bar|pie|line | ● |
title | string | 차트 제목(상단) | ○ |
categories | string[] | 가로축/원 조각 라벨(데이터 포인트당 1개) | ● |
series | ChartSeries[] | 데이터 계열(막대/선=다중 계열; 원=첫 계열만 사용) | ● |
width | number | 박스 폭 own-render px(기본 400; ×75 → HWPUNIT) | ○ |
height | number | 박스 높이 own-render px(기본 260) | ○ |
ChartSeries (중첩 오브젝트) — #[serde(deny_unknown_fields)]. values는 categories와 1:1 정렬.
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
name | string | 계열 이름(범례에 표시) | ● |
values | number[] | 계열 값(개수 = categories 개수) | ● |
실패(정직한 op 에러, 무변경): 알 수 없는 차트 종류 …(type≠bar/pie/line), categories 가 비어 있습니다,
series 가 비어 있습니다, series … 의 값 개수(…)가 categories 개수(…)와 다릅니다(계열 길이 불일치).
6.11 읽기 전용 모델 조회 (이슈 #64 D0; additive)
세 Intent는
hwp-session에 이미 있는 순수 모델 read를 op-bus에 노출만 한다. 신규 Tauri 커맨드 없음 — 데스크톱TauriAdapter는apply_intent_json래퍼(기존HitTestCell선례). 읽기 전용: undo 단위/리비전 범프 없음. AI 편집 화이트리스트에는 넣지 않는다.
BlockRunsPath — 중첩 셀의 현재 styled runs
{ "intent": "BlockRunsPath", "section": 0, "path": [{ "block": 1, "row": 0, "col": 0 }] }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
section | integer | 구역 인덱스 | ● |
path | {block,row,col}[] | 내림 CellPath(길이 1 = 평면 셀) | ● |
결과 {kind:"runs", runs} — runs는 RunSpec[](경로 미해소면 []). wasm blockRunsPath 바인딩과
같은 hwp_session::block_runs_path.
TableGrid — 표 블록의 ACTIVE 셀 격자
{ "intent": "TableGrid", "section": 0, "block": 1 }
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
section | integer | 구역 인덱스 | ● |
block | integer | 표 블록 인덱스 | ● |
결과 {kind:"tableGrid", grid} — grid는 {section, block, rows, cols, cells:[{row,col,text}]}
또는 표가 아니면 null(018). (row, col)은 SetTableCell이 쓰는 좌표(edit_target).
DocProfile — 결정론 문서 프로필
{ "intent": "DocProfile" }
필드 없음. 결과 {kind:"docProfile", profile} — title + 구조 카운트 + headings + table inventory
- excerpt. 조판 없음, LLM 호출 0.
6.12 배치 속성 (#483; additive)
양식을 채운 뒤 쪽 배치를 맞추는 속성. 내보낸 XML 을 직접 고치는 대신 Intent 로 — 편집기 레인(제안 → 커밋)과 일괄 레인(
apply_intents, 원자 묶음 포함)이 같은 연산을 써서 같은section*.xml·header.xml이 나온다. 1 undo 단위. AI 편집 화이트리스트에는 넣지 않는다(호스트 경로 전용).
SetParagraphShape — 본문 문단의 배치 · 모양 패치(그 문단만, 1 undo 단위)
{"intent":"SetParagraphShape","section":0,"block":0,"keep_with_next":true,"widow_orphan":true,"page_break_before":false,"align":"justify","line_spacing_pct":160}
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
section | integer | 구역 인덱스 | ● |
block | integer | 본문(최상위) 문단 블록 인덱스 — 문단이 아니면 invalid target | ● |
keep_with_next | boolean | 다음 문단과 함께(hh:breakSetting keepWithNext) | ○ |
widow_orphan | boolean | 외톨이줄 보호(widowOrphan) | ○ |
page_break_before | boolean | 문단 앞에서 항상 쪽 나눔(문단 모양 pageBreakBefore). 문단 하나에만 붙는 강제 쪽 나누기(<hp:p pageBreak>)와는 다르다 | ○ |
align | string | left|center|right|justify|distribute|distribute_space | ○ |
line_spacing_pct | integer | 줄 간격 %(≥ 1, PERCENT) | ○ |
필드는 모두 선택이고 하나 이상 있어야 한다(없으면 invalid intent).
- 그 문단만 바뀐다. 내보내기는 그 문단이 쓰던 원래
hh:paraPr를 통째로 복제하고 요청한 속성만 바꾼다 — 번호 · 글머리표(hh:heading) · 탭 · 테두리 · 여백 · 다른 나눔 설정은 그대로다. 같은 모양을 쓰던 다른 문단의paraPrIDRef는 바뀌지 않는다. - 같은 원본에 같은 변경을 받은 문단은 paraPr 하나를 같이 쓴다. 지금 값과 같은 패치는 원래 id 를 다시 써서 내보낸 결과가 편집 전과 같다. 같은 문단을 다시 고치면 원본에서 한 번에 만든 모양 하나가 된다.
- 문단 본문(run · 줄 배치 캐시
linesegarray)은 바이트 그대로다. 단align·line_spacing_pct로 새 모양이 되면 그 문단의linesegarray는 버린다(한/글이 다시 잰다). - 조판:
page_break_before는 조판이 이미 읽는 값이라 열린 문서의 쪽이 바로 나뉜다.keep_with_next·widow_orphan은 아직 조판이 읽지 않는다(값을 쓰고 내보내기까지 — 쪽 수 게이트에 영향이 있어 따로 연다). 칸 안 문단은 아직 대상이 아니다.
SetTableProps — 이미 있는 표의 배치 속성(1 undo 단위)
{"intent":"SetTableProps","section":0,"index":1,"treat_as_char":false,"page_break":"cell","repeat_header":false}
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
section | integer | 구역 인덱스 | ● |
index | integer | 표 블록 인덱스 — 최상위, 또는 path 가 있으면 그 부모 칸 안의 블록 인덱스 | ● |
path | CellStep[] | 칸 안 표: 부모 칸까지 내려가는 CellPath(DeleteNestedBlock 과 같은 주소). 생략 = 최상위 | ○ |
treat_as_char | boolean | 글자처럼 취급(<hp:pos treatAsChar>). false 면 문단 사이에 놓인 표라 쪽 경계에서 나뉜다 | ○ |
page_break | string | 쪽 경계 나눔(<hp:tbl pageBreak>): none(나누지 않음) | table(행 경계) | cell(행 안에서도) | ○ |
repeat_header | boolean | 제목 행 반복(<hp:tbl repeatHeader>) | ○ |
- 하나 이상 있어야 한다. 표가 아닌 블록은
invalid target. - 내보내기는 그 표의 여는 태그
pageBreak·repeatHeader와 표 자신의<hp:pos treatAsChar>만 바꾼다 — 칸 · 크기 · 위치 기준은 바이트 그대로다. 1×1 틀 표는 주소가 가리키는 표(틀)에 붙는다 — 안쪽 표는path로. repeat_header: true는 표의 반복 설정을 켠다. 어느 행이 반복되는지는 문서의 제목 셀 표시를 따른다(새로 넣은 표는 첫 행).- 조판: 다시 열면 파서가
pageBreak·repeatHeader를 읽는다. 열린 세션의 화면에는 아직 반영하지 않는다(쓰기 · 내보내기까지).
7. 드리프트 방지
- 정본 테스트:
crates/hwp-mcp/tests/schema_v0.rs.- 위 57개 예제가 실제로
deserialize_intent로 파싱됨(문서↔코드 필드명/타입 일치 보증). Synthetic대상은 결정적 3×2 표 문서에 op-bus로 디스패치되어 편집을 만든다(리비전 범프).Showcase대상은 실제 HWPX를 열어 엔드투엔드로 디스패치된다.- unknown 태그/필드, 태그·필수 필드 누락,
intent_version(없음/0/범위밖/비정수)를 고정.
- 위 57개 예제가 실제로
- 검증:
cargo test -p hwp-mcp(레인 (A) 스키마 + (B) 툴),cargo test -p hwp-ops.