3-11. 문서를 다시 검증하고, 실험용 편집기를 열다

지난 편은 HWPX를 읽고 검사하는 코드가 생겼지만 제품 JS/WASM의 본문 API에는 아직 연결되지 않았다는 말로 끝났다. 이번 일주일은 두 갈래였다. 먼저 이틀 가까이 저장소의 계약 문서를 현재 코드와 다시 대조했고, 그 뒤 HWP5와 HWPX 모두 읽기 전용 텍스트 API → Canvas 미리보기 → 실험용 편집·저장까지 한 줄로 이어 붙였다.

다만 여기서 말하는 편집은 시스템 글꼴로 그린 문단 텍스트 위에서 동작하는 실험이다. 원본 서식·표·그림·페이지 조판을 화면에 재현하지 않고, 편집 후 재조판도 하지 않는다. 이 경계는 아래에서 계속 반복한다.

이 편은 3-10의 기준 커밋 6383b4dd 다음부터 10월 2일 04:36 KST의 7bf1d632까지 공개 main에 들어간 329커밋을 다룬다. PR 없이 main에 직접 커밋했고, 10월 2일 새벽 이후 10월 4일까지 공개 커밋은 없다.

날짜커밋들어간 것
9/2610수식 shape 필드·캡션·주석, 구역·필드 파라미터 목록, 인라인 문자열 컨트롤, 단 정의, 구역 번호 컨트롤 검사
9/27177각주·미주 본문과 내부 번호 검사, 문서 검증 현황 도구, ICC·HWP5·HWPX·PNG·EMF 등 계약 문서 재검증(docs 커밋 170개)
9/28102EMF·HWP5 차트·HWPX·JPEG 문서 재검증(docs 커밋 93개), EMF·ZIP 결함 수정, HWP5 읽기 전용 문단 텍스트 API
9/293zlib 래퍼 스트림 호환, DocumentProperties 길이 진단, 읽기 전용 문서 모델 투영
9/303스타일 참조 보존 실험, 단순 문단 텍스트 splice 실험, 문단 소유권 경계 수정
10/127기존 글자 모양 적용, Canvas 미리보기, JS/WASM 실험용 편집 API, 직접 입력, HWPX 원문 보존 편집·저장
10/27머리말·꼬리말과 보호 개체 주변 입력, 편집본 다운로드, 두 포맷의 undo/redo, HWP5 문단 분할·병합

날짜와 개수는 3-10과 같이 main 커미터 시각을 한국 시간으로 집계했다. 9/26 행은 3-10 기준 커밋 이후의 10개만 센다.

검사 범위의 마지막 확장

9월 26일 오후 늦게부터 27일 새벽까지는 3-10의 HWPX 검사 계층이 이어졌다. 수식의 shape 필드·캡션 하위 목록·주석, 구역과 필드의 파라미터 목록, metaTag 텍스트, 인라인 문자열 컨트롤, 필드 marker 연결, 단 정의와 구역 번호 컨트롤을 차례로 읽었다. 각주·미주 본문 검사 뒤에는 note 안의 번호 컨트롤과 텍스트, 원문 위치를 보존했고 HWP5 쪽에도 note 내부 자동 번호 진단을 붙였다.

이 묶음도 성격은 3-10과 같다. 원값을 읽고 관계를 확인하는 검사이고, 이 요소들을 화면에 그리는 기능은 아니다.

263개의 문서 커밋

9월 27일 새벽 문서 검증 현황 도구가 들어갔다. Git이 추적하는 프로젝트 Markdown마다 "현재 내용 전체를 다시 검증했다"는 기록을 SHA-256과 근거 문장으로 남기고, 문서가 바뀌면 그 항목을 자동으로 stale로 돌리는 방식이다. 저장소 문서는 이 수치를 목록·해시 관점의 게이트라고 명시하고, 제품 구현률로 쓰지 않는다고 적어 두었다.

그 뒤 9월 27일과 28일 이틀 동안 263개의 docs 커밋이 이어졌다. 범위별로는 ICC가 68개로 가장 많고, HWP5 66개, EMF 40개, HWPX 35개, PNG 16개 순이다. 각 문서의 계약을 현재 Zig 모듈·공식 명세·실파일 조사 결과와 다시 대조하고, 이번에 재실행하지 않은 과거 수치는 "당시 기록"으로 구분했다. 9월 28일 기록에서는 추적 Markdown 560여 개가 모두 검증 해시와 일치하고 인라인 로컬 링크 대상의 누락이 없었다. 저장소 문서 스스로 밝히듯 이것이 모든 문장의 옳음을 기계적으로 증명하지는 않는다.

