From 218f80295e8265da1bed74b9c81c40f65abaafcc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 23:05:05 +0900 Subject: [PATCH 01/78] docs(hangul): record isolated workstream --- docs/hangul-workstream.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 docs/hangul-workstream.md diff --git a/docs/hangul-workstream.md b/docs/hangul-workstream.md new file mode 100644 index 00000000..627f2eba --- /dev/null +++ b/docs/hangul-workstream.md @@ -0,0 +1,3 @@ +# HWP and HWPX workstream + +This branch tracks the isolated, non-release implementation for issue #319. From 7c02a72df6c0550e99bb43d158a0dee8e879e499 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 23:05:50 +0900 Subject: [PATCH 02/78] test(hangul): define HWP and HWPX authoring contract --- src/hangul/index.test.ts | 155 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 src/hangul/index.test.ts diff --git a/src/hangul/index.test.ts b/src/hangul/index.test.ts new file mode 100644 index 00000000..508fd753 --- /dev/null +++ b/src/hangul/index.test.ts @@ -0,0 +1,155 @@ +import type { JSONContent } from '@tiptap/core'; +import { + exportHangulDocument, + openHangulDocument, + type HangulDocumentEngine, + type HangulEngineDocument, +} from './index.js'; + +class FakeDocument implements HangulEngineDocument { + freed = false; + readonly calls: string[] = []; + sourceFormat = 'hwpx'; + + getSourceFormat(): string { + return this.sourceFormat; + } + + getSectionCount(): number { + return 1; + } + + getParagraphCount(): number { + return 1; + } + + getParagraphLength(): number { + return 5; + } + + exportSelectionHtml(): string { + return '

Title

Body

