편집
모든 편집 메서드는 DocumentResult를 돌려줍니다 — .document가 새 문서, .report가 보고서입니다. 받은 문서는 바뀌지 않으므로 결과를 다시 받아야 합니다.
import hwpforge
doc = hwpforge.Document.open("form.hwpx")
result = doc.fill({"user_email": "kim@example.com"})
doc = result.document # 이 줄이 없으면 아무것도 바뀌지 않은 것입니다
편집기는 두 부류입니다. 보존 우선(preserve-first) 편집기(fill·patch)는 건드리는 XML 조각만 바꾸고 나머지 바이트를 그대로 두며, 재인코드 편집기(set_cell·stamp·restyle)는 문서를 모델로 디코드해 고친 뒤 패키지를 다시 씁니다. insert_para/delete_para는 구역 XML에서 해당 문단만 넣고 빼지만, 재인코드 편집기와 같은 사전 검사(원본 패키지의 엔트리가 재인코드 뒤에도 남는지, 디코드–인코드 왕복이 안전한지)를 통과해야 합니다. 이 차이가 아래 “한컴 저장 문서” 표를 만듭니다.
한컴 저장 문서에서 되는 것과 안 되는 것
한컴 오피스가 저장한 .hwpx에는 HwpForge 인코더가 재현하지 않는 패키지 엔트리(Preview/PrvText.txt·Preview/PrvImage.png·META-INF/container.rdf)가 들어 있습니다. 네 연산은 이런 문서를 조용히 손상시키는 대신 fail-closed로 거부합니다.
| 연산 | 한컴 저장 문서 | 결과 |
|---|---|---|
fill | 동작 | 누름틀만 쓰고 나머지 엔트리는 바이트 그대로 보존 |
patch | 동작 | 기존 문단·셀의 텍스트만 바꾸고 패키지를 보존 |
set_cell·insert_para·delete_para·stamp | 거부 | INPUT_ENTRIES_NOT_CARRIED 또는 INPUT_NOT_ROUNDTRIP_SAFE |
restyle | 동작 | 재인코드하므로 위 엔트리와 조판 캐시가 사라지며 warnings로 알림 |
from_json(base=...) | 부분 승계 | 이미지만 승계하고 위 엔트리는 버려집니다(별도 경고 없음) |
hancom = hwpforge.Document.open("hancom.hwpx")
try:
hancom.set_cell(table=0, at="0,0", text="값")
except hwpforge.HwpForgeError as exc:
print(exc.code) # INPUT_ENTRIES_NOT_CARRIED
print(exc.hint) # 되는 길: 텍스트는 export_section → 편집 → patch, 누름틀은 fill
따라서 실제 정부 서식을 다룰 때 통하는 길은 둘입니다: 누름틀은 fill, 그 밖의 텍스트는 export_section → 편집 → patch. 구조를 정말 바꿔야 할 때만 from_json(base=...)을 쓰고, 결과를 한컴에서 열어 확인하세요. 이 제한을 푸는 작업이 진행 중이며(GitHub #143), 풀리면 이 표가 바뀝니다.
fill — 누름틀 채우기
form = hwpforge.Document.open("form.hwpx")
result = form.fill({"user_email": "kim@example.com"})
print(result.report["filled"])
# [{'name': 'user_email', 'section': 0, 'previous': '회사 이메일을 입력하세요'}]
print(result.document.read(field="user_email")["fields"][0]["current"])
# kim@example.com
- 이름이 없는 필드는
FIELD_NOT_FOUND(메시지에 사용 가능한 이름 목록이 붙습니다), 채울 수 없는 필드는FIELD_NOT_FILLABLE, 빈 문자열은EMPTY_FIELD_VALUE, 값 맵이 비어 있으면NO_VALUES입니다. 값은 문자열이어야 하며 다른 형은TypeError입니다. - 같은 이름의 필드가 문서에 둘 이상이면 어느 쪽인지 모호하므로 자동으로 전부 채우지 않고
FIELD_NAME_AMBIGUOUS로 거부합니다(아무것도 쓰지 않습니다). 문서에서 이름을 유일하게 만든 뒤 다시 채우세요. fill은 바꾼 문단의 조판 캐시만 무효화하고 나머지는 보존하므로, 한컴 저장 문서에서도 안전합니다.
try:
form.fill({"nope": "x"})
except hwpforge.HwpForgeError as exc:
print(exc.code, "|", exc.message)
# FIELD_NOT_FOUND | field 'nope' not found; available: [user_email]
patch — 구역 JSON을 편집해 되돌려 넣기
텍스트를 바꾸는 보존 우선 경로입니다. export_section이 낸 JSON의 텍스트를 고쳐 patch로 넣으면 그 구역 XML의 텍스트 슬롯만 치환됩니다.
import json
exported = hancom.export_section(section=0)
section = json.loads(exported.text) # 키: section_index, section, styles, preservation
patched = hancom.patch(section=0, patch=json.dumps(section, ensure_ascii=False))
print(patched.report) # {'section': 0, 'warnings': []}
print(hancom.diff(patched.document)["identical"]) # 바꾼 게 없으면 True
- JSON의 텍스트만 바꿀 수 있습니다. 문단을 더하거나 빼거나 표 구조·서식 참조를 바꾸면
PATCH_FAILED(구조 변경 감지)로 거부됩니다. 구조 변경은insert_para/delete_para/set_cell또는from_json의 몫입니다. preservation키는 텍스트 슬롯과 원본 바이트 스팬의 대응표입니다. 손대지 말고 그대로 돌려보내세요.- JSON이 구역 스키마에 맞지 않으면
JSON_PARSE_FAILED, 격자 주소가 더 이상 맞지 않으면PATCH_FAILED입니다. 스키마는hwpforge.schema(kind="exported-section")으로 받을 수 있습니다.
텍스트를 실제로 바꾸는 예:
section = json.loads(hancom.export_section(section=0).text)
first_run = section["section"]["paragraphs"][0]["runs"][0]
if "text" in first_run:
first_run["text"] = "바뀐 첫 문단"
doc2 = hancom.patch(section=0, patch=json.dumps(section, ensure_ascii=False)).document
문단의 runs[i]는 텍스트 런이면 text 키를, 표·그림 같은 컨트롤이면 다른 키를 가집니다. 표 셀의 텍스트도 같은 JSON 안에 있으므로(runs[i]["table"]["rows"][r]["cells"][c]["paragraphs"]…) 한컴 저장 문서의 셀 텍스트는 이 길로 바꿉니다.
set_cell — 표 셀에 쓰기
격자 주소(at)나 라벨 기준(right_of·below)으로 셀 하나를 지정하거나, specs로 여럿을 한 번에 씁니다. 두 형태는 배타적입니다.
grid = hwpforge.Document.open("grid.hwpx")
print([(c["row"], c["col"], c["text"]) for c in grid.read(table=0)["table"]["cells"]])
# [(0, 0, '성명'), (0, 1, ''), (1, 0, '비고'), (1, 1, ''), (2, 1, '')]
one = grid.set_cell(table=0, at="0,1", text="홍길동")
print(one.report["results"][0])
# {'table': 0, 'requested': {'row': 0, 'col': 1}, 'anchor': {'row': 0, 'col': 1}, 'resolution': 'exact', 'cleared': False}
by_label = grid.set_cell(table=0, right_of="성명", text="홍길동")
many = grid.set_cell(specs=[
{"table": 0, "at": {"row": 0, "col": 1}, "text": "홍길동"},
{"table": 0, "right_of": "비고", "text": "없음"},
])
print([r["resolution"] for r in many.report["results"]])
at은 메서드 인자로는"row,col"문자열,specs안에서는{"row": r, "col": c}객체입니다(0부터).right_of/below는 그 텍스트를 가진 셀의 오른쪽/아래 셀입니다. 라벨이 없거나 그 방향에 셀이 없으면CELL_NOT_FOUND, 라벨이 여럿이면 모호함으로 거부됩니다.- 병합된 영역을 지정하면 기준 셀로 해석되며
resolution이 그 사실을 말합니다(exact가 아닌 값). 표가 격자 주소를 지원하지 않으면(outline의addressable거짓) 거부됩니다. - 표 번호는
outline()["outline"]["tables"][i]["ordinal"]이며, 없으면TABLE_NOT_FOUND. - 재인코드 편집기이므로 한컴 저장 문서는
INPUT_ENTRIES_NOT_CARRIED/INPUT_NOT_ROUNDTRIP_SAFE로 거부됩니다. 그 문서의 셀 텍스트는patch로 바꾸세요.
insert_para / delete_para — 문단 넣고 빼기
plain = hwpforge.Document.open("plain.hwpx")
print([p["text"] for p in plain.read(section=0, paras="0..3")["paragraphs"]["paragraphs"]])
# ['첫째 문단입니다.', '둘째 문단입니다.', '셋째 문단입니다.', '넷째 문단입니다.']
ins = plain.insert_para(section=0, anchor=1, text=["새 문단 하나", "새 문단 둘"])
print(ins.report) # {'inserted': 2, 'deleted': 0, 'warnings': []}
above = plain.insert_para(section=0, anchor=1, text="위에", before=True)
dl = ins.document.delete_para(section=0, indexes=[2, 3])
print(dl.report) # {'inserted': 0, 'deleted': 2, 'warnings': []}
text는 문자열 하나 = 문단 하나, 시퀀스 = 원소마다 문단 하나입니다. 문자열을 글자 단위 시퀀스로 풀지 않습니다. 시퀀스의 원소는str만 받습니다.anchor/indexes는 그 구역의 최상위 문단 인덱스입니다(표 셀 안의 문단은 세지 않습니다). 범위를 넘으면PARAGRAPH_OUT_OF_RANGE.- 삭제는 전부-아니면-무(all-or-nothing)이며 fail-closed 정책이 있습니다: 구역 속성을 가진 첫 문단(
SECTION_PROPERTIES_PARAGRAPH), 책갈피·상호참조·각주 등 참조를 가진 문단, 쪽/단 나눔을 가진 문단(HARD_BREAK_LOSS), 구역을 비우는 삭제는 거부됩니다. 같은 이유로 첫 문단 앞에는 넣을 수 없습니다(INSERT_BEFORE_SECTION_PROPERTIES). - 두 연산은 대상 구역 XML만 바꾸고 다른 엔트리는 바이트 그대로 둡니다. 다만 현재는 재인코드 편집기와 같은 사전 검사를 거치므로 한컴 저장 문서를
INPUT_ENTRIES_NOT_CARRIED로 거부합니다(위 표).
try:
plain.delete_para(section=0, indexes=[0])
except hwpforge.HwpForgeError as exc:
print(exc.code) # SECTION_PROPERTIES_PARAGRAPH
stamp — 템플릿에 누름틀 심기
stamp_plan이 찾은 후보를 누름틀로 바꾸는 연산입니다. 흐름은 계획 → 명세(spec) 작성 → 스탬프 이고, 가드 없는 후보는 전부 이름을 붙이거나 "ignore"로 명시해야 합니다 — 하나라도 빠지면 STAMP_CANDIDATE_UNCOVERED로 아무것도 쓰지 않습니다.
template = hwpforge.Document.open("template.hwpx")
plan = template.stamp_plan()
specs = []
for i, candidate in enumerate(plan["text"]):
spec = dict(candidate) # section, path, span, marker 를 그대로 복사
if i == 0:
spec["action"] = {"field": {"name": "applicant", "hint": "신청인 이름"}}
else:
spec["action"] = "ignore"
specs.append(spec)
stamped = template.stamp(specs)
print(stamped.report["stamped"])
# [{'name': 'applicant', 'section': 0, 'path': 'paragraphs[0].runs[0].text', 'span': {...}, 'marker': '( )', 'pattern': 'paren_blank'}]
print(stamped.report["ignored"], stamped.report["skipped_guarded"])
print([(f["name"], f["hint"]) for f in stamped.document.fields()["fields"]])
# [('applicant', '신청인 이름')]
명세 작성 규칙:
- 텍스트 후보: 계획의 후보 객체를 복사하고
action만 더합니다.section·path·span·marker가 빠지면 거부되고, 여분 키(pattern·guard)는 무시됩니다. - 셀 후보: 복사하지 말고
table·at·action으로 새로 만듭니다(label을 붙이면text는 계획의labels[].normalized여야 합니다). 후보 객체를 통째로 넘기면 알 수 없는 키로 거부됩니다. action은"ignore"또는{"field": {"name": ..., "hint": ...}}입니다.- 버전 있는 요청은
{"schema_version": plan["schema_version"], "source_sha256": plan["source_sha256"], "text": [...], "cells": [...]}이며, 지문이 문서와 다르면 거부됩니다 — 계획을 만든 문서에만 적용된다는 뜻입니다.
request = {
"schema_version": plan["schema_version"],
"source_sha256": plan["source_sha256"],
"text": specs,
}
stamped = template.stamp(request, manifest=False) # 보고서에서 manifest 생략
보고서의 manifest는 무엇을 어디에 심었는지의 기록(schema_version·source_sha256·output_sha256·fields)이고, stamped/stamped_cells는 적용 단계의 결과입니다. 재인코드 편집기이므로 의미가 손상될 인코딩은 ENCODE_SEMANTIC_LOSS로 거부하며(exc.details에 원인 경고), 한컴 저장 문서는 편집 전 사전 검사에서 거부됩니다.
restyle — 다른 프리셋으로
print([p["name"] for p in hwpforge.templates()["presets"]]) # ['default', 'modern', 'classic', 'latest']
restyled = hancom.restyle(preset="modern")
print(restyled.report["preset"], restyled.report["paragraphs"])
print([w["code"] for w in restyled.report["warnings"]])
문서를 다시 인코드하므로 조판 캐시와 인코더가 모르는 엔트리는 사라지고 그 사실이 warnings로 옵니다. 없는 프리셋은 PRESET_NOT_FOUND, 의미 손상은 ENCODE_SEMANTIC_LOSS입니다.
편집 뒤에 할 일
result.report["warnings"]를 읽습니다. 비어 있어야 정상이고,LAYOUT_CACHE_DROPPED같은 코드는 한컴에서 다시 저장해야 쪽 배치가 복원된다는 뜻입니다.result.document.validate()["ok"]로 모델이 유효한지 봅니다.original.diff(result.document)로 바뀐 범위가 의도와 같은지 봅니다 —package["changed"]가 대상 구역 하나뿐인지가 좋은 검사입니다.save합니다. 파일명은.hwpx로.