Compare commits

...

1 commit

Author SHA1 Message Date
Jeffrey (Dongkyu) Kim
9e239f52d9 WIP: remove k-skill-rhwp package and migrate rhwp scripts
Saving in-progress work before switching to hotfix branch for Manus.ai compat.
2026-05-11 11:32:07 +09:00
25 changed files with 820 additions and 1930 deletions

View file

@ -0,0 +1,11 @@
---
---
Removed `k-skill-rhwp` wrapper package. Skills (`rhwp-edit`, `rhwp-advanced`,
`hwp`, `corporate-registration-consulting`) now call `@rhwp/core` directly via
inline Node.js scripts. No replacement package — see `rhwp-edit/SKILL.md` for
the WASM init recipe and API usage.
`corporate-registration-consulting/scripts/fill_official_hwp.py` now uses a
batch Node.js helper (`_rhwp_set_cell.mjs`) that initializes WASM once and
applies all cell edits in a single pass.

View file

@ -65,7 +65,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 캐치테이블 예약 스나이핑 | `catchtable-sniper` | 로그인된 캐치테이블 Chrome 세션으로 빈자리 감시, 오픈런, 자동 예약 시도 | 필요 | [캐치테이블 예약 스나이핑 가이드](docs/features/catchtable-sniper.md) |
| 로또 당첨 확인 | `lotto-results` | 로또 최신 회차, 특정 회차, 번호 대조 | 불필요 | [로또 결과 가이드](docs/features/lotto-results.md) |
| HWP 문서 조회/변환 | `hwp` | `.hwp/.hwpx` → Markdown/JSON 변환, 문서 비교, 양식 필드 추출, Markdown→HWPX 역변환 (kordoc 기반 read-only) | 불필요 | [HWP 문서 처리 가이드](docs/features/hwp.md) |
| HWP 문서 편집 | `rhwp-edit` | `.hwp` 본문 텍스트 삽입/삭제, 표 생성, 셀 수정, replace-all (`k-skill-rhwp` CLI + `@rhwp/core` WASM, HWP 5.x round-trip) | 불필요 | [HWP 문서 편집 가이드](docs/features/rhwp-edit.md) |
| HWP 문서 편집 | `rhwp-edit` | `.hwp` 본문 텍스트 삽입/삭제, 표 생성, 셀 수정, replace-all (`@rhwp/core` WASM 직접 사용, HWP 5.x round-trip) | 불필요 | [HWP 문서 편집 가이드](docs/features/rhwp-edit.md) |
| HWP 레이아웃·IR 디버깅 | `rhwp-advanced` | 업스트림 `rhwp` Rust CLI(`export-svg --debug-overlay`, `dump`, `dump-pages`, `ir-diff`, `thumbnail`, `convert`)로 HWP 레이아웃 진단·IR 덤프·버전 비교·썸네일 추출·배포용 문서 잠금 해제 | 불필요 | [HWP 레이아웃·IR 디버깅 가이드](docs/features/rhwp-advanced.md) |
| ~~근처 블루리본 맛집~~ ⚠️ 지원 중단 | ~~`blue-ribbon-nearby`~~ | ~~현재 위치 기준 근처 블루리본 선정 맛집 조회~~ | ~~불필요~~ | ~~[근처 블루리본 맛집 가이드](docs/features/blue-ribbon-nearby.md)~~ |
| 근처 술집 조회 | `kakao-bar-nearby` | 현재 위치 기준 영업 상태·메뉴·좌석·전화번호가 포함된 근처 술집 조회 | 불필요 | [근처 술집 조회 가이드](docs/features/kakao-bar-nearby.md) |

View file

@ -19,7 +19,7 @@ metadata:
- “주식회사 법인 설립등기 처음 하는데 전체 절차 알려줘”
- “법인명, 이사, 주소를 넣어 정관과 첨부서류 초안을 만들어줘”
- “등록면허세, 과밀억제권역 중과, 소프트웨어 업종 감면/중과 제외 가능성을 체크해줘”
- “등기 신청서류를 HWP로 만들어야 해서 rhwp-edit/k-skill-rhwp로 채울 수 있게 준비해줘”
- “등기 신청서류를 HWP로 만들어야 해서 rhwp-edit(`@rhwp/core`)로 채울 수 있게 준비해줘”
## 운영 원칙
@ -29,8 +29,8 @@ metadata:
4. **정관은 최대한 저장된 표준정관을 그대로 따른다.** 불필요한 창작 문구를 만들지 말고 `templates/attachment-hwp/standard-articles-startup-moj.hwp`, `templates/attachment-hwp/articles-of-incorporation.hwp`의 구조와 표현을 우선 유지한다. 목적/업태·종목, 상호, 본점, 주식 수, 임원, 결산기처럼 사용자 회사에 맞게 바꿔야 하는 부분만 고치고, 애매한 부분은 에이전트가 법령·양식·기존 템플릿을 더 확인해 가능한 초안을 제시한다.
5. **일반 영리 주식회사 발기설립을 기본값으로 빠르게 진행한다.** 모집설립은 일반적이지 않으므로 기본 플로우에서는 제외하고, 사용자가 별도로 요청하지 않는 한 저장된 발기설립 양식과 첨부서류 양식을 채우는 데 집중한다.
6. **이미 저장해 둔 HWP 양식을 우선 활용해 완성한다.** 에이전트가 매번 공식 양식을 새로 찾게 하지 말고, 이 스킬에 저장된 `templates/official/form-65-1-stock-company-incorporation-promoter.hwp``templates/attachment-hwp/*.hwp` 사본을 레포 밖 작업 디렉터리에 복사해 채운다. 최신 양식 확인은 제출 전 대조 안내로만 두고, 실제 초안 작성의 기본 경로는 저장된 양식 채우기다. 발기설립 신청서는 `scripts/fill_official_hwp.py``templates/official/form-65-1-fill-map.json`으로 작성하고, 정관·주식발행사항동의서·주식인수증·발기인회의사록·주주명부·조사보고서·취임승낙서·이사회의사록·인감신고서·위임장 등은 `templates/attachment-hwp/`의 저장된 HWP 양식을 채운다.
7. **사용자가 서류 작성을 요청하면 기본 산출물은 실제 HWP 파일이다.** 단순 절차 설명만 요청한 경우가 아니면 Markdown만 반환하지 말고, 레포 밖 비공개 작업 디렉터리에 공식 신청서와 첨부서류 HWP 사본을 만들고, `rhwp-edit`/`k-skill-rhwp`로 그 사본의 자리표시자와 표 셀에 사용자 값을 입력한다. Markdown은 검토용 요약·체크리스트·정관 대조본으로만 보조 제공한다.
8. **HWP 편집은 기존 rhwp 계열 스킬을 적극적으로 재사용한다.** 문서 생성/편집은 [`rhwp-edit`](../rhwp-edit/SKILL.md)`k-skill-rhwp`, HWP/HWPX 조회·필드 추출은 [`hwp`](../hwp/SKILL.md), 레이아웃 디버깅은 [`rhwp-advanced`](../rhwp-advanced/SKILL.md)를 사용한다. 본문 자리표시자는 `replace-all`, 공식 신청서와 표 기반 첨부서류는 `set-cell-text`, 구조 확인은 `info`/`list-paragraphs`, 생성 후 확인은 `info`와 가능하면 `render` 또는 `kordoc` 변환으로 검증한다.
7. **사용자가 서류 작성을 요청하면 기본 산출물은 실제 HWP 파일이다.** 단순 절차 설명만 요청한 경우가 아니면 Markdown만 반환하지 말고, 레포 밖 비공개 작업 디렉터리에 공식 신청서와 첨부서류 HWP 사본을 만들고, `rhwp-edit`(`@rhwp/core` 직접 사용)로 그 사본의 자리표시자와 표 셀에 사용자 값을 입력한다. Markdown은 검토용 요약·체크리스트·정관 대조본으로만 보조 제공한다.
8. **HWP 편집은 기존 rhwp 계열 스킬을 적극적으로 재사용한다.** 문서 생성/편집은 [`rhwp-edit`](../rhwp-edit/SKILL.md)(`@rhwp/core` 직접 사용), HWP/HWPX 조회·필드 추출은 [`hwp`](../hwp/SKILL.md), 레이아웃 디버깅은 [`rhwp-advanced`](../rhwp-advanced/SKILL.md)를 사용한다. 본문 자리표시자는 `replaceAll`, 공식 신청서와 표 기반 첨부서류는 `insertTextInCell`/`deleteTextInCell`, 구조 확인은 `getDocumentInfo`/`getSectionCount`/`getParagraphCount`, 생성 후 확인은 `getDocumentInfo`와 가능하면 `renderPageSvg` 또는 `kordoc` 변환으로 검증한다.
9. **양식의 어느 부분을 고칠지 문서마다 명시한다.** 특히 정관은 앞부분 제2조 목적/사업 내용에 실제 수행할 업태와 종목을 빠짐없이 채우고, 맨 마지막 부칙 아래 작성일자·발기인 성명·서명/기명날인란을 제출일·발기인별 실제 인감 날인 기준으로 확인한다. 각 첨부서류는 상단 법인명/본점, 중간 결의·인수·취임 내용, 하단 날짜·성명·날인란을 순서대로 확인한다.
10. **dummy 값을 지양하고 필요한 개인정보를 직접 받아 로컬 제출본에 채운다.** 빠른 초안을 위해 기본값을 제안하되, 제출용 HWP에는 `홍길동`, `서울특별시 ...`, `000000-0000000` 같은 dummy를 남기지 않는다. 사용자가 실제 이름·주소·생년월일·주식 수·인감 관련 표시 등 필요한 정보를 입력하면 에이전트가 그 값을 레포 밖 사본에 반영한다. 모르는 항목만 자리표시자로 남기고, 남은 자리표시자 목록을 사용자에게 알려준다.
11. **간인·법인인감 준비를 반드시 안내한다.** 정관처럼 여러 장으로 된 문서, 의사록/결정서, 위임장 등 원본성이 중요한 문서는 제출 전 각 장 사이에 간인이 필요한지 관할 등기소 요구를 확인하고 간인하도록 안내한다. 법인인감은 인감신고서와 함께 사용할 실제 도장을 미리 제작·준비해야 하며, 발기인/임원 개인 인감 또는 서명 요구와 구분해 설명한다.

View file

@ -0,0 +1,95 @@
#!/usr/bin/env node
import { readFileSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
globalThis.measureTextWidth = (font, text) => {
const match = String(font || "").match(/([0-9.]+)px/);
const size = match ? parseFloat(match[1]) : 12;
let width = 0;
for (const ch of String(text || "")) {
const cp = ch.codePointAt(0) ?? 0;
width += cp >= 0x1100 && cp <= 0xffdc ? size : size * 0.55;
}
return width;
};
function parseJsonResult(raw, op) {
try {
const parsed = JSON.parse(raw);
if (!parsed || parsed.ok !== true) {
throw new Error(`${op} rejected by rhwp: ${raw}`);
}
return parsed;
} catch (error) {
if (error instanceof SyntaxError) {
throw new Error(`${op} returned non-JSON payload: ${raw}`);
}
throw error;
}
}
function loadOperations(path) {
const ops = JSON.parse(readFileSync(path, "utf8"));
if (!Array.isArray(ops)) {
throw new Error("ops.json must be a JSON array");
}
return ops;
}
function asInteger(value, name) {
if (!Number.isInteger(value)) {
throw new Error(`operation ${name} must be an integer`);
}
return value;
}
async function main() {
const [inputPath, outputPath, opsPath] = process.argv.slice(2);
if (!inputPath || !outputPath || !opsPath) {
throw new Error("usage: _rhwp_set_cell.mjs <input.hwp> <output.hwp> <ops.json>");
}
const core = await import("@rhwp/core");
const wasmBytes = readFileSync(require.resolve("@rhwp/core/rhwp_bg.wasm"));
await core.default({ module_or_path: wasmBytes });
const ops = loadOperations(opsPath);
const inputBytes = readFileSync(inputPath);
const doc = new core.HwpDocument(new Uint8Array(inputBytes));
try {
for (const op of ops) {
const section = asInteger(op.section, "section");
const parentParagraph = asInteger(op.parentParagraph, "parentParagraph");
const control = asInteger(op.control, "control");
const cell = asInteger(op.cell, "cell");
const cellParagraph = op.cellParagraph === undefined ? 0 : asInteger(op.cellParagraph, "cellParagraph");
const text = op.text === undefined || op.text === null ? "" : String(op.text);
const length = doc.getCellParagraphLength(section, parentParagraph, control, cell, cellParagraph);
if (length > 0) {
parseJsonResult(
doc.deleteTextInCell(section, parentParagraph, control, cell, cellParagraph, 0, length),
"deleteTextInCell"
);
}
parseJsonResult(
doc.insertTextInCell(section, parentParagraph, control, cell, cellParagraph, 0, text),
"insertTextInCell"
);
}
const bytes = doc.exportHwp();
writeFileSync(outputPath, Buffer.from(bytes));
console.log(JSON.stringify({ ok: true, count: ops.length, outputPath, bytesWritten: bytes.length }));
} finally {
doc.free();
}
}
main().catch((error) => {
console.error(error?.stack || error?.message || String(error));
process.exit(1);
});

View file

@ -9,9 +9,9 @@ from __future__ import annotations
import argparse
import json
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
@ -19,6 +19,7 @@ SKILL_DIR = SCRIPT_DIR.parent
OFFICIAL_DIR = SKILL_DIR / "templates" / "official"
DEFAULT_FORM = OFFICIAL_DIR / "form-65-1-stock-company-incorporation-promoter.hwp"
DEFAULT_MAP = OFFICIAL_DIR / "form-65-1-fill-map.json"
HELPER = SCRIPT_DIR / "_rhwp_set_cell.mjs"
def load_json(path: Path) -> dict:
@ -36,35 +37,11 @@ def stringify(value) -> str:
return str(value)
def run_set_cell(current: Path, output: Path, spec: dict, text: str, cwd: Path) -> None:
cmd = [
"npx",
"k-skill-rhwp",
"set-cell-text",
str(current),
str(output),
"--section",
str(spec["section"]),
"--parent-paragraph",
str(spec["parentParagraph"]),
"--control",
str(spec["control"]),
"--cell",
str(spec["cell"]),
"--text",
text,
]
result = subprocess.run(cmd, cwd=cwd, text=True, capture_output=True)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip() or result.stdout.strip())
def fill_form(data: dict, form_path: Path, map_path: Path, output_path: Path, cwd: Path) -> list[str]:
fill_map = load_json(map_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
temp_path = output_path.with_suffix(output_path.suffix + ".tmp")
shutil.copyfile(form_path, temp_path)
written: list[str] = []
ops: list[dict] = []
for field_name, spec in fill_map["fields"].items():
if field_name in data:
@ -73,13 +50,33 @@ def fill_form(data: dict, form_path: Path, map_path: Path, output_path: Path, cw
text = stringify(spec["default"])
else:
continue
next_path = output_path.with_suffix(output_path.suffix + f".{len(written)}.tmp")
run_set_cell(temp_path, next_path, spec, text, cwd)
temp_path.unlink(missing_ok=True)
temp_path = next_path
ops.append(
{
"section": spec["section"],
"parentParagraph": spec["parentParagraph"],
"control": spec["control"],
"cell": spec["cell"],
"cellParagraph": spec.get("cellParagraph", 0),
"text": text,
}
)
written.append(field_name)
shutil.move(str(temp_path), str(output_path))
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".json", delete=False) as f:
json.dump(ops, f, ensure_ascii=False)
ops_json_path = Path(f.name)
try:
cmd = ["node", str(HELPER), str(form_path), str(output_path), str(ops_json_path)]
result = subprocess.run(cmd, cwd=cwd, text=True, capture_output=True)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip() or result.stdout.strip())
summary = json.loads(result.stdout)
if not summary.get("ok"):
raise RuntimeError(result.stdout.strip())
finally:
ops_json_path.unlink(missing_ok=True)
return written
@ -89,7 +86,7 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument("--output", required=True, type=Path, help="Output HWP path outside the repository")
parser.add_argument("--form", type=Path, default=DEFAULT_FORM, help="Official HWP source form")
parser.add_argument("--map", dest="map_path", type=Path, default=DEFAULT_MAP, help="HWP cell fill map")
parser.add_argument("--cwd", type=Path, default=Path.cwd(), help="Directory where npx k-skill-rhwp is available")
parser.add_argument("--cwd", type=Path, default=Path.cwd(), help="Directory where Node can resolve @rhwp/core")
args = parser.parse_args(argv)
data = load_json(args.input_json)

