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)뿐 |
즉 **대부분의 실패는 "코드 없는 평문 문자열"**이다. 대표 에러 문자열:
| 상황 | 대표 문자열(부분 일치) |
|---|---|
| 엔벨로프가 오브젝트가 아님 | 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별 |
| 구조 문단 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) | ● |
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 | 표 블록 인덱스 | ● |
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 | 블록 인덱스 | ● |
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 | 기본 false | ○ |
italic | bool | 기본 false | ○ |
underline | bool | 기본 false | ○ |
strike | bool | 기본 false | ○ |
size_pt | number | pt | ○ |
color | string | "#RRGGBB" | ○ |
highlight | string | "#RRGGBB"(형광/음영) | ○ |
font | string | 글꼴 패밀리 | ○ |
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 커버리지 — 각 논리 행은 덮이지 않은 셀만 나열) | ● |
CellSpec (중첩 오브젝트) — #[serde(default, deny_unknown_fields)]. 모든 필드 선택
({} = 빈 일반 셀). 알 수 없는 셀 키는 거부.
| 필드 | 타입 | 단위/값 | 필수 |
|---|---|---|---|
text | string | 셀 텍스트(생략 시 "") | ○ |
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 ...(범위 밖).
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 | 문단 모양 오버라이드(아래; 생략={}=전부 상속) | ○(생략 시 상속) |
서식 상속(불릿 채움 수리) — runs가 서식을 하나도 지정하지 않으면(기본 RunSpec) 새 문단의
글자 모양은 삽입 지점의 이웃 본문 문단에서 물려받는다. para도 비어 있으면 문단 모양(들여쓰기·
정렬)을 같은 이웃에서 물려받는다. 이웃 탐색은 index-1에서 뒤로 → index에서 앞으로, 빈
스페이서 문단(양식이 줄간격용으로 끼워 둔 6~8pt 빈 줄)과 표 앵커 문단은 건너뛴다. 크기·정렬 등을
명시하면 그 값이 이긴다(기존 동작). 이유: 예전에는 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.
7. 드리프트 방지
- 정본 테스트:
crates/hwp-mcp/tests/schema_v0.rs.- 위 52개 예제가 실제로
deserialize_intent로 파싱됨(문서↔코드 필드명/타입 일치 보증). Synthetic대상은 결정적 3×2 표 문서에 op-bus로 디스패치되어 편집을 만든다(리비전 범프).Showcase대상은 실제 HWPX를 열어 엔드투엔드로 디스패치된다.- unknown 태그/필드, 태그·필수 필드 누락,
intent_version(없음/0/범위밖/비정수)를 고정.
- 위 52개 예제가 실제로
- 검증:
cargo test -p hwp-mcp(레인 (A) 스키마 + (B) 툴),cargo test -p hwp-ops.