문서만 고친 것은 아니다. 재검증 중에 제품 코드의 어긋남도 드러났다. EMF에서는 32비트 POLYPOLYLINE의 하위 선 최소 점 수, 그라데이션 사각형 mode의 별도 padding 배열, 팔레트 없는 TS graphics, FillPolygon에서 시작점과 끝점이 같을 때 중복으로 생기던 닫힘 선분을 고쳤다. ZIP 경로 검사에서는 C:/... 같은 Windows 드라이브 접두부가 엔트리 이름으로 통과하던 반례를 먼저 재현한 뒤 거부하도록 고쳤다.

HWP5: 읽기 전용 API부터

9월 28일 createHwp5Reader가 들어갔다. CFB → FileHeader·DocInfo → BodyText Section → 문단 토큰을 이어, Zig 판의 HWP5 본문 텍스트를 JS/WASM으로 꺼내는 첫 공개 경로다. 결과는 구역별 문단의 텍스트와 원시 토큰이고, 제어 토큰은 남기되 표시 문자열로 펼치지 않는다. 서식·표·개체는 결과에 없으므로 이 값을 재저장에 쓰면 안 된다고 API 문서에 적었다.

실파일에서 관측된 CFB 메타데이터 편차는 원본을 건드리지 않는 임시 복사본에서만 제한적으로 보정하고, 그 복사본이 전체 strict 검사를 다시 통과할 때만 미리보기를 진행한다. 기본 CFB API의 strict 판정은 바꾸지 않았다.

같은 날 거부 파일을 전수 분류하면서 미리보기와 무관한 서식·표 파서 오류에 막히던 경우를 걷어 냈다. 로컬 .hwp 경로 791개 중 677개가 미리보기에 성공하고 114개는 이유별로 명시적으로 거부된다. 거부의 대부분은 CFB 서명이 아닌 파일이나 비문서였고, 배포용·암호화 문서도 따로 셌다. 허용된 파일 중 독립 오라클로 열 수 있는 676개의 문단 459,401개는 별도 JS 레코드 순회와 Node raw DEFLATE로 추출한 텍스트 원시 바이트와 모두 일치했다. 대조 대상은 텍스트 바이트에 한정된다.

9월 29일에는 zlib 래퍼를 쓴 압축 스트림을 검증된 경우에만 받아들이는 호환 경로와, 너무 짧은 DocumentProperties를 임의 보정하지 않고 길이 오류로 진단하는 수정이 들어갔다. 이어 읽기 전용 문서 모델 투영이 생겼다. Document → Section → Paragraph → Token 구조에 문단 텍스트와 스타일 참조만 옮긴 부분 투영이고, 미투영 레코드·리소스 내용·조판은 담지 않는다.

원본을 지키는 편집 실험

9월 30일부터 편집이 시작됐다. 첫 실험은 문단 헤더의 스타일 참조 하나만 바꾸는 것이었다. 원본 CFB와 Section은 불변으로 두고, 저장할 때 허용한 필드만 덮어써 비편집 바이트가 그대로인지 독립 대조기로 비교했다. 이어 단순 문단 텍스트 splice와 10월 1일 기존 글자 모양 ID 적용이 같은 세션에 붙었다.

이 실험들의 공통 규칙은 조판을 다시 하지 않는다는 사실을 숨기지 않는 것이다. 텍스트나 서식을 바꾼 문단은 원본 줄 배치(LineSeg)를 제거하고, 기본 save()는 LayoutReflowRequired로 저장을 거부한다. 호출자가 allowStaleLayout을 명시해야 저장하며, 그 결과가 한컴 프로그램에서 올바르게 조판된다는 실측은 없다. 새 글꼴이나 서식 리소스를 만들지 않고, 문서에 이미 있는 ID만 쓴다.

검증 과정에서 별도 루트 레코드 뒤에 붙은 텍스트를 편집 문단의 것으로 덮어쓰던 저장기 결함도 재현했다. 레코드 계층 경계에서 문단 소유권을 닫도록 고쳤다.

Canvas 위에서 입력하기

10월 1일 아키텍처 문서에 웹 미리보기의 화면 렌더링을 Canvas 기반으로 한다는 결정이 기록됐다. 같은 날 브라우저 Canvas 미리보기가 들어갔다. 파싱은 Worker에서 하고, Canvas에는 시스템 글꼴로 문단 텍스트를 흘려 그린다. 원래 글자 모양·표 테두리·그림·필드 표시·페이지 조판은 그리지 않는다.