View file

@ -81,11 +81,11 @@ chmod 700 "$workdir"
python3 corporate-registration-consulting/scripts/fill_official_hwp.py \
--input-json "$workdir/form-data.json" \
--output "$workdir/form-65-1-filled.hwp"
npx k-skill-rhwp info "$workdir/form-65-1-filled.hwp"
node --input-type=module -e 'import{readFileSync}from"node:fs";globalThis.measureTextWidth=(f,t)=>{const m=String(f||"").match(/([0-9.]+)px/);const s=m?parseFloat(m[1]):12;let w=0;for(const c of String(t||"")){w+=(c.codePointAt(0)>=0x1100&&c.codePointAt(0)<=0xffdc)?s:s*0.55}return w};const c=await import("@rhwp/core");await c.default({module_or_path:readFileSync(await import("module").then(m=>m.createRequire(import.meta.url).resolve("@rhwp/core/rhwp_bg.wasm")))});const d=new c.HwpDocument(new Uint8Array(readFileSync(process.argv[1])));console.log(d.getDocumentInfo());d.free()' "$workdir/form-65-1-filled.hwp"
```
## HWP/HWPX 처리 주의
- 공식 파일을 새로 내려받거나 번들 HWP를 채운 산출물은 레포 밖 임시 디렉터리에 보관한다.
- `k-skill-rhwp info <공식양식>`로 구조를 확인한 뒤 표/셀은 `set-cell-text`, 본문 자리표시자는 `replace-all`을 우선 사용한다. 다만 replace-all에 의존하지 말고 각 양식의 앞부분·본문·하단 날짜·서명/날인란을 순차 검토한다. 번들 발기설립 HWP는 `form-65-1-fill-map.json`의 셀 매핑을 사용한다.
- `@rhwp/core`의 `getDocumentInfo`/`getSectionCount`로 구조를 확인한 뒤 표/셀은 `insertTextInCell`/`deleteTextInCell`, 본문 자리표시자는 `replaceAll`을 우선 사용한다. 다만 replaceAll에 의존하지 말고 각 양식의 앞부분·본문·하단 날짜·서명/날인란을 순차 검토한다. 번들 발기설립 HWP는 `form-65-1-fill-map.json`의 셀 매핑을 사용한다.
- 공식 양식은 표와 칸이 많으므로 자동 치환 후 반드시 사람이 한컴오피스/호환 뷰어로 열어 누락 셀, 줄바꿈, 날인란, 첨부서류 목록을 확인한다.

View file

@ -1,7 +1,7 @@
{
"form": "form-65-1-stock-company-incorporation-promoter.hwp",
"title": "[양식 제65-1호] 주식회사 설립 등기(발기설립)",
"engine": "k-skill-rhwp set-cell-text",
"engine": "@rhwp/core insertTextInCell",
"warning": "이 매핑은 국가법령정보센터 HWP 별지 양식의 표 셀 인덱스에 맞춘 보조 자동작성 맵입니다. 자동 작성 후 한컴오피스/호환 뷰어에서 셀 위치, 줄바꿈, 날인란, 첨부서면 통수를 반드시 사람이 확인하세요.",
"fields": {
"registration_purpose": { "section": 0, "parentParagraph": 2, "control": 0, "cell": 18, "default": "주식회사설립" },

View file

@ -2,7 +2,7 @@
`rhwp-advanced` 스킬은 **업스트림 `rhwp` Rust CLI** 를 실제로 설치해서 HWP 파일의 **구조·레이아웃·버전 차이·썸네일**을 꺼내 보는 디버깅/검사 스킬이다. 편집은 하지 않는다.
- 편집 → [`rhwp-edit` 스킬](rhwp-edit.md) (`k-skill-rhwp` CLI + `@rhwp/core` WASM)
- 편집 → [`rhwp-edit` 스킬](rhwp-edit.md) (`@rhwp/core` WASM 직접 사용)
- 조회/변환 → [`hwp` 스킬](hwp.md) (kordoc)
## 준비
@ -39,7 +39,7 @@ command -v rhwp && rhwp --help | head
| 배포용(읽기전용) 잠금 해제 | `rhwp convert` | `rhwp convert locked.hwp unlocked.hwp` |
| 표 템플릿 신규 문서 생성 | `rhwp gen-table` | `rhwp gen-table out.hwp` |
> **편집 서브커맨드는 없다.** v0.7.3 기준 업스트림 `rhwp` CLI 에는 `edit` / `insert-text` / `save` 같은 in-place 편집 명령이 없다. 편집은 `rhwp-edit` 스킬 (`k-skill-rhwp` CLI) 이 맡는다.
> **편집 서브커맨드는 없다.** v0.7.3 기준 업스트림 `rhwp` CLI 에는 `edit` / `insert-text` / `save` 같은 in-place 편집 명령이 없다. 편집은 `rhwp-edit` 스킬(`@rhwp/core` 직접 사용)이 맡는다.
## 자주 쓰는 플로우
@ -84,7 +84,7 @@ rhwp thumbnail sample.hwp --data-uri
```bash
rhwp convert locked.hwp unlocked.hwp
# 이후 편집은 `k-skill-rhwp` CLI (rhwp-edit 스킬)
# 이후 편집은 `rhwp-edit` 스킬(`@rhwp/core` 직접 호출)
```
## 검증 포인트

View file

@ -1,76 +1,153 @@
# HWP 문서 편집 (rhwp-edit)
`rhwp-edit` 스킬은 **`.hwp` 문서를 실제로 편집**하는 스킬이다. 본문에 텍스트를 넣고, 표를 만들고, 특정 셀 내용을 바꾸고, 전체 치환을 하는 식의 round-trip 편집을 Node CLI 한 줄로 돌린다.
`rhwp-edit` 스킬은 **`.hwp` 문서를 실제로 편집**하는 스킬이다. 본문에 텍스트를 넣고, 표를 만들고, 특정 셀 내용을 바꾸고, 전체 치환을 하는 식의 round-trip 편집을 Node 인라인 스크립트 한 줄로 돌린다.
엔진은 이 레포에서 새로 발행하는 npm 패키지 **`k-skill-rhwp`** 이다. `k-skill-rhwp` 업스트림 `@rhwp/core` (Rust + WebAssembly, MIT, [edwardkim/rhwp](https://github.com/edwardkim/rhwp)) 의 편집 API 를 얇게 래핑해 `insert-text`, `delete-text`, `replace-all`, `create-table`, `set-cell-text`, `render` 같은 CLI 서브커맨드로 노출한다. Rust toolchain 설치는 필요 없고, 번들된 WASM 이 그대로 돌아간다.
엔진은 업스트림 `@rhwp/core` (Rust + WebAssembly, MIT, [edwardkim/rhwp](https://github.com/edwardkim/rhwp)) 를 직접 호출한다. Rust toolchain 설치 불필요.
이 스킬은 **편집 전용**이다.
- 조회/Markdown·JSON 변환·양식 필드 추출은 → [`hwp` 스킬](hwp.md) (kordoc)
- 페이지 SVG 디버깅·IR 덤프·ir-diff·썸네일·배포용 문서 잠금 해제 는 → [`rhwp-advanced` 스킬](rhwp-advanced.md) (업스트림 `rhwp` Rust CLI)
- 페이지 SVG 디버깅·IR 덤프·ir-diff·썸네일·배포용 문서 잠금 해제는 → [`rhwp-advanced` 스킬](rhwp-advanced.md) (업스트림 `rhwp` Rust CLI)
## 준비
- Node.js 18+
- `k-skill-rhwp` 설치 — 셋 중 하나
- `@rhwp/core` 설치
```bash
# 일회성
npx --yes k-skill-rhwp --help
# 전역
npm install -g k-skill-rhwp
# 프로젝트 로컬
npm install k-skill-rhwp
npm install @rhwp/core
```
- `@rhwp/core@^0.7.3``k-skill-rhwp` 가 dependency 로 함께 끌어온다. 별도 설치 불필요.
- 업스트림 Rust `rhwp` 바이너리는 이 스킬이 요구하지 않는다(`rhwp-advanced` 스킬에서 따로 설치).
## 주요 시나리오
모든 예시는 `node --input-type=module -e '...'` 형태의 인라인 스크립트로 실행한다.
### 0) WASM 초기화 (공통 보일러플레이트)
첫 번째 예시에 아래 초기화 코드를 포함한다. 이후 예시에서는 초기화 부분을 생략한다.
```bash
node --input-type=module -e '
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const fs = require("node:fs");
globalThis.measureTextWidth = (font, text) => {
const match = String(font || "").match(/([0-9.]+)px/);
const size = match ? parseFloat(match[1]) : 12;
let width = 0;
for (const ch of String(text || "")) {
const cp = ch.codePointAt(0) ?? 0;
width += (cp >= 0x1100 && cp <= 0xffdc) ? size : size * 0.55;
}
return width;
};
const core = await import("@rhwp/core");
const wasmBytes = fs.readFileSync(require.resolve("@rhwp/core/rhwp_bg.wasm"));
await core.default({ module_or_path: wasmBytes });
// 이후 core.HwpDocument 등 API 사용
console.log("WASM 초기화 완료");
'
```
### 1) 빈 HWP 한 장 만들기
```bash
npx k-skill-rhwp create-blank ./out/blank.hwp
# => { "bytesWritten": 12800, "outputPath": "/abs/path/out/blank.hwp" }
node --input-type=module -e '
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const fs = require("node:fs");
globalThis.measureTextWidth = (font, text) => {
const match = String(font || "").match(/([0-9.]+)px/);
const size = match ? parseFloat(match[1]) : 12;
let width = 0;
for (const ch of String(text || "")) {
const cp = ch.codePointAt(0) ?? 0;
width += (cp >= 0x1100 && cp <= 0xffdc) ? size : size * 0.55;
}
return width;
};
const core = await import("@rhwp/core");
const wasmBytes = fs.readFileSync(require.resolve("@rhwp/core/rhwp_bg.wasm"));
await core.default({ module_or_path: wasmBytes });
const doc = core.HwpDocument.createBlank();
const bytes = doc.save();
doc.free();
fs.writeFileSync("./out/blank.hwp", bytes);
console.log("bytesWritten:", bytes.length);
'
```
### 2) 본문 첫 문단 맨 앞에 제목 삽입
이후 예시에서는 초기화 부분을 생략한다.
```bash
npx k-skill-rhwp insert-text ./draft.hwp ./out/draft-with-title.hwp \
--section 0 --paragraph 0 --offset 0 \
--text "2026년 오픈소스 AI·SW 지원사업 신청서"
node --input-type=module -e '
// ... (WASM 초기화 생략) ...
const input = fs.readFileSync("./draft.hwp");
const doc = core.HwpDocument.fromBytes(input);
doc.insertText({ section: 0, paragraph: 0, offset: 0, text: "2026년 오픈소스 AI·SW 지원사업 신청서" });
const bytes = doc.save();
doc.free();
fs.writeFileSync("./out/draft-with-title.hwp", bytes);
'
```
### 3) `2025``2026` 일괄 치환
```bash
npx k-skill-rhwp replace-all ./draft.hwp ./out/2026.hwp \
--query 2025 --replacement 2026
node --input-type=module -e '
// ... (WASM 초기화 생략) ...
const input = fs.readFileSync("./draft.hwp");
const doc = core.HwpDocument.fromBytes(input);
doc.replaceAll({ query: "2025", replacement: "2026", caseSensitive: false });
const bytes = doc.save();
doc.free();
fs.writeFileSync("./out/2026.hwp", bytes);
'
```
대소문자 구분이 필요하면 `--case-sensitive` 를 붙인다. 길이가 다른 치환(예: `2026``이천이십칠`)도 문제없이 동작한다.
대소문자 구분이 필요하면 `caseSensitive: true` 로 설정한다. 길이가 다른 치환(예: `2026``이천이십칠`)도 문제없이 동작한다.
**스코프 주의** — `replace-all`**본문(body) 문단만** 스캔한다. 업스트림 `searchText` 가 본문만 커버하기 때문에 같은 스코프를 따른다. 표 셀, 머리말/꼬리말, 각주 본문의 텍스트는 `replace-all` 이 건드리지 않는다. 셀 내용을 바꾸려면 아래 4) 의 `set-cell-text` 를 쓴다.
**스코프 주의** — `replaceAll` 은 **본문(body) 문단만** 스캔한다. 업스트림 `searchText` 가 본문만 커버하기 때문에 같은 스코프를 따른다. 표 셀, 머리말/꼬리말, 각주 본문의 텍스트는 `replaceAll` 이 건드리지 않는다. 셀 내용을 바꾸려면 아래 4) 의 `setCellText` 를 쓴다.
**Unicode 대소문자 무시 주의** — 기본(`--case-sensitive` 없이) 모드는 `String.prototype.toLowerCase()` 의 UTF-16 길이 보존을 전제한다. 본문이나 쿼리에 터키어 `İ`(U+0130) 처럼 소문자화 시 길이가 늘어나는 문자가 섞여 있으면, 오프셋 드리프트로 인한 조용한 손상을 막기 위해 `replace-all` 은 exit code 1 과 함께 `case-insensitive matching is unsafe because case folding changes the UTF-16 length` 를 돌려준다. 이 경우 `--case-sensitive` 로 재실행하거나 입력을 미리 정규화한다. 한글·ASCII 본문에는 해당하지 않는다.
**줄바꿈 주의** — `query``\n` 을 포함하면 업스트림이 예상대로 동작하지 않을 수 있다. 줄바꿈을 포함한 치환은 피하고, 단일 문단 내 텍스트만 대상으로 한다.
**Unicode 대소문자 무시 주의** — `caseSensitive: false` 모드는 `String.prototype.toLowerCase()` 의 UTF-16 길이 보존을 전제한다. 본문이나 쿼리에 터키어 `İ`(U+0130) 처럼 소문자화 시 길이가 늘어나는 문자가 섞여 있으면, 오프셋 드리프트로 인한 조용한 손상이 발생할 수 있다. 이 경우 `caseSensitive: true` 로 재실행하거나 입력을 미리 정규화한다. 한글·ASCII 본문에는 해당하지 않는다.
### 4) 표 추가 후 특정 셀 채우기
`create-table` 은 만든 표의 `paraIdx` / `controlIdx` 를 같이 돌려준다. 그 두 값을 `set-cell-text` 에 그대로 넣으면 된다.
`createTable` 은 만든 표의 `paraIdx` / `controlIdx` 를 같이 돌려준다. 그 두 값을 `setCellText` 에 그대로 넣으면 된다.
```bash
# (1) 3행 4열 표 삽입
npx k-skill-rhwp create-table ./report.hwp ./out/with-table.hwp \
--section 0 --paragraph 1 --offset 0 --rows 3 --cols 4
node --input-type=module -e '
// ... (WASM 초기화 생략) ...
const input = fs.readFileSync("./report.hwp");
const doc = core.HwpDocument.fromBytes(input);
# (2) 위 결과의 paraIdx / controlIdx 로 (0,0) 셀 채우기
npx k-skill-rhwp set-cell-text ./out/with-table.hwp ./out/with-header.hwp \
--section 0 --parent-paragraph <paraIdx> --control <controlIdx> \
--cell 0 --text "합계"
// (1) 3행 4열 표 삽입
const tableInfo = doc.createTable({ section: 0, paragraph: 1, offset: 0, rows: 3, cols: 4 });
// (2) 위 결과의 paraIdx / controlIdx 로 (0,0) 셀 채우기
doc.setCellText({
section: 0,
parentParagraph: tableInfo.paraIdx,
control: tableInfo.controlIdx,
cell: 0,
text: "합계"
});
const bytes = doc.save();
doc.free();
fs.writeFileSync("./out/with-header.hwp", bytes);
'
```
### 5) 편집 전 구조 조회
@ -78,54 +155,49 @@ npx k-skill-rhwp set-cell-text ./out/with-table.hwp ./out/with-header.hwp \
좌표를 잘못 주면 WASM 이 "구역 인덱스 … 범위 초과" 같은 오류로 거절한다. 편집 전에 먼저 구조를 확인한다.
```bash
npx k-skill-rhwp info ./draft.hwp
npx k-skill-rhwp list-paragraphs ./draft.hwp --section 0
npx k-skill-rhwp search ./draft.hwp --query "2025"
node --input-type=module -e '
// ... (WASM 초기화 생략) ...
const input = fs.readFileSync("./draft.hwp");
const doc = core.HwpDocument.fromBytes(input);
const info = doc.getInfo();
doc.free();
console.log(JSON.stringify(info, null, 2));
'
```
`search``replace-all` 과 마찬가지로 **본문 문단만** 스캔한다. 표 셀/머리말/꼬리말/각주 안의 텍스트는 `search` 가 찾지 않는다. 셀 내용은 `info` 또는 `list-paragraphs` 로 표 좌표(`paraIdx` / `controlIdx`) 를 확인한 뒤 `set-cell-text` 로 직접 쓴다.
## Node API
CLI 가 아니라 스크립트에서 직접 호출할 수도 있다.
```js
const { insertText, createTable, setCellText, getDocumentInfo } = require("k-skill-rhwp");
await insertText({
input: "./draft.hwp",
output: "./draft-with-title.hwp",
section: 0,
paragraph: 0,
offset: 0,
text: "2026년 신청서"
});
const info = await getDocumentInfo("./draft-with-title.hwp");
console.log(info.sections[0].paragraphs[0].length);
```
WASM 은 첫 호출 때 한 번만 초기화되고, Node 기본 환경에서도 동작하도록 `globalThis.measureTextWidth` shim 이 자동으로 설치된다. 픽셀 정밀 레이아웃이 필요하면 `node-canvas` 기반 shim 을 첫 호출 전에 주입한다.
`getInfo()` 결과의 `sections[N].paragraphs` 배열로 문단 좌표를 확인한 뒤 편집 명령을 구성한다. 표 셀 내용은 `info` 로 표 좌표(`paraIdx` / `controlIdx`) 를 확인한 뒤 `setCellText` 로 직접 쓴다.
## 검증 포인트
- 편집 직후 `k-skill-rhwp info <output>` 결과의 `sections[N].paragraphs[M].length` 가 기대와 일치한다.
- 편집 직후 `getInfo()` 결과의 `sections[N].paragraphs[M].length` 가 기대와 일치한다.
- 새 표는 `sections[N].paragraphCount` 를 최소 1 이상 증가시킨다(위치에 따라 표 내부 문단도 합산됨).
- `k-skill-rhwp render <output> --page 0 --format svg``<svg>` 로 시작하는 문자열을 반환한다.
- 아래 인라인 스크립트로 SVG 렌더링이 정상 반환되는지 확인한다.
```bash
node --input-type=module -e '
// ... (WASM 초기화 생략) ...
const input = fs.readFileSync("./out/result.hwp");
const doc = core.HwpDocument.fromBytes(input);
const svg = doc.renderPage(0);
doc.free();
console.log(svg.startsWith("<svg") ? "OK" : "FAIL");
'
```
- 출력 파일 크기는 blank 기준 최소 12 KB 이상, 편집 후에도 비슷하거나 더 크다.
- 원본 파일 경로는 CLI 가 절대 덮어쓰지 않는다(항상 별도 `<output>` 를 지정한다).
- 원본 파일은 절대 덮어쓰지 않는다. 항상 별도 출력 경로에 `save()` 결과를 쓴다.
- `HwpDocument` 사용 후 반드시 `doc.free()` 를 호출해 WASM 메모리를 해제한다.
## 제약 / 주의
- **HWPX 원본 저장은 업스트림 `rhwp``#196` 으로 비활성화 상태**다. HWPX 파일을 입력으로 줘도 저장은 HWP 5.x 바이너리로만 된다. HWPX 출력이 반드시 필요하면 `hwp` 스킬의 kordoc `markdownToHwpx` 경로를 사용한다.
- **rhwp v0.7.x 는 베타**이다. 복잡한 표/이미지/차트/양식 필드가 많은 실제 사업 신청서를 round-trip 할 때 드물게 형식 손실이 발생할 수 있다. 편집 직후 `info` + `render` 로 빠른 육안 검증을 권장한다.
- **배포용(읽기전용) 문서**`rhwp-edit` CLI 는 아직 `convert` 를 노출하지 않는다. 잠금 해제는 `rhwp-advanced` 스킬의 `rhwp convert` 를 먼저 거친다.
- **rhwp v0.7.x 는 베타**이다. 복잡한 표/이미지/차트/양식 필드가 많은 실제 사업 신청서를 round-trip 할 때 드물게 형식 손실이 발생할 수 있다. 편집 직후 `getInfo()` + `renderPage()` 로 빠른 육안 검증을 권장한다.
- **배포용(읽기전용) 문서**`convertToEditable()` 메서드가 `HwpDocument` 에 있어 잠금 해제가 가능하지만, 복잡한 보안 문서는 `rhwp-advanced` 스킬의 `rhwp convert` 를 먼저 거치는 편이 안전하다.
- **개인정보가 포함된 원본** — 편집 산출물을 레포에 커밋하지 말고, 로그에 남길 때 본문 텍스트는 요약·마스킹한다.
- **한컴 보안모듈 / Windows GUI 자동화** — 이 스킬은 파일 포맷 엔진을 다룰 뿐, GUI 제어를 하지 않는다.
## 참고
- `k-skill-rhwp` 패키지 소스: `packages/k-skill-rhwp/`
- 업스트림 rhwp: https://github.com/edwardkim/rhwp
- `@rhwp/core` npm: https://www.npmjs.com/package/@rhwp/core
- 스킬 정의: [`rhwp-edit/SKILL.md`](../../rhwp-edit/SKILL.md)

