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

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) 타입드 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)은 커맨드별 invoke로 Intent 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_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에서 동결)

  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_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 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●

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 }
필드타입단위/값필수
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/height는 HWPUNIT.

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)●

#457: 새 칸은 바로 위 행(at == 0 이면 첫 행) 칸의 모양 · 높이를 따르고, HWPX 내보내기는 원본 칸 · 표 제목을 그대로 둔다. at 을 가로지르는 세로 병합은 새 행까지 늘어나고 그 열에는 새 칸을 만들지 않는다(겹침 방지). 위 행에서 끝나기만 하는 병합은 늘리지 않는다 — 행 복제는 TableAppendRow.

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표 블록 인덱스●

#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 }] }
필드타입단위/값필수
sectioninteger구역 인덱스●
indexinteger표 블록 인덱스(path 가 있으면 그 칸 안 블록 인덱스)●
atinteger첫 행/열(TableInsertCols 는 이 열 앞에, ==cols=끝)●
countinteger행/열 수(>0)●
pathCellStep[]칸 안 표: 표를 품은 칸까지의 내림 CellPath(DeleteNestedBlock 과 같은 주소)
  • 병합: 지우는 범위를 가로지르는 병합은 줄고(rowSpan/colSpan − 겹친 수), 범위 안에서 시작해 밖으로 이어지는 병합은 내용을 지키며 at 으로 옮긴다. 범위 안에 통째 들어간 칸은 지운다. 넣는 경계에 걸친 병합은 넓어진다.
  • 너비: 열 넣기/지우기는 표 너비를 그대로 둔다 — 새 열은 이웃 열 너비를 받고, 모든 열이 비율로 다시 나뉜다.
  • 거절: 범위 밖 · count 0 · 행/열을 모두 지우기 · 열 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": "바뀐 문단" }
필드타입단위/값필수
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블록 인덱스●

표 블록의 담는 문단(#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 }
필드타입단위/값필수
sectioninteger구역 인덱스●
pathCellStep[]삭제 대상의 부모 셀까지 내려가는 CellPath(비어 있으면 거부)●
indexinteger부모 셀 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": "맑은 고딕" }
필드타입단위/값필수
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/null생략 · null = 물려받기○
italicbool/null생략 · null = 물려받기○
underlinebool/null생략 · null = 물려받기○
strikebool/null생략 · null = 물려받기○
size_ptnumber/nullpt. 생략 · null = 물려받기○
colorstring/null"#RRGGBB". 생략 · null = 검정(물려받지 않는다)○
highlightstring/null"#RRGGBB"(형광/음영). 생략 = 물려받기, null = 음영 없음○
fontstring/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" }
필드타입단위/값필수
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를 쓸 수 있도록 디스패처가 null을 len으로 해석한다.

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 커버리지 — 각 논리 행은 덮이지 않은 셀만 나열)●
borderobject/nullA-3 추가. 모든 칸 변 + 표 외곽에 한 테두리: {"type":"solid"|"dash"|"dot"|"double"|"none","width_mm":0.12,"color":"#RRGGBB"}(모두 선택 — 기본 solid · 0.12 mm · 검정; 굵기는 HWP 선 굵기 0.1–5.0 mm 중 가장 가까운 값). 생략 = 문서 표 테두리(없으면 기본 실선)○
col_widthsnumber[]/nullA-3 추가. 열 너비 비율(열 수만큼, 모두 양수) → 구역 본문 폭에 맞춰 HWPUNIT 로 환산. 생략 = 같은 너비○
header_rowbool/nullA-3 추가. 첫 행을 머리 행으로 — 다음 쪽으로 이어지면 반복(HWPX repeatHeader). 생략 = 켬, false = 끔○
treat_as_charbool/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)]. 모든 필드 선택 ({} = 빈 일반 셀). 알 수 없는 셀 키는 거부.

필드타입단위/값필수
textstring셀 텍스트(생략 시 ""). \n(또는 \r\n)은 칸 안 새 문단(#441, 0.0.15 — 전에는 한 <hp:t> 안 글자로 들어갔다)○
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 ...(범위 밖), 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 }
필드타입단위/값필수
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문단 모양 오버라이드(아래; 생략={}=전부 상속)○(생략 시 상속)
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)]. 모든 필드 선택 (생략 = 이웃 문단 상속, 이웃이 없으면 문서 기본). 알 수 없는 키는 거부.

필드타입단위/값필수
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)]. values는 categories와 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 커맨드 없음 — 데스크톱 TauriAdapter는 apply_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} — runs는 RunSpec[](경로 미해소면 []). 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.

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}
필드타입단위/값필수
sectioninteger구역 인덱스●
blockinteger본문(최상위) 문단 블록 인덱스 — 문단이 아니면 invalid target●
keep_with_nextboolean다음 문단과 함께(hh:breakSetting keepWithNext)○
widow_orphanboolean외톨이줄 보호(widowOrphan)○
page_break_beforeboolean문단 앞에서 항상 쪽 나눔(문단 모양 pageBreakBefore). 문단 하나에만 붙는 강제 쪽 나누기(<hp:p pageBreak>)와는 다르다○
alignstringleft|center|right|justify|distribute|distribute_space○
line_spacing_pctinteger줄 간격 %(≥ 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}
필드타입단위/값필수
sectioninteger구역 인덱스●
indexinteger표 블록 인덱스 — 최상위, 또는 path 가 있으면 그 부모 칸 안의 블록 인덱스●
pathCellStep[]칸 안 표: 부모 칸까지 내려가는 CellPath(DeleteNestedBlock 과 같은 주소). 생략 = 최상위○
treat_as_charboolean글자처럼 취급(<hp:pos treatAsChar>). false 면 문단 사이에 놓인 표라 쪽 경계에서 나뉜다○
page_breakstring쪽 경계 나눔(<hp:tbl pageBreak>): none(나누지 않음) | table(행 경계) | cell(행 안에서도)○
repeat_headerboolean제목 행 반복(<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/범위밖/비정수)를 고정.
  • 검증: cargo test -p hwp-mcp (레인 (A) 스키마 + (B) 툴), cargo test -p hwp-ops.