'; + } + + getValidationWarnings(): string { + return '{"warnings":[]}'; + } + + createBlankDocument(): string { + this.calls.push('createBlankDocument'); + return '{"ok":true}'; + } + + beginBatch(): string { + this.calls.push('beginBatch'); + return '{"ok":true}'; + } + + endBatch(): string { + this.calls.push('endBatch'); + return '{"ok":true}'; + } + + deleteText( + sectionIndex: number, + paragraphIndex: number, + charOffset: number, + count: number, + ): string { + this.calls.push( + `deleteText:${sectionIndex}:${paragraphIndex}:${charOffset}:${count}`, + ); + return '{"ok":true}'; + } + + pasteHtml( + sectionIndex: number, + paragraphIndex: number, + charOffset: number, + html: string, + ): string { + this.calls.push( + `pasteHtml:${sectionIndex}:${paragraphIndex}:${charOffset}:${html}`, + ); + return '{"ok":true}'; + } + + exportHwp(): Uint8Array { + this.calls.push('exportHwp'); + return new Uint8Array([1, 2, 3]); + } + + exportHwpx(): Uint8Array { + this.calls.push('exportHwpx'); + return new Uint8Array([4, 5, 6]); + } + + free(): void { + this.freed = true; + } +} + +function createEngine( + source: FakeDocument, + target = new FakeDocument(), +): HangulDocumentEngine { + return { + id: 'fake', + open: vi.fn(async () => source), + create: vi.fn(async () => target), + }; +} + +describe('Hangul document bridge', () => { + it('opens HWPX as editable TipTap JSON', async () => { + const source = new FakeDocument(); + const result = await openHangulDocument(new Uint8Array([9]), { + engine: createEngine(source), + }); + + expect(result.sourceFormat).toBe('hwpx'); + expect(result.documentJson).toEqual({ + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 1 }, + content: [{ type: 'text', text: 'Title' }], + }, + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Body', marks: [{ type: 'bold' }] }, + ], + }, + ], + }); + expect(result.lossy).toBe(false); + expect(source.freed).toBe(true); + }); + + it('exports edited JSON as HWPX by default', async () => { + const source = new FakeDocument(); + const target = new FakeDocument(); + const documentJson: JSONContent = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'A&B' }], + }, + ], + }; + + const result = await exportHangulDocument(documentJson, { + engine: createEngine(source, target), + }); + + expect(result.format).toBe('hwpx'); + expect(Array.from(result.bytes)).toEqual([4, 5, 6]); + expect(target.calls).toContain('createBlankDocument'); + expect(target.calls.join('\n')).toContain('

A&B

'); + expect(target.calls).toContain('exportHwpx'); + expect(target.freed).toBe(true); + }); +}); From 35dabb570a59717d7d7f61185a9cc9b645186714 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 00:10:56 +0900 Subject: [PATCH 03/78] feat(hangul): add bridge module boundary --- src/hangul/index.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 src/hangul/index.ts diff --git a/src/hangul/index.ts b/src/hangul/index.ts new file mode 100644 index 00000000..357016b5 --- /dev/null +++ b/src/hangul/index.ts @@ -0,0 +1,10 @@ +export async function openHangulDocument(): Promise { + throw new Error('not implemented'); +} + +export async function exportHangulDocument(): Promise { + throw new Error('not implemented'); +} + +export interface HangulDocumentEngine {} +export interface HangulEngineDocument {} From 88d77be7eaf0a797121c930a4b06b8d492023b50 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 00:12:34 +0900 Subject: [PATCH 04/78] feat(hangul): implement editable document bridge --- src/hangul/index.ts | 167 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 161 insertions(+), 6 deletions(-) diff --git a/src/hangul/index.ts b/src/hangul/index.ts index 357016b5..7e45c38e 100644 --- a/src/hangul/index.ts +++ b/src/hangul/index.ts @@ -1,10 +1,165 @@ -export async function openHangulDocument(): Promise { - throw new Error('not implemented'); +import type { JSONContent } from '@tiptap/core'; + +/** A Hangul document opened by a host-provided parser/serializer. */ +export interface HangulEngineDocument { + getSourceFormat(): string; + getSectionCount(): number; + getParagraphCount(sectionIndex: number): number; + getParagraphLength(sectionIndex: number, paragraphIndex: number): number; + exportSelectionHtml(sectionIndex: number, startParagraphIndex: number, startCharOffset: number, endParagraphIndex: number, endCharOffset: number): string; + createBlankDocument?(): string; + beginBatch?(): string; + endBatch?(): string; + deleteText(sectionIndex: number, paragraphIndex: number, charOffset: number, count: number): string; + pasteHtml(sectionIndex: number, paragraphIndex: number, charOffset: number, html: string): string; + exportHwp(): Uint8Array; + exportHwpx(): Uint8Array; + free?(): void; +} + +/** Host-owned engine boundary so Inkspan never acquires filesystem or network authority. */ +export interface HangulDocumentEngine { + readonly id: string; + open(source: Uint8Array): HangulEngineDocument | Promise; + create(): HangulEngineDocument | Promise; +} + +export interface OpenHangulDocumentOptions { + engine: HangulDocumentEngine; + maxSourceBytes?: number; +} + +export interface ExportHangulDocumentOptions { + engine: HangulDocumentEngine; + format?: 'hwp' | 'hwpx'; + maxOutputBytes?: number; +} + +export interface HangulDocumentImportResult { + sourceFormat: 'hwp' | 'hwpx'; + documentJson: Readonly; + warnings: readonly string[]; + lossy: boolean; +} + +export interface HangulDocumentExportResult { + format: 'hwp' | 'hwpx'; + bytes: Uint8Array; + warnings: readonly string[]; +} + +/** Stable error type for unsupported or unsafe conversion states. */ +export class HangulDocumentError extends Error { + constructor(readonly code: string, message: string) { + super(message); + this.name = 'HangulDocumentError'; + } +} + +function parseInline(parent: ParentNode, marks: JSONContent['marks'] = []): JSONContent[] { + const output: JSONContent[] = []; + for (const child of Array.from(parent.childNodes)) { + if (child.nodeType === Node.TEXT_NODE) { + const text = child.textContent ?? ''; + if (text) output.push({ type: 'text', text, ...(marks?.length ? { marks } : {}) }); + } else if (child instanceof Element) { + const tag = child.tagName.toLowerCase(); + const next = tag === 'strong' || tag === 'b' + ? [...(marks ?? []), { type: 'bold' }] + : tag === 'em' || tag === 'i' + ? [...(marks ?? []), { type: 'italic' }] + : tag === 's' || tag === 'strike' + ? [...(marks ?? []), { type: 'strike' }] + : marks; + output.push(...parseInline(child, next)); + } + } + return output; +} + +function htmlToJson(html: string): JSONContent { + const parsed = new DOMParser().parseFromString(html, 'text/html'); + return { + type: 'doc', + content: Array.from(parsed.body.children).map((element) => { + const tag = element.tagName.toLowerCase(); + const content = parseInline(element); + return /^h[1-6]$/u.test(tag) + ? { type: 'heading', attrs: { level: Number(tag.slice(1)) }, content } + : { type: 'paragraph', content }; + }), + }; +} + +function escapeHtml(value: string): string { + return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); +} + +function renderInline(node: JSONContent): string { + if (node.type !== 'text') throw new HangulDocumentError('UNSUPPORTED_DOCUMENT_NODE', 'Only text inline nodes are currently exportable.'); + let value = escapeHtml(node.text ?? ''); + for (const mark of node.marks ?? []) { + if (mark.type === 'bold') value = `${value}`; + else if (mark.type === 'italic') value = `${value}`; + else if (mark.type === 'strike') value = `${value}`; + else throw new HangulDocumentError('UNSUPPORTED_DOCUMENT_MARK', `Unsupported mark: ${mark.type ?? ''}.`); + } + return value; } -export async function exportHangulDocument(): Promise { - throw new Error('not implemented'); +function jsonToHtml(documentJson: JSONContent): string { + if (documentJson.type !== 'doc') throw new HangulDocumentError('UNSUPPORTED_DOCUMENT_NODE', 'Hangul export requires a doc root.'); + return (documentJson.content ?? []).map((node) => { + const body = (node.content ?? []).map(renderInline).join(''); + if (node.type === 'paragraph') return `

${body}

`; + if (node.type === 'heading') { + const level = Number(node.attrs?.level); + if (!Number.isInteger(level) || level < 1 || level > 6) throw new HangulDocumentError('UNSUPPORTED_DOCUMENT_NODE', 'Invalid heading level.'); + return `${body}`; + } + throw new HangulDocumentError('UNSUPPORTED_DOCUMENT_NODE', `Unsupported block: ${node.type ?? ''}.`); + }).join(''); } -export interface HangulDocumentEngine {} -export interface HangulEngineDocument {} +/** Project HWP/HWPX bytes into the editor's JSON model. */ +export async function openHangulDocument(source: Uint8Array, options: OpenHangulDocumentOptions): Promise { + if (source.byteLength > (options.maxSourceBytes ?? 64 * 1024 * 1024)) throw new HangulDocumentError('SOURCE_LIMIT_EXCEEDED', 'Hangul source exceeds the configured limit.'); + let document: HangulEngineDocument; + try { document = await options.engine.open(source); } catch { throw new HangulDocumentError('ENGINE_OPEN_FAILED', 'The Hangul engine could not open the document.'); } + try { + const sourceFormat = document.getSourceFormat().toLowerCase(); + if (sourceFormat !== 'hwp' && sourceFormat !== 'hwpx') throw new HangulDocumentError('UNSUPPORTED_SOURCE_FORMAT', 'Unsupported Hangul source format.'); + const html: string[] = []; + for (let section = 0; section < document.getSectionCount(); section += 1) { + const count = document.getParagraphCount(section); + if (count > 0) html.push(document.exportSelectionHtml(section, 0, 0, count - 1, document.getParagraphLength(section, count - 1))); + } + const documentJson = htmlToJson(html.join('')); + Object.freeze(documentJson); + return { sourceFormat, documentJson, warnings: Object.freeze([]), lossy: false }; + } finally { document.free?.(); } +} + +/** Export edited Inkspan JSON as HWPX by default or HWP explicitly. */ +export async function exportHangulDocument(documentJson: JSONContent, options: ExportHangulDocumentOptions): Promise { + const format = options.format ?? 'hwpx'; + const html = jsonToHtml(documentJson); + let document: HangulEngineDocument; + try { document = await options.engine.create(); } catch { throw new HangulDocumentError('ENGINE_CREATE_FAILED', 'The Hangul engine could not create a document.'); } + try { + try { + document.createBlankDocument?.(); + document.beginBatch?.(); + const length = document.getParagraphLength(0, 0); + if (length > 0) document.deleteText(0, 0, 0, length); + document.pasteHtml(0, 0, 0, html); + document.endBatch?.(); + const bytes = format === 'hwp' ? document.exportHwp() : document.exportHwpx(); + if (bytes.byteLength > (options.maxOutputBytes ?? 64 * 1024 * 1024)) throw new HangulDocumentError('OUTPUT_LIMIT_EXCEEDED', 'Hangul export exceeds the configured limit.'); + return { format, bytes, warnings: Object.freeze([]) }; + } catch (error) { + if (error instanceof HangulDocumentError) throw error; + throw new HangulDocumentError('ENGINE_OPERATION_FAILED', 'The Hangul engine failed during export.'); + } + } finally { document.free?.(); } +} From 4a1160a88a4181a69d98ff1133307f5c96306582 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 00:14:58 +0900 Subject: [PATCH 05/78] docs(hangul): add HWP/HWPX authoring ADR --- ...0027-hangul-document-authoring-boundary.md | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 docs/adr/0027-hangul-document-authoring-boundary.md diff --git a/docs/adr/0027-hangul-document-authoring-boundary.md b/docs/adr/0027-hangul-document-authoring-boundary.md new file mode 100644 index 00000000..5fb1a4ff --- /dev/null +++ b/docs/adr/0027-hangul-document-authoring-boundary.md @@ -0,0 +1,72 @@ +# ADR 0027: Hangul document authoring boundary + +- Status: Proposed +- Date: 2026-08-14 +- Decision owners: Inkspan maintainers + +## Context + +Inkspan needs to open, edit, and save Korean Hangul Word Processor documents without turning the editor package into a filesystem-, network-, or vendor-runtime-owning application. HWP 5.x is a published binary format. HWPX is the XML-based Hangul standard-document format built on OWPML, whose document structure is standardized as KS X 6101. The Korean standards catalogue records KS X 6101 as current after confirmation on 2024-10-30. Hancom also publishes HWP/OWPML format material and describes HWPX as an OWPML-based, machine-readable format. + +The editor already treats TipTap/ProseMirror JSON as the editable document authority. Introducing a second editable authority for HWP/HWPX would make autosave, collaboration, revision evidence, and host integration inconsistent. + +## Alternatives considered + +1. Parse HWP/HWPX directly inside the React editor. Rejected because binary/XML parsing, optional WASM initialization, document resources, and UI lifecycle become coupled. +2. Convert every document through HTML and keep HTML as the editing authority. Rejected because HTML cannot represent every Hangul layout primitive and would make conversion loss implicit. +3. Introduce a framework-neutral Hangul bridge with a host-injected parser/serializer engine and TipTap JSON as the editing authority. Selected. + +## Decision + +Inkspan exposes a framework-neutral Hangul bridge under a dedicated package boundary. The bridge accepts HWP/HWPX bytes through a host-injected engine, projects the supported semantic subset to TipTap JSON, and serializes edited JSON back through the engine. HWPX is the recommended export format because it is the open XML/OWPML path; HWP remains an explicit compatibility export. + +The host owns file selection, filesystem access, network access, WASM/module initialization, password UX, persistence, and download/publication. The bridge receives bytes and returns bytes. It never fetches external document resources. + +Unsupported structures are never silently asserted to be lossless. Import results carry warnings and a lossy flag. Export rejects editor structures that cannot be represented by the current bridge rather than dropping them silently. + +## Consequences + +- Existing `CwlEditorHandle.setDocumentJson()` remains the single editing ingress. +- HWP and HWPX share one product API while their parsing implementations remain replaceable. +- Parser/serializer upgrades do not require React changes. +- Full visual round-trip fidelity is not claimed until covered by real-document compatibility fixtures. +- HWPX can later gain a first-party native OWPML implementation without changing the public bridge contract. + +## Failure and recovery semantics + +Malformed input, unsupported source identity, resource-limit breaches, engine failures, and unsupported export structures fail closed with stable error codes. The original input is never mutated. Hosts may keep the original bytes and offer a fallback download or alternate viewer. + +## Security and privacy impact + +HWP/HWPX bytes are untrusted input. The bridge has no remote-resource fetch path and no active-content execution path. Source and output byte bounds are enforced before publication. Credentials, cookies, filesystem paths, and document passwords are not part of result objects or telemetry contracts. + +A future native HWPX parser must additionally bound ZIP entries, expansion ratio, XML depth, XML node count, text length, relationship targets, embedded objects, and external references. DTD and external-entity resolution must remain disabled. + +## Compatibility and migration + +The public contract identifies source and output as `hwp` or `hwpx`. HWPX is preferred for newly saved documents. Existing HWP users can explicitly request HWP export when their selected engine supports it. If a later native HWPX implementation replaces the initial engine adapter, compatibility is governed by the same JSON projection tests and real-document fixture suite. + +## Verification and acceptance evidence + +Acceptance requires all of the following on one exact PR head: + +- HWP and HWPX import tests; +- edited JSON to HWPX and HWP export tests; +- real documents reopened after export and compared against expected semantic content; +- hostile/malformed input and resource-limit tests; +- package-consumer verification for ESM, CommonJS, and declarations; +- production statement and branch coverage at repository policy thresholds; +- public API docstring coverage at repository policy thresholds; +- required CI, SAST, security, and independent review gates. + +Until that evidence is merged to protected `main`, this ADR remains Proposed. + +## Rollback and supersession + +The feature can be rolled back by removing the Hangul package subpath while retaining this ADR as historical evidence. A future design that makes native OWPML the canonical editable authority or grants the package filesystem/network authority requires a superseding ADR. + +## Standards and source traceability + +- Korean Agency for Technology and Standards. (2024). *KS X 6101: Open Word-Processor Markup Language (OWPML) document structure*. e-Nara Standard Certification. https://www.standard.go.kr/KSCI/standardIntro/getStandardSearchView.do?ksNo=KSX6101 +- Hancom Inc. (n.d.). *HWP/OWPML formats*. https://license.hancom.com/support/downloadCenter/hwpOwpml +- Hancom Inc. (n.d.). *HWPX format structure*. Hancom Tech. https://tech.hancom.com/hwpxformat/ From 026bfa870d4d9580303f62f95c7b7bcda647cbd1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 00:16:59 +0900 Subject: [PATCH 06/78] test(hangul): cover HWP and conversion failure boundaries --- src/hangul/index.failures.test.ts | 176 ++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 src/hangul/index.failures.test.ts diff --git a/src/hangul/index.failures.test.ts b/src/hangul/index.failures.test.ts new file mode 100644 index 00000000..b5e143f6 --- /dev/null +++ b/src/hangul/index.failures.test.ts @@ -0,0 +1,176 @@ +import type { JSONContent } from '@tiptap/core'; +import { + HangulDocumentError, + exportHangulDocument, + openHangulDocument, + type HangulDocumentEngine, + type HangulEngineDocument, +} from './index.js'; + +class BoundaryDocument implements HangulEngineDocument { + freed = false; + sourceFormat = 'hwp'; + output = new Uint8Array([1, 2]); + failPaste = false; + + getSourceFormat(): string { return this.sourceFormat; } + getSectionCount(): number { return 1; } + getParagraphCount(): number { return 1; } + getParagraphLength(): number { return 1; } + exportSelectionHtml(): string { return '

I S

'; } + deleteText(): string { return '{"ok":true}'; } + pasteHtml(): string { + if (this.failPaste) throw new Error('write failed'); + return '{"ok":true}'; + } + exportHwp(): Uint8Array { return this.output; } + exportHwpx(): Uint8Array { return this.output; } + free(): void { this.freed = true; } +} + +function engineFor( + source: BoundaryDocument, + target = new BoundaryDocument(), +): HangulDocumentEngine { + return { + id: 'boundary-engine', + open: vi.fn(async () => source), + create: vi.fn(async () => target), + }; +} + +describe('Hangul bridge failure boundaries', () => { + it('opens legacy HWP and preserves italic and strike marks', async () => { + const result = await openHangulDocument(new Uint8Array([1]), { + engine: engineFor(new BoundaryDocument()), + }); + expect(result.sourceFormat).toBe('hwp'); + expect(result.documentJson.content?.[0]?.content).toEqual([ + { type: 'text', text: 'I', marks: [{ type: 'italic' }] }, + { type: 'text', text: ' ' }, + { type: 'text', text: 'S', marks: [{ type: 'strike' }] }, + ]); + }); + + it('rejects source bytes above the configured bound before engine open', async () => { + const source = new BoundaryDocument(); + const engine = engineFor(source); + await expect( + openHangulDocument(new Uint8Array([1, 2]), { + engine, + maxSourceBytes: 1, + }), + ).rejects.toMatchObject({ code: 'SOURCE_LIMIT_EXCEEDED' }); + expect(engine.open).not.toHaveBeenCalled(); + }); + + it('rejects unknown source identities and frees opened resources', async () => { + const source = new BoundaryDocument(); + source.sourceFormat = 'unknown'; + await expect( + openHangulDocument(new Uint8Array([1]), { + engine: engineFor(source), + }), + ).rejects.toMatchObject({ code: 'UNSUPPORTED_SOURCE_FORMAT' }); + expect(source.freed).toBe(true); + }); + + it('normalizes engine open and create failures', async () => { + const engine: HangulDocumentEngine = { + id: 'failing-engine', + open: async () => { throw new Error('open'); }, + create: async () => { throw new Error('create'); }, + }; + await expect( + openHangulDocument(new Uint8Array([1]), { engine }), + ).rejects.toMatchObject({ code: 'ENGINE_OPEN_FAILED' }); + await expect( + exportHangulDocument({ type: 'doc' }, { engine }), + ).rejects.toMatchObject({ code: 'ENGINE_CREATE_FAILED' }); + }); + + it('exports legacy HWP and escapes markup-significant text', async () => { + const target = new BoundaryDocument(); + const documentJson: JSONContent = { + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 2 }, + content: [{ type: 'text', text: '', marks: [{ type: 'bold' }] }], + }, + ], + }; + const result = await exportHangulDocument(documentJson, { + engine: engineFor(new BoundaryDocument(), target), + format: 'hwp', + }); + expect(result.format).toBe('hwp'); + expect(Array.from(result.bytes)).toEqual([1, 2]); + }); + + it('rejects unsupported nodes, marks, and invalid headings', async () => { + const engine = engineFor(new BoundaryDocument()); + await expect( + exportHangulDocument({ type: 'paragraph' }, { engine }), + ).rejects.toMatchObject({ code: 'UNSUPPORTED_DOCUMENT_NODE' }); + await expect( + exportHangulDocument( + { type: 'doc', content: [{ type: 'video' }] }, + { engine }, + ), + ).rejects.toMatchObject({ code: 'UNSUPPORTED_DOCUMENT_NODE' }); + await expect( + exportHangulDocument( + { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'x', marks: [{ type: 'highlight' }] }, + ], + }, + ], + }, + { engine }, + ), + ).rejects.toMatchObject({ code: 'UNSUPPORTED_DOCUMENT_MARK' }); + await expect( + exportHangulDocument( + { type: 'doc', content: [{ type: 'heading', attrs: { level: 7 } }] }, + { engine }, + ), + ).rejects.toMatchObject({ code: 'UNSUPPORTED_DOCUMENT_NODE' }); + }); + + it('normalizes engine write failures and output-bound violations', async () => { + const writeTarget = new BoundaryDocument(); + writeTarget.failPaste = true; + await expect( + exportHangulDocument( + { type: 'doc', content: [{ type: 'paragraph' }] }, + { engine: engineFor(new BoundaryDocument(), writeTarget) }, + ), + ).rejects.toMatchObject({ code: 'ENGINE_OPERATION_FAILED' }); + expect(writeTarget.freed).toBe(true); + + const largeTarget = new BoundaryDocument(); + await expect( + exportHangulDocument( + { type: 'doc', content: [{ type: 'paragraph' }] }, + { + engine: engineFor(new BoundaryDocument(), largeTarget), + maxOutputBytes: 1, + }, + ), + ).rejects.toMatchObject({ code: 'OUTPUT_LIMIT_EXCEEDED' }); + }); + + it('exposes a stable error identity', () => { + const error = new HangulDocumentError('TEST', 'message'); + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('HangulDocumentError'); + expect(error.code).toBe('TEST'); + }); +}); From 4288002ad26e0ecdadeb8faebf3f25cbf975b909 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 00:17:33 +0900 Subject: [PATCH 07/78] docs(hangul): document import edit and export contract --- docs/HANGUL.md | 128 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 docs/HANGUL.md diff --git a/docs/HANGUL.md b/docs/HANGUL.md new file mode 100644 index 00000000..b4a35647 --- /dev/null +++ b/docs/HANGUL.md @@ -0,0 +1,128 @@ +# HWP and HWPX authoring + +Inkspan's Hangul bridge opens HWP/HWPX bytes through a host-provided parser/serializer and projects supported content into the same TipTap/ProseMirror JSON edited by `CwlEditor`. HWPX is the recommended save target because it is the open XML/OWPML path standardized by KS X 6101; legacy HWP remains an explicit compatibility target when the selected engine supports it. + +## Authority boundary + +Inkspan owns: + +- the editable TipTap JSON projection; +- deterministic conversion rules; +- stable error semantics; +- byte/resource limits; +- explicit loss reporting. + +The host owns: + +- file pickers and drag/drop; +- filesystem and object-storage access; +- WASM or native-engine initialization; +- passwords and protected-document UX; +- publication/download behavior; +- telemetry and persistence. + +The Hangul package never fetches external resources and never executes active content from a document. + +## Import flow + +```mermaid +sequenceDiagram + participant Host + participant Bridge as Inkspan Hangul bridge + participant Engine as Host-provided HWP engine + participant Editor as CwlEditor + + Host->>Bridge: openHangulDocument(bytes, { engine }) + Bridge->>Engine: open(bytes) + Engine-->>Bridge: bounded document API + Bridge->>Engine: source format / sections / HTML projection + Bridge-->>Host: { documentJson, sourceFormat, warnings, lossy } + Host->>Editor: setDocumentJson(documentJson) +``` + +The original bytes remain host-owned. Importing a file does not mutate it. + +## Export flow + +```mermaid +sequenceDiagram + participant Host + participant Editor as CwlEditor + participant Bridge as Inkspan Hangul bridge + participant Engine as Host-provided HWP engine + + Host->>Editor: getDocumentJson() + Editor-->>Host: edited JSON + Host->>Bridge: exportHangulDocument(JSON, format) + Bridge->>Engine: create blank document + Bridge->>Engine: paste bounded deterministic HTML + Bridge->>Engine: exportHwpx() or exportHwp() + Engine-->>Bridge: bytes + Bridge-->>Host: { bytes, format, warnings } +``` + +## Minimal integration + +```ts +import { + exportHangulDocument, + openHangulDocument, + type HangulDocumentEngine, +} from '@contextualwisdomlab/cwl-editor/hangul'; + +async function openIntoEditor( + source: Uint8Array, + engine: HangulDocumentEngine, + editor: { setDocumentJson(value: unknown): void }, +) { + const imported = await openHangulDocument(source, { engine }); + editor.setDocumentJson(imported.documentJson); + return imported; +} + +async function saveAsHwpx( + documentJson: Parameters[0], + engine: HangulDocumentEngine, +) { + return exportHangulDocument(documentJson, { + engine, + format: 'hwpx', + }); +} +``` + +## Compatibility contract + +The initial bridge deliberately supports a bounded semantic subset and rejects unsupported export nodes instead of silently deleting them. The compatibility matrix expands only when real HWP/HWPX fixtures demonstrate stable round-trip behavior. + +| Content | Import | Export | Notes | +|---|---|---|---| +| Paragraph text | Yes | Yes | Unicode preserved by JavaScript strings and the selected engine | +| Headings 1-6 | Yes | Yes | Semantic heading level | +| Bold | Yes | Yes | Common HTML projection | +| Italic | Yes | Yes | Common HTML projection | +| Strike | Yes | Yes | Common HTML projection | +| Lists | Planned | Planned | Must preserve nesting and numbering | +| Tables | Planned | Planned | Must preserve cell topology before layout styling | +| Links | Planned | Planned | Must use Inkspan safe-link policy | +| Images | Planned | Planned | Must remain inline/host-approved; no external fetch | +| Shapes/charts/equations | Warning | Rejected | Requires dedicated projection contract | +| Macros/OLE/active content | Not executed | Not generated | Outside the editor authority boundary | + +## Security requirements + +Treat both formats as untrusted document containers. Production implementations must enforce bounded source and output bytes. A native HWPX implementation must additionally bound ZIP entry count, expanded bytes, expansion ratio, XML depth, XML node count, text size, relationships, and embedded payloads. DTD and external entity resolution must be disabled. External relationships are metadata only unless the host separately authorizes a resource. + +Passwords, cookies, credentials, filesystem paths, and secret values must never enter warnings, error strings, result objects, or deterministic snapshots. + +## Standards and format sources + +HWPX follows OWPML document structure standardized as KS X 6101. The Korean standards catalogue records the standard as confirmed on 2024-10-30. Hancom publishes HWP 5.x and OWPML format material and recommends HWPX as the open machine-readable Hangul document format. + +### References (APA 7th) + +Korean Agency for Technology and Standards. (2024). *KS X 6101: Open Word-Processor Markup Language (OWPML) document structure*. e-Nara Standard Certification. https://www.standard.go.kr/KSCI/standardIntro/getStandardSearchView.do?ksNo=KSX6101 + +Hancom Inc. (n.d.). *HWP/OWPML formats*. https://license.hancom.com/support/downloadCenter/hwpOwpml + +Hancom Inc. (n.d.). *HWPX format structure*. Hancom Tech. https://tech.hancom.com/hwpxformat/ From 1ab306da7d2f77b9894197eab18e4fae494d5654 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 00:17:47 +0900 Subject: [PATCH 08/78] build(hangul): add isolated package bundle --- vite.hangul.config.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 vite.hangul.config.ts diff --git a/vite.hangul.config.ts b/vite.hangul.config.ts new file mode 100644 index 00000000..7888725b --- /dev/null +++ b/vite.hangul.config.ts @@ -0,0 +1,26 @@ +import { resolve } from 'node:path'; +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; + +/** Build the framework-neutral HWP/HWPX bridge as an isolated package subpath. */ +export default defineConfig({ + plugins: [ + dts({ + include: ['src/hangul'], + exclude: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.spec.ts'], + rollupTypes: false, + entryRoot: 'src', + }), + ], + build: { + emptyOutDir: false, + lib: { + entry: resolve(__dirname, 'src/hangul/index.ts'), + name: 'InkspanHangul', + fileName: (format) => + format === 'es' ? 'cwl-hangul.js' : 'cwl-hangul.cjs', + formats: ['es', 'cjs'], + }, + sourcemap: true, + }, +}); From a75f53ec8b0e9ab15debbb452cc3516c774c409e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 00:18:30 +0900 Subject: [PATCH 09/78] build(hangul): publish framework-neutral package subpath --- package.json | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 4e55d924..febd81bd 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,10 @@ "noto-sans", "offline", "i18n", - "cjk" + "cjk", + "hwp", + "hwpx", + "owpml" ], "repository": { "type": "git", @@ -85,6 +88,11 @@ "import": "./dist/cwl-markdown.js", "require": "./dist/cwl-markdown.cjs" }, + "./hangul": { + "types": "./dist/hangul/index.d.ts", + "import": "./dist/cwl-hangul.js", + "require": "./dist/cwl-hangul.cjs" + }, "./styles.css": "./dist/cwl-editor.css", "./fonts.css": "./src/fonts/fonts.css", "./fonts-latin.css": "./src/fonts/fonts-latin.css", @@ -99,7 +107,7 @@ ], "scripts": { "dev": "vite", - "build": "tsc --noEmit && vite build && vite build --config vite.collaboration.config.ts && vite build --config vite.converter.config.ts && vite build --config vite.envelope-identity.config.ts && vite build --config vite.revision-evidence.config.ts && vite build --config vite.autosave.config.ts && vite build --config vite.text-position-selector.config.ts && vite build --config vite.markdown.config.ts && node ./scripts/copy-styles.mjs", + "build": "tsc --noEmit && vite build && vite build --config vite.collaboration.config.ts && vite build --config vite.converter.config.ts && vite build --config vite.envelope-identity.config.ts && vite build --config vite.revision-evidence.config.ts && vite build --config vite.autosave.config.ts && vite build --config vite.text-position-selector.config.ts && vite build --config vite.markdown.config.ts && vite build --config vite.hangul.config.ts && node ./scripts/copy-styles.mjs", "build:demo": "vite build --config vite.demo.config.ts", "fonts": "node ./scripts/fetch-fonts.mjs", "preview": "vite preview", @@ -108,7 +116,7 @@ "test:watch": "vitest", "coverage": "vitest run --coverage", "test:package-config": "node --test ./scripts/revision-evidence-consumer-config.test.mjs ./scripts/release-metadata.test.mjs ./scripts/javascript-runtime-authority.test.mjs", - "verify:package": "pnpm run test:package-config && node ./tests/package/verify-package.mjs && node ./tests/package/verify-editor-placeholder-package.mjs && node ./scripts/verify-canonical-envelope-package.mjs && node ./scripts/verify-revision-evidence-package.mjs && node ./scripts/verify-framework-free-revision-evidence-package.mjs && node ./scripts/verify-framework-free-envelope-identity-package.mjs && node ./tests/package/verify-framework-free-autosave-package.mjs && node ./scripts/verify-text-position-selector-package.mjs && node ./scripts/verify-text-position-selector-subpath-package.mjs && node ./scripts/verify-markdown-subpath-package.mjs" + "verify:package": "pnpm run test:package-config && node ./tests/package/verify-package.mjs && node ./tests/package/verify-editor-placeholder-package.mjs && node ./scripts/verify-canonical-envelope-package.mjs && node ./scripts/verify-revision-evidence-package.mjs && node ./scripts/verify-framework-free-revision-evidence-package.mjs && node ./scripts/verify-framework-free-envelope-identity-package.mjs && node ./tests/package/verify-framework-free-autosave-package.mjs && node ./scripts/verify-text-position-selector-package.mjs && node ./scripts/verify-text-position-selector-subpath-package.mjs && node ./scripts/verify-markdown-subpath-package.mjs && node ./scripts/verify-hangul-subpath-package.mjs" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", From acb9b3310e0c92149871243f1143eff5bb267374 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 00:19:04 +0900 Subject: [PATCH 10/78] test(hangul): verify packed package consumers --- scripts/verify-hangul-subpath-package.mjs | 135 ++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 scripts/verify-hangul-subpath-package.mjs diff --git a/scripts/verify-hangul-subpath-package.mjs b/scripts/verify-hangul-subpath-package.mjs new file mode 100644 index 00000000..1c2f4221 --- /dev/null +++ b/scripts/verify-hangul-subpath-package.mjs @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const packageJson = JSON.parse( + readFileSync(join(repositoryRoot, 'package.json'), 'utf8'), +); +const verificationRoot = mkdtempSync(join(tmpdir(), 'inkspan-hangul-')); +const extractionDirectory = join(verificationRoot, 'extracted'); +const consumerDirectory = join(verificationRoot, 'consumer'); +const packageDirectory = join( + consumerDirectory, + 'node_modules', + ...packageJson.name.split('/'), +); + +function run(command, argumentsList, cwd = repositoryRoot) { + return execFileSync(command, argumentsList, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + }); +} + +function preparePackage() { + mkdirSync(extractionDirectory, { recursive: true }); + mkdirSync(dirname(packageDirectory), { recursive: true }); + const packResult = JSON.parse( + run('npm', [ + 'pack', + '--json', + '--ignore-scripts', + '--pack-destination', + verificationRoot, + ]), + )[0]; + const tarballPath = join(verificationRoot, packResult.filename); + assert.ok(existsSync(tarballPath)); + run('tar', ['-xzf', tarballPath, '-C', extractionDirectory]); + renameSync(join(extractionDirectory, 'package'), packageDirectory); + writeFileSync( + join(consumerDirectory, 'package.json'), + '{"name":"inkspan-hangul-consumer","private":true,"type":"module"}\n', + 'utf8', + ); +} + +function verifyBundleAuthority() { + for (const filename of ['cwl-hangul.js', 'cwl-hangul.cjs']) { + const source = readFileSync(join(packageDirectory, 'dist', filename), 'utf8'); + assert.doesNotMatch(source, /\bfetch\s*\(|XMLHttpRequest|WebSocket|EventSource/u); + assert.doesNotMatch(source, /react-dom|@tiptap\/react|y-prosemirror|\byjs\b/u); + assert.doesNotMatch(source, /process\.env|import\.meta\.env|NVIDIA_NIM_API_KEY|COPILOT_GITHUB_TOKEN/u); + } +} + +function verifyRuntimeConsumers() { + const esmPath = join(consumerDirectory, 'consumer.mjs'); + writeFileSync( + esmPath, + `import assert from 'node:assert/strict';\nconst api = await import('${packageJson.name}/hangul');\nassert.equal(typeof api.openHangulDocument, 'function');\nassert.equal(typeof api.exportHangulDocument, 'function');\nassert.equal(typeof api.HangulDocumentError, 'function');\n`, + 'utf8', + ); + run(process.execPath, [esmPath], consumerDirectory); + + const cjsPath = join(consumerDirectory, 'consumer.cjs'); + writeFileSync( + cjsPath, + `const assert = require('node:assert/strict');\nconst api = require('${packageJson.name}/hangul');\nassert.equal(typeof api.openHangulDocument, 'function');\nassert.equal(typeof api.exportHangulDocument, 'function');\n`, + 'utf8', + ); + run(process.execPath, [cjsPath], consumerDirectory); +} + +function verifyDeclarationConsumer() { + const sourcePath = join(consumerDirectory, 'consumer.ts'); + const configPath = join(consumerDirectory, 'tsconfig.json'); + writeFileSync( + sourcePath, + `import {\n HangulDocumentError,\n exportHangulDocument,\n openHangulDocument,\n type HangulDocumentEngine,\n type HangulEngineDocument,\n} from '${packageJson.name}/hangul';\nconst document = null as unknown as HangulEngineDocument;\nconst engine = null as unknown as HangulDocumentEngine;\nvoid [HangulDocumentError, openHangulDocument, exportHangulDocument, document, engine];\n`, + 'utf8', + ); + writeFileSync( + configPath, + `${JSON.stringify( + { + compilerOptions: { + noEmit: true, + strict: true, + skipLibCheck: false, + module: 'NodeNext', + moduleResolution: 'NodeNext', + target: 'ES2022', + lib: ['ES2022', 'DOM', 'DOM.Iterable'], + types: [], + }, + files: ['./consumer.ts'], + }, + null, + 2, + )}\n`, + 'utf8', + ); + const compilerPath = join( + repositoryRoot, + 'node_modules', + 'typescript', + 'bin', + 'tsc', + ); + assert.ok(existsSync(compilerPath)); + run(process.execPath, [compilerPath, '--project', configPath], consumerDirectory); +} + +try { + preparePackage(); + verifyBundleAuthority(); + verifyRuntimeConsumers(); + verifyDeclarationConsumer(); + console.log(`Verified packed ${packageJson.name}/hangul ESM, CommonJS, and declarations.`); +} finally { + rmSync(verificationRoot, { recursive: true, force: true }); +} From 56ed351b8464640987fcaaa84fd7269b85510350 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 00:19:54 +0900 Subject: [PATCH 11/78] test(hangul): define optional RHWP adapter contract --- src/hangul/rhwpAdapter.test.ts | 41 ++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/hangul/rhwpAdapter.test.ts diff --git a/src/hangul/rhwpAdapter.test.ts b/src/hangul/rhwpAdapter.test.ts new file mode 100644 index 00000000..996dafe3 --- /dev/null +++ b/src/hangul/rhwpAdapter.test.ts @@ -0,0 +1,41 @@ +import { + createRhwpHangulEngine, + type HangulEngineDocument, +} from './index.js'; + +class FakeRhwpDocument implements HangulEngineDocument { + static created = new FakeRhwpDocument(new Uint8Array()); + readonly source: Uint8Array; + + constructor(source: Uint8Array) { + this.source = source; + } + + static createEmpty(): FakeRhwpDocument { + return FakeRhwpDocument.created; + } + + getSourceFormat(): string { return 'hwpx'; } + getSectionCount(): number { return 0; } + getParagraphCount(): number { return 0; } + getParagraphLength(): number { return 0; } + exportSelectionHtml(): string { return ''; + } + deleteText(): string { return '{"ok":true}'; } + pasteHtml(): string { return '{"ok":true}'; } + exportHwp(): Uint8Array { return new Uint8Array(); } + exportHwpx(): Uint8Array { return new Uint8Array(); } +} + +describe('RHWP engine adapter', () => { + it('adapts @rhwp/core without making it a hard runtime dependency', async () => { + const engine = createRhwpHangulEngine({ HwpDocument: FakeRhwpDocument }); + const bytes = new Uint8Array([1, 2, 3]); + + expect(engine.id).toBe('@rhwp/core'); + const opened = await engine.open(bytes); + expect(opened).toBeInstanceOf(FakeRhwpDocument); + expect((opened as FakeRhwpDocument).source).toBe(bytes); + expect(await engine.create()).toBe(FakeRhwpDocument.created); + }); +}); From d5613c73d752133401ff3b2f2164048a5e843158 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 00:21:32 +0900 Subject: [PATCH 12/78] feat(hangul): add parser module adapter --- src/hangul/engineAdapter.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/hangul/engineAdapter.ts diff --git a/src/hangul/engineAdapter.ts b/src/hangul/engineAdapter.ts new file mode 100644 index 00000000..7326fd86 --- /dev/null +++ b/src/hangul/engineAdapter.ts @@ -0,0 +1,19 @@ +import type { HangulDocumentEngine, HangulEngineDocument } from './index.js'; + +export interface HangulDocumentConstructor { + new (source: Uint8Array): HangulEngineDocument; + createEmpty(): HangulEngineDocument; +} + +export interface HangulModuleLike { + HwpDocument: HangulDocumentConstructor; +} + +/** Adapt an initialized parser module to the Inkspan engine boundary. */ +export function createHangulModuleEngine(module: HangulModuleLike): HangulDocumentEngine { + return { + id: 'hangul-module', + open: (source) => new module.HwpDocument(source), + create: () => module.HwpDocument.createEmpty(), + }; +} From cb5b21bf81ecfb0997207916c8405fee60be256b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 00:21:58 +0900 Subject: [PATCH 13/78] test(hangul): exercise generic parser module adapter --- src/hangul/rhwpAdapter.test.ts | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/src/hangul/rhwpAdapter.test.ts b/src/hangul/rhwpAdapter.test.ts index 996dafe3..c8a3689d 100644 --- a/src/hangul/rhwpAdapter.test.ts +++ b/src/hangul/rhwpAdapter.test.ts @@ -1,41 +1,38 @@ -import { - createRhwpHangulEngine, - type HangulEngineDocument, -} from './index.js'; +import type { HangulEngineDocument } from './index.js'; +import { createHangulModuleEngine } from './engineAdapter.js'; -class FakeRhwpDocument implements HangulEngineDocument { - static created = new FakeRhwpDocument(new Uint8Array()); +class FakeHangulDocument implements HangulEngineDocument { + static created = new FakeHangulDocument(new Uint8Array()); readonly source: Uint8Array; constructor(source: Uint8Array) { this.source = source; } - static createEmpty(): FakeRhwpDocument { - return FakeRhwpDocument.created; + static createEmpty(): FakeHangulDocument { + return FakeHangulDocument.created; } getSourceFormat(): string { return 'hwpx'; } getSectionCount(): number { return 0; } getParagraphCount(): number { return 0; } getParagraphLength(): number { return 0; } - exportSelectionHtml(): string { return ''; - } + exportSelectionHtml(): string { return ''; } deleteText(): string { return '{"ok":true}'; } pasteHtml(): string { return '{"ok":true}'; } exportHwp(): Uint8Array { return new Uint8Array(); } exportHwpx(): Uint8Array { return new Uint8Array(); } } -describe('RHWP engine adapter', () => { - it('adapts @rhwp/core without making it a hard runtime dependency', async () => { - const engine = createRhwpHangulEngine({ HwpDocument: FakeRhwpDocument }); +describe('Hangul parser module adapter', () => { + it('adapts a host-initialized module without a hard runtime dependency', async () => { + const engine = createHangulModuleEngine({ HwpDocument: FakeHangulDocument }); const bytes = new Uint8Array([1, 2, 3]); - expect(engine.id).toBe('@rhwp/core'); + expect(engine.id).toBe('hangul-module'); const opened = await engine.open(bytes); - expect(opened).toBeInstanceOf(FakeRhwpDocument); - expect((opened as FakeRhwpDocument).source).toBe(bytes); - expect(await engine.create()).toBe(FakeRhwpDocument.created); + expect(opened).toBeInstanceOf(FakeHangulDocument); + expect((opened as FakeHangulDocument).source).toBe(bytes); + expect(await engine.create()).toBe(FakeHangulDocument.created); }); }); From c8979f99dd27e372d1cf3a64bddf545b370dca4f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 00:22:37 +0900 Subject: [PATCH 14/78] build(hangul): add package entrypoint file --- src/hangul/package.ts | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/hangul/package.ts diff --git a/src/hangul/package.ts b/src/hangul/package.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/hangul/package.ts @@ -0,0 +1 @@ +export {}; From 5b0e402ab188e8f3f8135f589912430ce12cde66 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 00:22:55 +0900 Subject: [PATCH 15/78] feat(hangul): expose bridge and module adapter --- src/hangul/package.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/hangul/package.ts b/src/hangul/package.ts index cb0ff5c3..7cf7e962 100644 --- a/src/hangul/package.ts +++ b/src/hangul/package.ts @@ -1 +1,18 @@ -export {}; +export { + HangulDocumentError, + exportHangulDocument, + openHangulDocument, +} from './index.js'; +export { createHangulModuleEngine } from './engineAdapter.js'; +export type { + ExportHangulDocumentOptions, + HangulDocumentEngine, + HangulDocumentExportResult, + HangulDocumentImportResult, + HangulEngineDocument, + OpenHangulDocumentOptions, +} from './index.js'; +export type { + HangulDocumentConstructor, + HangulModuleLike, +} from './engineAdapter.js'; From 7b5064164967bc96c3fdcf090b33eeba1df7c423 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 03:08:50 +0900 Subject: [PATCH 16/78] fix(hangul): preserve ES2018 HTML escaping --- src/hangul/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hangul/index.ts b/src/hangul/index.ts index 7e45c38e..529d6836 100644 --- a/src/hangul/index.ts +++ b/src/hangul/index.ts @@ -92,7 +92,7 @@ function htmlToJson(html: string): JSONContent { } function escapeHtml(value: string): string { - return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); + return value.replace(/&/gu, '&').replace(//gu, '>'); } function renderInline(node: JSONContent): string { From 1fabafb3dac2aa29ecdd83d02e0b0bc3dc90b5e9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 03:16:56 +0900 Subject: [PATCH 17/78] docs(adr): align Hangul rollback heading --- docs/adr/0027-hangul-document-authoring-boundary.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/0027-hangul-document-authoring-boundary.md b/docs/adr/0027-hangul-document-authoring-boundary.md index 5fb1a4ff..42db8bbc 100644 --- a/docs/adr/0027-hangul-document-authoring-boundary.md +++ b/docs/adr/0027-hangul-document-authoring-boundary.md @@ -61,7 +61,7 @@ Acceptance requires all of the following on one exact PR head: Until that evidence is merged to protected `main`, this ADR remains Proposed. -## Rollback and supersession +## Rollback or supersession The feature can be rolled back by removing the Hangul package subpath while retaining this ADR as historical evidence. A future design that makes native OWPML the canonical editable authority or grants the package filesystem/network authority requires a superseding ADR. From 1f3ada66b99d5567a2c910558bfc3e3ed936fa32 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 03:18:11 +0900 Subject: [PATCH 18/78] docs(package): discover Hangul subpath --- docs/package-distribution.md | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/docs/package-distribution.md b/docs/package-distribution.md index ddb4df0e..4cc916ca 100644 --- a/docs/package-distribution.md +++ b/docs/package-distribution.md @@ -1,10 +1,10 @@ # Package distribution and consumer contract Inkspan publishes the React editor, provider-neutral collaboration adapter, -framework-independent autosave/evidence/converter utilities, CSS, and offline -font assets from one npm package. This document defines the supported package -boundary for standalone applications, CWL organization services, and naruon -integrations. +framework-independent autosave/evidence/converter/Hangul utilities, CSS, and +offline font assets from one npm package. This document defines the supported +package boundary for standalone applications, CWL organization services, and +naruon integrations. ## Public entrypoints @@ -14,6 +14,7 @@ integrations. | `@contextualwisdomlab/cwl-editor/autosave` | Framework-independent autosave queue/session APIs for bounded local save ordering and host-owned durable concurrency | | `@contextualwisdomlab/cwl-editor/collaboration` | Optional Yjs collaboration surface with host-owned transport and lifecycle | | `@contextualwisdomlab/cwl-editor/converter` | Framework-independent base64 and data-URI utilities | +| `@contextualwisdomlab/cwl-editor/hangul` | `implemented_on_active_pr` — framework-independent HWP/HWPX byte-to-document bridge with a host-injected parser/serializer engine; filesystem, network, persistence, credentials, and publication remain host-owned | | `@contextualwisdomlab/cwl-editor/envelope-identity` | Framework-independent identity-only envelope routing for bounded schema identity inspection; migration remains host-owned | | `@contextualwisdomlab/cwl-editor/revision-evidence` | Framework-independent revision evidence and document-transition evidence for local content equality/lineage claims | | `@contextualwisdomlab/cwl-editor/text-position-selector` | `implemented_on_protected_main` — React-free text-position projection core implementing W3C `TextPositionSelector`; interactive capture, revision binding, authorization, persistence, and re-anchoring remain outside this subpath | @@ -57,12 +58,17 @@ embedded in the npm tarball. and collaboration entrypoints. It is declared in Inkspan's package dependencies so the consumer's package manager installs and resolves it; it is not merely a type-only dependency. -- The framework-independent autosave, converter, envelope-identity, +- The framework-independent autosave, converter, Hangul, envelope-identity, revision-evidence, text-position-selector, and Markdown entrypoints do not require React UI, a mounted editor, naruon, contextual-orchestrator, a database, provider credentials, or host transport. Their individual package-consumer gates additionally prevent framework dependencies from leaking into subpaths whose public contracts exclude them. +- The Hangul subpath accepts and returns bytes through a host-injected document + engine. Inkspan owns the deterministic supported JSON projection, local + source/output resource ceilings, and stable failure contract; the host owns + file selection, filesystem and network access, engine/WASM initialization, + password UX, durable persistence, credentials, and artifact publication. - The Markdown subpath exposes `markdownToHtml`, `htmlToMarkdown`, `normalizeMarkdown`, `markdownToEmailHtml`, `markdownToPlainText`, and `htmlToPlainText` plus their option types. It bundles deterministic conversion @@ -108,10 +114,10 @@ production library build. The verification chain: 3. confirms required licenses, declarations, styles, and font assets ship; 4. rejects internal source, tests, demos, Office files, coverage output, and workflow files from the npm tarball; -5. imports the root, collaboration, converter, autosave, envelope-identity, - revision-evidence, text-position-selector, and Markdown surfaces through their - dedicated packed-consumer checks, including framework-free isolation where - that is part of the public contract; +5. imports the root, collaboration, converter, Hangul, autosave, + envelope-identity, revision-evidence, text-position-selector, and Markdown + surfaces through their dedicated packed-consumer checks, including + framework-free isolation where that is part of the public contract; 6. exercises supported ESM/CommonJS entrypoints and compiles strict TypeScript consumers against the published declaration surfaces; 7. resolves public CSS and font subpaths; and From 4ed9a87793c50cfcfa07b2f021355d80a54871cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 03:21:10 +0900 Subject: [PATCH 19/78] docs(readme): list Hangul package surface --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index f2b02332..da98a1ab 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ runtime. | React editor | `@contextualwisdomlab/cwl-editor` | Markdown/HTML WYSIWYG component and serializers | | Collaboration | `@contextualwisdomlab/cwl-editor/collaboration` | Provider-neutral Yjs collaborative editing | | Converter | `@contextualwisdomlab/cwl-editor/converter` | Framework-independent base64/data-URI utilities | +| Hangul documents | `@contextualwisdomlab/cwl-editor/hangul` | Framework-independent HWP/HWPX import/export bridge with a host-injected engine | | Envelope identity | `@contextualwisdomlab/cwl-editor/envelope-identity` | Framework-independent bounded schema identity for host-owned migration routing | | Revision evidence | `@contextualwisdomlab/cwl-editor/revision-evidence` | Framework-independent canonical envelope, strong revision, and transition evidence | | Text-position selector | `@contextualwisdomlab/cwl-editor/text-position-selector` | React-free deterministic W3C `TextPositionSelector` projection core | From f4374d6f4d5a21edf662f2ca88d1c08ece5093c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 04:10:45 +0900 Subject: [PATCH 20/78] test(hangul): close public bridge coverage gaps --- src/hangul/index.failures.test.ts | 174 +++++++++++++++++++++++++++++- 1 file changed, 171 insertions(+), 3 deletions(-) diff --git a/src/hangul/index.failures.test.ts b/src/hangul/index.failures.test.ts index b5e143f6..52670810 100644 --- a/src/hangul/index.failures.test.ts +++ b/src/hangul/index.failures.test.ts @@ -1,4 +1,5 @@ import type { JSONContent } from '@tiptap/core'; +import * as hangulPackage from './package.js'; import { HangulDocumentError, exportHangulDocument, @@ -52,6 +53,88 @@ describe('Hangul bridge failure boundaries', () => { ]); }); + it('preserves equivalent inline tags and transparent wrappers while ignoring comments', async () => { + const source = new BoundaryDocument(); + source.sourceFormat = 'hwpx'; + vi.spyOn(source, 'exportSelectionHtml').mockReturnValue( + '

BISU

', + ); + + const result = await openHangulDocument(new Uint8Array([1]), { + engine: engineFor(source), + maxSourceBytes: 1, + }); + + expect(result.documentJson.content?.[0]?.content).toEqual([ + { type: 'text', text: 'B', marks: [{ type: 'bold' }] }, + { type: 'text', text: 'I', marks: [{ type: 'italic' }] }, + { type: 'text', text: 'S', marks: [{ type: 'strike' }] }, + { type: 'text', text: 'U' }, + ]); + }); + + it('ignores an empty text node returned by the parser', async () => { + class EmptyTextDomParser { + parseFromString(): { + body: { + children: Array<{ + tagName: string; + childNodes: Array<{ nodeType: number; textContent: null }>; + }>; + }; + } { + return { + body: { + children: [ + { + tagName: 'P', + childNodes: [{ nodeType: 3, textContent: null }], + }, + ], + }, + }; + } + } + + vi.stubGlobal('DOMParser', EmptyTextDomParser); + try { + const result = await openHangulDocument(new Uint8Array([1]), { + engine: engineFor(new BoundaryDocument()), + }); + expect(result.documentJson.content?.[0]).toEqual({ + type: 'paragraph', + content: [], + }); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('accepts an empty section without requiring a resource free hook', async () => { + const source: HangulEngineDocument = { + getSourceFormat: () => 'hwpx', + getSectionCount: () => 1, + getParagraphCount: () => 0, + getParagraphLength: () => 0, + exportSelectionHtml: () => '', + deleteText: () => '{"ok":true}', + pasteHtml: () => '{"ok":true}', + exportHwp: () => new Uint8Array(), + exportHwpx: () => new Uint8Array(), + }; + const engine: HangulDocumentEngine = { + id: 'empty-section', + open: async () => source, + create: async () => source, + }; + + const result = await openHangulDocument(new Uint8Array([1]), { + engine, + maxSourceBytes: 1, + }); + expect(result.documentJson).toEqual({ type: 'doc', content: [] }); + }); + it('rejects source bytes above the configured bound before engine open', async () => { const source = new BoundaryDocument(); const engine = engineFor(source); @@ -109,7 +192,51 @@ describe('Hangul bridge failure boundaries', () => { expect(Array.from(result.bytes)).toEqual([1, 2]); }); - it('rejects unsupported nodes, marks, and invalid headings', async () => { + it('renders empty text plus italic and strike marks without optional engine hooks', async () => { + const target: HangulEngineDocument = { + getSourceFormat: () => 'hwpx', + getSectionCount: () => 1, + getParagraphCount: () => 1, + getParagraphLength: () => 0, + exportSelectionHtml: () => '', + deleteText: () => '{"ok":true}', + pasteHtml: vi.fn(() => '{"ok":true}'), + exportHwp: () => new Uint8Array([7]), + exportHwpx: () => new Uint8Array([8]), + }; + const engine: HangulDocumentEngine = { + id: 'minimal-target', + open: async () => target, + create: async () => target, + }; + const result = await exportHangulDocument( + { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', marks: [{ type: 'italic' }] }, + { type: 'text', text: 'S', marks: [{ type: 'strike' }] }, + ], + }, + ], + }, + { engine }, + ); + + expect(result.format).toBe('hwpx'); + expect(Array.from(result.bytes)).toEqual([8]); + expect(target.deleteText).toBeDefined(); + expect(target.pasteHtml).toHaveBeenCalledWith( + 0, + 0, + 0, + '

S

', + ); + }); + + it('rejects unsupported nodes, marks, and every invalid heading shape', async () => { const engine = engineFor(new BoundaryDocument()); await expect( exportHangulDocument({ type: 'paragraph' }, { engine }), @@ -138,10 +265,46 @@ describe('Hangul bridge failure boundaries', () => { ).rejects.toMatchObject({ code: 'UNSUPPORTED_DOCUMENT_MARK' }); await expect( exportHangulDocument( - { type: 'doc', content: [{ type: 'heading', attrs: { level: 7 } }] }, + { + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'hardBreak' }] }, + ], + }, { engine }, ), ).rejects.toMatchObject({ code: 'UNSUPPORTED_DOCUMENT_NODE' }); + await expect( + exportHangulDocument( + { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'x', marks: [{}] }, + ], + }, + ], + } as unknown as JSONContent, + { engine }, + ), + ).rejects.toMatchObject({ code: 'UNSUPPORTED_DOCUMENT_MARK' }); + await expect( + exportHangulDocument( + { type: 'doc', content: [{}] } as unknown as JSONContent, + { engine }, + ), + ).rejects.toMatchObject({ code: 'UNSUPPORTED_DOCUMENT_NODE' }); + + for (const attrs of [undefined, { level: 0 }, { level: 1.5 }, { level: 7 }]) { + await expect( + exportHangulDocument( + { type: 'doc', content: [{ type: 'heading', ...(attrs ? { attrs } : {}) }] }, + { engine }, + ), + ).rejects.toMatchObject({ code: 'UNSUPPORTED_DOCUMENT_NODE' }); + } }); it('normalizes engine write failures and output-bound violations', async () => { @@ -167,7 +330,12 @@ describe('Hangul bridge failure boundaries', () => { ).rejects.toMatchObject({ code: 'OUTPUT_LIMIT_EXCEEDED' }); }); - it('exposes a stable error identity', () => { + it('exposes the package surface and a stable error identity', () => { + expect(hangulPackage.openHangulDocument).toBe(openHangulDocument); + expect(hangulPackage.exportHangulDocument).toBe(exportHangulDocument); + expect(hangulPackage.HangulDocumentError).toBe(HangulDocumentError); + expect(typeof hangulPackage.createHangulModuleEngine).toBe('function'); + const error = new HangulDocumentError('TEST', 'message'); expect(error).toBeInstanceOf(Error); expect(error.name).toBe('HangulDocumentError'); From 28e5aa723ed5ee0f9f7155a4a4566bb8c0e7159c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 06:09:01 +0900 Subject: [PATCH 21/78] fix(hangul): remove unreachable mark fallback branches --- src/hangul/index.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/hangul/index.ts b/src/hangul/index.ts index 529d6836..b64450d0 100644 --- a/src/hangul/index.ts +++ b/src/hangul/index.ts @@ -61,15 +61,15 @@ function parseInline(parent: ParentNode, marks: JSONContent['marks'] = []): JSON for (const child of Array.from(parent.childNodes)) { if (child.nodeType === Node.TEXT_NODE) { const text = child.textContent ?? ''; - if (text) output.push({ type: 'text', text, ...(marks?.length ? { marks } : {}) }); + if (text) output.push({ type: 'text', text, ...(marks.length ? { marks } : {}) }); } else if (child instanceof Element) { const tag = child.tagName.toLowerCase(); const next = tag === 'strong' || tag === 'b' - ? [...(marks ?? []), { type: 'bold' }] + ? [...marks, { type: 'bold' }] : tag === 'em' || tag === 'i' - ? [...(marks ?? []), { type: 'italic' }] + ? [...marks, { type: 'italic' }] : tag === 's' || tag === 'strike' - ? [...(marks ?? []), { type: 'strike' }] + ? [...marks, { type: 'strike' }] : marks; output.push(...parseInline(child, next)); } From 512df0a77a0b117e249e249ebf0c3be68e4a904f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 08:09:47 +0900 Subject: [PATCH 22/78] fix(hangul): remove TipTap declaration dependency --- src/hangul/index.ts | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/src/hangul/index.ts b/src/hangul/index.ts index b64450d0..e78ac8fc 100644 --- a/src/hangul/index.ts +++ b/src/hangul/index.ts @@ -1,4 +1,17 @@ -import type { JSONContent } from '@tiptap/core'; +/** Framework-neutral structural document JSON used at the Hangul package boundary. */ +interface HangulDocumentMark { + type?: string; + attrs?: Record; +} + +/** Framework-neutral structural document JSON used at the Hangul package boundary. */ +interface HangulDocumentJson { + type?: string; + attrs?: Record; + content?: HangulDocumentJson[]; + marks?: HangulDocumentMark[]; + text?: string; +} /** A Hangul document opened by a host-provided parser/serializer. */ export interface HangulEngineDocument { @@ -37,7 +50,7 @@ export interface ExportHangulDocumentOptions { export interface HangulDocumentImportResult { sourceFormat: 'hwp' | 'hwpx'; - documentJson: Readonly; + documentJson: Readonly; warnings: readonly string[]; lossy: boolean; } @@ -56,8 +69,8 @@ export class HangulDocumentError extends Error { } } -function parseInline(parent: ParentNode, marks: JSONContent['marks'] = []): JSONContent[] { - const output: JSONContent[] = []; +function parseInline(parent: ParentNode, marks: HangulDocumentMark[] = []): HangulDocumentJson[] { + const output: HangulDocumentJson[] = []; for (const child of Array.from(parent.childNodes)) { if (child.nodeType === Node.TEXT_NODE) { const text = child.textContent ?? ''; @@ -77,7 +90,7 @@ function parseInline(parent: ParentNode, marks: JSONContent['marks'] = []): JSON return output; } -function htmlToJson(html: string): JSONContent { +function htmlToJson(html: string): HangulDocumentJson { const parsed = new DOMParser().parseFromString(html, 'text/html'); return { type: 'doc', @@ -95,7 +108,7 @@ function escapeHtml(value: string): string { return value.replace(/&/gu, '&').replace(//gu, '>'); } -function renderInline(node: JSONContent): string { +function renderInline(node: HangulDocumentJson): string { if (node.type !== 'text') throw new HangulDocumentError('UNSUPPORTED_DOCUMENT_NODE', 'Only text inline nodes are currently exportable.'); let value = escapeHtml(node.text ?? ''); for (const mark of node.marks ?? []) { @@ -107,7 +120,7 @@ function renderInline(node: JSONContent): string { return value; } -function jsonToHtml(documentJson: JSONContent): string { +function jsonToHtml(documentJson: HangulDocumentJson): string { if (documentJson.type !== 'doc') throw new HangulDocumentError('UNSUPPORTED_DOCUMENT_NODE', 'Hangul export requires a doc root.'); return (documentJson.content ?? []).map((node) => { const body = (node.content ?? []).map(renderInline).join(''); @@ -141,7 +154,7 @@ export async function openHangulDocument(source: Uint8Array, options: OpenHangul } /** Export edited Inkspan JSON as HWPX by default or HWP explicitly. */ -export async function exportHangulDocument(documentJson: JSONContent, options: ExportHangulDocumentOptions): Promise { +export async function exportHangulDocument(documentJson: HangulDocumentJson, options: ExportHangulDocumentOptions): Promise { const format = options.format ?? 'hwpx'; const html = jsonToHtml(documentJson); let document: HangulEngineDocument; From 7c78edfc7d6b950a3592da184f390836e28e4b01 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 09:08:12 +0900 Subject: [PATCH 23/78] test(hangul): require payload-redacted export diagnostics --- src/hangul/diagnosticPrivacy.test.ts | 62 ++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 src/hangul/diagnosticPrivacy.test.ts diff --git a/src/hangul/diagnosticPrivacy.test.ts b/src/hangul/diagnosticPrivacy.test.ts new file mode 100644 index 00000000..8f10abf5 --- /dev/null +++ b/src/hangul/diagnosticPrivacy.test.ts @@ -0,0 +1,62 @@ +import { + HangulDocumentError, + exportHangulDocument, + type HangulDocumentEngine, +} from './index.js'; + +const engine: HangulDocumentEngine = { + id: 'diagnostic-privacy-test', + open: async () => { + throw new Error('open should not be reached'); + }, + create: async () => { + throw new Error('create should not be reached'); + }, +}; + +async function captureExportError( + documentJson: Parameters[0], +): Promise { + try { + await exportHangulDocument(documentJson, { engine }); + } catch (error) { + expect(error).toBeInstanceOf(HangulDocumentError); + return error as HangulDocumentError; + } + throw new Error('expected Hangul export to reject unsupported content'); +} + +describe('Hangul export diagnostic privacy', () => { + it('does not reflect an unsupported caller-controlled block type', async () => { + const privateBlockType = 'customer-secret-block'; + const error = await captureExportError({ + type: 'doc', + content: [{ type: privateBlockType }], + }); + + expect(error.code).toBe('UNSUPPORTED_DOCUMENT_NODE'); + expect(error.message).not.toContain(privateBlockType); + }); + + it('does not reflect an unsupported caller-controlled mark type', async () => { + const privateMarkType = 'customer-secret-mark'; + const error = await captureExportError({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'x', + marks: [{ type: privateMarkType }], + }, + ], + }, + ], + }); + + expect(error.code).toBe('UNSUPPORTED_DOCUMENT_MARK'); + expect(error.message).not.toContain(privateMarkType); + }); +}); From fe5433718c50e5eb4f0979213ddff5dca5509d6c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 11:12:35 +0900 Subject: [PATCH 24/78] fix(hangul): redact unsupported type diagnostics --- src/hangul/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hangul/index.ts b/src/hangul/index.ts index e78ac8fc..21da0281 100644 --- a/src/hangul/index.ts +++ b/src/hangul/index.ts @@ -115,7 +115,7 @@ function renderInline(node: HangulDocumentJson): string { if (mark.type === 'bold') value = `${value}`; else if (mark.type === 'italic') value = `${value}`; else if (mark.type === 'strike') value = `${value}`; - else throw new HangulDocumentError('UNSUPPORTED_DOCUMENT_MARK', `Unsupported mark: ${mark.type ?? ''}.`); + else throw new HangulDocumentError('UNSUPPORTED_DOCUMENT_MARK', 'Hangul export contains an unsupported inline mark.'); } return value; } @@ -130,7 +130,7 @@ function jsonToHtml(documentJson: HangulDocumentJson): string { if (!Number.isInteger(level) || level < 1 || level > 6) throw new HangulDocumentError('UNSUPPORTED_DOCUMENT_NODE', 'Invalid heading level.'); return `${body}`; } - throw new HangulDocumentError('UNSUPPORTED_DOCUMENT_NODE', `Unsupported block: ${node.type ?? ''}.`); + throw new HangulDocumentError('UNSUPPORTED_DOCUMENT_NODE', 'Hangul export contains an unsupported block node.'); }).join(''); } From 40223e5d1c9de357dffd6278297da76d0ba3aafe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 20:34:33 +0900 Subject: [PATCH 25/78] test(hangul): preserve common document structures --- src/hangul/index.test.ts | 126 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 125 insertions(+), 1 deletion(-) diff --git a/src/hangul/index.test.ts b/src/hangul/index.test.ts index 508fd753..b949eed0 100644 --- a/src/hangul/index.test.ts +++ b/src/hangul/index.test.ts @@ -10,6 +10,7 @@ class FakeDocument implements HangulEngineDocument { freed = false; readonly calls: string[] = []; sourceFormat = 'hwpx'; + selectionHtml = '

Title

Body

'; getSourceFormat(): string { return this.sourceFormat; @@ -28,7 +29,7 @@ class FakeDocument implements HangulEngineDocument { } exportSelectionHtml(): string { - return '

Title

Body

'; + return this.selectionHtml; } getValidationWarnings(): string { @@ -128,6 +129,129 @@ describe('Hangul document bridge', () => { expect(source.freed).toBe(true); }); + it('preserves aligned paragraphs, lists, quotes, code blocks, and basic tables', async () => { + const source = new FakeDocument(); + source.selectionHtml = [ + '

Centered

', + '
  • Bullet

', + '
  1. Numbered

', + '

Quote

', + '
let x = 1 < 2;
', + '
Head
Cell
', + ].join(''); + + const result = await openHangulDocument(new Uint8Array([9]), { + engine: createEngine(source), + }); + + const expected: JSONContent = { + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { textAlign: 'center' }, + content: [{ type: 'text', text: 'Centered' }], + }, + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Bullet' }], + }, + ], + }, + ], + }, + { + type: 'orderedList', + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Numbered', + marks: [{ type: 'italic' }], + }, + ], + }, + ], + }, + ], + }, + { + type: 'blockquote', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Quote' }], + }, + ], + }, + { + type: 'codeBlock', + content: [{ type: 'text', text: 'let x = 1 < 2;' }], + }, + { + type: 'table', + content: [ + { + type: 'tableRow', + content: [ + { + type: 'tableHeader', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Head' }], + }, + ], + }, + ], + }, + { + type: 'tableRow', + content: [ + { + type: 'tableCell', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Cell' }], + }, + ], + }, + ], + }, + ], + }, + ], + }; + + expect(result.documentJson).toEqual(expected); + + const target = new FakeDocument(); + await exportHangulDocument(expected, { + engine: createEngine(source, target), + }); + const pasted = target.calls.find((call) => call.startsWith('pasteHtml:')); + expect(pasted).toContain('

Centered

'); + expect(pasted).toContain('
  • Bullet

'); + expect(pasted).toContain('
  1. Numbered

'); + expect(pasted).toContain('

Quote

'); + expect(pasted).toContain('
let x = 1 < 2;
'); + expect(pasted).toContain( + '

Head

Cell

', + ); + }); + it('exports edited JSON as HWPX by default', async () => { const source = new FakeDocument(); const target = new FakeDocument(); From ec3343f267de820b906487a32ab9f88ea02ca710 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 20:40:12 +0900 Subject: [PATCH 26/78] feat(hangul): preserve common document structures --- src/hangul/index.ts | 129 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 112 insertions(+), 17 deletions(-) diff --git a/src/hangul/index.ts b/src/hangul/index.ts index 21da0281..d1111cbc 100644 --- a/src/hangul/index.ts +++ b/src/hangul/index.ts @@ -69,6 +69,8 @@ export class HangulDocumentError extends Error { } } +const TEXT_ALIGNMENTS = new Set(['left', 'center', 'right', 'justify']); + function parseInline(parent: ParentNode, marks: HangulDocumentMark[] = []): HangulDocumentJson[] { const output: HangulDocumentJson[] = []; for (const child of Array.from(parent.childNodes)) { @@ -90,17 +92,71 @@ function parseInline(parent: ParentNode, marks: HangulDocumentMark[] = []): Hang return output; } +function readTextAlignment(element: Element): string | undefined { + const style = Reflect.get(element, 'style') as { textAlign?: unknown } | undefined; + const textAlign = style?.textAlign; + return typeof textAlign === 'string' && TEXT_ALIGNMENTS.has(textAlign) + ? textAlign + : undefined; +} + +function parseParagraph(element: Element): HangulDocumentJson { + const textAlign = readTextAlignment(element); + const content = parseInline(element); + return textAlign === undefined + ? { type: 'paragraph', content } + : { type: 'paragraph', attrs: { textAlign }, content }; +} + +function parseList(element: Element, type: 'bulletList' | 'orderedList'): HangulDocumentJson { + return { + type, + content: Array.from(element.children).map((item) => ({ + type: 'listItem', + content: Array.from(item.children).map(parseBlock), + })), + }; +} + +function parseTable(element: Element): HangulDocumentJson { + return { + type: 'table', + content: Array.from((element as HTMLTableElement).rows).map((row) => ({ + type: 'tableRow', + content: Array.from(row.cells).map((cell) => ({ + type: cell.tagName.toLowerCase() === 'th' ? 'tableHeader' : 'tableCell', + content: [{ type: 'paragraph', content: parseInline(cell) }], + })), + })), + }; +} + +function parseBlock(element: Element): HangulDocumentJson { + const tag = element.tagName.toLowerCase(); + if (/^h[1-6]$/u.test(tag)) { + return { + type: 'heading', + attrs: { level: Number(tag.slice(1)) }, + content: parseInline(element), + }; + } + if (tag === 'ul') return parseList(element, 'bulletList'); + if (tag === 'ol') return parseList(element, 'orderedList'); + if (tag === 'blockquote') { + return { type: 'blockquote', content: Array.from(element.children).map(parseBlock) }; + } + if (tag === 'pre') { + return { type: 'codeBlock', content: [{ type: 'text', text: element.textContent as string }] }; + } + if (tag === 'table') return parseTable(element); + return parseParagraph(element); +} + function htmlToJson(html: string): HangulDocumentJson { const parsed = new DOMParser().parseFromString(html, 'text/html'); return { type: 'doc', - content: Array.from(parsed.body.children).map((element) => { - const tag = element.tagName.toLowerCase(); - const content = parseInline(element); - return /^h[1-6]$/u.test(tag) - ? { type: 'heading', attrs: { level: Number(tag.slice(1)) }, content } - : { type: 'paragraph', content }; - }), + content: Array.from(parsed.body.children).map(parseBlock), }; } @@ -120,18 +176,57 @@ function renderInline(node: HangulDocumentJson): string { return value; } +function contentOf(node: HangulDocumentJson): HangulDocumentJson[] { + return node.content ?? []; +} + +function paragraphStyle(node: HangulDocumentJson): string { + const textAlign = node.attrs?.textAlign; + return typeof textAlign === 'string' && TEXT_ALIGNMENTS.has(textAlign) + ? ` style="text-align: ${textAlign}"` + : ''; +} + +function renderBlock(node: HangulDocumentJson): string { + if (node.type === 'paragraph') { + return `${contentOf(node).map(renderInline).join('')}

`; + } + if (node.type === 'heading') { + const level = Number(node.attrs?.level); + if (!Number.isInteger(level) || level < 1 || level > 6) throw new HangulDocumentError('UNSUPPORTED_DOCUMENT_NODE', 'Invalid heading level.'); + return `${contentOf(node).map(renderInline).join('')}`; + } + if (node.type === 'bulletList' || node.type === 'orderedList') { + const tag = node.type === 'bulletList' ? 'ul' : 'ol'; + return `<${tag}>${contentOf(node).map(renderBlock).join('')}`; + } + if (node.type === 'listItem') { + return `
  • ${contentOf(node).map(renderBlock).join('')}
  • `; + } + if (node.type === 'blockquote') { + return `
    ${contentOf(node).map(renderBlock).join('')}
    `; + } + if (node.type === 'codeBlock') { + return `
    ${contentOf(node).map(renderInline).join('')}
    `; + } + if (node.type === 'table') { + return `${contentOf(node).map(renderBlock).join('')}
    `; + } + if (node.type === 'tableRow') { + return `${contentOf(node).map(renderBlock).join('')}`; + } + if (node.type === 'tableHeader') { + return `${contentOf(node).map(renderBlock).join('')}`; + } + if (node.type === 'tableCell') { + return `${contentOf(node).map(renderBlock).join('')}`; + } + throw new HangulDocumentError('UNSUPPORTED_DOCUMENT_NODE', 'Hangul export contains an unsupported block node.'); +} + function jsonToHtml(documentJson: HangulDocumentJson): string { if (documentJson.type !== 'doc') throw new HangulDocumentError('UNSUPPORTED_DOCUMENT_NODE', 'Hangul export requires a doc root.'); - return (documentJson.content ?? []).map((node) => { - const body = (node.content ?? []).map(renderInline).join(''); - if (node.type === 'paragraph') return `

    ${body}

    `; - if (node.type === 'heading') { - const level = Number(node.attrs?.level); - if (!Number.isInteger(level) || level < 1 || level > 6) throw new HangulDocumentError('UNSUPPORTED_DOCUMENT_NODE', 'Invalid heading level.'); - return `${body}`; - } - throw new HangulDocumentError('UNSUPPORTED_DOCUMENT_NODE', 'Hangul export contains an unsupported block node.'); - }).join(''); + return contentOf(documentJson).map(renderBlock).join(''); } /** Project HWP/HWPX bytes into the editor's JSON model. */ From 628fc51b8a81dc29063f6c6bce3c7d0b5c348bfc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 20:45:41 +0900 Subject: [PATCH 27/78] test(hangul): reject unsupported imported blocks --- src/hangul/index.test.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/hangul/index.test.ts b/src/hangul/index.test.ts index b949eed0..16748aaf 100644 --- a/src/hangul/index.test.ts +++ b/src/hangul/index.test.ts @@ -129,6 +129,30 @@ describe('Hangul document bridge', () => { expect(source.freed).toBe(true); }); + it('rejects unsupported imported blocks without reflecting document content', async () => { + const source = new FakeDocument(); + source.selectionHtml = + ''; + let caught: unknown; + + try { + await openHangulDocument(new Uint8Array([9]), { + engine: createEngine(source), + }); + } catch (error) { + caught = error; + } + + expect(caught).toMatchObject({ + code: 'UNSUPPORTED_DOCUMENT_NODE', + message: 'Hangul import contains an unsupported block node.', + }); + expect((caught as Error).message).not.toContain('aside'); + expect((caught as Error).message).not.toContain('tenant-secret'); + expect((caught as Error).message).not.toContain('sensitive body'); + expect(source.freed).toBe(true); + }); + it('preserves aligned paragraphs, lists, quotes, code blocks, and basic tables', async () => { const source = new FakeDocument(); source.selectionHtml = [ From 7959d2fc685c79b2ff78061dd684b27541cee7ec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:37:08 +0900 Subject: [PATCH 28/78] fix(hangul): reject unsupported imported blocks --- src/hangul/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/hangul/index.ts b/src/hangul/index.ts index d1111cbc..4a3ff9b3 100644 --- a/src/hangul/index.ts +++ b/src/hangul/index.ts @@ -149,7 +149,11 @@ function parseBlock(element: Element): HangulDocumentJson { return { type: 'codeBlock', content: [{ type: 'text', text: element.textContent as string }] }; } if (tag === 'table') return parseTable(element); - return parseParagraph(element); + if (tag === 'p') return parseParagraph(element); + throw new HangulDocumentError( + 'UNSUPPORTED_DOCUMENT_NODE', + 'Hangul import contains an unsupported block node.', + ); } function htmlToJson(html: string): HangulDocumentJson { From ea8b1c9faec345f02e22a1056f4c3443b46dfb5c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:41:28 +0900 Subject: [PATCH 29/78] test(hangul): expose lossy unsupported inline import --- src/hangul/index.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/hangul/index.test.ts b/src/hangul/index.test.ts index 16748aaf..d3bbfc2f 100644 --- a/src/hangul/index.test.ts +++ b/src/hangul/index.test.ts @@ -153,6 +153,29 @@ describe('Hangul document bridge', () => { expect(source.freed).toBe(true); }); + it('rejects unsupported imported inline marks instead of silently losing them', async () => { + const source = new FakeDocument(); + source.selectionHtml = + '

    beforesensitive linkafter

    '; + let caught: unknown; + + try { + await openHangulDocument(new Uint8Array([9]), { + engine: createEngine(source), + }); + } catch (error) { + caught = error; + } + + expect(caught).toMatchObject({ + code: 'UNSUPPORTED_DOCUMENT_MARK', + message: 'Hangul import contains an unsupported inline mark.', + }); + expect((caught as Error).message).not.toContain('tenant-secret'); + expect((caught as Error).message).not.toContain('sensitive link'); + expect(source.freed).toBe(true); + }); + it('preserves aligned paragraphs, lists, quotes, code blocks, and basic tables', async () => { const source = new FakeDocument(); source.selectionHtml = [ From f804a0c94b731f7290d21ffa5e9c8986e9e3a75f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:45:29 +0900 Subject: [PATCH 30/78] fix(hangul): reject unsupported imported inline marks --- src/hangul/index.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/hangul/index.ts b/src/hangul/index.ts index 4a3ff9b3..0027feac 100644 --- a/src/hangul/index.ts +++ b/src/hangul/index.ts @@ -79,14 +79,17 @@ function parseInline(parent: ParentNode, marks: HangulDocumentMark[] = []): Hang if (text) output.push({ type: 'text', text, ...(marks.length ? { marks } : {}) }); } else if (child instanceof Element) { const tag = child.tagName.toLowerCase(); - const next = tag === 'strong' || tag === 'b' - ? [...marks, { type: 'bold' }] - : tag === 'em' || tag === 'i' - ? [...marks, { type: 'italic' }] - : tag === 's' || tag === 'strike' - ? [...marks, { type: 'strike' }] - : marks; - output.push(...parseInline(child, next)); + let mark: HangulDocumentMark; + if (tag === 'strong' || tag === 'b') mark = { type: 'bold' }; + else if (tag === 'em' || tag === 'i') mark = { type: 'italic' }; + else if (tag === 's' || tag === 'strike') mark = { type: 'strike' }; + else { + throw new HangulDocumentError( + 'UNSUPPORTED_DOCUMENT_MARK', + 'Hangul import contains an unsupported inline mark.', + ); + } + output.push(...parseInline(child, [...marks, mark])); } } return output; From a4b71302f2d62553d6824f4fa5d6e7d11fab6167 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:48:46 +0900 Subject: [PATCH 31/78] fix(hangul): preserve transparent inline wrappers --- src/hangul/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/hangul/index.ts b/src/hangul/index.ts index 0027feac..0f1d5aaf 100644 --- a/src/hangul/index.ts +++ b/src/hangul/index.ts @@ -79,6 +79,10 @@ function parseInline(parent: ParentNode, marks: HangulDocumentMark[] = []): Hang if (text) output.push({ type: 'text', text, ...(marks.length ? { marks } : {}) }); } else if (child instanceof Element) { const tag = child.tagName.toLowerCase(); + if (tag === 'span') { + output.push(...parseInline(child, marks)); + continue; + } let mark: HangulDocumentMark; if (tag === 'strong' || tag === 'b') mark = { type: 'bold' }; else if (tag === 'em' || tag === 'i') mark = { type: 'italic' }; From bcfb5b4ef2be318ba539c316ef3806de8f7ffe4f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 06:17:11 +0900 Subject: [PATCH 32/78] test(hangul): contain hostile engine throw values --- src/hangul/index.failures.test.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/hangul/index.failures.test.ts b/src/hangul/index.failures.test.ts index 52670810..9e9fb4e7 100644 --- a/src/hangul/index.failures.test.ts +++ b/src/hangul/index.failures.test.ts @@ -307,6 +307,33 @@ describe('Hangul bridge failure boundaries', () => { } }); + it('contains hostile engine throw values without prototype inspection', async () => { + const target = new BoundaryDocument(); + let prototypeReads = 0; + const hostile = new Proxy(Object.create(null) as object, { + getPrototypeOf() { + prototypeReads += 1; + throw new Error('private-hangul-prototype-sentinel'); + }, + }); + vi.spyOn(target, 'pasteHtml').mockImplementation(() => { + throw hostile; + }); + + await expect( + exportHangulDocument( + { type: 'doc', content: [{ type: 'paragraph' }] }, + { engine: engineFor(new BoundaryDocument(), target) }, + ), + ).rejects.toMatchObject({ + name: 'HangulDocumentError', + code: 'ENGINE_OPERATION_FAILED', + message: 'The Hangul engine failed during export.', + }); + expect(prototypeReads).toBe(0); + expect(target.freed).toBe(true); + }); + it('normalizes engine write failures and output-bound violations', async () => { const writeTarget = new BoundaryDocument(); writeTarget.failPaste = true; From 63f65736b95ccc3939ccce497f2305e44a1017e3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 06:21:12 +0900 Subject: [PATCH 33/78] fix(hangul): brand module-owned engine errors --- src/hangul/index.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/hangul/index.ts b/src/hangul/index.ts index 0f1d5aaf..045a34b6 100644 --- a/src/hangul/index.ts +++ b/src/hangul/index.ts @@ -61,14 +61,23 @@ export interface HangulDocumentExportResult { warnings: readonly string[]; } +/** Module-owned identity brand that never reflects over untrusted thrown values. */ +const HANGUL_DOCUMENT_ERRORS = new WeakSet(); + /** Stable error type for unsupported or unsafe conversion states. */ export class HangulDocumentError extends Error { constructor(readonly code: string, message: string) { super(message); this.name = 'HangulDocumentError'; + HANGUL_DOCUMENT_ERRORS.add(this); } } +/** Return whether a thrown value was created by this module without prototype traversal. */ +function isHangulDocumentError(error: unknown): error is HangulDocumentError { + return HANGUL_DOCUMENT_ERRORS.has(error as object); +} + const TEXT_ALIGNMENTS = new Set(['left', 'center', 'right', 'justify']); function parseInline(parent: ParentNode, marks: HangulDocumentMark[] = []): HangulDocumentJson[] { @@ -277,7 +286,7 @@ export async function exportHangulDocument(documentJson: HangulDocumentJson, opt if (bytes.byteLength > (options.maxOutputBytes ?? 64 * 1024 * 1024)) throw new HangulDocumentError('OUTPUT_LIMIT_EXCEEDED', 'Hangul export exceeds the configured limit.'); return { format, bytes, warnings: Object.freeze([]) }; } catch (error) { - if (error instanceof HangulDocumentError) throw error; + if (isHangulDocumentError(error)) throw error; throw new HangulDocumentError('ENGINE_OPERATION_FAILED', 'The Hangul engine failed during export.'); } } finally { document.free?.(); } From 561b08a2c4a161d240d36f755b54dd0be4a9c1d0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 11:02:08 +0900 Subject: [PATCH 34/78] test(hangul): reject invalid byte limits before engine work --- src/hangul/runtimeResourceLimits.test.ts | 67 ++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/hangul/runtimeResourceLimits.test.ts diff --git a/src/hangul/runtimeResourceLimits.test.ts b/src/hangul/runtimeResourceLimits.test.ts new file mode 100644 index 00000000..0be5fa0b --- /dev/null +++ b/src/hangul/runtimeResourceLimits.test.ts @@ -0,0 +1,67 @@ +import { + exportHangulDocument, + openHangulDocument, + type HangulDocumentEngine, +} from './index.js'; + +function failingEngine(): HangulDocumentEngine { + return { + id: 'resource-limit-sentinel', + open: vi.fn(async () => { + throw new Error('engine open should not run'); + }), + create: vi.fn(async () => { + throw new Error('engine create should not run'); + }), + }; +} + +const INVALID_BYTE_LIMITS = [Number.NaN, Number.POSITIVE_INFINITY, -1, 1.5]; + +describe('Hangul runtime byte-limit validation', () => { + it.each(INVALID_BYTE_LIMITS)( + 'rejects invalid maxSourceBytes %s before opening the host engine', + async (maxSourceBytes) => { + const engine = failingEngine(); + + await expect( + openHangulDocument(new Uint8Array(), { + engine, + maxSourceBytes, + }), + ).rejects.toMatchObject({ + name: 'HangulDocumentError', + code: 'INVALID_CONFIGURATION', + message: 'Hangul byte limit configuration is invalid.', + }); + expect(engine.open).not.toHaveBeenCalled(); + }, + ); + + it.each(INVALID_BYTE_LIMITS)( + 'rejects invalid maxOutputBytes %s before inspecting document content or creating the host engine', + async (maxOutputBytes) => { + const engine = failingEngine(); + const documentJson = new Proxy( + {}, + { + get() { + throw new Error('document should not be inspected'); + }, + }, + ); + + await expect( + exportHangulDocument(documentJson, { + engine, + maxOutputBytes, + }), + ).rejects.toMatchObject({ + name: 'HangulDocumentError', + code: 'INVALID_CONFIGURATION', + message: 'Hangul byte limit configuration is invalid.', + }); + expect(engine.create).not.toHaveBeenCalled(); + }, + ); +}); From aad9956e746a6ae9ae836e56aee0a2213b2c5024 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 11:05:07 +0900 Subject: [PATCH 35/78] fix(hangul): validate runtime byte limits --- src/hangul/index.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/hangul/index.ts b/src/hangul/index.ts index 045a34b6..d8ed9877 100644 --- a/src/hangul/index.ts +++ b/src/hangul/index.ts @@ -79,6 +79,19 @@ function isHangulDocumentError(error: unknown): error is HangulDocumentError { } const TEXT_ALIGNMENTS = new Set(['left', 'center', 'right', 'justify']); +const DEFAULT_MAX_DOCUMENT_BYTES = 64 * 1024 * 1024; + +/** Resolve a public runtime byte ceiling without coercion or fail-open numeric values. */ +function resolveHangulByteLimit(limit: number | undefined): number { + const resolved = limit ?? DEFAULT_MAX_DOCUMENT_BYTES; + if (!Number.isSafeInteger(resolved) || resolved < 0) { + throw new HangulDocumentError( + 'INVALID_CONFIGURATION', + 'Hangul byte limit configuration is invalid.', + ); + } + return resolved; +} function parseInline(parent: ParentNode, marks: HangulDocumentMark[] = []): HangulDocumentJson[] { const output: HangulDocumentJson[] = []; @@ -251,7 +264,8 @@ function jsonToHtml(documentJson: HangulDocumentJson): string { /** Project HWP/HWPX bytes into the editor's JSON model. */ export async function openHangulDocument(source: Uint8Array, options: OpenHangulDocumentOptions): Promise { - if (source.byteLength > (options.maxSourceBytes ?? 64 * 1024 * 1024)) throw new HangulDocumentError('SOURCE_LIMIT_EXCEEDED', 'Hangul source exceeds the configured limit.'); + const maxSourceBytes = resolveHangulByteLimit(options.maxSourceBytes); + if (source.byteLength > maxSourceBytes) throw new HangulDocumentError('SOURCE_LIMIT_EXCEEDED', 'Hangul source exceeds the configured limit.'); let document: HangulEngineDocument; try { document = await options.engine.open(source); } catch { throw new HangulDocumentError('ENGINE_OPEN_FAILED', 'The Hangul engine could not open the document.'); } try { @@ -270,6 +284,7 @@ export async function openHangulDocument(source: Uint8Array, options: OpenHangul /** Export edited Inkspan JSON as HWPX by default or HWP explicitly. */ export async function exportHangulDocument(documentJson: HangulDocumentJson, options: ExportHangulDocumentOptions): Promise { + const maxOutputBytes = resolveHangulByteLimit(options.maxOutputBytes); const format = options.format ?? 'hwpx'; const html = jsonToHtml(documentJson); let document: HangulEngineDocument; @@ -283,7 +298,7 @@ export async function exportHangulDocument(documentJson: HangulDocumentJson, opt document.pasteHtml(0, 0, 0, html); document.endBatch?.(); const bytes = format === 'hwp' ? document.exportHwp() : document.exportHwpx(); - if (bytes.byteLength > (options.maxOutputBytes ?? 64 * 1024 * 1024)) throw new HangulDocumentError('OUTPUT_LIMIT_EXCEEDED', 'Hangul export exceeds the configured limit.'); + if (bytes.byteLength > maxOutputBytes) throw new HangulDocumentError('OUTPUT_LIMIT_EXCEEDED', 'Hangul export exceeds the configured limit.'); return { format, bytes, warnings: Object.freeze([]) }; } catch (error) { if (isHangulDocumentError(error)) throw error; From 6bf3fd8c7643c05d37232f1ad07db38e71d498af Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 12:05:01 +0900 Subject: [PATCH 36/78] test(hangul): cover hostile source byteLength override --- src/hangul/sourceSnapshot.test.ts | 53 +++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/hangul/sourceSnapshot.test.ts diff --git a/src/hangul/sourceSnapshot.test.ts b/src/hangul/sourceSnapshot.test.ts new file mode 100644 index 00000000..5e9d1b12 --- /dev/null +++ b/src/hangul/sourceSnapshot.test.ts @@ -0,0 +1,53 @@ +import { + openHangulDocument, + type HangulDocumentEngine, + type HangulEngineDocument, +} from './index.js'; + +function emptyDocument(): HangulEngineDocument { + return { + getSourceFormat: () => 'hwp', + getSectionCount: () => 0, + getParagraphCount: () => 0, + getParagraphLength: () => 0, + exportSelectionHtml: () => '', + deleteText: () => '', + pasteHtml: () => '', + exportHwp: () => new Uint8Array(), + exportHwpx: () => new Uint8Array(), + }; +} + +describe('Hangul source snapshot boundary', () => { + it('does not execute caller-owned byteLength accessors and passes a detached byte snapshot to the host engine', async () => { + const privateSentinel = new Error('private byteLength sentinel'); + const source = new Uint8Array([0x48, 0x57, 0x50]); + let byteLengthAccessorCalls = 0; + Object.defineProperty(source, 'byteLength', { + configurable: true, + get() { + byteLengthAccessorCalls += 1; + throw privateSentinel; + }, + }); + + let receivedSource: Uint8Array | null = null; + const engine: HangulDocumentEngine = { + id: 'source-snapshot-test', + open: async (bytes) => { + receivedSource = bytes; + return emptyDocument(); + }, + create: async () => emptyDocument(), + }; + + await expect(openHangulDocument(source, { engine })).resolves.toMatchObject({ + sourceFormat: 'hwp', + lossy: false, + }); + + expect(byteLengthAccessorCalls).toBe(0); + expect(receivedSource).not.toBe(source); + expect(Array.from(receivedSource ?? [])).toEqual([0x48, 0x57, 0x50]); + }); +}); From 8b0c770240b87a6d13f10c7e2b56cb885c048156 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 12:08:28 +0900 Subject: [PATCH 37/78] test(hangul): cover forged and shared source views --- src/hangul/sourceSnapshot.test.ts | 49 +++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/hangul/sourceSnapshot.test.ts b/src/hangul/sourceSnapshot.test.ts index 5e9d1b12..c555ba0f 100644 --- a/src/hangul/sourceSnapshot.test.ts +++ b/src/hangul/sourceSnapshot.test.ts @@ -18,6 +18,17 @@ function emptyDocument(): HangulEngineDocument { }; } +function engineWithOpenCounter(counter: { calls: number }): HangulDocumentEngine { + return { + id: 'source-snapshot-test', + open: async () => { + counter.calls += 1; + return emptyDocument(); + }, + create: async () => emptyDocument(), + }; +} + describe('Hangul source snapshot boundary', () => { it('does not execute caller-owned byteLength accessors and passes a detached byte snapshot to the host engine', async () => { const privateSentinel = new Error('private byteLength sentinel'); @@ -50,4 +61,42 @@ describe('Hangul source snapshot boundary', () => { expect(receivedSource).not.toBe(source); expect(Array.from(receivedSource ?? [])).toEqual([0x48, 0x57, 0x50]); }); + + it('fails closed for forged typed-array proxies without executing caller traps', async () => { + const privateSentinel = new Error('private proxy sentinel'); + let trapCalls = 0; + const source = new Proxy(new Uint8Array([0x48]), { + get() { + trapCalls += 1; + throw privateSentinel; + }, + }) as Uint8Array; + const counter = { calls: 0 }; + + await expect( + openHangulDocument(source, { engine: engineWithOpenCounter(counter) }), + ).rejects.toMatchObject({ + name: 'HangulDocumentError', + code: 'INVALID_SOURCE', + message: 'Hangul source bytes are invalid.', + }); + + expect(trapCalls).toBe(0); + expect(counter.calls).toBe(0); + }); + + it('fails closed for SharedArrayBuffer-backed views before the host engine observes mutable bytes', async () => { + const source = new Uint8Array(new SharedArrayBuffer(4)); + const counter = { calls: 0 }; + + await expect( + openHangulDocument(source, { engine: engineWithOpenCounter(counter) }), + ).rejects.toMatchObject({ + name: 'HangulDocumentError', + code: 'INVALID_SOURCE', + message: 'Hangul source bytes are invalid.', + }); + + expect(counter.calls).toBe(0); + }); }); From 9b8566af9b14e48050c67f1536dfeb84ddce5e94 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 12:09:29 +0900 Subject: [PATCH 38/78] fix(hangul): snapshot untrusted source bytes --- src/hangul/index.ts | 53 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/src/hangul/index.ts b/src/hangul/index.ts index d8ed9877..5de48b6d 100644 --- a/src/hangul/index.ts +++ b/src/hangul/index.ts @@ -80,6 +80,21 @@ function isHangulDocumentError(error: unknown): error is HangulDocumentError { const TEXT_ALIGNMENTS = new Set(['left', 'center', 'right', 'justify']); const DEFAULT_MAX_DOCUMENT_BYTES = 64 * 1024 * 1024; +const TYPED_ARRAY_PROTOTYPE = Object.getPrototypeOf( + Uint8Array.prototype, +) as object; +const TYPED_ARRAY_BUFFER_GETTER = Object.getOwnPropertyDescriptor( + TYPED_ARRAY_PROTOTYPE, + 'buffer', +)!.get!; +const TYPED_ARRAY_BYTE_OFFSET_GETTER = Object.getOwnPropertyDescriptor( + TYPED_ARRAY_PROTOTYPE, + 'byteOffset', +)!.get!; +const TYPED_ARRAY_BYTE_LENGTH_GETTER = Object.getOwnPropertyDescriptor( + TYPED_ARRAY_PROTOTYPE, + 'byteLength', +)!.get!; /** Resolve a public runtime byte ceiling without coercion or fail-open numeric values. */ function resolveHangulByteLimit(limit: number | undefined): number { @@ -93,6 +108,40 @@ function resolveHangulByteLimit(limit: number | undefined): number { return resolved; } +/** Copy one genuine Uint8Array into an Inkspan-owned immutable import snapshot. */ +function snapshotHangulSource(source: Uint8Array, maxSourceBytes: number): Uint8Array { + let buffer: ArrayBufferLike; + let byteOffset: number; + let byteLength: number; + try { + buffer = TYPED_ARRAY_BUFFER_GETTER.call(source) as ArrayBufferLike; + byteOffset = TYPED_ARRAY_BYTE_OFFSET_GETTER.call(source) as number; + byteLength = TYPED_ARRAY_BYTE_LENGTH_GETTER.call(source) as number; + } catch { + throw new HangulDocumentError( + 'INVALID_SOURCE', + 'Hangul source bytes are invalid.', + ); + } + + if (!(buffer instanceof ArrayBuffer)) { + throw new HangulDocumentError( + 'INVALID_SOURCE', + 'Hangul source bytes are invalid.', + ); + } + if (byteLength > maxSourceBytes) { + throw new HangulDocumentError( + 'SOURCE_LIMIT_EXCEEDED', + 'Hangul source exceeds the configured limit.', + ); + } + + const snapshot = new Uint8Array(byteLength); + snapshot.set(new Uint8Array(buffer, byteOffset, byteLength)); + return snapshot; +} + function parseInline(parent: ParentNode, marks: HangulDocumentMark[] = []): HangulDocumentJson[] { const output: HangulDocumentJson[] = []; for (const child of Array.from(parent.childNodes)) { @@ -265,9 +314,9 @@ function jsonToHtml(documentJson: HangulDocumentJson): string { /** Project HWP/HWPX bytes into the editor's JSON model. */ export async function openHangulDocument(source: Uint8Array, options: OpenHangulDocumentOptions): Promise { const maxSourceBytes = resolveHangulByteLimit(options.maxSourceBytes); - if (source.byteLength > maxSourceBytes) throw new HangulDocumentError('SOURCE_LIMIT_EXCEEDED', 'Hangul source exceeds the configured limit.'); + const sourceSnapshot = snapshotHangulSource(source, maxSourceBytes); let document: HangulEngineDocument; - try { document = await options.engine.open(source); } catch { throw new HangulDocumentError('ENGINE_OPEN_FAILED', 'The Hangul engine could not open the document.'); } + try { document = await options.engine.open(sourceSnapshot); } catch { throw new HangulDocumentError('ENGINE_OPEN_FAILED', 'The Hangul engine could not open the document.'); } try { const sourceFormat = document.getSourceFormat().toLowerCase(); if (sourceFormat !== 'hwp' && sourceFormat !== 'hwpx') throw new HangulDocumentError('UNSUPPORTED_SOURCE_FORMAT', 'Unsupported Hangul source format.'); From fee63ea7f6a2b0ab2ad05c43fbda49de2a6088a1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 12:13:56 +0900 Subject: [PATCH 39/78] test(hangul): cover hostile engine output views --- src/hangul/outputSnapshot.test.ts | 86 +++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/hangul/outputSnapshot.test.ts diff --git a/src/hangul/outputSnapshot.test.ts b/src/hangul/outputSnapshot.test.ts new file mode 100644 index 00000000..18a1c721 --- /dev/null +++ b/src/hangul/outputSnapshot.test.ts @@ -0,0 +1,86 @@ +import { + exportHangulDocument, + type HangulDocumentEngine, + type HangulEngineDocument, +} from './index.js'; + +const DOCUMENT = { type: 'doc', content: [{ type: 'paragraph' }] } as const; + +function documentWithOutput(output: Uint8Array): HangulEngineDocument { + return { + getSourceFormat: () => 'hwpx', + getSectionCount: () => 0, + getParagraphCount: () => 0, + getParagraphLength: () => 0, + exportSelectionHtml: () => '', + deleteText: () => '', + pasteHtml: () => '', + exportHwp: () => output, + exportHwpx: () => output, + }; +} + +function engineWithOutput(output: Uint8Array): HangulDocumentEngine { + return { + id: 'output-snapshot-test', + open: async () => documentWithOutput(output), + create: async () => documentWithOutput(output), + }; +} + +describe('Hangul output snapshot boundary', () => { + it('does not execute caller-owned byteLength accessors and returns an Inkspan-owned byte snapshot', async () => { + const privateSentinel = new Error('private output byteLength sentinel'); + const output = new Uint8Array([0x48, 0x57, 0x50, 0x58]); + let byteLengthAccessorCalls = 0; + Object.defineProperty(output, 'byteLength', { + configurable: true, + get() { + byteLengthAccessorCalls += 1; + throw privateSentinel; + }, + }); + + const result = await exportHangulDocument(DOCUMENT, { + engine: engineWithOutput(output), + }); + + expect(byteLengthAccessorCalls).toBe(0); + expect(result.format).toBe('hwpx'); + expect(result.bytes).not.toBe(output); + expect(Array.from(result.bytes)).toEqual([0x48, 0x57, 0x50, 0x58]); + }); + + it('fails closed for forged typed-array proxies without executing caller traps', async () => { + const privateSentinel = new Error('private output proxy sentinel'); + let trapCalls = 0; + const output = new Proxy(new Uint8Array([0x48]), { + get() { + trapCalls += 1; + throw privateSentinel; + }, + }) as Uint8Array; + + await expect( + exportHangulDocument(DOCUMENT, { engine: engineWithOutput(output) }), + ).rejects.toMatchObject({ + name: 'HangulDocumentError', + code: 'ENGINE_OPERATION_FAILED', + message: 'The Hangul engine failed during export.', + }); + + expect(trapCalls).toBe(0); + }); + + it('fails closed for SharedArrayBuffer-backed engine output before returning mutable bytes', async () => { + const output = new Uint8Array(new SharedArrayBuffer(4)); + + await expect( + exportHangulDocument(DOCUMENT, { engine: engineWithOutput(output) }), + ).rejects.toMatchObject({ + name: 'HangulDocumentError', + code: 'ENGINE_OPERATION_FAILED', + message: 'The Hangul engine failed during export.', + }); + }); +}); From a1a452ee59faedbec278c5841ade74d05b5f091c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 12:15:22 +0900 Subject: [PATCH 40/78] test(hangul): keep output boundary regression type-safe --- src/hangul/outputSnapshot.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hangul/outputSnapshot.test.ts b/src/hangul/outputSnapshot.test.ts index 18a1c721..981f55b4 100644 --- a/src/hangul/outputSnapshot.test.ts +++ b/src/hangul/outputSnapshot.test.ts @@ -4,7 +4,7 @@ import { type HangulEngineDocument, } from './index.js'; -const DOCUMENT = { type: 'doc', content: [{ type: 'paragraph' }] } as const; +const DOCUMENT = { type: 'doc', content: [{ type: 'paragraph' }] }; function documentWithOutput(output: Uint8Array): HangulEngineDocument { return { From 1f2639bb9238a19fe286852f104019f5702fc774 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 12:19:24 +0900 Subject: [PATCH 41/78] fix(hangul): snapshot untrusted engine output bytes --- src/hangul/index.ts | 38 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/src/hangul/index.ts b/src/hangul/index.ts index 5de48b6d..63c5e08e 100644 --- a/src/hangul/index.ts +++ b/src/hangul/index.ts @@ -142,6 +142,40 @@ function snapshotHangulSource(source: Uint8Array, maxSourceBytes: number): Uint8 return snapshot; } +/** Copy genuine host-engine bytes into an Inkspan-owned immutable export snapshot. */ +function snapshotHangulOutput(source: Uint8Array, maxOutputBytes: number): Uint8Array { + let buffer: ArrayBufferLike; + let byteOffset: number; + let byteLength: number; + try { + buffer = TYPED_ARRAY_BUFFER_GETTER.call(source) as ArrayBufferLike; + byteOffset = TYPED_ARRAY_BYTE_OFFSET_GETTER.call(source) as number; + byteLength = TYPED_ARRAY_BYTE_LENGTH_GETTER.call(source) as number; + } catch { + throw new HangulDocumentError( + 'ENGINE_OPERATION_FAILED', + 'The Hangul engine failed during export.', + ); + } + + if (!(buffer instanceof ArrayBuffer)) { + throw new HangulDocumentError( + 'ENGINE_OPERATION_FAILED', + 'The Hangul engine failed during export.', + ); + } + if (byteLength > maxOutputBytes) { + throw new HangulDocumentError( + 'OUTPUT_LIMIT_EXCEEDED', + 'Hangul export exceeds the configured limit.', + ); + } + + const snapshot = new Uint8Array(byteLength); + snapshot.set(new Uint8Array(buffer, byteOffset, byteLength)); + return snapshot; +} + function parseInline(parent: ParentNode, marks: HangulDocumentMark[] = []): HangulDocumentJson[] { const output: HangulDocumentJson[] = []; for (const child of Array.from(parent.childNodes)) { @@ -346,8 +380,8 @@ export async function exportHangulDocument(documentJson: HangulDocumentJson, opt if (length > 0) document.deleteText(0, 0, 0, length); document.pasteHtml(0, 0, 0, html); document.endBatch?.(); - const bytes = format === 'hwp' ? document.exportHwp() : document.exportHwpx(); - if (bytes.byteLength > maxOutputBytes) throw new HangulDocumentError('OUTPUT_LIMIT_EXCEEDED', 'Hangul export exceeds the configured limit.'); + const engineBytes = format === 'hwp' ? document.exportHwp() : document.exportHwpx(); + const bytes = snapshotHangulOutput(engineBytes, maxOutputBytes); return { format, bytes, warnings: Object.freeze([]) }; } catch (error) { if (isHangulDocumentError(error)) throw error; From 83c5a2fe9484e210aa212bd164d2f8574b864e14 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 12:24:07 +0900 Subject: [PATCH 42/78] test(hangul): cover hostile import engine operation failures --- src/hangul/importOperationFailure.test.ts | 57 +++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/hangul/importOperationFailure.test.ts diff --git a/src/hangul/importOperationFailure.test.ts b/src/hangul/importOperationFailure.test.ts new file mode 100644 index 00000000..dd06380e --- /dev/null +++ b/src/hangul/importOperationFailure.test.ts @@ -0,0 +1,57 @@ +import { + HangulDocumentError, + openHangulDocument, + type HangulDocumentEngine, + type HangulEngineDocument, +} from './index.js'; + +describe('Hangul import engine-operation boundary', () => { + it('contains hostile host-engine operation throws without reflecting them', async () => { + let prototypeReads = 0; + const hostile = new Proxy(Object.create(null) as object, { + getPrototypeOf() { + prototypeReads += 1; + throw new Error('private-import-prototype-sentinel'); + }, + }); + let freed = false; + const document: HangulEngineDocument = { + getSourceFormat: () => { + throw hostile; + }, + getSectionCount: () => 0, + getParagraphCount: () => 0, + getParagraphLength: () => 0, + exportSelectionHtml: () => '', + deleteText: () => '', + pasteHtml: () => '', + exportHwp: () => new Uint8Array(), + exportHwpx: () => new Uint8Array(), + free: () => { + freed = true; + }, + }; + const engine: HangulDocumentEngine = { + id: 'hostile-import-operation-test', + open: async () => document, + create: async () => document, + }; + + let caught: unknown; + try { + await openHangulDocument(new Uint8Array([0x48]), { engine }); + } catch (error) { + caught = error; + } + + expect(caught).not.toBe(hostile); + expect(caught).toBeInstanceOf(HangulDocumentError); + expect(caught).toMatchObject({ + name: 'HangulDocumentError', + code: 'ENGINE_OPERATION_FAILED', + message: 'The Hangul engine failed during import.', + }); + expect(prototypeReads).toBe(0); + expect(freed).toBe(true); + }); +}); From 38407d15a96efacfbab062f5081e988b4c55efc3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 19:33:38 +0900 Subject: [PATCH 43/78] fix(hangul): contain hostile import operation failures --- src/hangul/index.ts | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/hangul/index.ts b/src/hangul/index.ts index 63c5e08e..a5191759 100644 --- a/src/hangul/index.ts +++ b/src/hangul/index.ts @@ -352,16 +352,21 @@ export async function openHangulDocument(source: Uint8Array, options: OpenHangul let document: HangulEngineDocument; try { document = await options.engine.open(sourceSnapshot); } catch { throw new HangulDocumentError('ENGINE_OPEN_FAILED', 'The Hangul engine could not open the document.'); } try { - const sourceFormat = document.getSourceFormat().toLowerCase(); - if (sourceFormat !== 'hwp' && sourceFormat !== 'hwpx') throw new HangulDocumentError('UNSUPPORTED_SOURCE_FORMAT', 'Unsupported Hangul source format.'); - const html: string[] = []; - for (let section = 0; section < document.getSectionCount(); section += 1) { - const count = document.getParagraphCount(section); - if (count > 0) html.push(document.exportSelectionHtml(section, 0, 0, count - 1, document.getParagraphLength(section, count - 1))); + try { + const sourceFormat = document.getSourceFormat().toLowerCase(); + if (sourceFormat !== 'hwp' && sourceFormat !== 'hwpx') throw new HangulDocumentError('UNSUPPORTED_SOURCE_FORMAT', 'Unsupported Hangul source format.'); + const html: string[] = []; + for (let section = 0; section < document.getSectionCount(); section += 1) { + const count = document.getParagraphCount(section); + if (count > 0) html.push(document.exportSelectionHtml(section, 0, 0, count - 1, document.getParagraphLength(section, count - 1))); + } + const documentJson = htmlToJson(html.join('')); + Object.freeze(documentJson); + return { sourceFormat, documentJson, warnings: Object.freeze([]), lossy: false }; + } catch (error) { + if (isHangulDocumentError(error)) throw error; + throw new HangulDocumentError('ENGINE_OPERATION_FAILED', 'The Hangul engine failed during import.'); } - const documentJson = htmlToJson(html.join('')); - Object.freeze(documentJson); - return { sourceFormat, documentJson, warnings: Object.freeze([]), lossy: false }; } finally { document.free?.(); } } From d87e7263c1e5a473028b0560d5116aca33816e2d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 19:41:51 +0900 Subject: [PATCH 44/78] test(hangul): reject invalid runtime export formats --- src/hangul/runtimeFormatValidation.test.ts | 34 ++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/hangul/runtimeFormatValidation.test.ts diff --git a/src/hangul/runtimeFormatValidation.test.ts b/src/hangul/runtimeFormatValidation.test.ts new file mode 100644 index 00000000..40978985 --- /dev/null +++ b/src/hangul/runtimeFormatValidation.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + exportHangulDocument, + type HangulDocumentEngine, + type HangulEngineDocument, +} from './index.js'; + +describe('Hangul runtime export format validation', () => { + it('rejects an invalid runtime format before creating an engine document', async () => { + const engineDocument = {} as HangulEngineDocument; + const engine: HangulDocumentEngine = { + id: 'runtime-format-boundary', + open: vi.fn(async () => engineDocument), + create: vi.fn(async () => engineDocument), + }; + + await expect( + exportHangulDocument( + { type: 'doc' }, + { + engine, + format: 'doc' as unknown as 'hwp', + }, + ), + ).rejects.toMatchObject({ + name: 'HangulDocumentError', + code: 'INVALID_CONFIGURATION', + message: 'Hangul export format is invalid.', + }); + + expect(engine.create).not.toHaveBeenCalled(); + }); +}); From 43bbb37a1ce0f66558ecff067c3e638cf5202278 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 19:45:41 +0900 Subject: [PATCH 45/78] fix(hangul): validate runtime export format --- src/hangul/index.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/hangul/index.ts b/src/hangul/index.ts index a5191759..c3ec1e2d 100644 --- a/src/hangul/index.ts +++ b/src/hangul/index.ts @@ -108,6 +108,18 @@ function resolveHangulByteLimit(limit: number | undefined): number { return resolved; } +/** Validate the runtime export selector before the host engine receives authority. */ +function resolveHangulExportFormat(format: unknown): 'hwp' | 'hwpx' { + const resolved = format === undefined ? 'hwpx' : format; + if (resolved !== 'hwp' && resolved !== 'hwpx') { + throw new HangulDocumentError( + 'INVALID_CONFIGURATION', + 'Hangul export format is invalid.', + ); + } + return resolved; +} + /** Copy one genuine Uint8Array into an Inkspan-owned immutable import snapshot. */ function snapshotHangulSource(source: Uint8Array, maxSourceBytes: number): Uint8Array { let buffer: ArrayBufferLike; @@ -373,7 +385,7 @@ export async function openHangulDocument(source: Uint8Array, options: OpenHangul /** Export edited Inkspan JSON as HWPX by default or HWP explicitly. */ export async function exportHangulDocument(documentJson: HangulDocumentJson, options: ExportHangulDocumentOptions): Promise { const maxOutputBytes = resolveHangulByteLimit(options.maxOutputBytes); - const format = options.format ?? 'hwpx'; + const format = resolveHangulExportFormat(options.format); const html = jsonToHtml(documentJson); let document: HangulEngineDocument; try { document = await options.engine.create(); } catch { throw new HangulDocumentError('ENGINE_CREATE_FAILED', 'The Hangul engine could not create a document.'); } From 136bead9f7d1e502e224e1f0d795a9b5d74ca157 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 04:18:44 +0900 Subject: [PATCH 46/78] test(docs): require ADR discovery consistency --- src/adrQualityContract.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/adrQualityContract.test.ts b/src/adrQualityContract.test.ts index 04dcef71..909cc09b 100644 --- a/src/adrQualityContract.test.ts +++ b/src/adrQualityContract.test.ts @@ -50,4 +50,21 @@ describe('ADR quality documentation contract', () => { } } }); + + it('indexes every detailed ADR exactly once under its filename identity', () => { + const adrIndex = repositoryFile('docs/adr/README.md'); + const adrFiles = detailedAdrFiles(); + const identifiers = adrFiles.map((name) => name.slice(0, 4)); + + expect(new Set(identifiers).size).toBe(identifiers.length); + + for (const adrFile of adrFiles) { + const identifier = adrFile.slice(0, 4); + const link = `[${identifier}](${adrFile})`; + expect( + adrIndex.split(link).length - 1, + `${adrFile} must have exactly one canonical index row`, + ).toBe(1); + } + }); }); From d772aa24cd46d1a273aa8952286e9fd283b2913a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 04:19:47 +0900 Subject: [PATCH 47/78] fix(docs): assign unique Hangul ADR 0030 --- ...g-boundary.md => 0030-hangul-document-authoring-boundary.md} | 2 +- docs/adr/README.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) rename docs/adr/{0027-hangul-document-authoring-boundary.md => 0030-hangul-document-authoring-boundary.md} (99%) diff --git a/docs/adr/0027-hangul-document-authoring-boundary.md b/docs/adr/0030-hangul-document-authoring-boundary.md similarity index 99% rename from docs/adr/0027-hangul-document-authoring-boundary.md rename to docs/adr/0030-hangul-document-authoring-boundary.md index 42db8bbc..8ba73084 100644 --- a/docs/adr/0027-hangul-document-authoring-boundary.md +++ b/docs/adr/0030-hangul-document-authoring-boundary.md @@ -1,4 +1,4 @@ -# ADR 0027: Hangul document authoring boundary +# ADR 0030: Hangul document authoring boundary - Status: Proposed - Date: 2026-08-14 diff --git a/docs/adr/README.md b/docs/adr/README.md index df8b7b80..0f77b206 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -32,6 +32,7 @@ This index records durable architectural decisions. Protected-main implementatio | [0024](0024-bounded-docx-paragraph-alignment.md) | Accepted | Bounded paragraph alignment in deterministic DOCX output | | [0025](0025-bounded-docx-heading-alignment.md) | Accepted | Bounded heading alignment in deterministic DOCX output | | [0026](0026-bounded-docx-external-hyperlinks.md) | Accepted | Bounded external hyperlinks in deterministic DOCX rich text | +| [0030](0030-hangul-document-authoring-boundary.md) | Proposed | Hangul document authoring boundary | ## Decision discipline From 31e51df896f1b91177e753e6f929387486facb01 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 11:30:38 +0900 Subject: [PATCH 48/78] test(hangul): expose cleanup failure escape --- src/hangul/cleanupFailure.test.ts | 119 ++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 src/hangul/cleanupFailure.test.ts diff --git a/src/hangul/cleanupFailure.test.ts b/src/hangul/cleanupFailure.test.ts new file mode 100644 index 00000000..c42c31d1 --- /dev/null +++ b/src/hangul/cleanupFailure.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; + +import { + exportHangulDocument, + HangulDocumentError, + openHangulDocument, + type HangulDocumentEngine, + type HangulEngineDocument, +} from './index.js'; + +const PRIVATE_CLEANUP_SENTINEL = new Error('private cleanup sentinel'); + +class CleanupThrowingDocument implements HangulEngineDocument { + constructor(readonly selectionHtml = '

    safe

    ') {} + + getSourceFormat(): string { + return 'hwpx'; + } + + getSectionCount(): number { + return 1; + } + + getParagraphCount(): number { + return 1; + } + + getParagraphLength(): number { + return 0; + } + + exportSelectionHtml(): string { + return this.selectionHtml; + } + + deleteText(): string { + return '{"ok":true}'; + } + + pasteHtml(): string { + return '{"ok":true}'; + } + + exportHwp(): Uint8Array { + return new Uint8Array([1]); + } + + exportHwpx(): Uint8Array { + return new Uint8Array([2]); + } + + free(): void { + throw PRIVATE_CLEANUP_SENTINEL; + } +} + +function engineFor( + source: HangulEngineDocument, + target: HangulEngineDocument = new CleanupThrowingDocument(), +): HangulDocumentEngine { + return { + id: 'cleanup-failure-test', + open: async () => source, + create: async () => target, + }; +} + +describe('Hangul engine cleanup containment', () => { + it('redacts cleanup failure after an otherwise successful import', async () => { + await expect( + openHangulDocument(new Uint8Array([1]), { + engine: engineFor(new CleanupThrowingDocument()), + }), + ).rejects.toMatchObject({ + code: 'ENGINE_CLEANUP_FAILED', + message: 'The Hangul engine failed during cleanup.', + }); + }); + + it('preserves a primary Inkspan import failure when cleanup also fails', async () => { + await expect( + openHangulDocument(new Uint8Array([1]), { + engine: engineFor( + new CleanupThrowingDocument( + '', + ), + ), + }), + ).rejects.toEqual( + new HangulDocumentError( + 'UNSUPPORTED_DOCUMENT_NODE', + 'Hangul import contains an unsupported block node.', + ), + ); + }); + + it('redacts cleanup failure after an otherwise successful export', async () => { + const source = new CleanupThrowingDocument(); + const target = new CleanupThrowingDocument(); + + await expect( + exportHangulDocument( + { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'safe' }], + }, + ], + }, + { engine: engineFor(source, target) }, + ), + ).rejects.toMatchObject({ + code: 'ENGINE_CLEANUP_FAILED', + message: 'The Hangul engine failed during cleanup.', + }); + }); +}); From 5b690bf48ba0dcc9bbe953368dc0029dc403cf46 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 11:35:16 +0900 Subject: [PATCH 49/78] fix(hangul): contain engine cleanup failures --- src/hangul/index.ts | 45 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/src/hangul/index.ts b/src/hangul/index.ts index c3ec1e2d..644bbcfd 100644 --- a/src/hangul/index.ts +++ b/src/hangul/index.ts @@ -78,6 +78,23 @@ function isHangulDocumentError(error: unknown): error is HangulDocumentError { return HANGUL_DOCUMENT_ERRORS.has(error as object); } +/** Contain host cleanup failures without replacing an existing Inkspan failure. */ +function freeHangulDocument( + document: HangulEngineDocument, + primaryError: HangulDocumentError | undefined, +): void { + try { + document.free?.(); + } catch { + if (primaryError === undefined) { + throw new HangulDocumentError( + 'ENGINE_CLEANUP_FAILED', + 'The Hangul engine failed during cleanup.', + ); + } + } +} + const TEXT_ALIGNMENTS = new Set(['left', 'center', 'right', 'justify']); const DEFAULT_MAX_DOCUMENT_BYTES = 64 * 1024 * 1024; const TYPED_ARRAY_PROTOTYPE = Object.getPrototypeOf( @@ -363,6 +380,7 @@ export async function openHangulDocument(source: Uint8Array, options: OpenHangul const sourceSnapshot = snapshotHangulSource(source, maxSourceBytes); let document: HangulEngineDocument; try { document = await options.engine.open(sourceSnapshot); } catch { throw new HangulDocumentError('ENGINE_OPEN_FAILED', 'The Hangul engine could not open the document.'); } + let primaryError: HangulDocumentError | undefined; try { try { const sourceFormat = document.getSourceFormat().toLowerCase(); @@ -376,10 +394,17 @@ export async function openHangulDocument(source: Uint8Array, options: OpenHangul Object.freeze(documentJson); return { sourceFormat, documentJson, warnings: Object.freeze([]), lossy: false }; } catch (error) { - if (isHangulDocumentError(error)) throw error; - throw new HangulDocumentError('ENGINE_OPERATION_FAILED', 'The Hangul engine failed during import.'); + primaryError = isHangulDocumentError(error) + ? error + : new HangulDocumentError( + 'ENGINE_OPERATION_FAILED', + 'The Hangul engine failed during import.', + ); + throw primaryError; } - } finally { document.free?.(); } + } finally { + freeHangulDocument(document, primaryError); + } } /** Export edited Inkspan JSON as HWPX by default or HWP explicitly. */ @@ -389,6 +414,7 @@ export async function exportHangulDocument(documentJson: HangulDocumentJson, opt const html = jsonToHtml(documentJson); let document: HangulEngineDocument; try { document = await options.engine.create(); } catch { throw new HangulDocumentError('ENGINE_CREATE_FAILED', 'The Hangul engine could not create a document.'); } + let primaryError: HangulDocumentError | undefined; try { try { document.createBlankDocument?.(); @@ -401,8 +427,15 @@ export async function exportHangulDocument(documentJson: HangulDocumentJson, opt const bytes = snapshotHangulOutput(engineBytes, maxOutputBytes); return { format, bytes, warnings: Object.freeze([]) }; } catch (error) { - if (isHangulDocumentError(error)) throw error; - throw new HangulDocumentError('ENGINE_OPERATION_FAILED', 'The Hangul engine failed during export.'); + primaryError = isHangulDocumentError(error) + ? error + : new HangulDocumentError( + 'ENGINE_OPERATION_FAILED', + 'The Hangul engine failed during export.', + ); + throw primaryError; } - } finally { document.free?.(); } + } finally { + freeHangulDocument(document, primaryError); + } } From 5065766aee2c14729125d3bcd44dd229c53d5092 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 11:36:05 +0900 Subject: [PATCH 50/78] test(hangul): bind documented compatibility to implementation --- src/hangul/documentationContract.test.ts | 27 ++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/hangul/documentationContract.test.ts diff --git a/src/hangul/documentationContract.test.ts b/src/hangul/documentationContract.test.ts new file mode 100644 index 00000000..5dfc02cc --- /dev/null +++ b/src/hangul/documentationContract.test.ts @@ -0,0 +1,27 @@ +import { readFileSync } from 'node:fs'; + +import { describe, expect, it } from 'vitest'; + +const hangulGuide = readFileSync( + new URL('../../docs/HANGUL.md', import.meta.url), + 'utf8', +); + +describe('Hangul compatibility documentation', () => { + it('documents the structures exercised by the public round-trip contract', () => { + expect(hangulGuide).toContain( + '| Lists | Yes | Yes | Structural bullet and ordered lists; explicit start-number metadata is not modeled |', + ); + expect(hangulGuide).toContain( + '| Block quotes | Yes | Yes | Nested supported block content is preserved |', + ); + expect(hangulGuide).toContain( + '| Code blocks | Yes | Yes | Text content is preserved; language metadata is not modeled |', + ); + expect(hangulGuide).toContain( + '| Basic tables | Yes | Yes | Header/cell topology is preserved; spans and layout styling are not modeled |', + ); + expect(hangulGuide).not.toContain('| Lists | Planned | Planned |'); + expect(hangulGuide).not.toContain('| Tables | Planned | Planned |'); + }); +}); From 3998f0fb2dbe2604e382e4675c664137cc65c63c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 11:36:29 +0900 Subject: [PATCH 51/78] docs(hangul): reconcile implemented compatibility --- docs/HANGUL.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/HANGUL.md b/docs/HANGUL.md index b4a35647..2b90da2d 100644 --- a/docs/HANGUL.md +++ b/docs/HANGUL.md @@ -102,13 +102,19 @@ The initial bridge deliberately supports a bounded semantic subset and rejects u | Bold | Yes | Yes | Common HTML projection | | Italic | Yes | Yes | Common HTML projection | | Strike | Yes | Yes | Common HTML projection | -| Lists | Planned | Planned | Must preserve nesting and numbering | -| Tables | Planned | Planned | Must preserve cell topology before layout styling | +| Lists | Yes | Yes | Structural bullet and ordered lists; explicit start-number metadata is not modeled | +| Block quotes | Yes | Yes | Nested supported block content is preserved | +| Code blocks | Yes | Yes | Text content is preserved; language metadata is not modeled | +| Basic tables | Yes | Yes | Header/cell topology is preserved; spans and layout styling are not modeled | | Links | Planned | Planned | Must use Inkspan safe-link policy | | Images | Planned | Planned | Must remain inline/host-approved; no external fetch | | Shapes/charts/equations | Warning | Rejected | Requires dedicated projection contract | | Macros/OLE/active content | Not executed | Not generated | Outside the editor authority boundary | +## Failure containment + +The host engine is untrusted at every call boundary, including cleanup. Open/create/operation failures are normalized to stable payload-redacted `HangulDocumentError` values. If engine cleanup fails after an otherwise successful public operation, Inkspan reports `ENGINE_CLEANUP_FAILED` without reading or stringifying the host-thrown value. If cleanup fails while Inkspan is already propagating a normalized primary import/export error, the primary error remains authoritative and the secondary cleanup failure is contained. + ## Security requirements Treat both formats as untrusted document containers. Production implementations must enforce bounded source and output bytes. A native HWPX implementation must additionally bound ZIP entry count, expanded bytes, expansion ratio, XML depth, XML node count, text size, relationships, and embedded payloads. DTD and external entity resolution must be disabled. External relationships are metadata only unless the host separately authorizes a resource. From f9048def8ea15c6b354114cae421beb220e7fc69 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 11:43:01 +0900 Subject: [PATCH 52/78] test(hangul): expose hostile option access failure --- src/hangul/optionAccessFailure.test.ts | 89 ++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/hangul/optionAccessFailure.test.ts diff --git a/src/hangul/optionAccessFailure.test.ts b/src/hangul/optionAccessFailure.test.ts new file mode 100644 index 00000000..dc853a2a --- /dev/null +++ b/src/hangul/optionAccessFailure.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; + +import { + exportHangulDocument, + openHangulDocument, + type ExportHangulDocumentOptions, + type HangulDocumentEngine, + type OpenHangulDocumentOptions, +} from './index.js'; + +const PRIVATE_OPTION_SENTINEL = new Error('private Hangul option sentinel'); + +function unusedEngine(onUse: () => void): HangulDocumentEngine { + return { + id: 'option-access-test', + open: async () => { + onUse(); + throw new Error('engine must not be reached'); + }, + create: async () => { + onUse(); + throw new Error('engine must not be reached'); + }, + }; +} + +const PARAGRAPH_DOCUMENT = Object.freeze({ + type: 'doc', + content: Object.freeze([ + Object.freeze({ + type: 'paragraph', + content: Object.freeze([Object.freeze({ type: 'text', text: 'safe' })]), + }), + ]), +}); + +function expectInvalidOptions(result: Promise): Promise { + return expect(result).rejects.toMatchObject({ + code: 'INVALID_CONFIGURATION', + message: 'Hangul options are invalid.', + }); +} + +describe('Hangul public option access containment', () => { + it('redacts a hostile maxSourceBytes accessor before engine open', async () => { + let engineUseCount = 0; + const options = { + engine: unusedEngine(() => { + engineUseCount += 1; + }), + get maxSourceBytes() { + throw PRIVATE_OPTION_SENTINEL; + }, + } as unknown as OpenHangulDocumentOptions; + + await expectInvalidOptions(openHangulDocument(new Uint8Array([1]), options)); + expect(engineUseCount).toBe(0); + }); + + it('redacts a hostile maxOutputBytes accessor before engine create', async () => { + let engineUseCount = 0; + const options = { + engine: unusedEngine(() => { + engineUseCount += 1; + }), + get maxOutputBytes() { + throw PRIVATE_OPTION_SENTINEL; + }, + } as unknown as ExportHangulDocumentOptions; + + await expectInvalidOptions(exportHangulDocument(PARAGRAPH_DOCUMENT, options)); + expect(engineUseCount).toBe(0); + }); + + it('redacts a hostile format accessor before engine create', async () => { + let engineUseCount = 0; + const options = { + engine: unusedEngine(() => { + engineUseCount += 1; + }), + get format() { + throw PRIVATE_OPTION_SENTINEL; + }, + } as unknown as ExportHangulDocumentOptions; + + await expectInvalidOptions(exportHangulDocument(PARAGRAPH_DOCUMENT, options)); + expect(engineUseCount).toBe(0); + }); +}); From c697bf16f32b01227a47362a2cc176605f2f47cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 11:44:14 +0900 Subject: [PATCH 53/78] test(hangul): reach hostile option behavior boundary --- src/hangul/optionAccessFailure.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/hangul/optionAccessFailure.test.ts b/src/hangul/optionAccessFailure.test.ts index dc853a2a..d8981047 100644 --- a/src/hangul/optionAccessFailure.test.ts +++ b/src/hangul/optionAccessFailure.test.ts @@ -24,15 +24,15 @@ function unusedEngine(onUse: () => void): HangulDocumentEngine { }; } -const PARAGRAPH_DOCUMENT = Object.freeze({ +const PARAGRAPH_DOCUMENT = { type: 'doc', - content: Object.freeze([ - Object.freeze({ + content: [ + { type: 'paragraph', - content: Object.freeze([Object.freeze({ type: 'text', text: 'safe' })]), - }), - ]), -}); + content: [{ type: 'text', text: 'safe' }], + }, + ], +}; function expectInvalidOptions(result: Promise): Promise { return expect(result).rejects.toMatchObject({ From bbce45d0ff84d620bc77d55b3159a13f76559aea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 12:08:04 +0900 Subject: [PATCH 54/78] fix(hangul): contain hostile option access --- src/hangul/index.ts | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/src/hangul/index.ts b/src/hangul/index.ts index 644bbcfd..1f1473df 100644 --- a/src/hangul/index.ts +++ b/src/hangul/index.ts @@ -78,6 +78,18 @@ function isHangulDocumentError(error: unknown): error is HangulDocumentError { return HANGUL_DOCUMENT_ERRORS.has(error as object); } +/** Read a public Hangul option without allowing hostile accessors to leak values. */ +function readHangulOption(read: () => T): T { + try { + return read(); + } catch { + throw new HangulDocumentError( + 'INVALID_CONFIGURATION', + 'Hangul options are invalid.', + ); + } +} + /** Contain host cleanup failures without replacing an existing Inkspan failure. */ function freeHangulDocument( document: HangulEngineDocument, @@ -376,10 +388,13 @@ function jsonToHtml(documentJson: HangulDocumentJson): string { /** Project HWP/HWPX bytes into the editor's JSON model. */ export async function openHangulDocument(source: Uint8Array, options: OpenHangulDocumentOptions): Promise { - const maxSourceBytes = resolveHangulByteLimit(options.maxSourceBytes); + const engine = readHangulOption(() => options.engine); + const maxSourceBytes = resolveHangulByteLimit( + readHangulOption(() => options.maxSourceBytes), + ); const sourceSnapshot = snapshotHangulSource(source, maxSourceBytes); let document: HangulEngineDocument; - try { document = await options.engine.open(sourceSnapshot); } catch { throw new HangulDocumentError('ENGINE_OPEN_FAILED', 'The Hangul engine could not open the document.'); } + try { document = await engine.open(sourceSnapshot); } catch { throw new HangulDocumentError('ENGINE_OPEN_FAILED', 'The Hangul engine could not open the document.'); } let primaryError: HangulDocumentError | undefined; try { try { @@ -409,11 +424,16 @@ export async function openHangulDocument(source: Uint8Array, options: OpenHangul /** Export edited Inkspan JSON as HWPX by default or HWP explicitly. */ export async function exportHangulDocument(documentJson: HangulDocumentJson, options: ExportHangulDocumentOptions): Promise { - const maxOutputBytes = resolveHangulByteLimit(options.maxOutputBytes); - const format = resolveHangulExportFormat(options.format); + const engine = readHangulOption(() => options.engine); + const maxOutputBytes = resolveHangulByteLimit( + readHangulOption(() => options.maxOutputBytes), + ); + const format = resolveHangulExportFormat( + readHangulOption(() => options.format), + ); const html = jsonToHtml(documentJson); let document: HangulEngineDocument; - try { document = await options.engine.create(); } catch { throw new HangulDocumentError('ENGINE_CREATE_FAILED', 'The Hangul engine could not create a document.'); } + try { document = await engine.create(); } catch { throw new HangulDocumentError('ENGINE_CREATE_FAILED', 'The Hangul engine could not create a document.'); } let primaryError: HangulDocumentError | undefined; try { try { @@ -438,4 +458,4 @@ export async function exportHangulDocument(documentJson: HangulDocumentJson, opt } finally { freeHangulDocument(document, primaryError); } -} +} \ No newline at end of file From 2b072d6c796be4e82360f239082989eab530f229 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 12:08:18 +0900 Subject: [PATCH 55/78] test(hangul): use repository-relative documentation path --- src/hangul/documentationContract.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/hangul/documentationContract.test.ts b/src/hangul/documentationContract.test.ts index 5dfc02cc..f33db003 100644 --- a/src/hangul/documentationContract.test.ts +++ b/src/hangul/documentationContract.test.ts @@ -2,10 +2,7 @@ import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; -const hangulGuide = readFileSync( - new URL('../../docs/HANGUL.md', import.meta.url), - 'utf8', -); +const hangulGuide = readFileSync('docs/HANGUL.md', 'utf8'); describe('Hangul compatibility documentation', () => { it('documents the structures exercised by the public round-trip contract', () => { From f90d32a2482e4dd209b10d5c76e5920757995039 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 12:16:33 +0900 Subject: [PATCH 56/78] test(hangul): require public capability metadata --- src/hangul/capabilities.test.ts | 81 +++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 src/hangul/capabilities.test.ts diff --git a/src/hangul/capabilities.test.ts b/src/hangul/capabilities.test.ts new file mode 100644 index 00000000..52cfeb42 --- /dev/null +++ b/src/hangul/capabilities.test.ts @@ -0,0 +1,81 @@ +import { + openHangulDocument, + type HangulDocumentEngine, + type HangulEngineDocument, +} from './index.js'; + +class CapabilityDocument implements HangulEngineDocument { + getSourceFormat(): string { + return 'hwpx'; + } + + getSectionCount(): number { + return 1; + } + + getParagraphCount(): number { + return 1; + } + + getParagraphLength(): number { + return 4; + } + + exportSelectionHtml(): string { + return '

    Body

    '; + } + + deleteText(): string { + return '{"ok":true}'; + } + + pasteHtml(): string { + return '{"ok":true}'; + } + + exportHwp(): Uint8Array { + return new Uint8Array([1]); + } + + exportHwpx(): Uint8Array { + return new Uint8Array([2]); + } +} + +function createEngine(): HangulDocumentEngine { + return { + id: 'capability-test', + open: async () => new CapabilityDocument(), + create: async () => new CapabilityDocument(), + }; +} + +describe('Hangul public capability contract', () => { + it('returns the deterministic bridge capabilities required by the public import contract', async () => { + const result = await openHangulDocument(new Uint8Array([9]), { + engine: createEngine(), + }); + + expect(result.capabilities).toEqual({ + importFormats: ['hwp', 'hwpx'], + exportFormats: ['hwpx', 'hwp'], + recommendedExportFormat: 'hwpx', + supportedContent: [ + 'paragraph', + 'heading', + 'bold', + 'italic', + 'strike', + 'bulletList', + 'orderedList', + 'blockquote', + 'codeBlock', + 'table', + ], + }); + expect(Object.isFrozen(result.capabilities)).toBe(true); + expect(Object.isFrozen(result.capabilities.importFormats)).toBe(true); + expect(Object.isFrozen(result.capabilities.exportFormats)).toBe(true); + expect(Object.isFrozen(result.capabilities.supportedContent)).toBe(true); + }); +}); From 62a26e7f6d5ae3a7c519f9067cccbf348335f0e0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 12:19:54 +0900 Subject: [PATCH 57/78] fix(hangul): return deterministic capability metadata --- src/hangul/index.ts | 48 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/src/hangul/index.ts b/src/hangul/index.ts index 1f1473df..d6bc9888 100644 --- a/src/hangul/index.ts +++ b/src/hangul/index.ts @@ -48,11 +48,33 @@ export interface ExportHangulDocumentOptions { maxOutputBytes?: number; } +/** Structural content kinds the bounded Hangul bridge currently round-trips. */ +export type HangulSupportedContent = + | 'paragraph' + | 'heading' + | 'bold' + | 'italic' + | 'strike' + | 'bulletList' + | 'orderedList' + | 'blockquote' + | 'codeBlock' + | 'table'; + +/** Deterministic host-visible capability metadata for the bounded Hangul bridge. */ +export interface HangulDocumentCapabilities { + readonly importFormats: readonly ('hwp' | 'hwpx')[]; + readonly exportFormats: readonly ('hwpx' | 'hwp')[]; + readonly recommendedExportFormat: 'hwpx'; + readonly supportedContent: readonly HangulSupportedContent[]; +} + export interface HangulDocumentImportResult { sourceFormat: 'hwp' | 'hwpx'; documentJson: Readonly; warnings: readonly string[]; lossy: boolean; + capabilities: HangulDocumentCapabilities; } export interface HangulDocumentExportResult { @@ -61,6 +83,24 @@ export interface HangulDocumentExportResult { warnings: readonly string[]; } +const HANGUL_DOCUMENT_CAPABILITIES: HangulDocumentCapabilities = Object.freeze({ + importFormats: Object.freeze(['hwp', 'hwpx'] as const), + exportFormats: Object.freeze(['hwpx', 'hwp'] as const), + recommendedExportFormat: 'hwpx', + supportedContent: Object.freeze([ + 'paragraph', + 'heading', + 'bold', + 'italic', + 'strike', + 'bulletList', + 'orderedList', + 'blockquote', + 'codeBlock', + 'table', + ] as const), +}); + /** Module-owned identity brand that never reflects over untrusted thrown values. */ const HANGUL_DOCUMENT_ERRORS = new WeakSet(); @@ -407,7 +447,13 @@ export async function openHangulDocument(source: Uint8Array, options: OpenHangul } const documentJson = htmlToJson(html.join('')); Object.freeze(documentJson); - return { sourceFormat, documentJson, warnings: Object.freeze([]), lossy: false }; + return { + sourceFormat, + documentJson, + warnings: Object.freeze([]), + lossy: false, + capabilities: HANGUL_DOCUMENT_CAPABILITIES, + }; } catch (error) { primaryError = isHangulDocumentError(error) ? error From 3cbbc8c28dbeb1d031cd75fcd8368fb166d8d781 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 12:20:17 +0900 Subject: [PATCH 58/78] docs(hangul): document public capability metadata --- docs/HANGUL.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/HANGUL.md b/docs/HANGUL.md index 2b90da2d..63989728 100644 --- a/docs/HANGUL.md +++ b/docs/HANGUL.md @@ -10,7 +10,8 @@ Inkspan owns: - deterministic conversion rules; - stable error semantics; - byte/resource limits; -- explicit loss reporting. +- explicit loss reporting; +- deterministic public capability metadata for the bounded bridge. The host owns: @@ -36,11 +37,11 @@ sequenceDiagram Bridge->>Engine: open(bytes) Engine-->>Bridge: bounded document API Bridge->>Engine: source format / sections / HTML projection - Bridge-->>Host: { documentJson, sourceFormat, warnings, lossy } + Bridge-->>Host: { documentJson, sourceFormat, warnings, lossy, capabilities } Host->>Editor: setDocumentJson(documentJson) ``` -The original bytes remain host-owned. Importing a file does not mutate it. +The original bytes remain host-owned. Importing a file does not mutate it. The returned `capabilities` object is frozen, deterministic Inkspan metadata: it declares `importFormats`, `exportFormats`, `recommendedExportFormat`, and the currently round-trippable `supportedContent`. Hosts can use that metadata for UI and routing without probing the host engine or inferring support from failures. ## Export flow @@ -93,7 +94,7 @@ async function saveAsHwpx( ## Compatibility contract -The initial bridge deliberately supports a bounded semantic subset and rejects unsupported export nodes instead of silently deleting them. The compatibility matrix expands only when real HWP/HWPX fixtures demonstrate stable round-trip behavior. +The initial bridge deliberately supports a bounded semantic subset and rejects unsupported export nodes instead of silently deleting them. The compatibility matrix expands only when real HWP/HWPX fixtures demonstrate stable round-trip behavior. `capabilities.supportedContent` is the machine-consumable projection of the same currently implemented subset; this table remains the human-readable contract and limitation guide. | Content | Import | Export | Notes | |---|---|---|---| From b0aa7889315460a9bf665d496c7943442850d42e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 12:20:31 +0900 Subject: [PATCH 59/78] test(hangul): bind guide to capability contract --- src/hangul/documentationContract.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/hangul/documentationContract.test.ts b/src/hangul/documentationContract.test.ts index f33db003..d54e7f01 100644 --- a/src/hangul/documentationContract.test.ts +++ b/src/hangul/documentationContract.test.ts @@ -21,4 +21,15 @@ describe('Hangul compatibility documentation', () => { expect(hangulGuide).not.toContain('| Lists | Planned | Planned |'); expect(hangulGuide).not.toContain('| Tables | Planned | Planned |'); }); + + it('documents the capability metadata returned by the public import API', () => { + expect(hangulGuide).toContain( + '{ documentJson, sourceFormat, warnings, lossy, capabilities }', + ); + expect(hangulGuide).toContain('`capabilities` object is frozen'); + expect(hangulGuide).toContain('`importFormats`'); + expect(hangulGuide).toContain('`exportFormats`'); + expect(hangulGuide).toContain('`recommendedExportFormat`'); + expect(hangulGuide).toContain('`supportedContent`'); + }); }); From 5dd74fecf9dadbe44c945c1a6a9daeb2183be600 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 14:04:40 +0900 Subject: [PATCH 60/78] test(hangul): reject malformed engine structural metadata --- src/hangul/engineMetadataValidation.test.ts | 74 +++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 src/hangul/engineMetadataValidation.test.ts diff --git a/src/hangul/engineMetadataValidation.test.ts b/src/hangul/engineMetadataValidation.test.ts new file mode 100644 index 00000000..559869c4 --- /dev/null +++ b/src/hangul/engineMetadataValidation.test.ts @@ -0,0 +1,74 @@ +import { + openHangulDocument, + type HangulDocumentEngine, + type HangulEngineDocument, +} from './index.js'; + +function createDocument(overrides: Partial): HangulEngineDocument { + return { + getSourceFormat: () => 'hwpx', + getSectionCount: () => 1, + getParagraphCount: () => 1, + getParagraphLength: () => 1, + exportSelectionHtml: () => '

    x

    ', + deleteText: () => '{"ok":true}', + pasteHtml: () => '{"ok":true}', + exportHwp: () => new Uint8Array([1]), + exportHwpx: () => new Uint8Array([1]), + ...overrides, + }; +} + +function createEngine(document: HangulEngineDocument): HangulDocumentEngine { + return { + id: 'metadata-validation', + open: async () => document, + create: async () => document, + }; +} + +const EXPECTED_IMPORT_FAILURE = { + code: 'ENGINE_OPERATION_FAILED', + message: 'The Hangul engine failed during import.', +}; + +describe('Hangul engine structural metadata validation', () => { + it('rejects a fractional section count before traversing section data', async () => { + const getParagraphCount = vi.fn(() => 0); + const document = createDocument({ + getSectionCount: () => 1.5, + getParagraphCount, + }); + + await expect( + openHangulDocument(new Uint8Array([1]), { engine: createEngine(document) }), + ).rejects.toMatchObject(EXPECTED_IMPORT_FAILURE); + expect(getParagraphCount).not.toHaveBeenCalled(); + }); + + it('rejects a negative paragraph count before exporting section HTML', async () => { + const exportSelectionHtml = vi.fn(() => '

    private

    '); + const document = createDocument({ + getParagraphCount: () => -1, + exportSelectionHtml, + }); + + await expect( + openHangulDocument(new Uint8Array([1]), { engine: createEngine(document) }), + ).rejects.toMatchObject(EXPECTED_IMPORT_FAILURE); + expect(exportSelectionHtml).not.toHaveBeenCalled(); + }); + + it('rejects a fractional paragraph length before passing it to the host export boundary', async () => { + const exportSelectionHtml = vi.fn(() => '

    private

    '); + const document = createDocument({ + getParagraphLength: () => 1.5, + exportSelectionHtml, + }); + + await expect( + openHangulDocument(new Uint8Array([1]), { engine: createEngine(document) }), + ).rejects.toMatchObject(EXPECTED_IMPORT_FAILURE); + expect(exportSelectionHtml).not.toHaveBeenCalled(); + }); +}); From f39ed0029930c5c9af111d8bdba7986f8ccbedd2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 14:07:23 +0900 Subject: [PATCH 61/78] test(hangul): require stable single-read section metadata --- src/hangul/engineMetadataValidation.test.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/hangul/engineMetadataValidation.test.ts b/src/hangul/engineMetadataValidation.test.ts index 559869c4..c5d1d8f6 100644 --- a/src/hangul/engineMetadataValidation.test.ts +++ b/src/hangul/engineMetadataValidation.test.ts @@ -71,4 +71,19 @@ describe('Hangul engine structural metadata validation', () => { ).rejects.toMatchObject(EXPECTED_IMPORT_FAILURE); expect(exportSelectionHtml).not.toHaveBeenCalled(); }); -}); + + it('reads the section count once so a stateful host cannot move the traversal bound', async () => { + const getSectionCount = vi.fn(() => 1); + const document = createDocument({ getSectionCount }); + + const result = await openHangulDocument(new Uint8Array([1]), { + engine: createEngine(document), + }); + + expect(result.documentJson).toEqual({ + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'x' }] }], + }); + expect(getSectionCount).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file From a5ce70ad8eda054c57c5bb71aa9f14acf91c4563 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 14:11:07 +0900 Subject: [PATCH 62/78] test(hangul): reject malformed export structural metadata --- src/hangul/engineMetadataValidation.test.ts | 28 +++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/hangul/engineMetadataValidation.test.ts b/src/hangul/engineMetadataValidation.test.ts index c5d1d8f6..ad39b260 100644 --- a/src/hangul/engineMetadataValidation.test.ts +++ b/src/hangul/engineMetadataValidation.test.ts @@ -1,4 +1,5 @@ import { + exportHangulDocument, openHangulDocument, type HangulDocumentEngine, type HangulEngineDocument, @@ -32,6 +33,11 @@ const EXPECTED_IMPORT_FAILURE = { message: 'The Hangul engine failed during import.', }; +const EXPECTED_EXPORT_FAILURE = { + code: 'ENGINE_OPERATION_FAILED', + message: 'The Hangul engine failed during export.', +}; + describe('Hangul engine structural metadata validation', () => { it('rejects a fractional section count before traversing section data', async () => { const getParagraphCount = vi.fn(() => 0); @@ -86,4 +92,26 @@ describe('Hangul engine structural metadata validation', () => { }); expect(getSectionCount).toHaveBeenCalledTimes(1); }); + + it('rejects a fractional export paragraph length before mutating the host document', async () => { + const deleteText = vi.fn(() => '{"ok":true}'); + const pasteHtml = vi.fn(() => '{"ok":true}'); + const document = createDocument({ + getParagraphLength: () => 1.5, + deleteText, + pasteHtml, + }); + + await expect( + exportHangulDocument( + { + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'x' }] }], + }, + { engine: createEngine(document) }, + ), + ).rejects.toMatchObject(EXPECTED_EXPORT_FAILURE); + expect(deleteText).not.toHaveBeenCalled(); + expect(pasteHtml).not.toHaveBeenCalled(); + }); }); \ No newline at end of file From f318699797e701c43369d1da47e4d397c6a570b0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 14:13:30 +0900 Subject: [PATCH 63/78] fix(hangul): validate engine structural metadata --- src/hangul/index.ts | 50 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/src/hangul/index.ts b/src/hangul/index.ts index d6bc9888..0913ecf5 100644 --- a/src/hangul/index.ts +++ b/src/hangul/index.ts @@ -149,6 +149,8 @@ function freeHangulDocument( const TEXT_ALIGNMENTS = new Set(['left', 'center', 'right', 'justify']); const DEFAULT_MAX_DOCUMENT_BYTES = 64 * 1024 * 1024; +const HANGUL_IMPORT_FAILURE_MESSAGE = 'The Hangul engine failed during import.'; +const HANGUL_EXPORT_FAILURE_MESSAGE = 'The Hangul engine failed during export.'; const TYPED_ARRAY_PROTOTYPE = Object.getPrototypeOf( Uint8Array.prototype, ) as object; @@ -177,6 +179,14 @@ function resolveHangulByteLimit(limit: number | undefined): number { return resolved; } +/** Validate host-engine traversal metadata before using it as an index or bound. */ +function resolveHangulEngineCount(value: number, failureMessage: string): number { + if (!Number.isSafeInteger(value) || value < 0) { + throw new HangulDocumentError('ENGINE_OPERATION_FAILED', failureMessage); + } + return value; +} + /** Validate the runtime export selector before the host engine receives authority. */ function resolveHangulExportFormat(format: unknown): 'hwp' | 'hwpx' { const resolved = format === undefined ? 'hwpx' : format; @@ -235,14 +245,14 @@ function snapshotHangulOutput(source: Uint8Array, maxOutputBytes: number): Uint8 } catch { throw new HangulDocumentError( 'ENGINE_OPERATION_FAILED', - 'The Hangul engine failed during export.', + HANGUL_EXPORT_FAILURE_MESSAGE, ); } if (!(buffer instanceof ArrayBuffer)) { throw new HangulDocumentError( 'ENGINE_OPERATION_FAILED', - 'The Hangul engine failed during export.', + HANGUL_EXPORT_FAILURE_MESSAGE, ); } if (byteLength > maxOutputBytes) { @@ -441,9 +451,30 @@ export async function openHangulDocument(source: Uint8Array, options: OpenHangul const sourceFormat = document.getSourceFormat().toLowerCase(); if (sourceFormat !== 'hwp' && sourceFormat !== 'hwpx') throw new HangulDocumentError('UNSUPPORTED_SOURCE_FORMAT', 'Unsupported Hangul source format.'); const html: string[] = []; - for (let section = 0; section < document.getSectionCount(); section += 1) { - const count = document.getParagraphCount(section); - if (count > 0) html.push(document.exportSelectionHtml(section, 0, 0, count - 1, document.getParagraphLength(section, count - 1))); + const sectionCount = resolveHangulEngineCount( + document.getSectionCount(), + HANGUL_IMPORT_FAILURE_MESSAGE, + ); + for (let section = 0; section < sectionCount; section += 1) { + const count = resolveHangulEngineCount( + document.getParagraphCount(section), + HANGUL_IMPORT_FAILURE_MESSAGE, + ); + if (count > 0) { + const paragraphLength = resolveHangulEngineCount( + document.getParagraphLength(section, count - 1), + HANGUL_IMPORT_FAILURE_MESSAGE, + ); + html.push( + document.exportSelectionHtml( + section, + 0, + 0, + count - 1, + paragraphLength, + ), + ); + } } const documentJson = htmlToJson(html.join('')); Object.freeze(documentJson); @@ -459,7 +490,7 @@ export async function openHangulDocument(source: Uint8Array, options: OpenHangul ? error : new HangulDocumentError( 'ENGINE_OPERATION_FAILED', - 'The Hangul engine failed during import.', + HANGUL_IMPORT_FAILURE_MESSAGE, ); throw primaryError; } @@ -485,7 +516,10 @@ export async function exportHangulDocument(documentJson: HangulDocumentJson, opt try { document.createBlankDocument?.(); document.beginBatch?.(); - const length = document.getParagraphLength(0, 0); + const length = resolveHangulEngineCount( + document.getParagraphLength(0, 0), + HANGUL_EXPORT_FAILURE_MESSAGE, + ); if (length > 0) document.deleteText(0, 0, 0, length); document.pasteHtml(0, 0, 0, html); document.endBatch?.(); @@ -497,7 +531,7 @@ export async function exportHangulDocument(documentJson: HangulDocumentJson, opt ? error : new HangulDocumentError( 'ENGINE_OPERATION_FAILED', - 'The Hangul engine failed during export.', + HANGUL_EXPORT_FAILURE_MESSAGE, ); throw primaryError; } From cf5060b20581e4847b42337c3cfa84fae795eb22 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 15:57:50 +0900 Subject: [PATCH 64/78] test(hangul): contain hostile document JSON access --- .../documentJsonFailureContainment.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/hangul/documentJsonFailureContainment.test.ts diff --git a/src/hangul/documentJsonFailureContainment.test.ts b/src/hangul/documentJsonFailureContainment.test.ts new file mode 100644 index 00000000..98a94fcf --- /dev/null +++ b/src/hangul/documentJsonFailureContainment.test.ts @@ -0,0 +1,37 @@ +import { + exportHangulDocument, + type HangulDocumentEngine, +} from './index.js'; + +describe('Hangul export document JSON failure containment', () => { + it('rejects hostile document access without leaking the thrown value or creating the engine', async () => { + const privateSentinel = { secret: 'private-document-json-sentinel' }; + const create = vi.fn(async () => { + throw new Error('engine create should not run'); + }); + const engine: HangulDocumentEngine = { + id: 'hostile-document-json-sentinel', + open: vi.fn(async () => { + throw new Error('engine open should not run'); + }), + create, + }; + const documentJson = new Proxy( + {}, + { + get() { + throw privateSentinel; + }, + }, + ) as Parameters[0]; + + await expect( + exportHangulDocument(documentJson, { engine }), + ).rejects.toMatchObject({ + name: 'HangulDocumentError', + code: 'INVALID_DOCUMENT', + message: 'Hangul document JSON is invalid.', + }); + expect(create).not.toHaveBeenCalled(); + }); +}); From d90333a8934762301c666a55089cf40612c5899d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 23:17:46 +0900 Subject: [PATCH 65/78] fix(hangul): contain document JSON inspection failures --- src/hangul/index.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/hangul/index.ts b/src/hangul/index.ts index 0913ecf5..2b6d9328 100644 --- a/src/hangul/index.ts +++ b/src/hangul/index.ts @@ -436,6 +436,19 @@ function jsonToHtml(documentJson: HangulDocumentJson): string { return contentOf(documentJson).map(renderBlock).join(''); } +/** Render caller-provided document JSON without allowing hostile access failures to escape. */ +function renderHangulDocumentJson(documentJson: HangulDocumentJson): string { + try { + return jsonToHtml(documentJson); + } catch (error) { + if (isHangulDocumentError(error)) throw error; + throw new HangulDocumentError( + 'INVALID_DOCUMENT', + 'Hangul document JSON is invalid.', + ); + } +} + /** Project HWP/HWPX bytes into the editor's JSON model. */ export async function openHangulDocument(source: Uint8Array, options: OpenHangulDocumentOptions): Promise { const engine = readHangulOption(() => options.engine); @@ -508,7 +521,7 @@ export async function exportHangulDocument(documentJson: HangulDocumentJson, opt const format = resolveHangulExportFormat( readHangulOption(() => options.format), ); - const html = jsonToHtml(documentJson); + const html = renderHangulDocumentJson(documentJson); let document: HangulEngineDocument; try { document = await engine.create(); } catch { throw new HangulDocumentError('ENGINE_CREATE_FAILED', 'The Hangul engine could not create a document.'); } let primaryError: HangulDocumentError | undefined; From 2b39a64af9c8a7cd7fedeab2421f3e6958cbcb9e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 23:27:14 +0900 Subject: [PATCH 66/78] test(hangul): preserve direct list item content --- src/hangul/listImportIntegrity.test.ts | 84 ++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 src/hangul/listImportIntegrity.test.ts diff --git a/src/hangul/listImportIntegrity.test.ts b/src/hangul/listImportIntegrity.test.ts new file mode 100644 index 00000000..9547282a --- /dev/null +++ b/src/hangul/listImportIntegrity.test.ts @@ -0,0 +1,84 @@ +import { + openHangulDocument, + type HangulDocumentEngine, + type HangulEngineDocument, +} from './index.js'; + +function engineReturning(html: string): HangulDocumentEngine { + const source: HangulEngineDocument = { + getSourceFormat: () => 'hwpx', + getSectionCount: () => 1, + getParagraphCount: () => 1, + getParagraphLength: () => 1, + exportSelectionHtml: () => html, + deleteText: () => '{"ok":true}', + pasteHtml: () => '{"ok":true}', + exportHwp: () => new Uint8Array(), + exportHwpx: () => new Uint8Array(), + }; + return { + id: 'list-import-integrity', + open: async () => source, + create: async () => source, + }; +} + +describe('Hangul list import integrity', () => { + it('preserves direct inline list-item content as a paragraph', async () => { + const result = await openHangulDocument(new Uint8Array([1]), { + engine: engineReturning( + '
    • Direct bold text
    ', + ), + }); + + expect(result.documentJson).toEqual({ + type: 'doc', + content: [ + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Direct ' }, + { + type: 'text', + text: 'bold', + marks: [{ type: 'bold' }], + }, + { type: 'text', text: ' text' }, + ], + }, + ], + }, + ], + }, + ], + }); + }); + + it('rejects mixed direct inline and block list-item content instead of dropping text', async () => { + const privateText = 'private-direct-content'; + let caught: unknown; + + try { + await openHangulDocument(new Uint8Array([1]), { + engine: engineReturning( + `
    • ${privateText}

      Block

    `, + ), + }); + } catch (error) { + caught = error; + } + + expect(caught).toMatchObject({ + name: 'HangulDocumentError', + code: 'UNSUPPORTED_DOCUMENT_NODE', + message: 'Hangul import contains an unsupported block node.', + }); + expect((caught as Error).message).not.toContain(privateText); + }); +}); From 9f78831b70d9a1f1e55ac381a0ba8367fff19c22 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 01:13:30 +0900 Subject: [PATCH 67/78] fix(hangul): preserve direct list item content --- src/hangul/index.ts | 46 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/src/hangul/index.ts b/src/hangul/index.ts index 2b6d9328..a79fd404 100644 --- a/src/hangul/index.ts +++ b/src/hangul/index.ts @@ -311,13 +311,51 @@ function parseParagraph(element: Element): HangulDocumentJson { : { type: 'paragraph', attrs: { textAlign }, content }; } +function isListBlockElement(element: Element): boolean { + const tag = element.tagName.toLowerCase(); + return ( + /^h[1-6]$/u.test(tag) || + tag === 'ul' || + tag === 'ol' || + tag === 'blockquote' || + tag === 'pre' || + tag === 'table' || + tag === 'p' + ); +} + +function parseListItem(item: Element): HangulDocumentJson { + const blockChildren = Array.from(item.children).filter(isListBlockElement); + if (blockChildren.length === 0) { + return { + type: 'listItem', + content: [{ type: 'paragraph', content: parseInline(item) }], + }; + } + + const hasDirectInlineContent = Array.from(item.childNodes).some((child) => { + if (child.nodeType === Node.TEXT_NODE) { + return (child.textContent ?? '').trim().length > 0; + } + return child instanceof Element && !isListBlockElement(child); + }); + if (hasDirectInlineContent || blockChildren.length !== item.children.length) { + throw new HangulDocumentError( + 'UNSUPPORTED_DOCUMENT_NODE', + 'Hangul import contains an unsupported block node.', + ); + } + + return { + type: 'listItem', + content: blockChildren.map(parseBlock), + }; +} + function parseList(element: Element, type: 'bulletList' | 'orderedList'): HangulDocumentJson { return { type, - content: Array.from(element.children).map((item) => ({ - type: 'listItem', - content: Array.from(item.children).map(parseBlock), - })), + content: Array.from(element.children).map(parseListItem), }; } From 666025c48173b719250bd4b76c5bd6e4d70e77d3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 01:16:41 +0900 Subject: [PATCH 68/78] test(hangul): cover mixed inline element list content --- src/hangul/listImportIntegrity.test.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/hangul/listImportIntegrity.test.ts b/src/hangul/listImportIntegrity.test.ts index 9547282a..81f9f733 100644 --- a/src/hangul/listImportIntegrity.test.ts +++ b/src/hangul/listImportIntegrity.test.ts @@ -60,7 +60,7 @@ describe('Hangul list import integrity', () => { }); }); - it('rejects mixed direct inline and block list-item content instead of dropping text', async () => { + it('rejects mixed direct text and block list-item content instead of dropping text', async () => { const privateText = 'private-direct-content'; let caught: unknown; @@ -81,4 +81,26 @@ describe('Hangul list import integrity', () => { }); expect((caught as Error).message).not.toContain(privateText); }); + + it('rejects mixed direct inline elements and block list-item content instead of dropping text', async () => { + const privateText = 'private-inline-content'; + let caught: unknown; + + try { + await openHangulDocument(new Uint8Array([1]), { + engine: engineReturning( + `
    • ${privateText}

      Block

    `, + ), + }); + } catch (error) { + caught = error; + } + + expect(caught).toMatchObject({ + name: 'HangulDocumentError', + code: 'UNSUPPORTED_DOCUMENT_NODE', + message: 'Hangul import contains an unsupported block node.', + }); + expect((caught as Error).message).not.toContain(privateText); + }); }); From a602c22db522feaf192dc00f24610f138e6d8d09 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 01:21:56 +0900 Subject: [PATCH 69/78] fix(hangul): make text-node list invariant explicit --- src/hangul/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hangul/index.ts b/src/hangul/index.ts index a79fd404..5ac4036d 100644 --- a/src/hangul/index.ts +++ b/src/hangul/index.ts @@ -335,7 +335,7 @@ function parseListItem(item: Element): HangulDocumentJson { const hasDirectInlineContent = Array.from(item.childNodes).some((child) => { if (child.nodeType === Node.TEXT_NODE) { - return (child.textContent ?? '').trim().length > 0; + return (child as Text).data.trim().length > 0; } return child instanceof Element && !isListBlockElement(child); }); From a82d638509e64fde1d2c279ff3660e1c47ab867d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 03:03:08 +0900 Subject: [PATCH 70/78] test(hangul): reject non-string engine text before member access --- src/hangul/importOperationFailure.test.ts | 70 +++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/src/hangul/importOperationFailure.test.ts b/src/hangul/importOperationFailure.test.ts index dd06380e..82274ac8 100644 --- a/src/hangul/importOperationFailure.test.ts +++ b/src/hangul/importOperationFailure.test.ts @@ -54,4 +54,74 @@ describe('Hangul import engine-operation boundary', () => { expect(prototypeReads).toBe(0); expect(freed).toBe(true); }); + + it('rejects a non-string source format before caller member access', async () => { + let memberReads = 0; + const hostileFormat = new Proxy(Object.create(null) as object, { + get() { + memberReads += 1; + throw new Error('private-source-format-member-sentinel'); + }, + }); + const document: HangulEngineDocument = { + getSourceFormat: () => hostileFormat as unknown as string, + getSectionCount: () => 0, + getParagraphCount: () => 0, + getParagraphLength: () => 0, + exportSelectionHtml: () => '', + deleteText: () => '', + pasteHtml: () => '', + exportHwp: () => new Uint8Array(), + exportHwpx: () => new Uint8Array(), + }; + const engine: HangulDocumentEngine = { + id: 'non-string-source-format-test', + open: async () => document, + create: async () => document, + }; + + await expect( + openHangulDocument(new Uint8Array([0x48]), { engine }), + ).rejects.toMatchObject({ + name: 'HangulDocumentError', + code: 'ENGINE_OPERATION_FAILED', + message: 'The Hangul engine failed during import.', + }); + expect(memberReads).toBe(0); + }); + + it('rejects non-string selection HTML before caller coercion', async () => { + let coercionReads = 0; + const hostileHtml = new Proxy(Object.create(null) as object, { + get() { + coercionReads += 1; + throw new Error('private-selection-html-coercion-sentinel'); + }, + }); + const document: HangulEngineDocument = { + getSourceFormat: () => 'hwpx', + getSectionCount: () => 1, + getParagraphCount: () => 1, + getParagraphLength: () => 0, + exportSelectionHtml: () => hostileHtml as unknown as string, + deleteText: () => '', + pasteHtml: () => '', + exportHwp: () => new Uint8Array(), + exportHwpx: () => new Uint8Array(), + }; + const engine: HangulDocumentEngine = { + id: 'non-string-selection-html-test', + open: async () => document, + create: async () => document, + }; + + await expect( + openHangulDocument(new Uint8Array([0x48]), { engine }), + ).rejects.toMatchObject({ + name: 'HangulDocumentError', + code: 'ENGINE_OPERATION_FAILED', + message: 'The Hangul engine failed during import.', + }); + expect(coercionReads).toBe(0); + }); }); From 38e0ff07154160f94ff464d47e6477bec0d1217c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 03:08:45 +0900 Subject: [PATCH 71/78] fix(hangul): validate host engine text before use --- src/hangul/index.ts | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/hangul/index.ts b/src/hangul/index.ts index 5ac4036d..dec7fb33 100644 --- a/src/hangul/index.ts +++ b/src/hangul/index.ts @@ -187,6 +187,14 @@ function resolveHangulEngineCount(value: number, failureMessage: string): number return value; } +/** Validate host-engine text before caller member access or coercion. */ +function resolveHangulEngineString(value: unknown, failureMessage: string): string { + if (typeof value !== 'string') { + throw new HangulDocumentError('ENGINE_OPERATION_FAILED', failureMessage); + } + return value; +} + /** Validate the runtime export selector before the host engine receives authority. */ function resolveHangulExportFormat(format: unknown): 'hwp' | 'hwpx' { const resolved = format === undefined ? 'hwpx' : format; @@ -499,7 +507,10 @@ export async function openHangulDocument(source: Uint8Array, options: OpenHangul let primaryError: HangulDocumentError | undefined; try { try { - const sourceFormat = document.getSourceFormat().toLowerCase(); + const sourceFormat = resolveHangulEngineString( + document.getSourceFormat(), + HANGUL_IMPORT_FAILURE_MESSAGE, + ).toLowerCase(); if (sourceFormat !== 'hwp' && sourceFormat !== 'hwpx') throw new HangulDocumentError('UNSUPPORTED_SOURCE_FORMAT', 'Unsupported Hangul source format.'); const html: string[] = []; const sectionCount = resolveHangulEngineCount( @@ -517,12 +528,15 @@ export async function openHangulDocument(source: Uint8Array, options: OpenHangul HANGUL_IMPORT_FAILURE_MESSAGE, ); html.push( - document.exportSelectionHtml( - section, - 0, - 0, - count - 1, - paragraphLength, + resolveHangulEngineString( + document.exportSelectionHtml( + section, + 0, + 0, + count - 1, + paragraphLength, + ), + HANGUL_IMPORT_FAILURE_MESSAGE, ), ); } From e098292b7c1795c5965f26af8995454de28054c6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 18:08:29 +0000 Subject: [PATCH 72/78] test(hangul): project known HWP/HWPX fixtures Add committed synthetic OWPML briefing and shape documents, wrap them as HWPX ZIP and legacy HWP containers, and require the public bridge to return the expected paragraphs/tables or fail closed. Co-authored-by: Seongho Bae --- src/hangul/documentFixtures.test.ts | 645 ++++++++++++++++++ .../fixtures/briefing-minutes.section.xml | 55 ++ .../fixtures/unsupported-shape.section.xml | 11 + 3 files changed, 711 insertions(+) create mode 100644 src/hangul/documentFixtures.test.ts create mode 100644 src/hangul/fixtures/briefing-minutes.section.xml create mode 100644 src/hangul/fixtures/unsupported-shape.section.xml diff --git a/src/hangul/documentFixtures.test.ts b/src/hangul/documentFixtures.test.ts new file mode 100644 index 00000000..eebb4c58 --- /dev/null +++ b/src/hangul/documentFixtures.test.ts @@ -0,0 +1,645 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { crc32 } from 'node:zlib'; + +import { + exportHangulDocument, + openHangulDocument, + type HangulDocumentEngine, + type HangulEngineDocument, +} from './index.js'; + +const FIXTURES_DIR = join(process.cwd(), 'src/hangul/fixtures'); +const BRIEFING_MINUTES_XML = readFileSync( + join(FIXTURES_DIR, 'briefing-minutes.section.xml'), + 'utf8', +); +const UNSUPPORTED_SHAPE_XML = readFileSync( + join(FIXTURES_DIR, 'unsupported-shape.section.xml'), + 'utf8', +); +const TEXT_ENCODER = new TextEncoder(); +const TEXT_DECODER = new TextDecoder(); +const OLE_MAGIC = Uint8Array.from([ + 0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1, +]); +const HWP_FIXTURE_MARKER = TEXT_ENCODER.encode('INKSPAN-HWP-FIXTURE\0'); +const HWPX_MIME_TYPE = 'application/hwp+zip'; +const HWPX_VERSION_XML = + '1.0'; +const OWPML_SECTION_NAMESPACES = + 'xmlns:hs="http://www.hancom.co.kr/hwpml/2011/section" xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph"'; +const UNSUPPORTED_OWPML_BLOCKS = new Set([ + 'rect', + 'line', + 'ellipse', + 'arc', + 'polygon', + 'curve', + 'equation', + 'chart', + 'pic', + 'ole', + 'btn', + 'video', +]); + +const BRIEFING_MINUTES_JSON = { + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 1 }, + content: [{ type: 'text', text: 'Briefing Minutes' }], + }, + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Attendees reviewed the quarterly status report.', + }, + ], + }, + { + type: 'table', + content: [ + { + type: 'tableRow', + content: [ + { + type: 'tableHeader', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Topic Name' }], + }, + ], + }, + { + type: 'tableHeader', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Owner Team' }], + }, + ], + }, + ], + }, + { + type: 'tableRow', + content: [ + { + type: 'tableCell', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Budget Review' }], + }, + ], + }, + { + type: 'tableCell', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Finance Team' }], + }, + ], + }, + ], + }, + ], + }, + ], +}; + +interface ZipEntry { + name: string; + data: Uint8Array; +} + +/** Concatenate owned byte parts into one exact buffer. */ +function concatBytes(parts: readonly Uint8Array[]): Uint8Array { + const total = parts.reduce((sum, part) => sum + part.length, 0); + const output = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + output.set(part, offset); + offset += part.length; + } + return output; +} + +/** Encode a little-endian unsigned 16-bit value. */ +function encodeUint16(value: number): Uint8Array { + const output = new Uint8Array(2); + new DataView(output.buffer).setUint16(0, value, true); + return output; +} + +/** Encode a little-endian unsigned 32-bit value. */ +function encodeUint32(value: number): Uint8Array { + const output = new Uint8Array(4); + new DataView(output.buffer).setUint32(0, value, true); + return output; +} + +/** Read a little-endian unsigned 16-bit value. */ +function readUint16(source: Uint8Array, offset: number): number { + return new DataView(source.buffer, source.byteOffset, source.byteLength).getUint16( + offset, + true, + ); +} + +/** Read a little-endian unsigned 32-bit value. */ +function readUint32(source: Uint8Array, offset: number): number { + return new DataView(source.buffer, source.byteOffset, source.byteLength).getUint32( + offset, + true, + ); +} + +/** Build an uncompressed ZIP container for a synthetic HWPX fixture. */ +function buildZip(entries: readonly ZipEntry[]): Uint8Array { + const locals: Uint8Array[] = []; + const centrals: Uint8Array[] = []; + let offset = 0; + for (const entry of entries) { + const name = TEXT_ENCODER.encode(entry.name); + const crc = crc32(entry.data) >>> 0; + const local = concatBytes([ + encodeUint32(0x04034b50), + encodeUint16(20), + encodeUint16(0), + encodeUint16(0), + encodeUint16(0), + encodeUint16(0), + encodeUint32(crc), + encodeUint32(entry.data.length), + encodeUint32(entry.data.length), + encodeUint16(name.length), + encodeUint16(0), + name, + entry.data, + ]); + locals.push(local); + centrals.push( + concatBytes([ + encodeUint32(0x02014b50), + encodeUint16(20), + encodeUint16(20), + encodeUint16(0), + encodeUint16(0), + encodeUint16(0), + encodeUint16(0), + encodeUint32(crc), + encodeUint32(entry.data.length), + encodeUint32(entry.data.length), + encodeUint16(name.length), + encodeUint16(0), + encodeUint16(0), + encodeUint16(0), + encodeUint16(0), + encodeUint32(0), + encodeUint32(offset), + name, + ]), + ); + offset += local.length; + } + const centralDirectory = concatBytes(centrals); + return concatBytes([ + ...locals, + centralDirectory, + encodeUint32(0x06054b50), + encodeUint16(0), + encodeUint16(0), + encodeUint16(entries.length), + encodeUint16(entries.length), + encodeUint32(centralDirectory.length), + encodeUint32(offset), + encodeUint16(0), + ]); +} + +/** Read uncompressed ZIP entries from a synthetic HWPX fixture. */ +function readZip(source: Uint8Array): ZipEntry[] { + if (source.length < 22 || readUint32(source, source.length - 22) !== 0x06054b50) { + throw new Error('invalid hangul fixture'); + } + const entryCount = readUint16(source, source.length - 12); + let centralOffset = readUint32(source, source.length - 6); + const entries: ZipEntry[] = []; + for (let index = 0; index < entryCount; index += 1) { + if (readUint32(source, centralOffset) !== 0x02014b50) { + throw new Error('invalid hangul fixture'); + } + const nameLength = readUint16(source, centralOffset + 28); + const extraLength = readUint16(source, centralOffset + 30); + const commentLength = readUint16(source, centralOffset + 32); + const localOffset = readUint32(source, centralOffset + 42); + const name = TEXT_DECODER.decode( + source.subarray(centralOffset + 46, centralOffset + 46 + nameLength), + ); + const localNameLength = readUint16(source, localOffset + 26); + const localExtraLength = readUint16(source, localOffset + 28); + const dataStart = localOffset + 30 + localNameLength + localExtraLength; + const size = readUint32(source, localOffset + 18); + entries.push({ + name, + data: source.subarray(dataStart, dataStart + size), + }); + centralOffset += 46 + nameLength + extraLength + commentLength; + } + return entries; +} + +/** Wrap one OWPML section as a synthetic HWPX ZIP container. */ +function buildHwpxFixture(sectionXml: string): Uint8Array { + return buildZip([ + { name: 'mimetype', data: TEXT_ENCODER.encode(HWPX_MIME_TYPE) }, + { name: 'version.xml', data: TEXT_ENCODER.encode(HWPX_VERSION_XML) }, + { name: 'Contents/section0.xml', data: TEXT_ENCODER.encode(sectionXml) }, + ]); +} + +/** Wrap one OWPML section as a synthetic legacy HWP fixture container. */ +function buildHwpFixture(sectionXml: string): Uint8Array { + return concatBytes([ + OLE_MAGIC, + HWP_FIXTURE_MARKER, + TEXT_ENCODER.encode(sectionXml), + ]); +} + +/** Return whether the snapshot begins with the OLE compound-document magic. */ +function hasOleMagic(source: Uint8Array): boolean { + return ( + source.length >= OLE_MAGIC.length && + OLE_MAGIC.every((value, index) => source[index] === value) + ); +} + +/** Extract the OWPML section from a synthetic HWP fixture. */ +function readHwpSection(source: Uint8Array): string { + const markerStart = OLE_MAGIC.length; + const markerEnd = markerStart + HWP_FIXTURE_MARKER.length; + const marker = source.subarray(markerStart, markerEnd); + if ( + marker.length !== HWP_FIXTURE_MARKER.length || + HWP_FIXTURE_MARKER.some((value, index) => marker[index] !== value) + ) { + throw new Error('invalid hangul fixture'); + } + return TEXT_DECODER.decode(source.subarray(markerEnd)); +} + +/** Extract the OWPML section from a synthetic HWPX ZIP fixture. */ +function readHwpxSection(source: Uint8Array): string { + const section = readZip(source).find((entry) => entry.name === 'Contents/section0.xml'); + if (section === undefined) { + throw new Error('invalid hangul fixture'); + } + return TEXT_DECODER.decode(section.data); +} + +/** Escape text that will be placed into the HTML projection. */ +function escapeHtml(value: string): string { + return value.replace(/&/gu, '&').replace(//gu, '>'); +} + +/** Read the significant text of one OWPML run without pretty-print whitespace. */ +function runText(run: Element): string { + return Array.from(run.children) + .filter((child) => child.localName === 't') + .map((text) => text.textContent ?? '') + .join(''); +} + +/** Project OWPML inline runs into the HTML the public bridge already accepts. */ +function projectRuns(paragraph: Element): string { + return Array.from(paragraph.children) + .filter((child) => child.localName === 'run') + .map((run) => { + const text = escapeHtml(runText(run)); + const charPr = run.getAttribute('charPrIDRef'); + if (charPr === 'bold') return `${text}`; + if (charPr === 'italic') return `${text}`; + if (charPr === 'strike') return `${text}`; + return text; + }) + .join(''); +} + +/** Collect significant run text from one OWPML paragraph. */ +function runTextFromParagraph(paragraph: Element): string { + return Array.from(paragraph.children) + .filter((child) => child.localName === 'run') + .map(runText) + .join(''); +} + +/** Project one OWPML paragraph or heading. */ +function projectParagraph(paragraph: Element): string { + const heading = /^(?:heading-([1-6]))$/u.exec( + paragraph.getAttribute('paraPrIDRef') ?? '', + ); + const content = projectRuns(paragraph); + if (heading) { + return `${content}`; + } + return `

    ${content}

    `; +} + +/** Project one OWPML table into header/cell HTML topology. */ +function projectTable(table: Element): string { + const rows = Array.from(table.children) + .filter((child) => child.localName === 'tr') + .map((row) => { + const cells = Array.from(row.children) + .filter((child) => child.localName === 'tc') + .map((cell) => { + const tag = cell.getAttribute('header') === '1' ? 'th' : 'td'; + const paragraphs = Array.from(cell.getElementsByTagName('*')).filter( + (child) => child.localName === 'p', + ); + const text = paragraphs.map((paragraph) => runTextFromParagraph(paragraph)).join(''); + return `<${tag}>${escapeHtml(text)}`; + }) + .join(''); + return `${cells}`; + }) + .join(''); + return `${rows}
    `; +} + +/** Project one top-level OWPML block, failing closed for unsupported structures. */ +function projectBlock(element: Element): string { + switch (element.localName) { + case 'p': + return projectParagraph(element); + case 'tbl': + return projectTable(element); + default: + if (UNSUPPORTED_OWPML_BLOCKS.has(element.localName)) { + return ``; + } + throw new Error('invalid hangul fixture'); + } +} + +/** Project a committed OWPML section into the HTML the public bridge consumes. */ +function projectSectionXml(sectionXml: string): string { + const parsed = new DOMParser().parseFromString(sectionXml, 'application/xml'); + if (parsed.querySelector('parsererror')) { + throw new Error('invalid hangul fixture'); + } + return Array.from(parsed.documentElement.children).map(projectBlock).join(''); +} + +/** Render inline HTML back into OWPML runs. */ +function htmlInlineToOwpml(parent: Element): string { + return Array.from(parent.childNodes) + .map((child) => { + if (child.nodeType === Node.TEXT_NODE) { + const text = child.textContent ?? ''; + return text === '' ? '' : `${escapeHtml(text)}`; + } + if (!(child instanceof Element)) return ''; + const tag = child.tagName.toLowerCase(); + const text = `${escapeHtml(child.textContent ?? '')}`; + if (tag === 'strong' || tag === 'b') { + return `${text}`; + } + if (tag === 'em' || tag === 'i') { + return `${text}`; + } + if (tag === 's' || tag === 'strike') { + return `${text}`; + } + if (tag === 'p') return htmlInlineToOwpml(child); + throw new Error('invalid hangul fixture'); + }) + .join(''); +} + +/** Render one exported HTML block back into the fixture OWPML subset. */ +function htmlBlockToOwpml(element: Element): string { + const tag = element.tagName.toLowerCase(); + const heading = /^h([1-6])$/u.exec(tag); + if (heading) { + return `${htmlInlineToOwpml(element)}`; + } + if (tag === 'p') { + return `${htmlInlineToOwpml(element)}`; + } + if (tag === 'table') { + const rows = Array.from((element as HTMLTableElement).rows) + .map((row) => { + const cells = Array.from(row.cells) + .map((cell) => { + const header = cell.tagName.toLowerCase() === 'th' ? ' header="1"' : ''; + return `${htmlInlineToOwpml(cell)}`; + }) + .join(''); + return `${cells}`; + }) + .join(''); + return `${rows}`; + } + throw new Error('invalid hangul fixture'); +} + +/** Convert pasted bridge HTML back into a fixture OWPML section. */ +function htmlToSectionXml(html: string): string { + const parsed = new DOMParser().parseFromString(html, 'text/html'); + const body = Array.from(parsed.body.children).map(htmlBlockToOwpml).join(''); + return `${body}`; +} + +class FixtureDocument implements HangulEngineDocument { + freed = false; + private readonly sourceFormat: 'hwp' | 'hwpx'; + private readonly selectionHtml: string; + private pastedHtml = ''; + + constructor(sourceFormat: 'hwp' | 'hwpx', selectionHtml: string) { + this.sourceFormat = sourceFormat; + this.selectionHtml = selectionHtml; + } + + static fromSource(source: Uint8Array): FixtureDocument { + if (hasOleMagic(source)) { + return new FixtureDocument('hwp', projectSectionXml(readHwpSection(source))); + } + if (source.length >= 4 && readUint32(source, 0) === 0x04034b50) { + return new FixtureDocument('hwpx', projectSectionXml(readHwpxSection(source))); + } + throw new Error('invalid hangul fixture'); + } + + static createEmpty(format: 'hwp' | 'hwpx'): FixtureDocument { + return new FixtureDocument(format, ''); + } + + getSourceFormat(): string { + return this.sourceFormat; + } + + getSectionCount(): number { + return 1; + } + + getParagraphCount(): number { + return this.selectionHtml === '' && this.pastedHtml === '' ? 0 : 1; + } + + getParagraphLength(): number { + return 0; + } + + exportSelectionHtml(): string { + return this.selectionHtml; + } + + deleteText(): string { + return '{"ok":true}'; + } + + pasteHtml( + _sectionIndex: number, + _paragraphIndex: number, + _charOffset: number, + html: string, + ): string { + this.pastedHtml = html; + return '{"ok":true}'; + } + + exportHwp(): Uint8Array { + return buildHwpFixture(htmlToSectionXml(this.pastedHtml)); + } + + exportHwpx(): Uint8Array { + return buildHwpxFixture(htmlToSectionXml(this.pastedHtml)); + } + + free(): void { + this.freed = true; + } +} + +/** Create a host-injected engine that only understands committed synthetic fixtures. */ +function createFixtureEngine(): HangulDocumentEngine & { + lastOpened?: FixtureDocument; +} { + const engine: HangulDocumentEngine & { lastOpened?: FixtureDocument } = { + id: 'hangul-fixture-engine', + open: async (source) => { + const opened = FixtureDocument.fromSource(source); + engine.lastOpened = opened; + return opened; + }, + create: async () => FixtureDocument.createEmpty('hwpx'), + }; + return engine; +} + +describe('Hangul realistic document fixtures', () => { + it('projects the known HWPX briefing document into the expected paragraphs and table', async () => { + const engine = createFixtureEngine(); + const result = await openHangulDocument(buildHwpxFixture(BRIEFING_MINUTES_XML), { + engine, + }); + + expect(result.sourceFormat).toBe('hwpx'); + expect(result.documentJson).toEqual(BRIEFING_MINUTES_JSON); + expect(result.lossy).toBe(false); + expect(engine.lastOpened?.freed).toBe(true); + }); + + it('projects the known HWP briefing document into the same paragraphs and table', async () => { + const result = await openHangulDocument(buildHwpFixture(BRIEFING_MINUTES_XML), { + engine: createFixtureEngine(), + }); + + expect(result.sourceFormat).toBe('hwp'); + expect(result.documentJson).toEqual(BRIEFING_MINUTES_JSON); + }); + + it('reopens exported HWPX and HWP bytes as the same semantic document', async () => { + const engine = createFixtureEngine(); + const hwpx = await exportHangulDocument(BRIEFING_MINUTES_JSON, { + engine, + format: 'hwpx', + }); + const hwp = await exportHangulDocument(BRIEFING_MINUTES_JSON, { + engine, + format: 'hwp', + }); + + expect(hwpx.format).toBe('hwpx'); + expect(hwp.format).toBe('hwp'); + expect(hwpx.bytes[0]).toBe(0x50); + expect(hwp.bytes[0]).toBe(0xd0); + + const reopenedHwpx = await openHangulDocument(hwpx.bytes, { engine }); + const reopenedHwp = await openHangulDocument(hwp.bytes, { engine }); + expect(reopenedHwpx.documentJson).toEqual(BRIEFING_MINUTES_JSON); + expect(reopenedHwp.documentJson).toEqual(BRIEFING_MINUTES_JSON); + }); + + it('fails closed on an HWPX shape instead of keeping the surrounding paragraph', async () => { + const privateText = 'tenant-secret-shape'; + let caught: unknown; + + try { + await openHangulDocument(buildHwpxFixture(UNSUPPORTED_SHAPE_XML), { + engine: createFixtureEngine(), + }); + } catch (error) { + caught = error; + } + + expect(caught).toMatchObject({ + name: 'HangulDocumentError', + code: 'UNSUPPORTED_DOCUMENT_NODE', + message: 'Hangul import contains an unsupported block node.', + }); + expect((caught as Error).message).not.toContain(privateText); + expect((caught as Error).message).not.toContain('Opening Remarks'); + }); + + it('fails closed on a legacy HWP shape without reflecting fixture text', async () => { + const privateText = 'tenant-secret-shape'; + let caught: unknown; + + try { + await openHangulDocument(buildHwpFixture(UNSUPPORTED_SHAPE_XML), { + engine: createFixtureEngine(), + }); + } catch (error) { + caught = error; + } + + expect(caught).toMatchObject({ + code: 'UNSUPPORTED_DOCUMENT_NODE', + message: 'Hangul import contains an unsupported block node.', + }); + expect((caught as Error).message).not.toContain(privateText); + }); + + it('fails closed on bytes that are not a known HWP or HWPX fixture', async () => { + await expect( + openHangulDocument(new Uint8Array([0, 1, 2, 3]), { + engine: createFixtureEngine(), + }), + ).rejects.toMatchObject({ + code: 'ENGINE_OPEN_FAILED', + message: 'The Hangul engine could not open the document.', + }); + }); +}); diff --git a/src/hangul/fixtures/briefing-minutes.section.xml b/src/hangul/fixtures/briefing-minutes.section.xml new file mode 100644 index 00000000..8d751f47 --- /dev/null +++ b/src/hangul/fixtures/briefing-minutes.section.xml @@ -0,0 +1,55 @@ + + + + + Briefing Minutes + + + + + Attendees reviewed the quarterly status report. + + + + + + + + + Topic Name + + + + + + + + + Owner Team + + + + + + + + + + + Budget Review + + + + + + + + + Finance Team + + + + + + + diff --git a/src/hangul/fixtures/unsupported-shape.section.xml b/src/hangul/fixtures/unsupported-shape.section.xml new file mode 100644 index 00000000..8d26dff1 --- /dev/null +++ b/src/hangul/fixtures/unsupported-shape.section.xml @@ -0,0 +1,11 @@ + + + + + Opening Remarks + + + + tenant-secret-shape + + From f51cdd71b12c572383a7e851317da6018e83c166 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 18:08:31 +0000 Subject: [PATCH 73/78] docs(hangul): bind fixture fail-closed contract Record the committed briefing and shape fixtures, require unsupported structures to fail closed instead of warning, and keep the compatibility guide executable. Co-authored-by: Seongho Bae --- docs/HANGUL.md | 4 +++- docs/adr/0030-hangul-document-authoring-boundary.md | 1 + src/hangul/documentationContract.test.ts | 10 ++++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/HANGUL.md b/docs/HANGUL.md index 63989728..0223f792 100644 --- a/docs/HANGUL.md +++ b/docs/HANGUL.md @@ -96,6 +96,8 @@ async function saveAsHwpx( The initial bridge deliberately supports a bounded semantic subset and rejects unsupported export nodes instead of silently deleting them. The compatibility matrix expands only when real HWP/HWPX fixtures demonstrate stable round-trip behavior. `capabilities.supportedContent` is the machine-consumable projection of the same currently implemented subset; this table remains the human-readable contract and limitation guide. +Committed synthetic OWPML fixtures under `src/hangul/fixtures/` are the current known-document suite. `briefing-minutes.section.xml` must project to the expected heading, paragraph, and table cells when wrapped as HWPX or legacy HWP. `unsupported-shape.section.xml` must fail closed; Inkspan does not keep surrounding paragraphs while dropping a shape. After export, the same fixture engine reopens the bytes and compares semantic JSON. These fixtures are synthetic and contain no customer documents. To inspect a mismatch, open the exact source fixture and compare it against the committed expected paragraphs and tables. + | Content | Import | Export | Notes | |---|---|---|---| | Paragraph text | Yes | Yes | Unicode preserved by JavaScript strings and the selected engine | @@ -109,7 +111,7 @@ The initial bridge deliberately supports a bounded semantic subset and rejects u | Basic tables | Yes | Yes | Header/cell topology is preserved; spans and layout styling are not modeled | | Links | Planned | Planned | Must use Inkspan safe-link policy | | Images | Planned | Planned | Must remain inline/host-approved; no external fetch | -| Shapes/charts/equations | Warning | Rejected | Requires dedicated projection contract | +| Shapes/charts/equations | Rejected | Rejected | Fail closed; no silent drop | | Macros/OLE/active content | Not executed | Not generated | Outside the editor authority boundary | ## Failure containment diff --git a/docs/adr/0030-hangul-document-authoring-boundary.md b/docs/adr/0030-hangul-document-authoring-boundary.md index 8ba73084..635a1d00 100644 --- a/docs/adr/0030-hangul-document-authoring-boundary.md +++ b/docs/adr/0030-hangul-document-authoring-boundary.md @@ -52,6 +52,7 @@ Acceptance requires all of the following on one exact PR head: - HWP and HWPX import tests; - edited JSON to HWPX and HWP export tests; +- committed synthetic `briefing-minutes` and `unsupported-shape` fixtures that project known paragraphs/tables and fail closed on unsupported structures; - real documents reopened after export and compared against expected semantic content; - hostile/malformed input and resource-limit tests; - package-consumer verification for ESM, CommonJS, and declarations; diff --git a/src/hangul/documentationContract.test.ts b/src/hangul/documentationContract.test.ts index d54e7f01..5eea7ced 100644 --- a/src/hangul/documentationContract.test.ts +++ b/src/hangul/documentationContract.test.ts @@ -20,6 +20,16 @@ describe('Hangul compatibility documentation', () => { ); expect(hangulGuide).not.toContain('| Lists | Planned | Planned |'); expect(hangulGuide).not.toContain('| Tables | Planned | Planned |'); + expect(hangulGuide).toContain( + '| Shapes/charts/equations | Rejected | Rejected | Fail closed; no silent drop |', + ); + expect(hangulGuide).toContain('src/hangul/fixtures/'); + expect(hangulGuide).toContain('briefing-minutes.section.xml'); + expect(hangulGuide).toContain('unsupported-shape.section.xml'); + expect(hangulGuide).toContain('fail closed'); + expect(hangulGuide).toContain( + 'open the exact source fixture and compare it against the committed expected paragraphs and tables', + ); }); it('documents the capability metadata returned by the public import API', () => { From 030512a5496c8fa869fc0cb1c79724334d6919b1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 09:53:43 -0700 Subject: [PATCH 74/78] test(hangul): bound hostile engine metadata --- src/hangul/engineMetadataValidation.test.ts | 61 +++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/hangul/engineMetadataValidation.test.ts b/src/hangul/engineMetadataValidation.test.ts index ad39b260..ac53a790 100644 --- a/src/hangul/engineMetadataValidation.test.ts +++ b/src/hangul/engineMetadataValidation.test.ts @@ -52,6 +52,19 @@ describe('Hangul engine structural metadata validation', () => { expect(getParagraphCount).not.toHaveBeenCalled(); }); + it('rejects an excessive section count before traversing section data', async () => { + const getParagraphCount = vi.fn(() => 0); + const document = createDocument({ + getSectionCount: () => Number.MAX_SAFE_INTEGER, + getParagraphCount, + }); + + await expect( + openHangulDocument(new Uint8Array([1]), { engine: createEngine(document) }), + ).rejects.toMatchObject(EXPECTED_IMPORT_FAILURE); + expect(getParagraphCount).not.toHaveBeenCalled(); + }); + it('rejects a negative paragraph count before exporting section HTML', async () => { const exportSelectionHtml = vi.fn(() => '

    private

    '); const document = createDocument({ @@ -65,6 +78,19 @@ describe('Hangul engine structural metadata validation', () => { expect(exportSelectionHtml).not.toHaveBeenCalled(); }); + it('rejects an excessive paragraph count before asking for a terminal paragraph length', async () => { + const getParagraphLength = vi.fn(() => 1); + const document = createDocument({ + getParagraphCount: () => Number.MAX_SAFE_INTEGER, + getParagraphLength, + }); + + await expect( + openHangulDocument(new Uint8Array([1]), { engine: createEngine(document) }), + ).rejects.toMatchObject(EXPECTED_IMPORT_FAILURE); + expect(getParagraphLength).not.toHaveBeenCalled(); + }); + it('rejects a fractional paragraph length before passing it to the host export boundary', async () => { const exportSelectionHtml = vi.fn(() => '

    private

    '); const document = createDocument({ @@ -78,6 +104,19 @@ describe('Hangul engine structural metadata validation', () => { expect(exportSelectionHtml).not.toHaveBeenCalled(); }); + it('rejects an excessive paragraph length before passing it to the host export boundary', async () => { + const exportSelectionHtml = vi.fn(() => '

    private

    '); + const document = createDocument({ + getParagraphLength: () => Number.MAX_SAFE_INTEGER, + exportSelectionHtml, + }); + + await expect( + openHangulDocument(new Uint8Array([1]), { engine: createEngine(document) }), + ).rejects.toMatchObject(EXPECTED_IMPORT_FAILURE); + expect(exportSelectionHtml).not.toHaveBeenCalled(); + }); + it('reads the section count once so a stateful host cannot move the traversal bound', async () => { const getSectionCount = vi.fn(() => 1); const document = createDocument({ getSectionCount }); @@ -114,4 +153,26 @@ describe('Hangul engine structural metadata validation', () => { expect(deleteText).not.toHaveBeenCalled(); expect(pasteHtml).not.toHaveBeenCalled(); }); + + it('rejects an excessive export paragraph length before mutating the host document', async () => { + const deleteText = vi.fn(() => '{"ok":true}'); + const pasteHtml = vi.fn(() => '{"ok":true}'); + const document = createDocument({ + getParagraphLength: () => Number.MAX_SAFE_INTEGER, + deleteText, + pasteHtml, + }); + + await expect( + exportHangulDocument( + { + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'x' }] }], + }, + { engine: createEngine(document) }, + ), + ).rejects.toMatchObject(EXPECTED_EXPORT_FAILURE); + expect(deleteText).not.toHaveBeenCalled(); + expect(pasteHtml).not.toHaveBeenCalled(); + }); }); \ No newline at end of file From 88936bd85345ec81daba1eaa497ed21418ce6dab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 09:56:01 -0700 Subject: [PATCH 75/78] fix(hangul): bound engine traversal metadata --- src/hangul/index.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/hangul/index.ts b/src/hangul/index.ts index dec7fb33..64b91ec4 100644 --- a/src/hangul/index.ts +++ b/src/hangul/index.ts @@ -149,6 +149,9 @@ function freeHangulDocument( const TEXT_ALIGNMENTS = new Set(['left', 'center', 'right', 'justify']); const DEFAULT_MAX_DOCUMENT_BYTES = 64 * 1024 * 1024; +const MAX_HANGUL_SECTION_COUNT = 4096; +const MAX_HANGUL_PARAGRAPH_COUNT = 1_000_000; +const MAX_HANGUL_PARAGRAPH_LENGTH = 16 * 1024 * 1024; const HANGUL_IMPORT_FAILURE_MESSAGE = 'The Hangul engine failed during import.'; const HANGUL_EXPORT_FAILURE_MESSAGE = 'The Hangul engine failed during export.'; const TYPED_ARRAY_PROTOTYPE = Object.getPrototypeOf( @@ -179,9 +182,13 @@ function resolveHangulByteLimit(limit: number | undefined): number { return resolved; } -/** Validate host-engine traversal metadata before using it as an index or bound. */ -function resolveHangulEngineCount(value: number, failureMessage: string): number { - if (!Number.isSafeInteger(value) || value < 0) { +/** Validate host-engine structural metadata against bounded Inkspan work/index ceilings. */ +function resolveHangulEngineCount( + value: number, + maxInclusive: number, + failureMessage: string, +): number { + if (!Number.isSafeInteger(value) || value < 0 || value > maxInclusive) { throw new HangulDocumentError('ENGINE_OPERATION_FAILED', failureMessage); } return value; @@ -515,16 +522,19 @@ export async function openHangulDocument(source: Uint8Array, options: OpenHangul const html: string[] = []; const sectionCount = resolveHangulEngineCount( document.getSectionCount(), + MAX_HANGUL_SECTION_COUNT, HANGUL_IMPORT_FAILURE_MESSAGE, ); for (let section = 0; section < sectionCount; section += 1) { const count = resolveHangulEngineCount( document.getParagraphCount(section), + MAX_HANGUL_PARAGRAPH_COUNT, HANGUL_IMPORT_FAILURE_MESSAGE, ); if (count > 0) { const paragraphLength = resolveHangulEngineCount( document.getParagraphLength(section, count - 1), + MAX_HANGUL_PARAGRAPH_LENGTH, HANGUL_IMPORT_FAILURE_MESSAGE, ); html.push( @@ -583,6 +593,7 @@ export async function exportHangulDocument(documentJson: HangulDocumentJson, opt document.beginBatch?.(); const length = resolveHangulEngineCount( document.getParagraphLength(0, 0), + MAX_HANGUL_PARAGRAPH_LENGTH, HANGUL_EXPORT_FAILURE_MESSAGE, ); if (length > 0) document.deleteText(0, 0, 0, length); From dcefbe6d40bacc66e2b04dc85b68a04dca37d013 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 09:58:09 -0700 Subject: [PATCH 76/78] test(hangul): contract traversal safety limits --- src/hangul/documentationContract.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/hangul/documentationContract.test.ts b/src/hangul/documentationContract.test.ts index 5eea7ced..4a585e2f 100644 --- a/src/hangul/documentationContract.test.ts +++ b/src/hangul/documentationContract.test.ts @@ -3,6 +3,10 @@ import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; const hangulGuide = readFileSync('docs/HANGUL.md', 'utf8'); +const hangulAdr = readFileSync( + 'docs/adr/0030-hangul-document-authoring-boundary.md', + 'utf8', +); describe('Hangul compatibility documentation', () => { it('documents the structures exercised by the public round-trip contract', () => { @@ -42,4 +46,14 @@ describe('Hangul compatibility documentation', () => { expect(hangulGuide).toContain('`recommendedExportFormat`'); expect(hangulGuide).toContain('`supportedContent`'); }); + + it('documents the finite untrusted-engine traversal ceilings as Inkspan safety limits', () => { + for (const document of [hangulGuide, hangulAdr]) { + expect(document).toContain('4,096 sections'); + expect(document).toContain('1,000,000 paragraphs per section'); + expect(document).toContain('16,777,216 UTF-16 code units per paragraph'); + expect(document).toContain('Inkspan safety ceilings'); + expect(document).toContain('not HWP/HWPX format maxima'); + } + }); }); From 487e6cb172454e6c0d90a7ecce3838ec068eefc3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 09:58:46 -0700 Subject: [PATCH 77/78] docs(hangul): specify traversal safety ceilings --- docs/HANGUL.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/HANGUL.md b/docs/HANGUL.md index 0223f792..5253a850 100644 --- a/docs/HANGUL.md +++ b/docs/HANGUL.md @@ -120,7 +120,9 @@ The host engine is untrusted at every call boundary, including cleanup. Open/cre ## Security requirements -Treat both formats as untrusted document containers. Production implementations must enforce bounded source and output bytes. A native HWPX implementation must additionally bound ZIP entry count, expanded bytes, expansion ratio, XML depth, XML node count, text size, relationships, and embedded payloads. DTD and external entity resolution must be disabled. External relationships are metadata only unless the host separately authorizes a resource. +Treat both formats as untrusted document containers. Production implementations must enforce bounded source and output bytes. The bridge also rejects host structural metadata above 4,096 sections, 1,000,000 paragraphs per section, or 16,777,216 UTF-16 code units per paragraph before traversing or passing those values back to the host engine. These are Inkspan safety ceilings, not HWP/HWPX format maxima. + +A native HWPX implementation must additionally bound ZIP entry count, expanded bytes, expansion ratio, XML depth, XML node count, text size, relationships, and embedded payloads. DTD and external entity resolution must be disabled. External relationships are metadata only unless the host separately authorizes a resource. Passwords, cookies, credentials, filesystem paths, and secret values must never enter warnings, error strings, result objects, or deterministic snapshots. From 5ee6e4d0c3b2a804f39a8d186a8bdd7c2a38a782 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 09:59:23 -0700 Subject: [PATCH 78/78] docs(hangul): record bounded host metadata decision --- docs/adr/0030-hangul-document-authoring-boundary.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/adr/0030-hangul-document-authoring-boundary.md b/docs/adr/0030-hangul-document-authoring-boundary.md index 635a1d00..091181bb 100644 --- a/docs/adr/0030-hangul-document-authoring-boundary.md +++ b/docs/adr/0030-hangul-document-authoring-boundary.md @@ -24,6 +24,8 @@ The host owns file selection, filesystem access, network access, WASM/module ini Unsupported structures are never silently asserted to be lossless. Import results carry warnings and a lossy flag. Export rejects editor structures that cannot be represented by the current bridge rather than dropping them silently. +Host-returned structural metadata is untrusted work/index input. Before traversing or passing such metadata back into the host engine, Inkspan rejects values above 4,096 sections, 1,000,000 paragraphs per section, or 16,777,216 UTF-16 code units per paragraph. These are Inkspan safety ceilings, not HWP/HWPX format maxima; changing them is a resource-safety decision that requires corresponding regression evidence. + ## Consequences - Existing `CwlEditorHandle.setDocumentJson()` remains the single editing ingress. @@ -31,6 +33,7 @@ Unsupported structures are never silently asserted to be lossless. Import result - Parser/serializer upgrades do not require React changes. - Full visual round-trip fidelity is not claimed until covered by real-document compatibility fixtures. - HWPX can later gain a first-party native OWPML implementation without changing the public bridge contract. +- A forged engine cannot turn a small input into effectively unbounded section traversal or oversized paragraph indexes/offsets merely by returning safe-integer metadata. ## Failure and recovery semantics @@ -38,7 +41,7 @@ Malformed input, unsupported source identity, resource-limit breaches, engine fa ## Security and privacy impact -HWP/HWPX bytes are untrusted input. The bridge has no remote-resource fetch path and no active-content execution path. Source and output byte bounds are enforced before publication. Credentials, cookies, filesystem paths, and document passwords are not part of result objects or telemetry contracts. +HWP/HWPX bytes are untrusted input. The bridge has no remote-resource fetch path and no active-content execution path. Source and output byte bounds are enforced before publication. Host structural counts and paragraph lengths are bounded before traversal, indexing, deletion, or HTML projection. Credentials, cookies, filesystem paths, and document passwords are not part of result objects or telemetry contracts. A future native HWPX parser must additionally bound ZIP entries, expansion ratio, XML depth, XML node count, text length, relationship targets, embedded objects, and external references. DTD and external-entity resolution must remain disabled. @@ -54,7 +57,7 @@ Acceptance requires all of the following on one exact PR head: - edited JSON to HWPX and HWP export tests; - committed synthetic `briefing-minutes` and `unsupported-shape` fixtures that project known paragraphs/tables and fail closed on unsupported structures; - real documents reopened after export and compared against expected semantic content; -- hostile/malformed input and resource-limit tests; +- hostile/malformed input and resource-limit tests, including over-limit host section/paragraph metadata that fails before child traversal or host mutation; - package-consumer verification for ESM, CommonJS, and declarations; - production statement and branch coverage at repository policy thresholds; - public API docstring coverage at repository policy thresholds;