View file

@ -43,7 +43,7 @@
- `pdfjs-dist`: https://www.npmjs.com/package/pdfjs-dist
- `rhwp` upstream (Rust + WebAssembly HWP parser/renderer/editor, MIT, by Edward Kim): https://github.com/edwardkim/rhwp
- `rhwp` CLI source (upstream subcommand truth table): https://github.com/edwardkim/rhwp/blob/main/src/main.rs
- `@rhwp/core` npm (WASM bindings used by `k-skill-rhwp`): https://www.npmjs.com/package/@rhwp/core
- `@rhwp/core` npm (WASM bindings used directly by the `rhwp-edit` skill): https://www.npmjs.com/package/@rhwp/core
- `@rhwp/editor` npm (upstream iframe editor — not wrapped by this repo, documented for reference): https://www.npmjs.com/package/@rhwp/editor
- rhwp HWPX-save-disabled issue #196 (data-safety gate until #197 ships): https://github.com/edwardkim/rhwp/issues/196
- korean-law-mcp: https://github.com/chrisryugj/korean-law-mcp

View file

@ -35,7 +35,7 @@ metadata:
- OCR이 필수인데 OCR provider 연결이 전혀 없는 이미지 기반 PDF만 있는 경우
- `.docx`, `.xlsx`, `.pdf` 만 다루더라도 문서 파싱 자체가 아니라 편집기 GUI 자동화가 필요한 경우
- 원본 프로그램의 실시간 UI 제어가 반드시 필요한 경우
- **본문 텍스트 직접 삽입·삭제·치환 또는 표 구조 변경**`rhwp-edit` 스킬`k-skill-rhwp` CLI 를 사용한다.
- **본문 텍스트 직접 삽입·삭제·치환 또는 표 구조 변경**`rhwp-edit` 스킬(`@rhwp/core` 직접 사용)을 사용한다.
- **페이지 SVG 렌더 디버깅·IR 덤프·ir-diff·썸네일 추출**`rhwp-advanced` 스킬의 업스트림 `rhwp` CLI 를 사용한다.
## Prerequisites

372
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -12,14 +12,17 @@
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py coupang-product-search/scripts/coupang_partners_mcp.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"typecheck": "tsc --noEmit",
"test": "node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_k_skill_cleaner scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_korean_slang_writing scripts.test_kakaotalk_mac scripts.test_coupang_partners_mcp_wrapper && PYTHONPATH=.:scripts:korean-scholarship-search/scripts python3 -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && PYTHONPATH=.:scripts:kosis-stats/scripts python3 -m unittest discover -s kosis-stats/tests -p 'test_run_kosis_stats.py' && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace market-kurly-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace public-restroom-nearby --dry-run && npm pack --workspace parking-lot-search --dry-run && npm pack --workspace court-auction-notice-search --dry-run && npm pack --workspace donation-place-search --dry-run && npm pack --workspace gongsijiga-search --dry-run && npm pack --workspace kbl-results --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run && npm pack --workspace k-skill-rhwp --dry-run",
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace market-kurly-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace public-restroom-nearby --dry-run && npm pack --workspace parking-lot-search --dry-run && npm pack --workspace court-auction-notice-search --dry-run && npm pack --workspace donation-place-search --dry-run && npm pack --workspace gongsijiga-search --dry-run && npm pack --workspace kbl-results --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run",
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
"version-packages": "changeset version",
"release:npm": "changeset publish"
},
"devDependencies": {
"@types/node": "^22.14.1",
"@changesets/cli": "^2.29.5",
"@types/node": "^22.14.1",
"typescript": "^5.8.2"
},
"dependencies": {
"@rhwp/core": "^0.7.10"
}
}

View file

@ -1,7 +0,0 @@
# k-skill-rhwp
## 0.2.0
### Minor Changes
- 4fc0139: Introduce the initial `k-skill-rhwp` Node CLI + library that wraps `@rhwp/core` WASM editing bindings as subcommands (`info`, `list-paragraphs`, `search`, `insert-text`, `delete-text`, `replace-all`, `create-table`, `set-cell-text`, `create-blank`, `render`). This is the editing engine backing the new `rhwp-edit` skill and is the counterpart to the existing `hwp` (kordoc, read/convert) and the new `rhwp-advanced` (upstream rhwp Rust CLI) skills. Case-insensitive `replace-all` rejects inputs whose case folding changes UTF-16 length (e.g. Turkish `İ` U+0130) with exit code 1 instead of silently drifting offsets; rerun with `--case-sensitive` for those documents. Closes #155.

View file