이어 실험용 HWP5 편집 API를 JS/WASM으로 노출하고, Canvas에 caret·선택·직접 입력을 연결했다. 확정된 문서 값은 Zig 세션만 소유하고, textarea와 화면 표시는 임시 사본이다. 한글 조합 중에는 native 명령을 보내지 않고 조합이 끝난 뒤 한 번만 반영한다. 브라우저 확인은 agent-browser의 Chromium과 합성 조합 이벤트로 했고, 실제 macOS 한글 입력기와 Safari·Firefox는 검증하지 않았다.

편집 범위는 추적 픽스처 전수 검사로 확인하며 넓혔다. 처음 기준선에서는 HWP 1,481문단에 접두어 삽입을 시도해 269문단만 저장 후 텍스트 대조에 성공했다. 중첩 목록 문단, 기존 빈 문단, 탭, 필드 편집 기반, 계산식 재계산, 문단을 넘는 하이퍼링크 라벨을 차례로 연결한 뒤 같은 1,481회 중 1,433회가 저장·독립 대조·재열기에 성공했다. 남은 48회는 차트 한 파일의 숫자 셀에 비숫자 접두어를 넣어 생긴 InvalidFormulaNumber 거부다. 이 수치도 각 문단 시작에 한 번 삽입한 결과이고, 임의 위치 편집이나 서식·조판 완료를 뜻하지 않는다.

HWPX: 바꾼 조각만 다시 쓴다

같은 날 저녁 HWPX도 공개 API에 올라왔다. 먼저 section 텍스트 이벤트를 공개 읽기 API와 읽기 전용 Canvas에 연결했다. 추적 HWPX 45개 중 비암호화 44개가 읽혔고 암호화 1개는 거부된다.

저장은 기존 ZIP을 다시 만드는 대신 선택한 항목만 교체하는 방식으로 했다. 바뀌지 않은 local record·extra·주석·순서를 그대로 복사하고, 바뀐 항목의 CRC·크기만 갱신한다. XML도 변경한 텍스트 조각 밖의 원문을 그대로 복사한다. 그래서 무변경 저장과 원래 텍스트로 되돌린 저장이 원본 ZIP과 바이트 단위로 같은지를 실제 픽스처로 확인했다. 독립 Python zipfile·XML 파서로 비암호화 44개 파일의 499개 항목을 비교해 변경 XML 외의 payload와 메타데이터가 일치함을 확인했다.

편집 세션을 WASM과 Canvas에 연결한 뒤에는 단 설정·쪽 번호 설정을 보존하는 문단, 필드 라벨과 계산 필드, 각주·미주 본문, 머리말·꼬리말 소유 문단, 보호 개체 앞뒤의 입력 위치로 범위를 넓혔다. 편집 이력 전수 검사에서는 1,379문단 중 1,174건이 편집, undo 후 원본 ZIP 복원, redo 후 편집본 바이트 일치까지 확인됐다. 거부는 UnsupportedParagraphControl 157건과, 숫자 셀에 비숫자 접두어를 넣어 생긴 InvalidFormulaNumber 48건이다. HWPX 저장은 원본 줄 배치를 재계산하지 않으므로 결과는 재조판이 필요한 상태로 안내한다.

10월 2일 새벽: 다운로드, 이력, Enter

10월 2일 새벽 7커밋 중 둘은 위의 머리말·꼬리말과 보호 개체 주변 입력이고, 나머지 다섯은 실험용 편집기의 기본 동작을 채웠다. Canvas에서 편집한 문서를 .edited.hwp·.edited.hwpx로 내려받고, HWPX와 HWP5에 native undo/redo를 붙였다. 두 포맷은 같은 이력 스택 모듈을 쓰고, 복원값은 JS의 역명령이 아니라 native 체크포인트다.

HWP5에는 문단 분할(Enter)과 경계 삭제로 인한 병합이 들어갔다. 새 문단의 Instance ID를 원본과 겹치지 않게 고르고, 같은 논리 목록에 속한 인접 문단만 합친다. 필드·보호 컨트롤·계산 결과가 걸린 문단의 구조 편집과 HWPX 구조 편집은 아직 거부한다.

남은 거리

일주일 사이 hwpjs는 "읽고 검사한다"에서 "제한된 범위에서 편집하고, 바꾸지 않은 바이트는 그대로 저장한다"로 한 걸음 옮겼다. 각 단계마다 독립 오라클과 거부 분류를 함께 남긴 것이 이번 구간의 특징이다.

그만큼 남은 것도 분명하다. 화면은 원본 서식과 페이지 배치를 그리지 않고, 편집 후 재조판이 없으며, 저장본을 한컴 프로그램에서 열어 대조한 기록도 없다. 표·그림 같은 개체의 구조 편집, 문서 전체 선택, 실제 한글 입력기 검증도 남아 있다. Rust 판의 HTML 뷰어를 신규 Zig 기능으로 세지 않는다는 이전 원칙도 그대로다.