오토한글auto-hwp 앱 내려받기GitHub

Intent 스키마

AI 가 낼 수 있는 편집 명령의 전체 계약(v0) — 필드·제약·거부 규칙.

원문: docs/INTENT-SCHEMA.md · 이 페이지는 빌드할 때 그 파일에서 생성됩니다.

이슈 008 산출물. 새 프로토콜이 아니라, 지금 crates/hwp-mcp/src/lib.rsIntent 표면을 전수 조사해 명문화한 문서다. "코어 하나, 셸 셋"의 계약은 이 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(예: SetCharFmtitalic을 빼면 이태릭 미변경). bool/String/정수 등 비-Option 필드는 필수(누락 시 missing field 에러).
  • 튜플 필드 cell: Option<(usize, usize)>는 JSON 배열 [row, col]로 온다(또는 생략/null).

두 개의 전송 레인 (참고)

Intent 표면은 하나지만 이를 나르는 전송은 둘이다. 이 문서는 (A) Intent JSON 표면을 동결한다.

레인위치형태intent_version
(A) 타입드 Intenthwp-mcp Intent/apply_intent이 문서의 JSON(내부 태그)적용됨(엔벨로프)
(B) MCP tools/*hwp-mcp handle/call_toolJSON-RPC 2.0 tool 호출(툴별 arguments)미적용(§5 참고)
  • Tauri 데스크톱 셸(hwp-viewer/src/lib.rs)은 커맨드별 invokeIntent variant를 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_intentintent_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에서 동결)

  1. unknown Intent/필드 = 명시적 거부. 보존·무시가 아니라 에러(§1, deny_unknown_fields).
  2. 필드 추가는 optional(Option<T>)로만. 기존 요청(그 필드 미포함)이 계속 파싱돼야 함.
  3. 의미 변경·필드 삭제·필수화는 intent_version 범프로만. v0 계약을 깨는 변경 금지.
  4. 단위 불변. 각 필드의 단위(HWPUNIT/px/mm/pt/비율)는 §6 표에 고정. 단위 슬립은 조용히 클릭선택/이동/리사이즈를 죽인다(공통 계약 §4.5).

deny_unknown_fieldsserde(flatten) 함정

deny_unknown_fieldsserde(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 Openpath⚠️ 임의 읽기 경로
Intent Exportpath⚠️ 임의 쓰기 경로(atomic_write)
MCP open_documentpath⚠️ 임의 읽기 경로
MCP export_hwpxpath⚠️ 임의 쓰기 경로

그 외 자유 문자열(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" }
필드타입단위/값필수
pathstring⚠️ 파일 경로(§5)

실패: 읽기 실패 read {path}: ..., 미인식 포맷 unrecognized format (not HWP/HWPX/DOCX/PDF).

PageCount — 현재 문서 페이지 수

{ "intent": "PageCount" }

필드 없음. 실패: no document open ....

Render — 현재 페이지 SVG (rhwp 빌드 필요)

{ "intent": "Render", "page": 0 }
필드타입단위/값필수
pageinteger0-based 페이지

실패: rhwp 미빌드 render needs a build with --features rhwp; 편집된 문서는 SVG 렌더 거부 (HTML 미리보기로 표시).

ApplyContent — AI 콘텐츠 JSON 적용(1 undo 단위)

{ "intent": "ApplyContent", "json": "{\"blocks\":[{\"type\":\"paragraph\",\"runs\":[{\"text\":\"에이전트 추가\"}]}]}" }
필드타입단위/값필수
jsonstringAiContent JSON(문자열로 인코딩)

Export — HWPX 직렬화 저장(atomic write)

{ "intent": "Export", "path": "/tmp/out.hwpx" }
필드타입단위/값필수
pathstring⚠️ 출력 경로(§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\"}]}" }
필드타입단위/값필수
jsonstringAiContent 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_fields decoder로 다시 읽고 기본값을 채운 typed 형태로 정규화한다. lifecycle/query/undo/redo/proposal 중첩은 preview 전에 거부한다.
  • ApplyContent의 JSON 문자열도 즉시 typed AiContent로 파싱·재직렬화한 뒤 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-free verification 보고서를 갖는 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 }
필드타입단위/값필수
querystring검색어
case_sensitivebool대소문자 구분
whole_wordbool온전한 단어

Replace — 찾아 바꾸기(1 undo 단위)

{ "intent": "Replace", "query": "문서", "replacement": "파일", "case_sensitive": false, "whole_word": false, "all": true }
필드타입단위/값필수
querystring검색어
replacementstring대체 텍스트
case_sensitivebool대소문자 구분
whole_wordbool온전한 단어
allbooltrue=전체, 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 }
필드타입단위/값필수
pageinteger0-based 페이지
xnumber페이지 px
ynumber페이지 px

CaretRect — 모델 대상(NodeId+offset)을 캐럿 사각형으로

{ "intent": "CaretRect", "page": 0, "node": 7, "offset": 3 }
필드타입단위/값필수
pageinteger0-based 페이지
nodeintegerNodeId(u64)
offsetinteger문단 내 문자(char) 인덱스

HitTestCell — 클릭 좌표를 셀 텍스트 캐럿 대상으로 매핑 (issue 053)

{ "intent": "HitTestCell", "page": 0, "x": 120.0, "y": 90.0 }
필드타입단위/값필수
pageinteger0-based 페이지
xnumber페이지 px
ynumber페이지 px

HitTestin_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 }
필드타입단위/값필수
sectioninteger구역 인덱스
blockinteger표 블록 인덱스
rowinteger셀 행(모델-전역)
colinteger셀 열(모델-전역)
parainteger셀 내 문단 서수(0-based) — 셀 텍스트를 "\n"으로 나눈 에디터 세그먼트 공간(blockRuns join / SetTableCellRuns split과 동일; 모델 문단 내 강제 줄바꿈도 경계)
offsetinteger문단 내 문자(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 }
필드타입단위/값필수
pageinteger0-based 페이지
xnumber페이지 px
ynumber페이지 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 }
필드타입단위/값필수
pageinteger캐럿이 소유해야 할 0-based 페이지
sectioninteger구역 인덱스
blockintegertop-level 문단 블록 인덱스
offsetintegerUnicode 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": "끼움" }
필드타입단위/값필수
nodeintegerNodeId
offsetinteger문단 내 char 인덱스
textstring삽입 텍스트

실패: 구조 문단 ... structural content and cannot be edited in place; 범위 밖 offset 에러.

DeleteBack — offset 직전 1문자 삭제(Backspace, 1 undo 단위)

{ "intent": "DeleteBack", "node": 7, "offset": 1 }
필드타입단위/값필수
nodeintegerNodeId
offsetinteger문단 내 char 인덱스(0=no-op)

6.5 이미지 오버레이 (라이브 이미지 필요)

⚠️ 단위: width/heightHWPUNIT.

SetImageSize — 이미지 리사이즈(1 undo 단위)

{ "intent": "SetImageSize", "section": 0, "index": 2, "width": 12000, "height": 9000 }
필드타입단위/값필수
sectioninteger구역 인덱스
indexinteger블록 인덱스
widthintegerHWPUNIT
heightintegerHWPUNIT

MoveImage — 이미지 블록 이동(DeleteBlock+InsertImageAt, 1 undo 단위)

{ "intent": "MoveImage", "section": 0, "from": 2, "to": 0, "width": 12000, "height": 9000 }
필드타입단위/값필수
sectioninteger구역 인덱스
frominteger원본 블록 인덱스
tointeger대상 블록 인덱스
widthintegerHWPUNIT(크기 보존)
heightintegerHWPUNIT(크기 보존)

InsertImage — 이미지 삽입(드롭/업로드, bytes 기반, 1 undo 단위)

웹은 파일 경로가 없다 — 이미지는 base64 바이트로 전달된다(data: 접두어 없이). 형식(png/jpg)은 매직바이트로 자동 감지하며(호출자가 확장자를 넘기지 않는다), PNG/JPEG 가 아니거나 크기 상한 (24 MiB, decoded)을 넘으면 정직하게 거부한다(조용한 무시 금지). BinData 로 임베드되어 HWPX 내보내기에 포함된다(왕복). InsertImageAt op 를 재사용하므로 레이아웃 로직은 무접촉.

{ "intent": "InsertImage", "section": 0, "block": null,
  "data_b64": "iVBORw0KGgo…", "width": 34016, "height": 25512 }
필드타입단위/값필수
sectioninteger구역 인덱스
blockinteger|null앵커 블록: Some(b)=블록 b (구역 끝으로 clamp), null=구역 끝○(없으면 구역 끝)
data_b64stringbase64 이미지 바이트(PNG/JPEG, data: 접두어 제외)
widthintegerHWPUNIT(표시 폭)
heightintegerHWPUNIT(표시 높이)

6.6 블록 / 표 구조

MoveBlock — 블록 이동(1 undo 단위)

{ "intent": "MoveBlock", "section": 0, "from": 0, "to": 1 }
필드타입단위/값필수
sectioninteger구역 인덱스
frominteger원본 블록 인덱스
tointeger대상 블록 인덱스(==len=끝)

TableInsertRows — 빈 본문 행 삽입(1 undo 단위)

{ "intent": "TableInsertRows", "section": 0, "index": 1, "at": 2, "count": 1, "cols": 3 }
필드타입단위/값필수
sectioninteger구역 인덱스
indexinteger표 블록 인덱스
atinteger논리 행 위치(==rows=append)
countinteger삽입 행 수(>0)
colsinteger행당 셀 수(>0)

SetTableCell — 셀 텍스트 교체(단일 평문 run, 1 undo 단위)

{ "intent": "SetTableCell", "section": 0, "index": 1, "row": 0, "col": 0, "text": "셀 값" }
필드타입단위/값필수
sectioninteger구역 인덱스
indexinteger표 블록 인덱스
rowinteger
colinteger
textstring셀 텍스트(빈 문자열=비움)

⚠️ 서식 보존 편집은 §6.7 SetTableCellRuns를 써라. 평문 SetTableCell은 run을 하나로 접는다(공통 계약 §4.7).

TableAppendRow — 마지막 행의 열 구성을 복제해 빈 행 1개 추가(merge-safe, 1 undo 단위)

{ "intent": "TableAppendRow", "section": 0, "index": 1 }
필드타입단위/값필수
sectioninteger구역 인덱스
indexinteger표 블록 인덱스

SetParagraphText — 단순 문단 텍스트 교체(char/para shape 보존, 1 undo 단위)

{ "intent": "SetParagraphText", "section": 0, "block": 0, "text": "바뀐 문단" }
필드타입단위/값필수
sectioninteger구역 인덱스
blockinteger블록 인덱스
textstring문단 텍스트

실패: 구조 문단이면 거부(UI는 채팅으로 폴백).

SetTableColWidths — 열 너비 비율(1 undo 단위)

{ "intent": "SetTableColWidths", "section": 0, "index": 1, "widths": [2, 1, 1] }
필드타입단위/값필수
sectioninteger구역 인덱스
indexinteger표 블록 인덱스
widthsinteger[]상대 비율(i32, 양수). len==표의 열 수

SetTableRowHeights — 행 최소높이 오버라이드(1 undo 단위)

{ "intent": "SetTableRowHeights", "section": 0, "index": 1, "heights": [0, 0] }
필드타입단위/값필수
sectioninteger구역 인덱스
indexinteger표 블록 인덱스
heightsinteger[]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 }
필드타입단위/값필수
sectioninteger구역 인덱스
left_mmnumbermm
right_mmnumbermm
top_mmnumbermm
bottom_mmnumbermm

DeleteBlock — 블록 삭제(1 undo 단위)

{ "intent": "DeleteBlock", "section": 0, "index": 0 }
필드타입단위/값필수
sectioninteger구역 인덱스
indexinteger블록 인덱스

DeleteNestedBlock — 셀 안의 중첩 블록 삭제(1 undo 단위, 수동 UI 전용)

{ "intent": "DeleteNestedBlock", "section": 0, "path": [{"block": 1, "row": 3, "col": 1}], "index": 2 }
필드타입단위/값필수
sectioninteger구역 인덱스
pathCellStep[]삭제 대상의 부모 셀까지 내려가는 CellPath(비어 있으면 거부)
indexinteger부모 셀 blocks 안의 중첩 블록 인덱스

DeleteBlock과 주소 공간을 분리한다. 중첩 표를 선택한 Backspace/Delete만 이 Intent를 사용하며, 최상위 바깥 표는 보존된다. AI 화이트리스트에는 넣지 않는다.

6.7 문자 / 문단 서식

⚠️ 단위: size_pt포인트(pt). 색은 "#RRGGBB". alignleft|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": "맑은 고딕" }
필드타입단위/값필수
sectioninteger구역 인덱스
blockinteger블록 인덱스
cell[int,int]/null[row,col]=셀, 생략/null=블록 문단
boldbool/null
italicbool/null
size_ptnumber/nullpt(CharShape.height=round(pt*100))
fontstring/null글꼴 패밀리(""=지움)

SetRunCharFmt — char 범위 [start,end)의 볼드/이태릭 패치(1 undo 단위)

{ "intent": "SetRunCharFmt", "section": 0, "block": 0, "cell": null, "start": 0, "end": 2, "bold": true, "italic": false }
필드타입단위/값필수
sectioninteger구역 인덱스
blockinteger블록 인덱스
cell[int,int]/null[row,col]=셀, 생략/null=블록 문단
startintegerchar 오프셋(바이트 아님)
endintegerchar 오프셋(반열림)
boldbool/null
italicbool/null

SetTableCellRuns — 셀을 스타일 run으로 교체(WYSIWYG 커밋, 1 undo 단위)

{ "intent": "SetTableCellRuns", "section": 0, "index": 1, "row": 0, "col": 0, "runs": [{"text": "강조", "bold": true}, {"text": " 일반"}] }
필드타입단위/값필수
sectioninteger구역 인덱스
indexinteger표 블록 인덱스
rowinteger
colinteger
runsRunSpec[]스타일 run 배열(아래)

SetParagraphRuns — 단순 문단을 스타일 run으로 교체(1 undo 단위)

{ "intent": "SetParagraphRuns", "section": 0, "block": 0, "runs": [{"text": "굵게", "bold": true}] }
필드타입단위/값필수
sectioninteger구역 인덱스
blockinteger블록 인덱스
runsRunSpec[]스타일 run 배열(아래)

RunSpec (중첩 오브젝트)#[serde(default, deny_unknown_fields)]. 모든 필드 선택 (생략 시 기본값). 알 수 없는 run 키는 거부.

필드타입단위/값필수
textstringrun 텍스트(생략 시 "")
boldbool기본 false
italicbool기본 false
underlinebool기본 false
strikebool기본 false
size_ptnumberpt
colorstring"#RRGGBB"
highlightstring"#RRGGBB"(형광/음영)
fontstring글꼴 패밀리

6.8 셀 음영 / 범위 서식

⚠️ 색은 "#RRGGBB" 또는 null(지움). selrow|col|cell|all(그 외=cell로 취급).

SetTableCellShade — 셀 배경색 설정/해제(sel 기준, 1 undo 단위)

{ "intent": "SetTableCellShade", "section": 0, "index": 1, "sel": "cell", "row": 0, "col": 0, "shade": "#FFFF00" }
필드타입단위/값필수
sectioninteger구역 인덱스
indexinteger표 블록 인덱스
selstringrow|col|cell|all
rowinteger(row,col) 기준
colinteger(row,col) 기준
shadestring/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" }
필드타입단위/값필수
sectioninteger구역 인덱스
indexinteger표 블록 인덱스
r0,c0,r1,c1integer포함 사각형 경계
shadestring/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" }
필드타입단위/값필수
sectioninteger구역 인덱스
indexinteger표 블록 인덱스
r0,c0,r1,c1integer포함 사각형 경계
boldbool/null
italicbool/null
size_ptnumber/nullpt
fontstring/null글꼴(""=지움)
colorstring/null"#RRGGBB"
alignstring/nullleft|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를 쓸 수 있도록 디스패처가 nulllen으로 해석한다.

InsertTableAt — 리치 표를 블록 인덱스 위치에 삽입(1 undo 단위)

{ "intent": "InsertTableAt", "section": 0, "index": 1, "rows": [[{"text":"머리","bold":true},{"text":"칸"}],[{"text":"A2"},{"text":"B2"}]] }
필드타입단위/값필수
sectioninteger구역 인덱스
indexinteger|null블록 인덱스(==len=끝-append, 범위 밖=에러), null/생략=구역 끝○(없으면 구역 끝)
rowsCellSpec[][]행별 셀 그리드(아래 CellSpec; HTML-table 커버리지 — 각 논리 행은 덮이지 않은 셀만 나열)

CellSpec (중첩 오브젝트)#[serde(default, deny_unknown_fields)]. 모든 필드 선택 ({} = 빈 일반 셀). 알 수 없는 셀 키는 거부.

필드타입단위/값필수
textstring셀 텍스트(생략 시 "")
col_spaninteger열 병합 폭(기본 1)
row_spaninteger행 병합 높이(기본 1)
boldbool기본 false
shadestring/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 }
필드타입단위/값필수
sectioninteger구역 인덱스
blockinteger나눌 문단의 블록 인덱스
atinteger문단 평문 기준 글자 오프셋(0=맨 앞, len=맨 끝)

머리는 정체성을 유지한다 — NodeId·원본 바이트 스팬·문단모양이 그대로다(그래야 무편집 왕복의 바이트 보존이 살아 있다). 꼬리만 block+1에 새로 삽입되며 para_shape/style상속하되 id/source는 비운다(NodeId 복제 금지, 합성 문단 취급 —InsertParagraphAt과 동일). 자른 자리의 런은 양쪽 모두 보존한다(한쪽이 비어도 — 이어 치는 글자가 그 스타일을 물려받게). 구조 문단(이미지/필드/복합)과 표 앵커 문단은 거부한다.

⚠️ InsertParagraphAt으로는 분리를 대신할 수 없다 — 그쪽은 ParaSpec에서 문단모양을 새로 합성하므로 정렬·들여쓰기·스타일이 날아간다.

MergeParagraph — 문단을 앞 문단에 붙임(1 undo 단위, SplitParagraph의 역연산)

{ "intent": "MergeParagraph", "section": 0, "block": 4 }
필드타입단위/값필수
sectioninteger구역 인덱스
blockinteger없어질 문단의 블록 인덱스(내용이 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"} }
필드타입단위/값필수
sectioninteger구역 인덱스
indexinteger|null블록 인덱스(==len=끝-append, 범위 밖=에러), null/생략=구역 끝○(없으면 구역 끝)
runsRunSpec[]스타일 run 배열(§6.7 RunSpec와 동일 wire)
paraParaSpec문단 모양 오버라이드(아래; 생략={}=전부 상속)○(생략 시 상속)

서식 상속(불릿 채움 수리)runs가 서식을 하나도 지정하지 않으면(기본 RunSpec) 새 문단의 글자 모양은 삽입 지점의 이웃 본문 문단에서 물려받는다. para도 비어 있으면 문단 모양(들여쓰기· 정렬)을 같은 이웃에서 물려받는다. 이웃 탐색은 index-1에서 뒤로 → index에서 앞으로, 빈 스페이서 문단(양식이 줄간격용으로 끼워 둔 6~8pt 빈 줄)과 표 앵커 문단은 건너뛴다. 크기·정렬 등을 명시하면 그 값이 이긴다(기존 동작). 이유: 예전에는 CharShape::default()(height 0 → 조판 10pt 폴백) + 기본 ParaShape(들여쓰기 0)로 합성돼, 12~14pt 개요 목록 한가운데 삽입하면 더 작은 글자가 불릿과 분리된 줄로 렌더됐다.

ParaSpec (중첩 오브젝트)#[serde(default, deny_unknown_fields)]. 모든 필드 선택 (생략 = 이웃 문단 상속, 이웃이 없으면 문서 기본). 알 수 없는 키는 거부.

필드타입단위/값필수
stylestring문단 스타일 이름(예: "개요 1", "본문")
alignstringleft|center|right|justify|distribute|distribute_space
line_spacing_pctinteger줄간격 %(예: 160)
indent_ptnumber첫 줄 들여쓰기 pt(음수=내어쓰기)
margin_left_ptnumberpt
margin_right_ptnumberpt
space_before_ptnumberpt
space_after_ptnumberpt

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] }] } }
필드타입단위/값필수
sectioninteger구역 인덱스
indexinteger|null블록 인덱스(==len=끝-append, 범위 밖=에러), null/생략=구역 끝○(없으면 구역 끝)
chartChartSpec차트 스펙(아래)

ChartSpec (중첩 오브젝트)#[serde(deny_unknown_fields)]. 알 수 없는 키는 거부(불변식 7). 와이어 필드는 type(Rust kind로 rename). title/width/height는 선택, categories/series는 필수.

필드타입단위/값필수
typestringbar|pie|line
titlestring차트 제목(상단)
categoriesstring[]가로축/원 조각 라벨(데이터 포인트당 1개)
seriesChartSeries[]데이터 계열(막대/선=다중 계열; 원=첫 계열만 사용)
widthnumber박스 폭 own-render px(기본 400; ×75 → HWPUNIT)
heightnumber박스 높이 own-render px(기본 260)

ChartSeries (중첩 오브젝트)#[serde(deny_unknown_fields)]. valuescategories와 1:1 정렬.

필드타입단위/값필수
namestring계열 이름(범례에 표시)
valuesnumber[]계열 값(개수 = categories 개수)

실패(정직한 op 에러, 무변경): 알 수 없는 차트 종류 …(type≠bar/pie/line), categories 가 비어 있습니다, series 가 비어 있습니다, series … 의 값 개수(…)가 categories 개수(…)와 다릅니다(계열 길이 불일치).

6.11 읽기 전용 모델 조회 (이슈 #64 D0; additive)

세 Intent는 hwp-session에 이미 있는 순수 모델 read를 op-bus에 노출만 한다. 신규 Tauri 커맨드 없음 — 데스크톱 TauriAdapterapply_intent_json 래퍼(기존 HitTestCell 선례). 읽기 전용: undo 단위/리비전 범프 없음. AI 편집 화이트리스트에는 넣지 않는다.

BlockRunsPath — 중첩 셀의 현재 styled runs

{ "intent": "BlockRunsPath", "section": 0, "path": [{ "block": 1, "row": 0, "col": 0 }] }
필드타입단위/값필수
sectioninteger구역 인덱스
path{block,row,col}[]내림 CellPath(길이 1 = 평면 셀)

결과 {kind:"runs", runs}runsRunSpec[](경로 미해소면 []). wasm blockRunsPath 바인딩과 같은 hwp_session::block_runs_path.

TableGrid — 표 블록의 ACTIVE 셀 격자

{ "intent": "TableGrid", "section": 0, "block": 1 }
필드타입단위/값필수
sectioninteger구역 인덱스
blockinteger표 블록 인덱스

결과 {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/범위밖/비정수)를 고정.
  • 검증: cargo test -p hwp-mcp (레인 (A) 스키마 + (B) 툴), cargo test -p hwp-ops.