@ -1,119 +0,0 @@
# k-skill-rhwp
Node-side HWP editing CLI that wraps [`@rhwp/core`](https://www.npmjs.com/package/@rhwp/core)
(Rust + WebAssembly, MIT, by Edward Kim) as subcommands.
- **Ships the `k-skill-rhwp` binary** for the `rhwp-edit` skill in
[NomaDamas/k-skill](https://github.com/NomaDamas/k-skill).
- **Round-trip safe HWP 5.x editing** — insert/delete text, replace-all, create
tables, set cell text, and render pages to SVG or HTML.
- **Node 18+ only.** No Rust toolchain required; the shipped WASM does the work.
For debugging the upstream `rhwp` Rust CLI (`export-svg --debug-overlay`,
`dump`, `ir-diff`, `thumbnail`, `convert`), see the `rhwp-advanced` skill —
this package does not wrap those commands.
For `.hwp` → Markdown / JSON / form-field extraction, see the `hwp` skill
(kordoc-based). This package is editing-only.
## Install
```bash
npm install k-skill-rhwp
# or run one-off
npx --yes k-skill-rhwp --help
```
## CLI
```bash
# Metadata / structure
k-skill-rhwp info <input.hwp>
k-skill-rhwp list-paragraphs <input.hwp> [--section N]
k-skill-rhwp search <input.hwp> --query TEXT [--from-section N] [--from-paragraph N] [--from-char N] [--case-sensitive]
# Body editing
k-skill-rhwp insert-text <input> <output> --section N --paragraph N --offset N --text TEXT
k-skill-rhwp delete-text <input> <output> --section N --paragraph N --offset N --count N
k-skill-rhwp replace-all <input> <output> --query TEXT --replacement TEXT [--case-sensitive]
# Tables
k-skill-rhwp create-table <input> <output> --section N --paragraph N --offset N --rows N --cols N
k-skill-rhwp set-cell-text <input> <output> --section N --parent-paragraph N --control N --cell N --text TEXT [--cell-paragraph N] [--no-replace]
# Rendering / creation
k-skill-rhwp create-blank <output.hwp>
k-skill-rhwp render <input.hwp> [--page N] [--format svg|html]
```
Every editing subcommand writes a brand-new HWP file (never overwrites the
input) and prints a JSON summary including `ok`, post-edit cursor position,
`bytesWritten`, and the resolved `outputPath`.
### Scope of `search` and `replace-all`
Both `search` and `replace-all` operate on **body paragraphs only**. Text
inside table cells, headers/footers, or footnotes is not scanned. This
mirrors the upstream `@rhwp/core` `searchText` scope. For cell text, use
`info` or `list-paragraphs` to locate the table and then `set-cell-text` to
write. `replace-all` also rejects any `--replacement` that contains newline
or paragraph-break characters (`\n`, `\r`, U+2028, U+2029) because they would
split a paragraph — split those into multiple `insert-text` calls instead.
`replace-all` uses **non-overlapping** replacement semantics: matches are
computed against the original text before any replacement runs, so
`--query a --replacement aa` against `aaa` replaces 3 originals and yields
`aaaaaa`, not an infinite loop.
Case-insensitive matching (the default) relies on `String.prototype.toLowerCase()`
preserving UTF-16 length so offsets taken in the lowercased haystack still apply
to the original text. A handful of Unicode characters (notably Turkish `İ`
U+0130, which lowercases to `i` + combining dot above U+0307) violate that
invariant. When either the query or a paragraph contains such a character,
`replace-all` refuses the operation with exit code 1 and a `case-insensitive
matching is unsafe because case folding changes the UTF-16 length` message
rather than silently drifting every subsequent offset. Rerun with
`--case-sensitive`, or normalize the input. ASCII, Hangul, and the common HWP
use cases (e.g. `2025 → 2026`) are not affected.
## Node API
```js
const { insertText, createTable, setCellText, getDocumentInfo } = require("k-skill-rhwp");
await insertText({
input: "./draft.hwp",
output: "./draft-with-title.hwp",
section: 0,
paragraph: 0,
offset: 0,
text: "2026년 신청서"
});
console.log(await getDocumentInfo("./draft-with-title.hwp"));
```
The first call loads `@rhwp/core` WASM once per process. The WASM requires a
`globalThis.measureTextWidth(font, text)` callback for text layout; this
package auto-installs a deterministic approximation shim on first use so it
works headless on Node without `canvas`. Replace the shim before the first
call if you need pixel-accurate metrics.
## Known limitations
- **HWPX round-trip is disabled upstream (rhwp #196).** HWPX input is
accepted, but output is always written as HWP 5.x binary.
- **rhwp v0.7.x is beta.** Complex tables, images, charts, or form fields may
occasionally lose fidelity on round-trip; verify with `info` and visual
render after non-trivial edits.
- **Windows security modules, Hancom GUI automation, read-only distribution
documents beyond `rhwp convert`** are out of scope.
## Upstream references
- rhwp (Rust): <https://github.com/edwardkim/rhwp>
- @rhwp/core (npm): <https://www.npmjs.com/package/@rhwp/core>
- k-skill repo: <https://github.com/NomaDamas/k-skill>
## License
MIT

View file

@ -1,14 +0,0 @@
#!/usr/bin/env node
"use strict";
const { main } = require("../src/cli");
main(process.argv.slice(2)).then(
(code) => {
process.exit(code);
},
(err) => {
process.stderr.write(`k-skill-rhwp: ${err && err.stack ? err.stack : err}\n`);
process.exit(2);
}
);

View file

@ -1,43 +0,0 @@
{
"name": "k-skill-rhwp",
"version": "0.2.0",
"description": "Node-side HWP editing CLI that wraps @rhwp/core WASM bindings for the rhwp-edit and rhwp-advanced skills",
"license": "MIT",
"main": "src/index.js",
"bin": {
"k-skill-rhwp": "bin/k-skill-rhwp.js"
},
"files": [
"src",
"bin",
"README.md"
],
"engines": {
"node": ">=18"
},
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/NomaDamas/k-skill.git"
},
"keywords": [
"k-skill",
"korea",
"hwp",
"hwpx",
"hancom",
"hangul",
"rhwp",
"editor",
"wasm"
],
"dependencies": {
"@rhwp/core": "^0.7.3"
},
"scripts": {
"lint": "node --check src/index.js && node --check src/cli.js && node --check src/wasm-init.js && node --check bin/k-skill-rhwp.js && node --check test/index.test.js && node --check test/cli.test.js",
"test": "node --test"
}
}

View file

@ -1,279 +0,0 @@
"use strict";
const path = require("node:path");
const lib = require("./index");
const PKG_VERSION = require("../package.json").version;
const USAGE = `k-skill-rhwp <command> [options]
Commands:
info <input> Print document info as JSON
list-paragraphs <input> [--section N] List paragraph lengths in a section
search <input> --query TEXT [--from-section N] [--from-paragraph N] [--from-char N]
Find first occurrence (forward, body paragraphs only)
insert-text <input> <output> --section N --paragraph N --offset N --text TEXT
delete-text <input> <output> --section N --paragraph N --offset N --count N
replace-all <input> <output> --query TEXT --replacement TEXT [--case-sensitive]
Replace every occurrence (body paragraphs only;
rejects replacements with newline/paragraph breaks)
create-table <input> <output> --section N --paragraph N --offset N --rows N --cols N
set-cell-text <input> <output> --section N --parent-paragraph N --control N --cell N
--text TEXT [--cell-paragraph N] [--no-replace]
create-blank <output> Write a blank HWP document to <output>
render <input> [--page N] [--format svg|html]
Print rendered SVG/HTML to stdout
Scope note:
'search' and 'replace-all' scan body paragraphs only. Text inside table cells,
headers/footers, and footnotes is NOT covered. For cell text, use 'info' or
'list-paragraphs' to locate the table, then 'set-cell-text' to write.
Case-insensitive 'replace-all' (the default) refuses inputs whose case folding
changes UTF-16 length (e.g. Turkish 'İ' U+0130) because those inputs would
silently drift every subsequent offset. Rerun with --case-sensitive for those
documents. ASCII and Hangul workflows are unaffected.
Global options:
--json Output machine-readable JSON (default for info/list/search)
--help, -h Show this help
--version, -v Print package version
Examples:
k-skill-rhwp info sample.hwp
k-skill-rhwp insert-text sample.hwp out.hwp --section 0 --paragraph 0 --offset 0 --text "hello"
k-skill-rhwp replace-all sample.hwp out.hwp --query 2025 --replacement 2026
`;
function parseArgs(argv) {
const args = { _: [], flags: {} };
let i = 0;
while (i < argv.length) {
const token = argv[i];
if (token === "--") {
args._.push(...argv.slice(i + 1));
break;
}
if (token.startsWith("--")) {
const eq = token.indexOf("=");
let name;
let value;
if (eq !== -1) {
name = token.slice(2, eq);
value = token.slice(eq + 1);
} else {
name = token.slice(2);
const next = argv[i + 1];
if (next === undefined || next.startsWith("-")) {
value = true;
} else {
value = next;
i += 1;
}
}
args.flags[name] = value;
} else if (token === "-h") {
args.flags.help = true;
} else if (token === "-v") {
args.flags.version = true;
} else {
args._.push(token);
}
i += 1;
}
return args;
}
function requireFlag(flags, name, { numeric = false } = {}) {
const raw = flags[name];
if (raw === undefined || raw === true || raw === "") {
throw new Error(`missing required --${name}`);
}
if (numeric) {
const n = Number(raw);
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
throw new Error(`--${name} must be a non-negative integer, got ${raw}`);
}
return n;
}
return String(raw);
}
function optionalNumber(flags, name, fallback) {
if (flags[name] === undefined) return fallback;
const n = Number(flags[name]);
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
throw new Error(`--${name} must be a non-negative integer, got ${flags[name]}`);
}
return n;
}
function boolFlag(flags, name) {
return flags[name] === true || flags[name] === "true";
}
function printJson(obj, stdout) {
stdout.write(`${JSON.stringify(obj, null, 2)}\n`);
}
function resolveInput(positional, index, label) {
const raw = positional[index];
if (!raw) {
throw new Error(`missing positional argument: ${label}`);
}
return path.resolve(raw);
}
async function dispatch(command, args, { stdout = process.stdout } = {}) {
const { flags } = args;
switch (command) {
case "info": {
const input = resolveInput(args._, 1, "<input>");
const info = await lib.getDocumentInfo(input);
printJson(info, stdout);
return 0;
}
case "list-paragraphs": {
const input = resolveInput(args._, 1, "<input>");
const section = optionalNumber(flags, "section", 0);
const result = await lib.listParagraphs(input, section);
printJson(result, stdout);
return 0;
}
case "search": {
const input = resolveInput(args._, 1, "<input>");
const query = requireFlag(flags, "query");
const fromSection = optionalNumber(flags, "from-section", 0);
const fromParagraph = optionalNumber(flags, "from-paragraph", 0);
const fromChar = optionalNumber(flags, "from-char", 0);
const forward = !boolFlag(flags, "backward");
const caseSensitive = boolFlag(flags, "case-sensitive");
const result = await lib.searchText({
input,
query,
fromSection,
fromParagraph,
fromChar,
forward,
caseSensitive
});
printJson(result, stdout);
return 0;
}
case "insert-text": {
const input = resolveInput(args._, 1, "<input>");
const output = resolveInput(args._, 2, "<output>");
const result = await lib.insertText({
input,
output,
section: requireFlag(flags, "section", { numeric: true }),
paragraph: requireFlag(flags, "paragraph", { numeric: true }),
offset: requireFlag(flags, "offset", { numeric: true }),
text: requireFlag(flags, "text")
});
printJson(result, stdout);
return 0;
}
case "delete-text": {
const input = resolveInput(args._, 1, "<input>");
const output = resolveInput(args._, 2, "<output>");
const result = await lib.deleteText({
input,
output,
section: requireFlag(flags, "section", { numeric: true }),
paragraph: requireFlag(flags, "paragraph", { numeric: true }),
offset: requireFlag(flags, "offset", { numeric: true }),
count: requireFlag(flags, "count", { numeric: true })
});
printJson(result, stdout);
return 0;
}
case "replace-all": {
const input = resolveInput(args._, 1, "<input>");
const output = resolveInput(args._, 2, "<output>");
const result = await lib.replaceAll({
input,
output,
query: requireFlag(flags, "query"),
replacement: flags.replacement === undefined ? "" : String(flags.replacement),
caseSensitive: boolFlag(flags, "case-sensitive")
});
printJson(result, stdout);
return 0;
}
case "create-table": {
const input = resolveInput(args._, 1, "<input>");
const output = resolveInput(args._, 2, "<output>");
const result = await lib.createTable({
input,
output,
section: requireFlag(flags, "section", { numeric: true }),
paragraph: requireFlag(flags, "paragraph", { numeric: true }),
offset: requireFlag(flags, "offset", { numeric: true }),
rows: requireFlag(flags, "rows", { numeric: true }),
cols: requireFlag(flags, "cols", { numeric: true })
});
printJson(result, stdout);
return 0;
}
case "set-cell-text": {
const input = resolveInput(args._, 1, "<input>");
const output = resolveInput(args._, 2, "<output>");
const result = await lib.setCellText({
input,
output,
section: requireFlag(flags, "section", { numeric: true }),
parentParagraph: requireFlag(flags, "parent-paragraph", { numeric: true }),
control: requireFlag(flags, "control", { numeric: true }),
cell: requireFlag(flags, "cell", { numeric: true }),
cellParagraph: optionalNumber(flags, "cell-paragraph", 0),
text: requireFlag(flags, "text"),
replace: !boolFlag(flags, "no-replace")
});
printJson(result, stdout);
return 0;
}
case "create-blank": {
const output = resolveInput(args._, 1, "<output>");
const result = await lib.createBlank(output);
printJson(result, stdout);
return 0;
}
case "render": {
const input = resolveInput(args._, 1, "<input>");
const page = optionalNumber(flags, "page", 0);
const format = flags.format ? String(flags.format) : "svg";
const output = await lib.renderPage(input, page, format);
stdout.write(output);
if (!output.endsWith("\n")) stdout.write("\n");
return 0;
}
default:
throw new Error(`unknown command: ${command}`);
}
}
async function main(argv, { stdout = process.stdout, stderr = process.stderr } = {}) {
const parsed = parseArgs(argv);
if (parsed.flags.version === true) {
stdout.write(`k-skill-rhwp ${PKG_VERSION}\n`);
return 0;
}
if (parsed.flags.help === true || parsed._.length === 0) {
stdout.write(USAGE);
return 0;
}
const [command] = parsed._;
try {
return await dispatch(command, parsed, { stdout });
} catch (err) {
stderr.write(`k-skill-rhwp: ${err && err.message ? err.message : err}\n`);
return 1;
}
}
module.exports = {
parseArgs,
main,
USAGE
};

View file

@ -1,333 +0,0 @@
"use strict";
const fs = require("node:fs");
const { getRhwpCore } = require("./wasm-init");
function parseJsonResult(raw, op) {
try {
const parsed = JSON.parse(raw);
if (!parsed || parsed.ok !== true) {
throw new Error(`${op} rejected by rhwp: ${raw}`);
}
return parsed;
} catch (err) {
if (err instanceof SyntaxError) {
throw new Error(`${op} returned non-JSON payload: ${raw}`);
}
throw err;
}
}
async function loadDocument(filePath) {
const core = await getRhwpCore();
const bytes = fs.readFileSync(filePath);
return new core.HwpDocument(new Uint8Array(bytes));
}
async function createBlankDocument() {
const core = await getRhwpCore();
const doc = core.HwpDocument.createEmpty();
doc.createBlankDocument();
return doc;
}
function writeHwp(doc, outputPath) {
const bytes = doc.exportHwp();
fs.writeFileSync(outputPath, Buffer.from(bytes));
return { bytesWritten: bytes.length, outputPath };
}
async function getDocumentInfo(filePath) {
const doc = await loadDocument(filePath);
try {
const info = JSON.parse(doc.getDocumentInfo());
const sectionCount = doc.getSectionCount();
const sections = [];
for (let s = 0; s < sectionCount; s += 1) {
const paragraphCount = doc.getParagraphCount(s);
const paragraphs = [];
for (let p = 0; p < paragraphCount; p += 1) {
paragraphs.push({
paragraphIndex: p,
length: doc.getParagraphLength(s, p)
});
}
sections.push({ sectionIndex: s, paragraphCount, paragraphs });
}
return {
sourceFormat: doc.getSourceFormat(),
pageCount: doc.pageCount(),
sectionCount,
sections,
documentInfo: info
};
} finally {
doc.free();
}
}
async function insertText(options) {
const { input, output, section, paragraph, offset, text } = options;
if (typeof text !== "string" || text.length === 0) {
throw new Error("insertText: text must be a non-empty string");
}
const doc = await loadDocument(input);
try {
const result = parseJsonResult(doc.insertText(section, paragraph, offset, text), "insertText");
const written = writeHwp(doc, output);
return { ...result, ...written };
} finally {
doc.free();
}
}
async function deleteText(options) {
const { input, output, section, paragraph, offset, count } = options;
if (!Number.isInteger(count) || count <= 0) {
throw new Error("deleteText: count must be a positive integer");
}
const doc = await loadDocument(input);
try {
const result = parseJsonResult(doc.deleteText(section, paragraph, offset, count), "deleteText");
const written = writeHwp(doc, output);
return { ...result, ...written };
} finally {
doc.free();
}
}
function findAllMatchOffsets(text, query, caseSensitive) {
let hay;
let needle;
if (caseSensitive) {
hay = text;
needle = query;
} else {
hay = text.toLowerCase();
needle = query.toLowerCase();
// Unicode safety: offsets are collected in the lowercased haystack but applied
// back to the original text via replaceText(...). That only round-trips when
// String.prototype.toLowerCase() preserves UTF-16 length. A handful of Unicode
// characters (e.g. 'İ' U+0130 → 'i' U+0069 + combining dot above U+0307) violate
// this, so every subsequent offset drifts and silently corrupts the document.
// Refuse the operation in that case — the user can rerun with caseSensitive:true
// or normalize the input.
if (hay.length !== text.length || needle.length !== query.length) {
throw new Error(
"replaceAll: case-insensitive matching is unsafe because case folding changes the UTF-16 length of the query or a paragraph (e.g. 'İ' U+0130 lowercases to 'i' + combining dot above U+0307). Rerun with caseSensitive:true or normalize the input first."
);
}
}
const offsets = [];
let i = 0;
while (i <= hay.length - needle.length) {
const idx = hay.indexOf(needle, i);
if (idx < 0) break;
offsets.push(idx);
i = idx + needle.length;
}
return offsets;
}
async function replaceAll(options) {
const {
input,
output,
query,
replacement,
caseSensitive = false
} = options;
if (typeof query !== "string" || query.length === 0) {
throw new Error("replaceAll: query must be a non-empty string");
}
const replacementText = replacement == null ? "" : String(replacement);
if (/[\n\r\u2028\u2029]/.test(replacementText)) {
throw new Error(
"replaceAll: replacement must not contain newline or paragraph-break characters; split into multiple edits instead"
);
}
const caseSensitiveFlag = caseSensitive === true;
const doc = await loadDocument(input);
try {
let count = 0;
const sectionCount = doc.getSectionCount();
for (let s = 0; s < sectionCount; s += 1) {
const paraCount = doc.getParagraphCount(s);
for (let p = 0; p < paraCount; p += 1) {
const len = doc.getParagraphLength(s, p);
if (len < query.length) continue;
const text = doc.getTextRange(s, p, 0, len);
const offsets = findAllMatchOffsets(text, query, caseSensitiveFlag);
for (let m = offsets.length - 1; m >= 0; m -= 1) {
parseJsonResult(
doc.replaceText(s, p, offsets[m], query.length, replacementText),
"replaceText"
);
count += 1;
}
}
}
const written = writeHwp(doc, output);
return { ok: true, count, ...written };
} finally {
doc.free();
}
}
async function searchText(options) {
const {
input,
query,
fromSection = 0,
fromParagraph = 0,
fromChar = 0,
forward = true,
caseSensitive = false
} = options;
if (typeof query !== "string" || query.length === 0) {
throw new Error("searchText: query must be a non-empty string");
}
const doc = await loadDocument(input);
try {
const raw = doc.searchText(
query,
fromSection,
fromParagraph,
fromChar,
forward === true,
caseSensitive === true
);
return JSON.parse(raw);
} finally {
doc.free();
}
}
async function createTable(options) {
const { input, output, section, paragraph, offset, rows, cols } = options;
if (!Number.isInteger(rows) || rows <= 0) {
throw new Error("createTable: rows must be a positive integer");
}
if (!Number.isInteger(cols) || cols <= 0) {
throw new Error("createTable: cols must be a positive integer");
}
const doc = await loadDocument(input);
try {
const raw = doc.createTable(section, paragraph, offset, rows, cols);
const result = parseJsonResult(raw, "createTable");
const written = writeHwp(doc, output);
return { ...result, ...written };
} finally {
doc.free();
}
}
async function setCellText(options) {
const {
input,
output,
section,
parentParagraph,
control,
cell,
cellParagraph = 0,
text,
replace = true
} = options;
if (typeof text !== "string") {
throw new Error("setCellText: text must be a string");
}
const doc = await loadDocument(input);
try {
if (replace === true) {
const length = doc.getCellParagraphLength(section, parentParagraph, control, cell, cellParagraph);
if (length > 0) {
parseJsonResult(
doc.deleteTextInCell(
section,
parentParagraph,
control,
cell,
cellParagraph,
0,
length
),
"deleteTextInCell"
);
}
}
const raw = doc.insertTextInCell(
section,
parentParagraph,
control,
cell,
cellParagraph,
0,
text
);
const result = parseJsonResult(raw, "insertTextInCell");
const written = writeHwp(doc, output);
return { ...result, ...written };
} finally {
doc.free();
}
}
async function createBlank(outputPath) {
if (typeof outputPath !== "string" || outputPath.length === 0) {
throw new Error("createBlank: outputPath is required");
}
const doc = await createBlankDocument();
try {
return writeHwp(doc, outputPath);
} finally {
doc.free();
}
}
async function listParagraphs(filePath, sectionIndex = 0) {
const doc = await loadDocument(filePath);
try {
const count = doc.getParagraphCount(sectionIndex);
const paragraphs = [];
for (let p = 0; p < count; p += 1) {
paragraphs.push({
paragraphIndex: p,
length: doc.getParagraphLength(sectionIndex, p)
});
}
return { sectionIndex, paragraphCount: count, paragraphs };
} finally {
doc.free();
}
}
async function renderPage(filePath, pageIndex = 0, format = "svg") {
const doc = await loadDocument(filePath);
try {
if (format === "html") return doc.renderPageHtml(pageIndex);
if (format === "svg") return doc.renderPageSvg(pageIndex);
throw new Error(`renderPage: unknown format '${format}' (expected 'svg' or 'html')`);
} finally {
doc.free();
}
}
module.exports = {
getRhwpCore,
loadDocument,
createBlankDocument,
writeHwp,
getDocumentInfo,
insertText,
deleteText,
replaceAll,
searchText,
createTable,
setCellText,
createBlank,
listParagraphs,
renderPage,
parseJsonResult
};

View file

@ -1,102 +0,0 @@
"use strict";
/**
* Lazy WASM initialization for @rhwp/core in Node.js.
*
* @rhwp/core is an ESM package shipping a WebAssembly binary meant for browsers.
* Two Node-specific concerns are handled here:
*
* 1. The WASM imports a `globalThis.measureTextWidth(font, text)` callback that is
* used for text layout (line breaking, justification). The canonical browser
* implementation uses a `<canvas>` 2D context. Headless Node has no Canvas.
* We install a deterministic approximation shim that treats CJK code points as
* full-width ( font size) and Latin as half-width ( 0.55 × font size).
* Accurate enough for round-trip editing and smoke tests; do not rely on it
* for pixel-perfect rendering.
*
* 2. The default `init(undefined)` path assumes `import.meta.url` and `fetch()`,
* neither of which point at a local filesystem WASM binary in Node. We
* resolve the shipped `rhwp_bg.wasm` via `require.resolve` and hand its bytes
* to init explicitly, which avoids any fetch/network I/O.
*
* The module is side-effect free: calling `getRhwpCore()` more than once returns
* the same promise. Callers are expected to reuse the resolved module object.
*/
const fs = require("node:fs");
let initPromise = null;
/**
* Install the `measureTextWidth` shim on globalThis if none exists yet.
*
* A user-supplied shim (for example node-canvas based) takes precedence.
*
* @returns {boolean} true when a shim was installed by this call.
*/
function installMeasureTextWidthShim() {
if (typeof globalThis.measureTextWidth === "function") {
return false;
}
globalThis.measureTextWidth = (font, text) => {
const match = String(font || "").match(/([0-9.]+)px/);
const size = match ? parseFloat(match[1]) : 12;
let width = 0;
const source = String(text || "");
for (const ch of source) {
const cp = ch.codePointAt(0) ?? 0;
// CJK / full-width Hangul-Jamo..Halfwidth block roughly fills a square.
// Latin, digits, punctuation are closer to half-width.
if (cp >= 0x1100 && cp <= 0xffdc) {
width += size;
} else {
width += size * 0.55;
}
}
return width;
};
return true;
}
/**
* Resolve the absolute path of the @rhwp/core WASM binary shipped with the
* package. The resolution is deliberately subpath-based so workspaces, globally
* linked copies, and hoisted node_modules all work.
*
* @returns {string}
*/
function resolveRhwpWasmPath() {
return require.resolve("@rhwp/core/rhwp_bg.wasm");
}
/**
* Lazily load and initialize @rhwp/core. Returns the ESM namespace object so
* callers can use `HwpDocument`, `version`, `extractThumbnail`, etc.
*
* Safe to call many times; the WASM is initialized exactly once.
*
* @returns {Promise<typeof import("@rhwp/core")>}
*/
function getRhwpCore() {
if (initPromise) return initPromise;
initPromise = (async () => {
installMeasureTextWidthShim();
const core = await import("@rhwp/core");
const wasmPath = resolveRhwpWasmPath();
const wasmBytes = fs.readFileSync(wasmPath);
await core.default({ module_or_path: wasmBytes });
return core;
})();
return initPromise;
}
function _resetForTests() {
initPromise = null;
}
module.exports = {
getRhwpCore,
installMeasureTextWidthShim,
resolveRhwpWasmPath,
_resetForTests
};

View file

@ -1,159 +0,0 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { Writable } = require("node:stream");
const { parseArgs, main, USAGE } = require("../src/cli");
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "k-skill-rhwp-cli-"));
test.after(() => {
fs.rmSync(tmpRoot, { recursive: true, force: true });
});
function collectStream() {
const chunks = [];
const stream = new Writable({
write(chunk, _enc, cb) {
chunks.push(chunk);
cb();
}
});
Object.defineProperty(stream, "buffer", {
get: () => Buffer.concat(chunks).toString("utf8")
});
return stream;
}
test("parseArgs handles positional args, --flag value, --flag=value, and boolean flags", () => {
const a = parseArgs([
"insert-text",
"in.hwp",
"out.hwp",
"--section",
"0",
"--paragraph=1",
"--text",
"hi",
"--case-sensitive"
]);
assert.deepEqual(a._, ["insert-text", "in.hwp", "out.hwp"]);
assert.equal(a.flags.section, "0");
assert.equal(a.flags.paragraph, "1");
assert.equal(a.flags.text, "hi");
assert.equal(a.flags["case-sensitive"], true);
});
test("parseArgs supports -- to pass remaining tokens positionally", () => {
const a = parseArgs(["cmd", "--", "--weird-text", "with --dashes"]);
assert.deepEqual(a._, ["cmd", "--weird-text", "with --dashes"]);
});
test("main prints usage with --help", async () => {
const stdout = collectStream();
const stderr = collectStream();
const code = await main(["--help"], { stdout, stderr });
assert.equal(code, 0);
assert.ok(stdout.buffer.includes("k-skill-rhwp"));
assert.ok(stdout.buffer.includes("info <input>"));
assert.equal(stderr.buffer, "");
assert.ok(USAGE.length > 100);
});
test("main prints version with -v", async () => {
const stdout = collectStream();
const stderr = collectStream();
const code = await main(["-v"], { stdout, stderr });
assert.equal(code, 0);
assert.match(stdout.buffer, /k-skill-rhwp \d+\.\d+\.\d+/);
});
test("main returns exit code 1 for unknown command with a helpful message", async () => {
const stdout = collectStream();
const stderr = collectStream();
const code = await main(["totally-fake-command"], { stdout, stderr });
assert.equal(code, 1);
assert.match(stderr.buffer, /unknown command: totally-fake-command/);
});
test("main info on a generated blank document prints valid JSON with sectionCount >= 1", async () => {
const blank = path.join(tmpRoot, "cli-blank.hwp");
const createOut = collectStream();
const createErr = collectStream();
const createCode = await main(["create-blank", blank], {
stdout: createOut,
stderr: createErr
});
assert.equal(createCode, 0, `create-blank failed: ${createErr.buffer}`);
assert.ok(fs.existsSync(blank));
const infoOut = collectStream();
const infoErr = collectStream();
const infoCode = await main(["info", blank], { stdout: infoOut, stderr: infoErr });
assert.equal(infoCode, 0, `info failed: ${infoErr.buffer}`);
const parsed = JSON.parse(infoOut.buffer);
assert.equal(parsed.sourceFormat, "hwp");
assert.equal(parsed.sectionCount, 1);
assert.ok(Array.isArray(parsed.sections));
});
test("main insert-text + info end-to-end round-trip through the CLI layer", async () => {
const blank = path.join(tmpRoot, "cli-rt-blank.hwp");
const edited = path.join(tmpRoot, "cli-rt-edited.hwp");
const nul = collectStream();
await main(["create-blank", blank], { stdout: nul, stderr: collectStream() });
const insertOut = collectStream();
const insertErr = collectStream();
const insertCode = await main(
[
"insert-text",
blank,
edited,
"--section",
"0",
"--paragraph",
"0",
"--offset",
"0",
"--text",
"안녕 CLI"
],
{ stdout: insertOut, stderr: insertErr }
);
assert.equal(insertCode, 0, `insert-text failed: ${insertErr.buffer}`);
const insertResult = JSON.parse(insertOut.buffer);
assert.equal(insertResult.ok, true);
const infoOut = collectStream();
await main(["info", edited], { stdout: infoOut, stderr: collectStream() });
const infoResult = JSON.parse(infoOut.buffer);
assert.equal(infoResult.sections[0].paragraphs[0].length, "안녕 CLI".length);
});
test("main reports missing required --section flag to stderr and exits 1", async () => {
const blank = path.join(tmpRoot, "cli-missing-flag.hwp");
await main(["create-blank", blank], { stdout: collectStream(), stderr: collectStream() });
const stdout = collectStream();
const stderr = collectStream();
const code = await main(
[
"insert-text",
blank,
path.join(tmpRoot, "cli-missing-out.hwp"),
"--paragraph",
"0",
"--offset",
"0",
"--text",
"hi"
],
{ stdout, stderr }
);
assert.equal(code, 1);
assert.match(stderr.buffer, /missing required --section/);
});

View file

@ -1,593 +0,0 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const {
createBlank,
createBlankDocument,
getDocumentInfo,
insertText,
deleteText,
replaceAll,
searchText,
createTable,
setCellText,
listParagraphs,
renderPage,
parseJsonResult
} = require("../src/index");
const {
getRhwpCore,
installMeasureTextWidthShim,
resolveRhwpWasmPath
} = require("../src/wasm-init");
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "k-skill-rhwp-test-"));
test.after(() => {
fs.rmSync(tmpRoot, { recursive: true, force: true });
});
function tempPath(name) {
return path.join(tmpRoot, name);
}
function sha1(filePath) {
return crypto.createHash("sha1").update(fs.readFileSync(filePath)).digest("hex");
}
async function newBlankFixture(name = "blank.hwp") {
const target = tempPath(name);
const doc = await createBlankDocument();
try {
const bytes = doc.exportHwp();
fs.writeFileSync(target, Buffer.from(bytes));
} finally {
doc.free();
}
return target;
}
test("installMeasureTextWidthShim installs a deterministic shim only once", () => {
const originalShim = globalThis.measureTextWidth;
delete globalThis.measureTextWidth;
try {
const first = installMeasureTextWidthShim();
assert.equal(first, true);
assert.equal(typeof globalThis.measureTextWidth, "function");
const again = installMeasureTextWidthShim();
assert.equal(again, false);
const latinWidth = globalThis.measureTextWidth("12px serif", "abc");
const cjkWidth = globalThis.measureTextWidth("12px serif", "가나다");
assert.ok(cjkWidth > latinWidth, `expected CJK ${cjkWidth} > latin ${latinWidth}`);
} finally {
globalThis.measureTextWidth = originalShim;
}
});
test("resolveRhwpWasmPath resolves the shipped @rhwp/core wasm binary", () => {
const wasmPath = resolveRhwpWasmPath();
assert.ok(path.isAbsolute(wasmPath), `expected absolute path, got ${wasmPath}`);
assert.ok(wasmPath.endsWith("rhwp_bg.wasm"), `unexpected wasm path ${wasmPath}`);
const stat = fs.statSync(wasmPath);
assert.ok(stat.size > 1024 * 1024, `wasm binary suspiciously small: ${stat.size}`);
});
test("parseJsonResult rejects non-JSON and {ok:false}", () => {
assert.throws(() => parseJsonResult("not-json", "x"), /non-JSON payload/);
assert.throws(() => parseJsonResult(JSON.stringify({ ok: false }), "x"), /rejected by rhwp/);
const ok = parseJsonResult(JSON.stringify({ ok: true, value: 1 }), "x");
assert.deepEqual(ok, { ok: true, value: 1 });
});
test("getRhwpCore returns a cached module with HwpDocument constructor", async () => {
const mod = await getRhwpCore();
assert.equal(typeof mod.HwpDocument, "function");
assert.equal(typeof mod.version, "function");
const again = await getRhwpCore();
assert.equal(again, mod, "getRhwpCore must return cached module");
});
test("createBlank writes a valid HWP file that round-trips via getDocumentInfo", async () => {
const target = tempPath("blank-via-cli-api.hwp");
const result = await createBlank(target);
assert.ok(result.bytesWritten > 1024, `blank HWP suspiciously small: ${result.bytesWritten}`);
assert.equal(result.outputPath, target);
assert.ok(fs.existsSync(target));
const info = await getDocumentInfo(target);
assert.equal(info.sourceFormat, "hwp");
assert.equal(info.sectionCount, 1);
assert.ok(info.pageCount >= 1);
assert.equal(info.sections[0].sectionIndex, 0);
assert.equal(info.sections[0].paragraphCount, 1);
});
test("insertText inserts text at paragraph start and round-trips on disk", async () => {
const src = await newBlankFixture("insert-src.hwp");
const dst = tempPath("insert-dst.hwp");
const result = await insertText({
input: src,
output: dst,
section: 0,
paragraph: 0,
offset: 0,
text: "안녕하세요 rhwp!"
});
assert.equal(result.ok, true);
assert.equal(typeof result.charOffset, "number");
assert.ok(result.bytesWritten > 1024);
const info = await getDocumentInfo(dst);
assert.equal(info.sections[0].paragraphs[0].length, "안녕하세요 rhwp!".length);
});
test("insertText rejects empty text synchronously", async () => {
const src = await newBlankFixture("insert-empty-src.hwp");
const dst = tempPath("insert-empty-dst.hwp");
await assert.rejects(
insertText({ input: src, output: dst, section: 0, paragraph: 0, offset: 0, text: "" }),
/non-empty string/
);
assert.equal(fs.existsSync(dst), false, "no file should be written on validation error");
});
test("deleteText removes characters and shortens the paragraph", async () => {
const src = await newBlankFixture("delete-src.hwp");
const mid = tempPath("delete-mid.hwp");
const dst = tempPath("delete-dst.hwp");
await insertText({
input: src,
output: mid,
section: 0,
paragraph: 0,
offset: 0,
text: "abcdef"
});
const result = await deleteText({
input: mid,
output: dst,
section: 0,
paragraph: 0,
offset: 0,
count: 3
});
assert.equal(result.ok, true);
const info = await getDocumentInfo(dst);
assert.equal(info.sections[0].paragraphs[0].length, 3);
});
test("deleteText rejects non-positive counts", async () => {
const src = await newBlankFixture("delete-zero-src.hwp");
const dst = tempPath("delete-zero-dst.hwp");
await assert.rejects(
deleteText({ input: src, output: dst, section: 0, paragraph: 0, offset: 0, count: 0 }),
/positive integer/
);
});
test("replaceAll persists same-length replacement into the output bytes (regression for silent no-op)", async () => {
const src = await newBlankFixture("replace-same-src.hwp");
const mid = tempPath("replace-same-mid.hwp");
const dst = tempPath("replace-same-dst.hwp");
await insertText({
input: src,
output: mid,
section: 0,
paragraph: 0,
offset: 0,
text: "2025 2025 2025"
});
const result = await replaceAll({
input: mid,
output: dst,
query: "2025",
replacement: "2026"
});
assert.equal(result.ok, true);
assert.equal(result.count, 3, `expected 3 replacements, got ${result.count}`);
const info = await getDocumentInfo(dst);
assert.equal(info.sections[0].paragraphs[0].length, "2026 2026 2026".length);
assert.notEqual(sha1(mid), sha1(dst), "replaceAll output must differ from input bytes");
const hitNew = await searchText({ input: dst, query: "2026" });
assert.equal(hitNew.found, true, "replacement 2026 must be findable after replaceAll");
assert.equal(hitNew.sec, 0);
assert.equal(hitNew.para, 0);
const hitOld = await searchText({ input: dst, query: "2025" });
assert.equal(hitOld.found, false, "query 2025 must not be findable after replaceAll");
});
test("replaceAll persists LONGER-length replacement and grows paragraph length by the correct amount", async () => {
const src = await newBlankFixture("replace-longer-src.hwp");
const mid = tempPath("replace-longer-mid.hwp");
const dst = tempPath("replace-longer-dst.hwp");
await insertText({
input: src,
output: mid,
section: 0,
paragraph: 0,
offset: 0,
text: "2026년 테스트"
});
const result = await replaceAll({
input: mid,
output: dst,
query: "2026",
replacement: "이천이십칠"
});
assert.equal(result.ok, true);
assert.equal(result.count, 1);
const info = await getDocumentInfo(dst);
assert.equal(
info.sections[0].paragraphs[0].length,
"이천이십칠년 테스트".length,
"paragraph length must grow when replacement is longer than query"
);
assert.notEqual(sha1(mid), sha1(dst));
assert.equal((await searchText({ input: dst, query: "이천이십칠" })).found, true);
assert.equal((await searchText({ input: dst, query: "2026" })).found, false);
});
test("replaceAll persists SHORTER-length replacement and shrinks paragraph length by the correct amount", async () => {
const src = await newBlankFixture("replace-shorter-src.hwp");
const mid = tempPath("replace-shorter-mid.hwp");
const dst = tempPath("replace-shorter-dst.hwp");
await insertText({
input: src,
output: mid,
section: 0,
paragraph: 0,
offset: 0,
text: "longlonglong tail"
});
const result = await replaceAll({
input: mid,
output: dst,
query: "longlonglong",
replacement: "AB"
});
assert.equal(result.ok, true);
assert.equal(result.count, 1);
const info = await getDocumentInfo(dst);
assert.equal(info.sections[0].paragraphs[0].length, "AB tail".length);
assert.notEqual(sha1(mid), sha1(dst));
});
test("replaceAll with empty replacement deletes every match (delete-to-empty)", async () => {
const src = await newBlankFixture("replace-delete-src.hwp");
const mid = tempPath("replace-delete-mid.hwp");
const dst = tempPath("replace-delete-dst.hwp");
await insertText({
input: src,
output: mid,
section: 0,
paragraph: 0,
offset: 0,
text: "foo-X-bar-X-baz"
});
const result = await replaceAll({
input: mid,
output: dst,
query: "-X-",
replacement: ""
});
assert.equal(result.ok, true);
assert.equal(result.count, 2);
const info = await getDocumentInfo(dst);
assert.equal(info.sections[0].paragraphs[0].length, "foobarbaz".length);
assert.equal((await searchText({ input: dst, query: "-X-" })).found, false);
});
test("replaceAll handles replacement containing the query without infinite loop (non-overlapping semantics)", async () => {
const src = await newBlankFixture("replace-contains-src.hwp");
const mid = tempPath("replace-contains-mid.hwp");
const dst = tempPath("replace-contains-dst.hwp");
await insertText({
input: src,
output: mid,
section: 0,
paragraph: 0,
offset: 0,
text: "aaa"
});
const result = await replaceAll({
input: mid,
output: dst,
query: "a",
replacement: "aa"
});
assert.equal(result.ok, true);
assert.equal(result.count, 3, "non-overlapping replace: each original 'a' matched once, not each expanded one");
const info = await getDocumentInfo(dst);
assert.equal(info.sections[0].paragraphs[0].length, 6, "'aaa' with a→aa produces 'aaaaaa' under non-overlapping semantics");
});
test("replaceAll with zero matches writes output and reports count 0", async () => {
const src = await newBlankFixture("replace-none-src.hwp");
const mid = tempPath("replace-none-mid.hwp");
const dst = tempPath("replace-none-dst.hwp");
await insertText({
input: src,
output: mid,
section: 0,
paragraph: 0,
offset: 0,
text: "no matches here"
});
const result = await replaceAll({
input: mid,
output: dst,
query: "XYZ",
replacement: "ABC"
});
assert.equal(result.ok, true);
assert.equal(result.count, 0);
const info = await getDocumentInfo(dst);
assert.equal(info.sections[0].paragraphs[0].length, "no matches here".length);
});
test("replaceAll honors case-sensitive flag", async () => {
const src = await newBlankFixture("replace-case-src.hwp");
const mid = tempPath("replace-case-mid.hwp");
const dst = tempPath("replace-case-dst.hwp");
await insertText({
input: src,
output: mid,
section: 0,
paragraph: 0,
offset: 0,
text: "abc ABC abc"
});
const result = await replaceAll({
input: mid,
output: dst,
query: "abc",
replacement: "xyz",
caseSensitive: true
});
assert.equal(result.ok, true);
assert.equal(result.count, 2, "case-sensitive replacement must skip ABC");
assert.equal((await searchText({ input: dst, query: "ABC", caseSensitive: true })).found, true);
});
test("replaceAll rejects replacement containing newlines (paragraph-break scope guard)", async () => {
const src = await newBlankFixture("replace-newline-src.hwp");
const mid = tempPath("replace-newline-mid.hwp");
const dst = tempPath("replace-newline-dst.hwp");
await insertText({
input: src,
output: mid,
section: 0,
paragraph: 0,
offset: 0,
text: "hello"
});
await assert.rejects(
replaceAll({
input: mid,
output: dst,
query: "hello",
replacement: "multi\nline"
}),
/newline|paragraph/i,
"replaceAll must refuse replacements containing paragraph-break characters"
);
});
test("replaceAll refuses case-insensitive matching when source text contains case-folding length-changing chars (e.g. Turkish İ U+0130)", async () => {
// Regression: without the guard, `ABCİABCİXYZ` + case-insensitive `İ → Z` reported
// { ok:true, count:2 } but silently produced `ABCZABCİZYZ` (the X at index 8 was
// corrupted while the second İ was left untouched). This is because
// String.prototype.toLowerCase() maps İ (U+0130) to i + combining dot above
// (U+0069 U+0307), which changes UTF-16 length and drifts every subsequent offset.
const src = await newBlankFixture("replace-unicode-drift-src.hwp");
const mid = tempPath("replace-unicode-drift-mid.hwp");
const dst = tempPath("replace-unicode-drift-dst.hwp");
await insertText({
input: src,
output: mid,
section: 0,
paragraph: 0,
offset: 0,
text: "ABCİABCİXYZ"
});
await assert.rejects(
replaceAll({
input: mid,
output: dst,
query: "İ",
replacement: "Z"
}),
/case.?insensitive|case.?fold|UTF-?16|U\+0130/i,
"replaceAll must refuse case-insensitive matching on inputs with length-changing case folding"
);
assert.equal(
fs.existsSync(dst),
false,
"no output file should be written when replaceAll rejects case-insensitive drift"
);
});
test("replaceAll refuses case-insensitive matching when the query itself contains case-folding length-changing chars", async () => {
const src = await newBlankFixture("replace-unicode-query-src.hwp");
const mid = tempPath("replace-unicode-query-mid.hwp");
const dst = tempPath("replace-unicode-query-dst.hwp");
await insertText({
input: src,
output: mid,
section: 0,
paragraph: 0,
offset: 0,
text: "plain ascii text"
});
await assert.rejects(
replaceAll({
input: mid,
output: dst,
query: "İ",
replacement: "X"
}),
/case.?insensitive|case.?fold|UTF-?16|U\+0130/i,
"replaceAll must refuse case-insensitive matching when the query has length-changing case folding"
);
assert.equal(fs.existsSync(dst), false);
});
test("replaceAll with --case-sensitive succeeds on inputs containing İ (guard only applies to case-insensitive path)", async () => {
const src = await newBlankFixture("replace-unicode-case-src.hwp");
const mid = tempPath("replace-unicode-case-mid.hwp");
const dst = tempPath("replace-unicode-case-dst.hwp");
await insertText({
input: src,
output: mid,
section: 0,
paragraph: 0,
offset: 0,
text: "ABCİABCİXYZ"
});
const result = await replaceAll({
input: mid,
output: dst,
query: "İ",
replacement: "Z",
caseSensitive: true
});
assert.equal(result.ok, true);
assert.equal(result.count, 2, "case-sensitive replacement must hit both İ occurrences");
const info = await getDocumentInfo(dst);
assert.equal(
info.sections[0].paragraphs[0].length,
"ABCZABCZXYZ".length,
"paragraph length must match fully-replaced output (both İ → Z, X stays)"
);
assert.equal(
(await searchText({ input: dst, query: "İ", caseSensitive: true })).found,
false,
"İ must be gone from case-sensitive output"
);
assert.equal(
(await searchText({ input: dst, query: "X", caseSensitive: true })).found,
true,
"X must be preserved (not corrupted by offset drift)"
);
});
test("replaceAll case-insensitive still works for normal ASCII/Hangul that do not change UTF-16 length under toLowerCase", async () => {
// Regression guard: the Unicode fix must not break the common case.
const src = await newBlankFixture("replace-unicode-ok-src.hwp");
const mid = tempPath("replace-unicode-ok-mid.hwp");
const dst = tempPath("replace-unicode-ok-dst.hwp");
await insertText({
input: src,
output: mid,
section: 0,
paragraph: 0,
offset: 0,
text: "hello WORLD 안녕 HELLO"
});
const result = await replaceAll({
input: mid,
output: dst,
query: "hello",
replacement: "hi"
});
assert.equal(result.ok, true);
assert.equal(result.count, 2, "case-insensitive must still match both 'hello' and 'HELLO'");
});
test("searchText reports a match location for present text", async () => {
const src = await newBlankFixture("search-src.hwp");
const edited = tempPath("search-edited.hwp");
await insertText({
input: src,
output: edited,
section: 0,
paragraph: 0,
offset: 0,
text: "find-me-please"
});
const hit = await searchText({ input: edited, query: "please" });
assert.equal(typeof hit, "object");
assert.ok(hit, "searchText must return a match payload");
assert.equal(hit.found, true);
});
test("createTable inserts a table and grows paragraph count", async () => {
const src = await newBlankFixture("table-src.hwp");
const dst = tempPath("table-dst.hwp");
const result = await createTable({
input: src,
output: dst,
section: 0,
paragraph: 0,
offset: 0,
rows: 2,
cols: 3
});
assert.equal(result.ok, true);
const info = await getDocumentInfo(dst);
assert.ok(info.sections[0].paragraphCount >= 1);
});
test("setCellText fills a cell after creating a table", async () => {
const src = await newBlankFixture("cell-src.hwp");
const tableFile = tempPath("cell-table.hwp");
const tableResult = await createTable({
input: src,
output: tableFile,
section: 0,
paragraph: 0,
offset: 0,
rows: 2,
cols: 2
});
assert.equal(tableResult.ok, true);
assert.equal(typeof tableResult.paraIdx, "number");
assert.equal(typeof tableResult.controlIdx, "number");
const filled = tempPath("cell-filled.hwp");
const cellResult = await setCellText({
input: tableFile,
output: filled,
section: 0,
parentParagraph: tableResult.paraIdx,
control: tableResult.controlIdx,
cell: 0,
cellParagraph: 0,
text: "A1 cell"
});
assert.equal(cellResult.ok, true);
assert.ok(fs.existsSync(filled));
});
test("listParagraphs returns per-paragraph lengths for a section", async () => {
const src = await newBlankFixture("list-src.hwp");
const edited = tempPath("list-edited.hwp");
await insertText({
input: src,
output: edited,
section: 0,
paragraph: 0,
offset: 0,
text: "para1"
});
const listing = await listParagraphs(edited, 0);
assert.equal(listing.sectionIndex, 0);
assert.equal(listing.paragraphCount, 1);
assert.equal(listing.paragraphs[0].length, 5);
});
test("renderPage returns SVG markup with <svg> wrapper for a blank document", async () => {
const src = await newBlankFixture("render-src.hwp");
const svg = await renderPage(src, 0, "svg");
assert.match(svg, /<svg[^>]*>/);
assert.match(svg, /<\/svg>/);
});
test("renderPage rejects unknown format", async () => {
const src = await newBlankFixture("render-bad-src.hwp");
await assert.rejects(renderPage(src, 0, "pdf"), /unknown format/);
});

View file

@ -13,7 +13,7 @@ metadata:
## What this skill does
**업스트림 `rhwp` CLI**(Rust 네이티브 바이너리)를 써서 HWP 파일의 **레이아웃 디버깅·IR 구조 검사·버전 비교·썸네일 추출·배포용 문서 잠금 해제** 를 수행한다.
`k-skill-rhwp`(Node 편집 CLI)가 다루지 못하는 구조 분석·렌더 문제 진단용이다.
`rhwp-edit` 스킬(`@rhwp/core` 직접 사용)이 다루지 못하는 구조 분석·렌더 문제 진단용이다.
이 스킬은 **편집을 하지 않는다**. 편집은 [`rhwp-edit`](../rhwp-edit/SKILL.md) 스킬, 문서 → Markdown/JSON 변환은 [`hwp`](../hwp/SKILL.md) 스킬(kordoc) 을 쓴다.
@ -28,10 +28,10 @@ metadata:
## When not to use
- **텍스트/표 편집**`rhwp-edit` 스킬 (`k-skill-rhwp` CLI)
- **텍스트/표 편집**`rhwp-edit` 스킬 (`@rhwp/core` 직접 사용)
- **HWP → Markdown/JSON/양식필드 변환**`hwp` 스킬 (`kordoc`)
- **GUI 자동화, 한컴 보안모듈 우회, Windows 레지스트리 제어** → 범위 밖이다.
- **Node 코드에서 라이브러리 API 로 편집**`k-skill-rhwp` 를 Node API 로 쓴다.
- **Node 코드에서 라이브러리 API 로 편집**`@rhwp/core` 를 직접 import 해서 쓴다(`rhwp-edit` 스킬 참조).
## Prerequisites
@ -64,7 +64,7 @@ metadata:
| 배포용(읽기전용) → 편집 가능 변환 | `rhwp convert` | `rhwp convert locked.hwp unlocked.hwp` |
| 빈 표 포함 문서 템플릿 생성 | `rhwp gen-table` | `rhwp gen-table out.hwp` |
> `rhwp` v0.7.3 CLI 에는 **편집(edit/insert-text/save) 서브커맨드가 없다.** 편집은 `rhwp-edit` 스킬 (`k-skill-rhwp` CLI) 을 쓴다.
> `rhwp` v0.7.3 CLI 에는 **편집(edit/insert-text/save) 서브커맨드가 없다.** 편집은 `rhwp-edit` 스킬(`@rhwp/core` 직접 사용)을 쓴다.
## Workflow
@ -120,7 +120,7 @@ metadata:
```bash
rhwp convert locked.hwp unlocked.hwp
# 이후 편집은 rhwp-edit 스킬의 k-skill-rhwp CLI 로 수행
# 이후 편집은 rhwp-edit 스킬(@rhwp/core 직접 호출)로 수행
```
4. **결과를 PR/보고서에 붙일 때**: SVG/PDF/썸네일은 파일 자체를 첨부하고, 덤프 출력은 너무 길면 상위 200~500 줄만 인용하고 전체는 파일로 첨부한다. 개인정보가 포함된 문서의 본문 텍스트는 마스킹한다.
@ -152,4 +152,4 @@ metadata:
- 업스트림: https://github.com/edwardkim/rhwp
- 편집 경로(이 repo): [`rhwp-edit`](../rhwp-edit/SKILL.md)
- 조회/변환 경로(이 repo): [`hwp`](../hwp/SKILL.md)
- 이 스킬은 **설치 안내 + 실행 레시피**에 가까운 안내형 스킬이다. 프로그램적 제어가 필요하면 `rhwp-edit` 의 Node API(`k-skill-rhwp`)를 쓰고, 여기서는 빠른 디버깅용으로만 사용한다.
- 이 스킬은 **설치 안내 + 실행 레시피**에 가까운 안내형 스킬이다. 프로그램적 제어가 필요하면 `rhwp-edit` 스킬의 `@rhwp/core` Node API를 쓰고, 여기서는 빠른 디버깅용으로만 사용한다.

View file

@ -1,20 +1,19 @@
---
name: rhwp-edit
description: Edit HWP documents — insert/delete text, replace-all, create tables, set cell text — with the k-skill-rhwp CLI that wraps the @rhwp/core WASM engine (rhwp by Edward Kim).
description: Edit HWP documents — insert/delete text, replace-all, create tables, set cell text — by calling @rhwp/core (WASM) directly via inline Node.js scripts (rhwp by Edward Kim).
license: MIT
metadata:
category: documents
locale: ko-KR
phase: v1.5
phase: v2.0
---
# rhwp-edit
## What this skill does
`k-skill-rhwp` CLI로 `.hwp` 문서의 **본문 텍스트**, **표 구조**, **셀 내용**을 round-trip 안전하게 수정한다.
CLI는 `@rhwp/core`(Rust + WebAssembly) 위에 얇은 Node 래퍼를 씌워 `insertText`, `deleteText`, `replaceAll`,
`createTable`, `setCellText` 같은 편집 동작을 서브커맨드로 노출한다. 결과는 항상 새 파일로 저장한다.
`@rhwp/core`(Rust + WebAssembly)를 **직접** 호출하는 인라인 Node.js 스크립트로 `.hwp` 문서의 **본문 텍스트**, **표 구조**, **셀 내용**을 round-trip 안전하게 수정한다.
에이전트는 `node --input-type=module -e '...'` 형태의 일회성 스크립트를 작성해 `insertText`, `deleteText`, `replaceAll`, `createTable`, `insertTextInCell` 같은 WASM 메서드를 직접 호출한다. 결과는 항상 새 파일로 저장한다.
이 스킬은 **편집 전용**이다. 문서를 Markdown/JSON으로 변환하거나 필드만 추출하려면 [`hwp`](../hwp/SKILL.md) 스킬을 사용한다.
페이지 렌더링 디버깅이나 IR 비교가 필요하면 [`rhwp-advanced`](../rhwp-advanced/SKILL.md) 스킬을 사용한다.
@ -30,7 +29,7 @@ CLI는 `@rhwp/core`(Rust + WebAssembly) 위에 얇은 Node 래퍼를 씌워 `ins
## When not to use
- **HWP → Markdown / JSON 변환**`hwp` 스킬(kordoc)을 쓴다. rhwp-edit은 바이너리 편집 전용이다.
- **HWPX 원본을 다시 HWPX로 저장** → rhwp v0.7.3 기준 업스트림이 `#196`으로 HWPX 저장 경로를 막아둔 상태다.
- **HWPX 원본을 다시 HWPX로 저장** → rhwp v0.7.x 기준 업스트림이 `#196`으로 HWPX 저장 경로를 막아둔 상태다.
HWPX를 입력으로 주면 내부적으로 HWP IR로 올라온 뒤 **HWP 5.x 바이너리로만** 저장된다. HWPX 출력이 꼭 필요하면 kordoc `markdownToHwpx`를 쓴다.
- **레이아웃(페이지네이션·SVG 렌더) 디버깅**`rhwp-advanced` 스킬로 업스트림 `rhwp` CLI(`export-svg --debug-overlay`, `dump-pages`, `ir-diff`)를 사용한다.
- **배포용(읽기전용) 잠금 해제 · IR 구조 덤프 · 썸네일 추출 등 고급 검사 명령**`rhwp-advanced` 스킬 참조.
@ -40,124 +39,220 @@ CLI는 `@rhwp/core`(Rust + WebAssembly) 위에 얇은 Node 래퍼를 씌워 `ins
- Node.js 18+
- 쓰기 권한이 있는 출력 경로
- `k-skill-rhwp` 설치(셋 중 하나):
- 일회성: `npx --yes k-skill-rhwp --help`
- 전역: `npm install -g k-skill-rhwp`
- 로컬: `npm install k-skill-rhwp`
- `k-skill-rhwp``@rhwp/core@^0.7.3`을 peer 없이 dependency로 끌어온다. 별도 설치 불필요.
- `@rhwp/core` 설치(셋 중 하나):
- 로컬: `npm install @rhwp/core`
- 전역: `npm install -g @rhwp/core`
- 레포 루트에 이미 설치된 경우 그대로 사용
- Rust/Cargo toolchain 불필요. 업스트림 `rhwp` CLI를 같이 쓰고 싶으면 `rhwp-advanced` 스킬로.
## Inputs
- 입력 HWP / HWPX 경로 (절대 또는 상대)
- 출력 HWP 경로 (항상 별도 파일. 원본을 덮어쓰지 않는다.)
- 편집 좌표: `--section N --paragraph N --offset N`
- 표 좌표: `--section N --parent-paragraph N --control N --cell N [--cell-paragraph N]`
- 텍스트/쿼리: `--text "..."`, `--query "..."`, `--replacement "..."`
- `create-table`: `--rows N --cols N`
- 선택 플래그: `--case-sensitive`, `--no-replace` (`set-cell-text` 에서 기존 셀 내용 보존), `--format svg|html` (`render`)
- 편집 좌표: `sectionIdx`, `paraIdx`, `charOffset`
- 표 좌표: `sectionIdx`, `parentParaIdx`, `controlIdx`, `cellIdx`, `cellParaIdx`
- 텍스트/쿼리: `text`, `query`, `newText`
- `createTable`: `rowCount`, `colCount`
- `replaceAll`: `caseSensitive` (boolean, 기본 `false`)
## WASM 초기화 보일러플레이트
모든 인라인 스크립트는 아래 초기화 블록으로 시작한다. **한 번만** 실행하면 된다.
```javascript
import fs from "node:fs";
// 1. measureTextWidth shim (headless Node용)
globalThis.measureTextWidth = (font, text) => {
const match = String(font || "").match(/([0-9.]+)px/);
const size = match ? parseFloat(match[1]) : 12;
let width = 0;
for (const ch of String(text || "")) {
const cp = ch.codePointAt(0) ?? 0;
width += (cp >= 0x1100 && cp <= 0xffdc) ? size : size * 0.55;
}
return width;
};
// 2. WASM 초기화 (한 번만)
const core = await import("@rhwp/core");
const wasmBytes = fs.readFileSync(require.resolve("@rhwp/core/rhwp_bg.wasm"));
await core.default({ module_or_path: wasmBytes });
// 3. 문서 로드
const doc = new core.HwpDocument(new Uint8Array(fs.readFileSync("input.hwp")));
// 4. 편집 후 저장
fs.writeFileSync("output.hwp", Buffer.from(doc.exportHwp()));
doc.free();
```
> `require.resolve("@rhwp/core/rhwp_bg.wasm")`으로 WASM 바이너리 경로를 찾는다. 설치 위치에 관계없이 동작한다.
> `doc.free()`는 WASM 힙 메모리를 즉시 해제한다. 스크립트 끝에 반드시 호출한다.
## Routing policy
| 작업 | 기본 경로 |
| 작업 | `@rhwp/core` 메서드 |
| --- | --- |
| 본문 문단에 텍스트 삽입 | `k-skill-rhwp insert-text` |
| 본문 문단에서 텍스트 삭제 | `k-skill-rhwp delete-text` |
| 단순 전체 치환(같은 서식 유지, **본문 문단만**) | `k-skill-rhwp replace-all --query ... --replacement ...` |
| 치환 대상 위치 사전 조회(**본문 문단만**) | `k-skill-rhwp search --query ... --from-section N --from-paragraph N` |
| 표 셀 안의 텍스트 확인 | `k-skill-rhwp list-paragraphs` + 셀 좌표 확인 후 `k-skill-rhwp set-cell-text` 로 직접 쓰기 |
| 빈 표 삽입 | `k-skill-rhwp create-table --rows N --cols N` |
| 표 셀 내용 교체/채우기 | `k-skill-rhwp set-cell-text --control N --cell N --text "..."` |
| 빈 HWP 생성 | `k-skill-rhwp create-blank <output.hwp>` |
| 구조 파악(섹션/문단 수·길이) | `k-skill-rhwp info <file>` / `list-paragraphs` |
| 페이지 SVG/HTML 미리보기 | `k-skill-rhwp render <file> --page N --format svg` |
| 본문 문단에 텍스트 삽입 | `doc.insertText(sectionIdx, paraIdx, charOffset, text)` |
| 본문 문단에서 텍스트 삭제 | `doc.deleteText(sectionIdx, paraIdx, charOffset, count)` |
| 단순 전체 치환(같은 서식 유지, **본문 문단만**) | `doc.replaceAll(query, newText, caseSensitive)` |
| 치환 대상 위치 사전 조회(**본문 문단만**) | `doc.searchText(query, fromSec, fromPara, fromChar, forward, caseSensitive)` |
| 표 셀 안의 텍스트 삽입 | `doc.insertTextInCell(sectionIdx, parentParaIdx, controlIdx, cellIdx, cellParaIdx, charOffset, text)` |
| 표 셀 안의 텍스트 삭제 | `doc.deleteTextInCell(sectionIdx, parentParaIdx, controlIdx, cellIdx, cellParaIdx, charOffset, count)` |
| 빈 표 삽입 | `doc.createTable(sectionIdx, paraIdx, charOffset, rowCount, colCount)` |
| 빈 HWP 생성 | `core.HwpDocument.createEmpty()` 또는 `doc.createBlankDocument()` |
| 구조 파악(섹션/문단 수·길이) | `doc.getDocumentInfo()`, `doc.getSectionCount()`, `doc.getParagraphCount(sectionIdx)`, `doc.getParagraphLength(sectionIdx, paraIdx)` |
| 특정 범위 텍스트 읽기 | `doc.getTextRange(sectionIdx, paraIdx, charOffset, count)` |
| 셀 문단 길이 확인 | `doc.getCellParagraphLength(sectionIdx, parentParaIdx, controlIdx, cellIdx, cellParaIdx)` |
| 페이지 SVG/HTML 미리보기 | `doc.renderPageSvg(pageNum)` / `doc.renderPageHtml(pageNum)` |
모든 편집 서브커맨드는 결과를 JSON 한 줄(CLI에서는 pretty-print)로 돌려준다. `ok: true`, 새 커서 위치(`charOffset`, `paraIdx`, `controlIdx`),
저장된 바이트 수(`bytesWritten`), 출력 경로(`outputPath`) 를 포함한다.
모든 편집 메서드는 JSON 문자열을 반환한다. `ok: true`, 새 커서 위치(`charOffset`, `paraIdx`, `controlIdx`) 등을 포함한다.
## Workflow
1. **입력 점검**: `k-skill-rhwp info <input>``sourceFormat`(hwp/hwpx), `sectionCount`, 섹션별 `paragraphCount`, 문단별 `length` 를 먼저 확인한다. 편집 좌표는 이 결과에서 뽑는다.
2. **검색이 필요한 경우**: `k-skill-rhwp search <input> --query "2025"` 로 섹션/문단/문자 오프셋을 먼저 얻고, 편집 명령에 그대로 넣는다.
3. **편집**: 아래 예시 중 해당하는 서브커맨드 하나로 실행한다. `--output` 은 항상 원본과 다른 경로를 지정한다.
1. **입력 점검**: `doc.getDocumentInfo()`로 `sectionCount`, 섹션별 `paragraphCount`, 문단별 `length`를 먼저 확인한다. 편집 좌표는 이 결과에서 뽑는다.
2. **검색이 필요한 경우**: `doc.searchText("2025", 0, 0, 0, true, false)`로 섹션/문단/문자 오프셋을 먼저 얻고, 편집 메서드에 그대로 넣는다.
3. **편집**: 아래 예시 중 해당하는 메서드 하나로 실행한다. 출력은 항상 원본과 다른 경로에 저장한다.
```bash
# 빈 문서 만들기
npx k-skill-rhwp create-blank ./out/blank.hwp
# 본문 첫 문단 앞에 제목 삽입
npx k-skill-rhwp insert-text ./in.hwp ./out/with-title.hwp \
--section 0 --paragraph 0 --offset 0 \
--text "2026년 오픈소스 AI·SW 지원사업 신청서"
# 2025 → 2026 일괄 치환
npx k-skill-rhwp replace-all ./in.hwp ./out/2026.hwp \
--query 2025 --replacement 2026
# 3행 4열 표 삽입(본문 2번째 문단 끝)
npx k-skill-rhwp create-table ./in.hwp ./out/with-table.hwp \
--section 0 --paragraph 1 --offset 0 --rows 3 --cols 4
# 방금 만든 표의 (0,0) 셀에 "합계" 삽입
# - create-table 결과의 paraIdx / controlIdx 를 그대로 재사용
npx k-skill-rhwp set-cell-text ./out/with-table.hwp ./out/with-cell.hwp \
--section 0 --parent-paragraph <paraIdx> --control <controlIdx> \
--cell 0 --text "합계"
node --input-type=module -e '
import fs from "node:fs";
globalThis.measureTextWidth = (font, text) => {
const match = String(font || "").match(/([0-9.]+)px/);
const size = match ? parseFloat(match[1]) : 12;
let w = 0;
for (const ch of String(text || "")) {
const cp = ch.codePointAt(0) ?? 0;
w += (cp >= 0x1100 && cp <= 0xffdc) ? size : size * 0.55;
}
return w;
};
const core = await import("@rhwp/core");
const wasmBytes = fs.readFileSync(require.resolve("@rhwp/core/rhwp_bg.wasm"));
await core.default({ module_or_path: wasmBytes });
const doc = core.HwpDocument.createEmpty();
fs.writeFileSync("./out/blank.hwp", Buffer.from(doc.exportHwp()));
doc.free();
console.log("done");
'
```
4. **round-trip 검증**: 편집 직후 `k-skill-rhwp info <output>` 를 다시 호출하고, 기대한 `paragraphs[].length` 또는 `paragraphCount` 변화를 직접 눈으로 확인한다.
필요하면 `k-skill-rhwp render <output> --page 0 --format html` 로 첫 페이지 렌더 문자열이 생성되는지 sanity check 한다.
```bash
# 본문 첫 문단 앞에 제목 삽입
node --input-type=module -e '
import fs from "node:fs";
globalThis.measureTextWidth = (font, text) => {
const match = String(font || "").match(/([0-9.]+)px/);
const size = match ? parseFloat(match[1]) : 12;
let w = 0;
for (const ch of String(text || "")) {
const cp = ch.codePointAt(0) ?? 0;
w += (cp >= 0x1100 && cp <= 0xffdc) ? size : size * 0.55;
}
return w;
};
const core = await import("@rhwp/core");
const wasmBytes = fs.readFileSync(require.resolve("@rhwp/core/rhwp_bg.wasm"));
await core.default({ module_or_path: wasmBytes });
const doc = new core.HwpDocument(new Uint8Array(fs.readFileSync("./in.hwp")));
const result = JSON.parse(doc.insertText(0, 0, 0, "2026년 오픈소스 AI·SW 지원사업 신청서"));
console.log(result);
fs.writeFileSync("./out/with-title.hwp", Buffer.from(doc.exportHwp()));
doc.free();
'
```
```bash
# 2025 → 2026 일괄 치환 (본문 문단만)
node --input-type=module -e '
import fs from "node:fs";
globalThis.measureTextWidth = (font, text) => {
const match = String(font || "").match(/([0-9.]+)px/);
const size = match ? parseFloat(match[1]) : 12;
let w = 0;
for (const ch of String(text || "")) {
const cp = ch.codePointAt(0) ?? 0;
w += (cp >= 0x1100 && cp <= 0xffdc) ? size : size * 0.55;
}
return w;
};
const core = await import("@rhwp/core");
const wasmBytes = fs.readFileSync(require.resolve("@rhwp/core/rhwp_bg.wasm"));
await core.default({ module_or_path: wasmBytes });
const doc = new core.HwpDocument(new Uint8Array(fs.readFileSync("./in.hwp")));
const result = JSON.parse(doc.replaceAll("2025", "2026", false));
console.log(result); // { ok: true, count: N }
fs.writeFileSync("./out/2026.hwp", Buffer.from(doc.exportHwp()));
doc.free();
'
```
```bash
# 3행 4열 표 삽입 (본문 2번째 문단 끝)
node --input-type=module -e '
import fs from "node:fs";
globalThis.measureTextWidth = (font, text) => {
const match = String(font || "").match(/([0-9.]+)px/);
const size = match ? parseFloat(match[1]) : 12;
let w = 0;
for (const ch of String(text || "")) {
const cp = ch.codePointAt(0) ?? 0;
w += (cp >= 0x1100 && cp <= 0xffdc) ? size : size * 0.55;
}
return w;
};
const core = await import("@rhwp/core");
const wasmBytes = fs.readFileSync(require.resolve("@rhwp/core/rhwp_bg.wasm"));
await core.default({ module_or_path: wasmBytes });
const doc = new core.HwpDocument(new Uint8Array(fs.readFileSync("./in.hwp")));
const result = JSON.parse(doc.createTable(0, 1, 0, 3, 4));
console.log(result); // { ok: true, paraIdx: N, controlIdx: N }
// 표의 (0,0) 셀에 "합계" 삽입 — createTable 결과의 paraIdx/controlIdx 재사용
const cellResult = JSON.parse(
doc.insertTextInCell(0, result.paraIdx, result.controlIdx, 0, 0, 0, "합계")
);
console.log(cellResult);
fs.writeFileSync("./out/with-table.hwp", Buffer.from(doc.exportHwp()));
doc.free();
'
```
4. **round-trip 검증**: 편집 직후 `doc.getDocumentInfo()`를 다시 호출하고, 기대한 `paragraphCount` 변화를 확인한다.
필요하면 `doc.renderPageHtml(0)`으로 첫 페이지 렌더 문자열이 생성되는지 sanity check 한다.
5. **민감 원본 보호**: 편집 대상이 개인정보/사업 신청서 등 비공개 문서라면 생성 파일을 레포에 커밋하지 않고, 로그에 남길 때도 본문을 요약·마스킹한다.
## Node API (선택)
CLI가 아니라 Node 코드에서 직접 편집하고 싶으면 같은 패키지를 라이브러리로 쓴다.
```js
const { insertText, getDocumentInfo } = require("k-skill-rhwp");
await insertText({
input: "./in.hwp",
output: "./out.hwp",
section: 0,
paragraph: 0,
offset: 0,
text: "안녕하세요"
});
console.log(await getDocumentInfo("./out.hwp"));
```
Node 18+, `@rhwp/core` WASM 은 첫 호출 시 한 번만 초기화된다. WASM 이 요구하는 `globalThis.measureTextWidth` 콜백은 자동 shim 되므로 별도 설정 없이 돌아간다(정밀 레이아웃이 필요하면 `node-canvas` 기반 shim을 먼저 주입한다).
## Verify outputs after every run
- `ok === true`, `bytesWritten` 가 수 KB 이상.
- `info` 재호출 결과에서 섹션/문단 수·길이 변화가 의도와 일치.
- 표 삽입의 경우 `paraIdx`/`controlIdx` 가 다음 `set-cell-text` 호출에 그대로 들어간다.
- 편집 메서드 반환값에서 `ok === true` 확인.
- `doc.getDocumentInfo()` 재호출 결과에서 섹션/문단 수·길이 변화가 의도와 일치.
- 표 삽입의 경우 `paraIdx`/`controlIdx`가 다음 `insertTextInCell` 호출에 그대로 들어간다.
- 출력 파일이 원본과 다른 경로이며 원본은 그대로다.
- `doc.free()` 호출 후 스크립트가 정상 종료됐다.
## Done when
- 사용자가 요청한 편집이 HWP 바이너리에 반영되어 새 파일로 저장됐다.
- `k-skill-rhwp info <output>` 가 같은 혹은 늘어난 `sectionCount`/`paragraphCount` 와 기대 `length` 를 돌려준다.
- `doc.getDocumentInfo()` 재호출이 같은 혹은 늘어난 `sectionCount`/`paragraphCount`와 기대 `length`를 돌려준다.
- 원본 파일은 건드리지 않았다.
## Failure modes
- **HWPX 원본 저장 불가(rhwp #196)**: HWPX → HWPX round-trip 은 upstream에서 비활성화 상태다. HWPX 입력이라도 출력은 HWP로만 저장된다. 원본 확장자에 의존하지 말고 항상 `.hwp` 로 저장한다.
- **좌표 범위 초과**: `section/paragraph/offset` 이 실제 문서 범위를 벗어나면 WASM에서 `렌더링 오류: 구역 인덱스 0 범위 초과` 같은 에러를 던지고 CLI는 exit code 1 + stderr 에 메시지를 찍는다. 편집 전에 `info` 로 좌표를 확인한다.
- **복잡한 표·이미지·양식 필드 round-trip**: 현재 업스트림 rhwp v0.7.x 는 베타다. 복잡한 표·이미지·차트·양식필드가 많은 실제 사업 신청서를 HWP round-trip 할 경우 드물게 형식 손실이 발생할 수 있다. round-trip 이 끝나면 `k-skill-rhwp render <output>` + 육안 확인을 권장한다.
- **배포용(읽기전용) 문서**: rhwp 자체는 `convertToEditable` 로 잠금 해제를 지원하지만 `k-skill-rhwp` CLI 서브커맨드는 아직 노출하지 않는다. 필요하면 `rhwp-advanced` 스킬의 업스트림 `rhwp convert` 경로를 쓴다.
- **WASM 초기화**: `@rhwp/core` 번들 WASM(~4 MB) 은 최초 호출 시 한 번 파싱한다. 첫 호출은 수십 ms~수백 ms 지연될 수 있다.
- **파일 인코딩**: 한국어 텍스트는 UTF-8 로 그대로 CLI 에 넘기면 된다. 셸에서 인용부호가 깨질 경우 `--text=$'...'` 같은 형식을 쓴다.
- **`search` / `replace-all` 은 본문 문단만 스캔한다**: 업스트림 `searchText` 가 본문(body) 범위로 제한되어 있고, `k-skill-rhwp replace-all` 도 같은 스코프를 그대로 따른다. **표(cell) 안의 텍스트, 머리말/꼬리말, 각주 본문**에서는 `search``found:false` 를 돌려주고 `replace-all` 도 해당 위치를 건드리지 않는다. 셀 내용이 대상이라면 `list-paragraphs` 또는 `info` 로 표 좌표를 잡고 `set-cell-text` 로 직접 쓴다.
- **문단 경계 / 개행 치환 금지**: `replace-all` 은 한 문단 안에서의 치환만 보장한다. `--replacement` 에 개행(`\n`, `\r`, U+2028, U+2029) 이 들어오면 CLI 는 exit code 1 과 "replacement must not contain newline or paragraph-break characters" 메시지를 돌려준다. 여러 문단을 만들고 싶으면 `insert-text` 를 여러 번 호출한다.
- **치환은 원본 매칭 기준 non-overlapping**: 예를 들어 query `a` / replacement `aa` / 원본 `aaa` 는 원본의 각 `a` 를 한 번씩 교체해 `aaaaaa` 가 된다. 치환으로 새로 들어온 문자열은 다시 매칭하지 않는다.
- **대소문자 무시 매칭은 UTF-16 길이가 보존되는 문자에만 안전하다**: 기본값인 대소문자 무시(`--case-sensitive` 없이) 모드는 `String.prototype.toLowerCase()` 가 UTF-16 길이를 그대로 유지한다는 전제 위에서 오프셋을 계산한다. 터키어 `İ`(U+0130) 처럼 소문자화 시 `i` + 결합 점(U+0307) 로 길이가 늘어나는 문자가 본문 또는 쿼리에 포함되면, 조용한 문서 손상을 방지하기 위해 `replace-all` 이 exit code 1 과 함께 `case-insensitive matching is unsafe because case folding changes the UTF-16 length` 메시지를 돌려준다. 이런 문서에는 `--case-sensitive` 로 다시 실행하거나, 입력을 미리 정규화한다. 한글·ASCII 본문에는 해당하지 않으며, `2025 → 2026` 같은 실제 사업 신청서 워크플로우는 아무 영향을 받지 않는다.
- **HWPX 원본 저장 불가(rhwp #196)**: HWPX → HWPX round-trip은 upstream에서 비활성화 상태다. HWPX 입력이라도 출력은 HWP로만 저장된다. 원본 확장자에 의존하지 말고 항상 `.hwp`로 저장한다.
- **좌표 범위 초과**: `sectionIdx/paraIdx/charOffset`이 실제 문서 범위를 벗어나면 WASM에서 예외를 던진다. 편집 전에 `getDocumentInfo()`로 좌표를 확인한다.
- **복잡한 표·이미지·양식 필드 round-trip**: 현재 업스트림 rhwp v0.7.x는 베타다. 복잡한 표·이미지·차트·양식필드가 많은 실제 사업 신청서를 HWP round-trip 할 경우 드물게 형식 손실이 발생할 수 있다. round-trip이 끝나면 `doc.renderPageHtml(0)` + 육안 확인을 권장한다.
- **배포용(읽기전용) 문서**: rhwp 자체는 `convertToEditable`로 잠금 해제를 지원하지만 `@rhwp/core` WASM API에서 직접 노출되지 않는 경우 `rhwp-advanced` 스킬의 업스트림 `rhwp convert` 경로를 쓴다.
- **WASM 초기화**: `@rhwp/core` 번들 WASM(~4 MB)은 최초 호출 시 한 번 파싱한다. 첫 호출은 수십 ms~수백 ms 지연될 수 있다.
- **파일 인코딩**: 한국어 텍스트는 UTF-8로 그대로 넘기면 된다. 셸 인라인 스크립트에서 인용부호가 깨질 경우 스크립트를 별도 `.mjs` 파일로 저장해 `node script.mjs`로 실행한다.
- **`searchText` / `replaceAll`은 본문 문단만 스캔한다**: 업스트림 `searchText`가 본문(body) 범위로 제한되어 있고, `replaceAll`도 같은 스코프를 따른다. **표(cell) 안의 텍스트, 머리말/꼬리말, 각주 본문**에서는 `searchText``found: false`를 돌려주고 `replaceAll`도 해당 위치를 건드리지 않는다. 셀 내용이 대상이라면 `getCellParagraphLength`로 좌표를 잡고 `insertTextInCell`/`deleteTextInCell`로 직접 쓴다.
- **문단 경계 / 개행 치환 금지**: `replaceAll`은 한 문단 안에서의 치환만 보장한다. `newText`에 개행 문자가 들어오면 예기치 않은 결과가 발생할 수 있다. 여러 문단을 만들고 싶으면 `insertText`를 여러 번 호출한다.
- **치환은 원본 매칭 기준 non-overlapping**: 예를 들어 query `a` / newText `aa` / 원본 `aaa`는 원본의 각 `a`를 한 번씩 교체해 `aaaaaa`가 된다. 치환으로 새로 들어온 문자열은 다시 매칭하지 않는다.
- **비 ASCII 대소문자 무시 매칭 주의**: `caseSensitive: false` 모드는 `String.prototype.toLowerCase()`가 UTF-16 길이를 유지한다는 전제 위에서 오프셋을 계산한다. 터키어 `İ`(U+0130)처럼 소문자화 시 길이가 늘어나는 문자가 포함된 문서에는 `caseSensitive: true`로 실행하거나 입력을 미리 정규화한다. 한글·ASCII 본문에는 해당하지 않으며, `2025 → 2026` 같은 실제 사업 신청서 워크플로우는 아무 영향을 받지 않는다.
## Notes
- 업스트림 rhwp: https://github.com/edwardkim/rhwp
- 업스트림 `@rhwp/core` npm: https://www.npmjs.com/package/@rhwp/core
- 업스트림은 활발히 개발 중이다(v0.7.3 2026-04-19 기준). breaking change 가능성을 고려해 `k-skill-rhwp` dependency 는 semver caret 으로 고정한다.
- 이 스킬은 **편집 전용** 스킬이다. 조회/변환은 `hwp`, 고급 디버깅은 `rhwp-advanced` 가 담당한다.
- 업스트림은 활발히 개발 중이다(v0.7.10 2026-05 기준). breaking change 가능성을 고려해 `@rhwp/core` dependency는 semver caret으로 고정한다.
- 이 스킬은 **편집 전용** 스킬이다. 조회/변환은 `hwp`, 고급 디버깅은 `rhwp-advanced`가 담당한다.

View file

@ -3450,7 +3450,8 @@ test("corporate-registration-consulting skill covers court registry workflow, ta
assert.match(skill, /inspection-report\.hwp/);
assert.match(skill, /officer-acceptance-director-ceo\.hwp/);
assert.match(skill, /rhwp-edit/);
assert.match(skill, /k-skill-rhwp/);
assert.doesNotMatch(skill, /k-skill-rhwp/);
assert.match(skill, /@rhwp\/core/);
assert.match(skill, /본문[\s\S]*replace-all|replace-all[\s\S]*본문/);
assert.match(skill, /replace-all[\s\S]*shortcut/);
assert.match(skill, /한 장 한 장[\s\S]*순차/);
@ -3681,23 +3682,25 @@ test("iros-registry-automation skill documents safe IROS registry certificate au
assert.match(roadmap, /등기부등본 자동화 스킬 출시/);
});
test("rhwp-edit skill pins the k-skill-rhwp CLI as the editing engine and disclaims kordoc/rhwp-advanced routing", () => {
test("rhwp-edit skill uses @rhwp/core directly and disclaims kordoc/rhwp-advanced routing", () => {
const skill = read(path.join("rhwp-edit", "SKILL.md"));
assert.match(skill, /^---\nname: rhwp-edit\n/);
assert.match(skill, /k-skill-rhwp/);
assert.doesNotMatch(skill, /k-skill-rhwp/);
assert.match(skill, /@rhwp\/core/);
assert.match(skill, /HwpDocument/);
assert.match(skill, /measureTextWidth/);
assert.match(skill, /hwp\/SKILL\.md/);
assert.match(skill, /rhwp-advanced\/SKILL\.md/);
assert.match(skill, /insert-text/);
assert.match(skill, /delete-text/);
assert.match(skill, /replace-all/);
assert.match(skill, /create-table/);
assert.match(skill, /set-cell-text/);
assert.match(skill, /insertText/);
assert.match(skill, /deleteText/);
assert.match(skill, /replaceAll/);
assert.match(skill, /createTable/);
assert.match(skill, /insertTextInCell/);
assert.match(skill, /create-blank/);
assert.match(skill, /#196/);
assert.match(skill, /본문 문단만/, "SKILL.md must document body-only scope for search/replace-all");
assert.match(skill, /set-cell-text/, "SKILL.md must reference set-cell-text for cell content workflow");
assert.match(skill, /insertTextInCell/, "SKILL.md must reference insertTextInCell for cell content workflow");
assert.match(skill, /non-overlapping|개행|문단 경계/, "SKILL.md must document replace-all edge cases");
assert.match(
skill,
@ -3746,9 +3749,10 @@ test("rhwp feature docs, README, install, roadmap, and sources are wired for the
assert.match(sources, /@rhwp\/core/);
assert.match(sources, /issues\/196/);
assert.match(editDoc, /k-skill-rhwp/);
assert.match(editDoc, /insert-text/);
assert.match(editDoc, /create-table/);
assert.doesNotMatch(editDoc, /k-skill-rhwp/);
assert.match(editDoc, /@rhwp\/core/);
assert.match(editDoc, /insertText/);
assert.match(editDoc, /createTable/);
assert.match(editDoc, /#196/);
assert.match(
editDoc,
@ -3767,26 +3771,6 @@ test("rhwp feature docs, README, install, roadmap, and sources are wired for the
assert.match(advancedDoc, /편집/);
});
test("k-skill-rhwp package ships CLI bin, WASM-init shim, and minor semver changeset", () => {
const packagePath = path.join(repoRoot, "packages", "k-skill-rhwp", "package.json");
assert.ok(fs.existsSync(packagePath), "expected packages/k-skill-rhwp/package.json");
const pkg = JSON.parse(fs.readFileSync(packagePath, "utf8"));
assert.equal(pkg.name, "k-skill-rhwp");
assert.ok(pkg.bin && pkg.bin["k-skill-rhwp"] === "bin/k-skill-rhwp.js", "expected bin mapping");
assert.ok(pkg.dependencies && pkg.dependencies["@rhwp/core"], "expected @rhwp/core dependency");
assert.ok(pkg.engines && /\^|>=\s*1[89]/.test(pkg.engines.node || ""), "expected Node 18+");
assert.ok(
fs.existsSync(path.join(repoRoot, "packages", "k-skill-rhwp", "src", "wasm-init.js")),
"expected src/wasm-init.js"
);
assert.ok(
fs.existsSync(path.join(repoRoot, "packages", "k-skill-rhwp", "bin", "k-skill-rhwp.js")),
"expected bin/k-skill-rhwp.js"
);
});
const README_SKILL_NAME_COLUMN_MAPPING = [
["SRT 예매", "srt-booking"],
["KTX 예매", "ktx-booking"],