From dd430bffc9ce016868285ccf1ac3400d19ac1238 Mon Sep 17 00:00:00 2001 From: Lee doyun <266472044+dragonnite1221-lgtm@users.noreply.github.com> Date: Wed, 13 May 2026 21:19:14 +0900 Subject: [PATCH] feat: add HWPX fragment paste support --- rhwp-studio/index.html | 1 + rhwp-studio/src/command/commands/insert.ts | 48 + rhwp-studio/src/core/wasm-bridge.ts | 30 + rhwp-studio/src/ui/yangsik-parts-dialog.ts | 329 ++++ rhwp-studio/vite-plugin-yangsik-fragments.ts | 122 ++ rhwp-studio/vite.config.ts | 2 + saved/04-blank_hwpx_empty.hwpx | Bin 0 -> 6876 bytes .../commands/cross_document_migrate.rs | 1011 ++++++++++++ src/document_core/commands/document.rs | 56 +- src/document_core/commands/fragment_paste.rs | 1389 +++++++++++++++++ .../commands/fragment_paste_in_document.rs | 394 +++++ src/document_core/commands/mod.rs | 3 + src/document_core/mod.rs | 15 + src/document_core/queries/mod.rs | 1 + src/document_core/queries/raw_xml.rs | 176 +++ src/model/event.rs | 4 + src/parser/hwpx/mod.rs | 36 +- src/wasm_api.rs | 310 +++- tests/fragment_paste_in_document.rs | 123 ++ tests/fragment_paste_integration.rs | 109 ++ 20 files changed, 4112 insertions(+), 47 deletions(-) create mode 100644 rhwp-studio/src/ui/yangsik-parts-dialog.ts create mode 100644 rhwp-studio/vite-plugin-yangsik-fragments.ts create mode 100644 saved/04-blank_hwpx_empty.hwpx create mode 100644 src/document_core/commands/cross_document_migrate.rs create mode 100644 src/document_core/commands/fragment_paste.rs create mode 100644 src/document_core/commands/fragment_paste_in_document.rs create mode 100644 src/document_core/queries/raw_xml.rs create mode 100644 tests/fragment_paste_in_document.rs create mode 100644 tests/fragment_paste_integration.rs diff --git a/rhwp-studio/index.html b/rhwp-studio/index.html index e6fc1b4a..787d5b0d 100644 --- a/rhwp-studio/index.html +++ b/rhwp-studio/index.html @@ -98,6 +98,7 @@
그림
글상자
수식Ctrl+N,M
+
양식 부품
필드 입력Ctrl+K+E
diff --git a/rhwp-studio/src/command/commands/insert.ts b/rhwp-studio/src/command/commands/insert.ts index f99c3593..660dc8e4 100644 --- a/rhwp-studio/src/command/commands/insert.ts +++ b/rhwp-studio/src/command/commands/insert.ts @@ -5,6 +5,12 @@ import { SymbolsDialog } from '@/ui/symbols-dialog'; import { BookmarkDialog } from '@/ui/bookmark-dialog'; import { showShapePicker } from '@/ui/shape-picker'; import type { ShapeType } from '@/ui/shape-picker'; +import { + YangsikPartsDialog, + fetchYangsikFragmentManifest, + fetchYangsikFragmentXml, + type FragmentManifestEntry, +} from '@/ui/yangsik-parts-dialog'; /** 스텁 커맨드 생성 헬퍼 */ function stub(id: string, label: string, icon?: string, shortcut?: string): CommandDef { @@ -391,6 +397,48 @@ export const insertCommands: CommandDef[] = [ toggleFlip(services, 'vertFlip'); }, }, + { + id: 'insert:yangsik-parts', + label: '양식 부품', + canExecute: (ctx) => ctx.hasDocument && ctx.isEditable, + async execute(services) { + try { + const fragments = await fetchYangsikFragmentManifest(); + if (fragments.length === 0) { + window.alert('양식 부품 카탈로그가 비어있습니다.'); + return; + } + const inserter = async (entry: FragmentManifestEntry): Promise => { + const ih = services.getInputHandler(); + if (!ih) return false; + const pos = ih.getPosition(); + try { + const fragmentXml = await fetchYangsikFragmentXml(entry.fragment_file); + const defs = entry.source_definitions ?? {}; + services.wasm.pasteHwpxFragmentInDocument( + pos.sectionIndex, + pos.paragraphIndex, + fragmentXml, + defs.char_prs ?? '', + defs.para_prs ?? '', + defs.styles ?? '', + defs.border_fills ?? '', + ); + ih.triggerAfterEdit(); + return true; + } catch (err) { + console.error('[insert:yangsik-parts] paste failed', err); + return false; + } + }; + new YangsikPartsDialog(fragments, inserter).show(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error('[insert:yangsik-parts]', msg); + window.alert(`양식 부품 목록을 불러오지 못했습니다:\n${msg}`); + } + }, + }, ]; /** 선택 개체의 속성을 조회/변경 헬퍼 (shape/picture 분기) */ diff --git a/rhwp-studio/src/core/wasm-bridge.ts b/rhwp-studio/src/core/wasm-bridge.ts index 6793ca35..9a0d6e91 100644 --- a/rhwp-studio/src/core/wasm-bridge.ts +++ b/rhwp-studio/src/core/wasm-bridge.ts @@ -355,6 +355,36 @@ export class WasmBridge { return this.doc.insertText(sec, para, charOffset, text); } + /** + * HWPX fragment 를 Document IR 에 byte-preserving paste 한다. + * rhwp `pasteHwpxFragmentInDocument` (wasm bridge) 호출 — section 의 raw XML 보존본 위에서 + * 직접 동작하므로 zip/unzip 라운드트립 없이 즉시 반영. + * + * 반환 JSON 스키마: + * `{"inserted_para_count":N,"id_remap_char_pr":{...},"id_remap_para_pr":{...}, + * "id_remap_style":{...},"id_remap_border_fill":{...}}` + */ + pasteHwpxFragmentInDocument( + sec: number, + afterParaIdx: number, + fragmentXml: string, + sourceCharPrs: string, + sourceParaPrs: string, + sourceStyles: string, + sourceBorderFills: string, + ): string { + if (!this.doc) throw new Error('문서가 로드되지 않았습니다'); + return this.doc.pasteHwpxFragmentInDocument( + sec, + afterParaIdx, + fragmentXml, + sourceCharPrs, + sourceParaPrs, + sourceStyles, + sourceBorderFills, + ); + } + deleteText(sec: number, para: number, charOffset: number, count: number): string { if (!this.doc) throw new Error('문서가 로드되지 않았습니다'); return this.doc.deleteText(sec, para, charOffset, count); diff --git a/rhwp-studio/src/ui/yangsik-parts-dialog.ts b/rhwp-studio/src/ui/yangsik-parts-dialog.ts new file mode 100644 index 00000000..247a387b --- /dev/null +++ b/rhwp-studio/src/ui/yangsik-parts-dialog.ts @@ -0,0 +1,329 @@ +/** + * ui/yangsik-parts-dialog.ts — 양식.hwpx 부품 즉시 삽입 다이얼로그 + * + * vite plugin endpoint 에서 manifest.json + fragment XML 을 fetch 하고, + * 카드 클릭 시 wasm `pasteHwpxFragmentInDocument` 로 byte-preserving paste 한다. + * + * absorb_repo 의 `personal-yangsik-parts.ts` 를 본체에 흡수하면서 personal layer + * (atoms/labels/registry) 의존성을 제거하고 manifest entry 만으로 카드를 합성. + */ + +import { ModalDialog } from './dialog'; + +const CATEGORY_ORDER = [ + '제목박스', + '글상자', + '내용박스', + '참고박스', + '붙임박스', + '결재란', + '단계표', + '사례박스', + '대외주의', + '그림틀', + '표', +]; + +interface FragmentSourceDefinitions { + char_prs?: string; + para_prs?: string; + styles?: string; + border_fills?: string; +} + +export interface FragmentManifestEntry { + part_name: string; + category?: string; + label_extra?: string; + kind?: string; + byte_length?: number; + fragment_file: string; + preview_text?: string; + source_definitions?: FragmentSourceDefinitions; +} + +interface FragmentManifest { + fragments?: FragmentManifestEntry[]; +} + +export type FragmentInserter = (entry: FragmentManifestEntry) => Promise; + +export class YangsikPartsDialog extends ModalDialog { + private fragments: FragmentManifestEntry[]; + private filtered: FragmentManifestEntry[]; + private activeCategory: string; + private searchQuery = ''; + private listEl?: HTMLDivElement; + private statusEl?: HTMLDivElement; + private categoryButtons = new Map(); + + constructor( + fragments: FragmentManifestEntry[], + private readonly insert: FragmentInserter, + ) { + super('양식 부품', 760); + this.fragments = fragments; + const present = CATEGORY_ORDER.find((t) => fragments.some((f) => f.category === t)); + this.activeCategory = present ?? (fragments[0]?.category ?? ''); + this.filtered = this.computeFiltered(); + } + + protected createBody(): HTMLElement { + const body = document.createElement('div'); + body.style.padding = '12px 16px'; + body.style.maxHeight = '70vh'; + body.style.display = 'flex'; + body.style.flexDirection = 'column'; + body.style.gap = '10px'; + + body.appendChild(this.buildToolbar()); + + const listWrap = document.createElement('div'); + listWrap.style.overflowY = 'auto'; + listWrap.style.maxHeight = '55vh'; + listWrap.style.border = '1px solid #e0e0e0'; + listWrap.style.borderRadius = '4px'; + listWrap.style.padding = '4px'; + listWrap.style.background = '#fafafa'; + this.listEl = listWrap; + body.appendChild(listWrap); + + const status = document.createElement('div'); + status.style.fontSize = '12px'; + status.style.color = '#666'; + status.style.minHeight = '16px'; + this.statusEl = status; + body.appendChild(status); + + this.renderList(); + return body; + } + + protected onConfirm(): boolean { + return false; + } + + private buildToolbar(): HTMLElement { + const bar = document.createElement('div'); + bar.style.display = 'flex'; + bar.style.flexDirection = 'column'; + bar.style.gap = '6px'; + + const row = document.createElement('div'); + row.style.display = 'flex'; + row.style.flexWrap = 'wrap'; + row.style.gap = '4px'; + + const counts = new Map(); + for (const f of this.fragments) { + const cat = f.category ?? ''; + counts.set(cat, (counts.get(cat) ?? 0) + 1); + } + const present = CATEGORY_ORDER.filter((t) => counts.has(t)); + + for (const t of present) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.textContent = `${t} (${counts.get(t)})`; + btn.style.padding = '4px 10px'; + btn.style.border = '1px solid #c0c0c0'; + btn.style.borderRadius = '14px'; + btn.style.background = t === this.activeCategory ? '#4285f4' : '#fff'; + btn.style.color = t === this.activeCategory ? '#fff' : '#333'; + btn.style.cursor = 'pointer'; + btn.style.fontSize = '12px'; + btn.addEventListener('click', () => { + this.activeCategory = t; + this.refreshChipStyles(); + this.filtered = this.computeFiltered(); + this.renderList(); + }); + this.categoryButtons.set(t, btn); + row.appendChild(btn); + } + bar.appendChild(row); + + const search = document.createElement('input'); + search.type = 'search'; + search.placeholder = '검색 (이름/미리보기)'; + search.style.width = '100%'; + search.style.padding = '6px 8px'; + search.style.border = '1px solid #c0c0c0'; + search.style.borderRadius = '4px'; + search.style.fontSize = '13px'; + search.addEventListener('input', () => { + this.searchQuery = search.value.trim(); + this.filtered = this.computeFiltered(); + this.renderList(); + }); + bar.appendChild(search); + + const hint = document.createElement('div'); + hint.style.fontSize = '11px'; + hint.style.color = '#888'; + hint.textContent = '카드를 누르면 현재 커서 위치에 박스/표 스타일 그대로 삽입됩니다.'; + bar.appendChild(hint); + + return bar; + } + + private refreshChipStyles(): void { + for (const [t, btn] of this.categoryButtons) { + const active = t === this.activeCategory; + btn.style.background = active ? '#4285f4' : '#fff'; + btn.style.color = active ? '#fff' : '#333'; + } + } + + private computeFiltered(): FragmentManifestEntry[] { + const q = this.searchQuery.toLowerCase(); + return this.fragments.filter((f) => { + if (f.category !== this.activeCategory) return false; + if (!q) return true; + const hay = `${f.part_name} ${f.preview_text ?? ''}`.toLowerCase(); + return hay.includes(q); + }); + } + + private renderList(): void { + if (!this.listEl) return; + this.listEl.innerHTML = ''; + const max = 200; + const items = this.filtered.slice(0, max); + if (items.length === 0) { + const empty = document.createElement('div'); + empty.style.padding = '20px'; + empty.style.color = '#888'; + empty.style.textAlign = 'center'; + empty.textContent = '결과 없음'; + this.listEl.appendChild(empty); + return; + } + for (const f of items) { + this.listEl.appendChild(this.buildCard(f)); + } + if (this.statusEl) { + this.statusEl.textContent = + this.filtered.length > max + ? `${this.filtered.length}건 중 ${max}건 표시 (검색으로 좁혀주세요)` + : `${this.filtered.length}건`; + } + } + + private buildCard(entry: FragmentManifestEntry): HTMLElement { + const card = document.createElement('button'); + card.type = 'button'; + card.style.display = 'flex'; + card.style.justifyContent = 'space-between'; + card.style.alignItems = 'flex-start'; + card.style.gap = '10px'; + card.style.padding = '8px 10px'; + card.style.margin = '3px 0'; + card.style.background = '#fff'; + card.style.border = '1px solid #d8d8d8'; + card.style.borderRadius = '4px'; + card.style.cursor = 'pointer'; + card.style.textAlign = 'left'; + card.style.width = '100%'; + + const left = document.createElement('div'); + left.style.flex = '1'; + left.style.minWidth = '0'; + + const title = document.createElement('div'); + title.textContent = entry.part_name; + title.style.fontSize = '13px'; + title.style.color = '#222'; + title.style.fontWeight = '500'; + left.appendChild(title); + + if (entry.preview_text) { + const preview = document.createElement('div'); + preview.textContent = entry.preview_text; + preview.style.fontSize = '12px'; + preview.style.color = '#555'; + preview.style.marginTop = '3px'; + preview.style.wordBreak = 'break-word'; + preview.style.whiteSpace = 'pre-wrap'; + preview.style.maxHeight = '3.5em'; + preview.style.overflow = 'hidden'; + left.appendChild(preview); + } + + const meta = document.createElement('div'); + meta.style.fontSize = '11px'; + meta.style.color = '#888'; + meta.style.marginTop = '4px'; + const labelExtra = entry.label_extra ? ` · ${entry.label_extra}` : ''; + const kindLabel = entry.kind === 'table' ? '표' : '문단'; + meta.textContent = `${kindLabel}${labelExtra}`; + left.appendChild(meta); + + const badge = document.createElement('span'); + badge.textContent = '삽입'; + badge.style.flexShrink = '0'; + badge.style.padding = '4px 12px'; + badge.style.border = '1px solid #c0c0c0'; + badge.style.borderRadius = '4px'; + badge.style.background = '#f5f5f5'; + badge.style.fontSize = '12px'; + badge.style.alignSelf = 'center'; + + card.appendChild(left); + card.appendChild(badge); + + card.addEventListener('click', async (e) => { + e.stopPropagation(); + badge.textContent = '삽입중…'; + const ok = await this.insert(entry); + if (ok) { + badge.textContent = '삽입됨'; + badge.style.background = '#4caf50'; + badge.style.color = '#fff'; + badge.style.borderColor = '#4caf50'; + setTimeout(() => { + badge.textContent = '삽입'; + badge.style.background = '#f5f5f5'; + badge.style.color = '#333'; + badge.style.borderColor = '#c0c0c0'; + }, 900); + } else { + badge.textContent = '삽입'; + if (this.statusEl) { + this.statusEl.textContent = + '삽입 실패: 본문 편집 위치가 없습니다 (커서를 본문에 둔 뒤 다시 시도해 주세요).'; + } + } + }); + + return card; + } +} + +/** + * vite base 가 `/rhwp/` 같이 prefix 인 환경에서도 동작하도록 BASE_URL 을 사용한다. + * BASE_URL 은 항상 trailing slash 포함 (예: `/`, `/rhwp/`). + */ +function apiUrl(tail: string): string { + const base = (import.meta as any).env?.BASE_URL ?? '/'; + return `${base}api/personal-templates/yangsik-fragments/${tail}`; +} + +/** + * vite plugin endpoint 에서 manifest 를 fetch 한다. + */ +export async function fetchYangsikFragmentManifest(): Promise { + const resp = await fetch(apiUrl('manifest')); + if (!resp.ok) return []; + const data = (await resp.json()) as FragmentManifest; + return data.fragments ?? []; +} + +/** + * vite plugin endpoint 에서 단일 fragment XML 을 fetch 한다. + */ +export async function fetchYangsikFragmentXml(fragmentFile: string): Promise { + const resp = await fetch(apiUrl(encodeURIComponent(fragmentFile))); + if (!resp.ok) throw new Error(`fragment fetch HTTP ${resp.status}`); + return await resp.text(); +} diff --git a/rhwp-studio/vite-plugin-yangsik-fragments.ts b/rhwp-studio/vite-plugin-yangsik-fragments.ts new file mode 100644 index 00000000..0d9a31f4 --- /dev/null +++ b/rhwp-studio/vite-plugin-yangsik-fragments.ts @@ -0,0 +1,122 @@ +/** + * vite plugin — `/api/personal-templates/yangsik-fragments/*` endpoint + * + * dev server 와 preview server 양쪽에 동일한 미들웨어를 등록해 양식 부품 데이터를 정적 제공한다. + * 데이터 디렉터리는 `YANGSIK_FRAGMENTS_DIR` 환경변수로 명시한 경우에만 사용한다. + */ + +import { existsSync, readFileSync, statSync } from 'fs'; +import { resolve } from 'path'; +import type { Plugin, Connect } from 'vite'; + +const FRAGMENTS_DIR = process.env.YANGSIK_FRAGMENTS_DIR + ? resolve(process.env.YANGSIK_FRAGMENTS_DIR) + : ''; + +const ROUTE_TAIL = '/api/personal-templates/yangsik-fragments'; +const MANIFEST_TAIL = '/api/personal-templates/yangsik-fragments/manifest'; + +function sendJson(res: any, status: number, payload: unknown): void { + const body = JSON.stringify(payload); + res.statusCode = status; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Content-Length', Buffer.byteLength(body)); + res.end(body); +} + +function sendStatus(res: any, status: number, message: string): void { + res.statusCode = status; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end(message); +} + +function safeFragmentPath(fragmentFile: string): string | null { + if (!FRAGMENTS_DIR) { + return null; + } + // `..`, 절대 경로, slash 차단 + if (!fragmentFile || fragmentFile.includes('/') || fragmentFile.includes('..')) { + return null; + } + if (!fragmentFile.endsWith('.xml')) { + return null; + } + const candidate = resolve(FRAGMENTS_DIR, fragmentFile); + // resolve 결과가 FRAGMENTS_DIR 밖으로 나가면 거부 + const root = resolve(FRAGMENTS_DIR) + '/'; + if (!candidate.startsWith(root)) { + return null; + } + try { + if (!statSync(candidate).isFile()) return null; + } catch { + return null; + } + return candidate; +} + +/** + * vite base 가 `/rhwp/` 등으로 설정되면 들어오는 path 도 prefix 를 가진다. + * ROUTE_TAIL 이 워낙 unique 하므로 첫 출현 위치부터 잘라 매칭한다. + */ +function stripBase(path: string): string { + const idx = path.indexOf(ROUTE_TAIL); + if (idx >= 0) { + return path.slice(idx); + } + return path; +} + +const handler: Connect.NextHandleFunction = (req, res, next) => { + const rawUrl = req.url ?? ''; + const url = new URL(rawUrl, 'http://localhost'); + const path = stripBase(url.pathname); + + if (path === MANIFEST_TAIL) { + if (!FRAGMENTS_DIR) { + return sendJson(res, 200, { fragments: [] }); + } + const manifestPath = resolve(FRAGMENTS_DIR, 'manifest.json'); + if (!existsSync(manifestPath)) { + return sendJson(res, 200, { fragments: [] }); + } + try { + const raw = readFileSync(manifestPath, 'utf-8'); + const data = JSON.parse(raw); + return sendJson(res, 200, data); + } catch { + return sendJson(res, 200, { fragments: [] }); + } + } + + if (path.startsWith(`${ROUTE_TAIL}/`)) { + const tail = decodeURIComponent(path.slice(`${ROUTE_TAIL}/`.length)); + const resolved = safeFragmentPath(tail); + if (!resolved) { + return sendStatus(res, 404, 'fragment not found'); + } + try { + const data = readFileSync(resolved); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/xml; charset=utf-8'); + res.setHeader('Content-Length', String(data.byteLength)); + return res.end(data); + } catch { + return sendStatus(res, 500, 'fragment read failed'); + } + } + + next(); +}; + +export function yangsikFragmentsPlugin(): Plugin { + return { + name: 'yangsik-fragments', + configureServer(server) { + server.middlewares.use(handler); + }, + configurePreviewServer(server) { + server.middlewares.use(handler); + }, + }; +} diff --git a/rhwp-studio/vite.config.ts b/rhwp-studio/vite.config.ts index 24cbdb63..34c6ba98 100644 --- a/rhwp-studio/vite.config.ts +++ b/rhwp-studio/vite.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from 'vite'; import { resolve, extname, join } from 'path'; import { readFileSync, readFile } from 'fs'; import { VitePWA } from 'vite-plugin-pwa'; +import { yangsikFragmentsPlugin } from './vite-plugin-yangsik-fragments'; const pkg = JSON.parse(readFileSync(resolve(__dirname, 'package.json'), 'utf-8')); @@ -24,6 +25,7 @@ export default defineConfig({ }, }, plugins: [ + yangsikFragmentsPlugin(), // [Task #741 후속] dev 서버 영역 영역 /samples/* 경로 영역 영역 parent samples/ dir 영역 // 영역 정적 serve 영역 — wasm-bridge.ts 영역 영역 외부 image fetch 영역 영역 영역. { diff --git a/saved/04-blank_hwpx_empty.hwpx b/saved/04-blank_hwpx_empty.hwpx new file mode 100644 index 0000000000000000000000000000000000000000..1ca849f3ff7d87589e454a1c7ef0b1e80ef9d5eb GIT binary patch literal 6876 zcmZ`;1z1!~+on4fTuMqhmUID0m2Raaq*<2kl^}F*+-K(5XYNswM@9i6{BzMD_#cQ$KY%~{G7Pv`n_EMi-0dKSc6L_g z#)eMjwl-g(uGzdvrCy;JKr1Eh`U&bCCJJYR^EE39*#!z__;5hxfkxe3g1q!z)oY2 z>_h|B49G9xW7}5YziND!{)m$loDgJd@vh-$^{a3z3?>)Wo^yPF9d;bM?KKxEjjfkY zS~2BJDSSH2(7kU6_6+TQ}_;9h| zcNd6*Bb=LGqg8xqZ5hA?xCQli=|#;Dh4ZlIaXN8rSL@vHt0?m7D0E5G-dq*9jJCIn z^voYQL`=HU87^fJXe&qh1N_RwQpD7Oi;FG^0{%4$NeUL-EVp(6)!H1}G}BeCMdSpZ zLLbYDD3w=OKM>1w6dn4c^Z+P$zAdviBNfjdN&9mn*g&g$PjyyKnM>$$!@R+LXzb+F zO#vJj?6n=eGDw$G$^0QVcO2+7?8s^2zL^1Tv#uk{7;*S;_kgbY4)2N^n4c2C%zs|8 zz2@eyA%PEw=FRGkiG4<0csRgEQaUm0IT*+0gZMWYOvxSdm~bMDkr5Dx;bbUEs)@48 zzL4THwzY9GG`E2`a5$Kl)+Y?uz2~<$<}0Tz>c8NFN#t~osH%ta9QmTJ$u9+Pge1B zJw{4sBKn=ERt^!>0F8cUT5pVL3h97v2D&izC#ln|&!AbnDwoTR28_w4+}Aq#Oo(XBB&sel#9=gdK+ zeNjKl!>OY7xCZ=G(rS&Y3|O{CJKfU25^qlsVGys$d!{XrSe#H&xow7|p{P+5StIX) zK`N^+ZL4$9@_XE@RLgnyKnqY5x!`I(|EJSsm{zY6*yg`rcK4b z1jpq`ID{2j{?XV=aEg$iQIfnABF zbQbiF83pE+nWN7VC6x{QsAj~8HS0Xgl->=YAwH}VL6N))(HQpG^o#}eam8;&8R<~z z^N0@%9ep}#9T~WIe9`^trM0{}QVoVzRVl>`c2WS(N>It;^Ng|=7^Itw^_SPKh@y^W;jA~Tp)xqHMk;K7d$4Oovy`@w zjBK@OO^~$`Gz{2!P}rwYcT8L)5%}og8uA1=UQpROKR%kBm0(s_MKzx3t0V%kE>Q^+ zvu?u~UgukRwB)9<6BK~5J;w;B({7?rYJH2qp%-IwR8;}VdclO37HO9$+&4Z(6K2ikF|Pbw%K*D?bG?0>o`eOkK%aNKeRnV&+8 z<1@&>Rc9DO*?hKc`0CSvUfzO_Q{(KSEw4jA8BY(Ib&Xh3XRoAYv1&g{-5o|@c8|^( zhE&nP6hcN$E7oV1m`t5qD4R@iOf;au{Tp9M@>ZzM%d;CkH|jO7mKCg080dWc;^U&X zPae^rG+UPGsY}b^woKXL<1*Fvi)He^3X5gb--L$rR3F|ZlV#IdQOW+QJfou6by^GE zZ%DgoVrp((mx@^z=Jt_r!#K<~*NO|vw!NHgdFK=AM>9ucfXgKT6)qWf2X$HF6^!qV z8g?Lv+s*hUPX<`A1U|PPhjEP{WiPvyGB17(wcyAcJbq}_!D=GF%}I%HQreN- zlQ&d0D^1*JRE50MXm${RI9Kc~(YZbszNxOetd{?ryc`~Yrnfc!U=+o86=8s6*Lk*L zo`L>|8(gHNo1mdDR7QUJEp)~e{iSfg#;`&N)D6KZ^e3K!!3%DLuef*pP~KbVfcUld zlCKs6jFK2AS+hyFLn_`;j`3rdQz&TZ6E+4wqY1irgLP>RSj@XlmYk{La5>U_lW(4@yi{uWw-#i<2!>a|u%?m2*xRwWE_jGKF zl&Rw2!%+2SWx^`Xn)bZ_vy)aWb_bq^?y;U~OBbU>@%sgOptDO&srY?o#X0$m=2wbu zQjRNQc67UUR&{@VZ8BX@|L|hL+0l3^%i%kk{H0>`YB_515xGI%JMU!T$?=Idf?5KP zZ%gT06f8C5g&sY|DIFMIj7i{J6WUOmWEaFAoH4bcu#4C=aZ12@9U!$_fsi77Uc=(!9K(LT zR||H(g|uqlpoEK3RS$CWihXRJ+VGln5O$y?F7XV~9tMPe>DpHJI(J*C3I~t5H0yTv zUm6}2f89OKd$O4jNSu#x253MW+HcrzFvBziy-jAQ&?r?e)vg${d42=LVXG0mP?}%5 zA=s>bPP2yvBn&C1I-#nYJ;`!$bf2tKC?%?Ds|auF&sExDoGwk2t^ZlyIJtp8kj7Iw z_sH1mwmM$6ahquKn;9kV%+S0vS-DieO{WEM3tyE|mHc$Hc~hEX=gg%0TFa%Vt5(b) z=NlZ7%I7h8`=TvJ#jZ-WjVblf&-gGy$ivD>O4ImO$7u87rr$-xI9|J8dyZtWZsFg3 zXU?)#MVsBQqS!=ejM-l?$OPN#;)ui~ZQGG%7YFA=Qy?#8m62j(ERG5y?)~)WY4Ns% zAwFwYRLIhuDLG5uBhKPm?YuKG5HH1ag)onwY4@SH6+d$b>v&A8&8bqoLyhRTlN-^CR?nvcf&*mKz zr2G;(f8gQj7=4Vl-r->GXtX6s%n9~<|1MPg%1|%P6WCJIeMvi--sQghDpx~A2dhDH zd_5*SwI}e0YaFihwRdaGoQzEfj1C=d|G6%(;Hm78%V$JsQy%|EvX#a0Aj>aFwE90{ zowP7@>-cpin04zczohKjzg z#wBV|Li#O5({l3_A|Ge`=Sv7Xvub=|n6ebR%y4-$c(Q&;LCb-0n5-G0T5Q;=5*5lC z54IofCGsV5C}eDec(&=0cg*p;Pqf9+`YwRpmz;6r)SRbY$4jqpxDHsya=q!6%?Cjv zJR4+6*(8(7<_=R{H@Ck2Do34uE3u^_%U(AOCgH7Ftm=bCtPVJg5c6iKSPmt}n+#~z zNRB^UF8yvzub@R}T7jW@xx_C=xSj_vUEsST;*4(ASxA$03Se?x5)HaFIzQ>^vU3aZ zVH3TU_AZ3kn#_Eg2DUJzZ}11(p)eBn$!vM5J*J-AO=jC>7j;&r+R$S7x{;+AD^Vqz z>oay&6f2=m_H~1@7+xWl>+^x>gYbB(T<(DCqaCT#m;94AnmYJX7Zkm0lpaM%Jx|{P zyrcu~y!pIEJS78_8okvC>iy9>jlOUg$sMIv`(b-fPWD`dQ7svwEPka#~0N>{~{jrm)xi?x&4aj zNMEvGXaA%SE&vJ_pk)6v5?*6-V|6@8R6R(1;KmwpsP=xTWVl^b7XDc(fjbZRLmlz5 zEwHjJkVe*xgfp}bCf1jx720dlBA>}4ltRJ-OZ=v#)#OFh>MS(M5Q^KEvgVvv+bs&0!E?k?s(Qs>4*i3?(>bS>Go8!Oxt#MDgKiFjSO zTsBUPx7~63hqY8~i}2T@P_P#yJeIUPL0-O`jcB`lf@C^!_r_+-tZ$#CaH$ViF$s6O z>6zuvEzL6%IgzFAn*W#1lqUT#BBh7s$O3FRH;l;zsv;hiXnyIdI>*ouJmck6p+ zwqCLzl`L8_Um0D-NhDMFC91|ku%<0622bNkFqQfaW78>o)l`;D!oOQs^qwx8Ve8W=1clom8Byd&I)i-+6bU^F&?JiOUy;QKH7fiZz({$M|=LNCKWb}MIV*`!s#&m9kj3*?3&Jp3#dZM{(< z!HoqErz-lVUhV_n4fvZUrEC@*aHUkBBYuz`Ikz!%T?!Lap?R1WsVwXeqxoStW#p}j zKq9{bg5nyNB;6DBaC{M%9e$)yiB2;F=ai8i@hvu&y2T z7PWUl|IwNx6%7lj%0%%dty&jKCC!#(EHJOh?=|sjxqPD)&CZ_@N&@j%5uoUJMyb|2 zQr0}+M)7g)84;sR{!&n{wgSB#g*$(zK%v|lQP4qY{jQKV;ZX}>o!8x$0R4<4oYp)b zrt{YZz5x)@V8lGWSDyu>Ycu<~I*C6XS-b=uOMeYTDaJ}SkEwDFm@K=JYCGE3VncjC z)@{|r-vxl<>4RXMBfqQ6R``-M{=_+h0z8+h4b$=a`ovh04dgKMU4R%f`ed{^!e3CZ z;=xle{@OWnBy>voTv0Ks8dcB%)>+hDwQK>_^S6wq73Su67w`XWJZR0VQ3L0)9Co!7 zOTLOb8lGAA9S+@uZYy4QQ{wOCNq8>PX~VNX5s;;YqmBNaiYMDgvI z>~#4odc@JEgz@d9J{{Nqh2;bPv?yf-tP^ zNAfzH(T{0fkhn%f?EncQFo7xiXdDOGpVX7tPU}x<`I;k9mYGhZ+5;BwLQ@&FQ$fr= z1Fd)|5$5cvi&!%+vNMt)&(9KUpEB`SZt^uzXHjaPN0vlWeyE&>Jul_2{aESPW=~mX zT=jkCk$kt|sS;T8?!o3t_qKqi-bcJ{bk$d)^=)KM4NsFPzz>^i4_Cc~FjGeGL3`0g z`D3_sR%s{5DB$`6&hgLtx;law}kLhE<3FYi(xAL=P@)f9}mMlEFGFdV2t zN6pb&SUZm}q82U(uJbOg(f^KK)QZ)wuHY(@CfYx8#J|zY_@6yLhnbyeR?<*>1{Y3` ze$b%^>FKCTYOIJ=QRBi4`sfU;OTaUWjQ8$W;Q#~jG4@$(1D&=u6Is5i9{BdMjZ0$B zYN-E{0h#u4enU^X*WD@PEDXZ!mozFE!7F5JGX294=BerqVB$BDBIMJ+vM5yfNwnd$ zxPc|}Wwm3i=(9RZaezAf{pylP21Z-h0bq&rb6F550(Uh;$YgGnJ69$#CXFzIc^PRq zbS97)rTNjUzp5Z3$uQWIPZ`aqqxwj#iXm!f@hda%Vbh!Ap{`yAqE>9{{f;>K_8Q3m zK1scGhPPJpQLV|X%?S9ChjHWEMq8Wgz`(ka@`ww1-&POr@mHB_LA6nZ-}e10C?qIB z*l5N{DBTenf?sikO7wJIr5JNl6DB)r-0j*GF=%Z|8Iv;B=Wn=i;V{O=8c;A~X|Mip z#XkM6jEl$AxZ$HPXuyY}1||R$53}Ww5?+*Ob)wkGE&c*y}+?Z&Tyxp_FBXI1<=0)rHZQ#ZNsyme0Qx^|e;|b|F{}|>_fm5fl zgj^$*jP(@{mgLhH8xweJU}oOkvFQZ)4_~y|4P>aPi7%q&POw-s{Kq@)WC|sw95U&P z4jCC*7v3DttgHm^^bJW~q+@%#+AmD!znNUF^2Q8VRFX$T0wVwaM*(m}=|7h*dxsQq6s;D7Y@G44x}zcEVSIm5pge+rcMA?_=NzabP+{}S~6)er9@+!q0VBYgTB z;r}GT`vCU^!QTLfaDabf#&3D>KEVAX={Eos!5;vBrcC!C?nkQM5OZ*?_uoGJ5x11& VQQ=YT*I5iULJRz@W0L-Q`X7cv-_ig8 literal 0 HcmV?d00001 diff --git a/src/document_core/commands/cross_document_migrate.rs b/src/document_core/commands/cross_document_migrate.rs new file mode 100644 index 00000000..2377c414 --- /dev/null +++ b/src/document_core/commands/cross_document_migrate.rs @@ -0,0 +1,1011 @@ +//! Cross-document paragraph migration primitive. +//! +//! 두 개의 별도 Document IR 사이에서 paragraph를 안전하게 이동하는 primitive. +//! ID 참조(charPr/paraPr/style/borderFill)는 destination DocInfo 에 머지된다. +//! +//! ## Stage 1 범위 +//! +//! - `IdRemap` / `MigrateReport` 타입 정의 +//! - 4개 카테고리(borderFill/charPr/paraPr/style)에 대한 정의 머지 + 위상 정렬 +//! - paragraph walker / 삽입 로직은 Stage 2~3 에서 추가 +//! +//! ## Dedup 정책 +//! +//! - `CharShape` / `ParaShape` : 명시적 `PartialEq` 보유 (raw_data 제외) → find-or-append +//! - `BorderFill` / `Style` : `PartialEq` 부재 → 항상 append (Stage 1 안전 default) +//! +//! 후속 태스크에서 `BorderFill`/`Style` 및 하위 타입 PartialEq 추가 시 +//! `append_border_fill`/`append_style` 을 `find_or_append_*` 패턴으로 전환 가능. +//! +//! ## Note: Style 의 ID ref +//! +//! `Style` 구조는 `para_shape_id (u16)`, `char_shape_id (u16)` 만 ID ref로 가지며 +//! `border_fill_id` 필드는 부재. 따라서 style 의 위상 의존은 paraShape/charShape 만. + +use crate::document_core::DocumentCore; +use crate::error::HwpError; +use crate::model::control::Control; +use crate::model::document::{DocInfo, Document}; +use crate::model::event::DocumentEvent; +use crate::model::paragraph::Paragraph; +use crate::model::style::{BorderFill, CharShape, ParaShape, Style}; +use crate::model::table::Table; +use std::collections::HashMap; +use std::ops::Range; + +/// 4개 ID 카테고리에 대한 source → target 매핑. +/// +/// `char_shape` 는 `CharShapeRef.char_shape_id (u32)` 와의 매칭을 위해 u32 키. +/// `Style.char_shape_id (u16)` 적용 시 `as u16` cast. +#[derive(Default, Debug, Clone)] +pub struct IdRemap { + pub border_fill: HashMap, + pub char_shape: HashMap, + pub para_shape: HashMap, + pub style: HashMap, +} + +/// Cross-document paragraph migration 의 결과. +#[derive(Debug)] +pub struct MigrateReport { + pub inserted_para_count: usize, + pub last_para_idx: usize, + pub last_char_offset: usize, + pub id_remap: IdRemap, +} + +/// 4개 카테고리에 대해 destination DocInfo 에 정의를 머지하고 remap 테이블을 반환. +/// +/// 의존성 위상 순서: `borderFill → charShape → paraShape → style`. +/// 후행 카테고리는 선행 카테고리의 ID ref 를 미리 remap 한 뒤 비교/append. +pub(crate) fn remap_definitions(src: &DocInfo, dst: &mut DocInfo) -> IdRemap { + let mut remap = IdRemap::default(); + + // 1. borderFill (cross-ref to other categories: 없음) + // Stage 1: PartialEq 부재 → 항상 append. + for (src_idx, src_bf) in src.border_fills.iter().enumerate() { + let target = append_border_fill(dst, src_bf.clone()); + remap.border_fill.insert(src_idx as u16, target); + } + + // 2. charShape (refers borderFill) + // PartialEq dedup 활성. + for (src_idx, src_cs) in src.char_shapes.iter().enumerate() { + let mut adjusted = src_cs.clone(); + adjusted.border_fill_id = remap_border_fill_id(&remap, adjusted.border_fill_id); + let target = find_or_append_char_shape(dst, adjusted); + remap.char_shape.insert(src_idx as u32, target); + } + + // 3. paraShape (refers borderFill) + // PartialEq dedup 활성. + // tab_def_id, numbering_id remap 은 후속 (현재 fragment dataset 미사용). + for (src_idx, src_ps) in src.para_shapes.iter().enumerate() { + let mut adjusted = src_ps.clone(); + adjusted.border_fill_id = remap_border_fill_id(&remap, adjusted.border_fill_id); + let target = find_or_append_para_shape(dst, adjusted); + remap.para_shape.insert(src_idx as u16, target); + } + + // 4. style (refers paraShape, charShape — borderFill ref 부재) + // Stage 1: PartialEq 부재 → 항상 append. + for (src_idx, src_st) in src.styles.iter().enumerate() { + let mut adjusted = src_st.clone(); + adjusted.para_shape_id = remap_para_shape_id(&remap, adjusted.para_shape_id); + adjusted.char_shape_id = remap_char_shape_id_u16(&remap, adjusted.char_shape_id); + let target = append_style(dst, adjusted); + remap.style.insert(src_idx as u8, target); + } + + remap +} + +// ─── Append / find-or-append 헬퍼 ─────────────────────────────────────────── + +fn append_border_fill(dst: &mut DocInfo, src: BorderFill) -> u16 { + dst.border_fills.push(src); + (dst.border_fills.len() - 1) as u16 +} + +fn find_or_append_char_shape(dst: &mut DocInfo, src: CharShape) -> u32 { + if let Some(idx) = dst.char_shapes.iter().position(|x| *x == src) { + return idx as u32; + } + dst.char_shapes.push(src); + (dst.char_shapes.len() - 1) as u32 +} + +fn find_or_append_para_shape(dst: &mut DocInfo, src: ParaShape) -> u16 { + if let Some(idx) = dst.para_shapes.iter().position(|x| *x == src) { + return idx as u16; + } + dst.para_shapes.push(src); + (dst.para_shapes.len() - 1) as u16 +} + +fn append_style(dst: &mut DocInfo, src: Style) -> u8 { + dst.styles.push(src); + (dst.styles.len() - 1) as u8 +} + +// ─── Remap lookup 헬퍼 (없으면 원본 ID 그대로 유지) ───────────────────────── + +fn remap_border_fill_id(remap: &IdRemap, src_id: u16) -> u16 { + *remap.border_fill.get(&src_id).unwrap_or(&src_id) +} + +fn remap_para_shape_id(remap: &IdRemap, src_id: u16) -> u16 { + *remap.para_shape.get(&src_id).unwrap_or(&src_id) +} + +/// `CharShapeRef.char_shape_id` 는 u32, `Style.char_shape_id` 는 u16. +/// remap 테이블은 u32 단일 키로 통합되어 있으므로 Style 적용 시 cast. +fn remap_char_shape_id_u16(remap: &IdRemap, src_id: u16) -> u16 { + let new_u32 = *remap + .char_shape + .get(&(src_id as u32)) + .unwrap_or(&(src_id as u32)); + new_u32 as u16 +} + +// ─── Paragraph walker (Stage 2) ───────────────────────────────────────────── + +/// `Paragraph` 의 ID ref 를 remap 결과에 따라 갈아끼운다. +/// `controls` 안의 Table/Header/Footer/Footnote/Endnote/CharOverlap 을 재귀 처리. +/// +/// Stage 2 미지원 variant (Shape, Picture, Equation, Form, HiddenComment 등): +/// - Shape 자체에는 ID ref 가 없지만 `ShapeComponentAttr.text_box.paragraphs` +/// (TextBox 내부 문단) 가 paragraph IR 을 포함. 후속 태스크에서 `walk_shape` 추가. +/// - 그 외는 ID ref 가 없거나 미사용 영역. +pub(crate) fn walk_paragraph(p: &mut Paragraph, remap: &IdRemap) { + if let Some(&new) = remap.para_shape.get(&p.para_shape_id) { + p.para_shape_id = new; + } + if let Some(&new) = remap.style.get(&p.style_id) { + p.style_id = new; + } + for cs in &mut p.char_shapes { + if let Some(&new) = remap.char_shape.get(&cs.char_shape_id) { + cs.char_shape_id = new; + } + } + for ctrl in &mut p.controls { + walk_control(ctrl, remap); + } +} + +fn walk_paragraphs(paragraphs: &mut [Paragraph], remap: &IdRemap) { + for p in paragraphs { + walk_paragraph(p, remap); + } +} + +fn walk_control(ctrl: &mut Control, remap: &IdRemap) { + match ctrl { + Control::Table(tbl) => walk_table(tbl, remap), + Control::Header(h) => walk_paragraphs(&mut h.paragraphs, remap), + Control::Footer(f) => walk_paragraphs(&mut f.paragraphs, remap), + Control::Footnote(fn_) => walk_paragraphs(&mut fn_.paragraphs, remap), + Control::Endnote(en) => walk_paragraphs(&mut en.paragraphs, remap), + Control::CharOverlap(co) => { + for id in &mut co.char_shape_ids { + if let Some(&new) = remap.char_shape.get(id) { + *id = new; + } + } + } + // Shape/Picture/Equation/Form/HiddenComment/SectionDef/ColumnDef/AutoNumber/ + // NewNumber/PageNumberPos/Bookmark/Hyperlink/Ruby/PageHide/Field/Unknown: + // Stage 2 미지원 (대부분 ID ref 없거나 Shape 내부 paragraphs 처리는 후속). + _ => {} + } +} + +fn walk_table(t: &mut Table, remap: &IdRemap) { + if let Some(&new) = remap.border_fill.get(&t.border_fill_id) { + t.border_fill_id = new; + } + for zone in &mut t.zones { + if let Some(&new) = remap.border_fill.get(&zone.border_fill_id) { + zone.border_fill_id = new; + } + } + for cell in &mut t.cells { + if let Some(&new) = remap.border_fill.get(&cell.border_fill_id) { + cell.border_fill_id = new; + } + walk_paragraphs(&mut cell.paragraphs, remap); + } + if let Some(caption) = &mut t.caption { + walk_paragraphs(&mut caption.paragraphs, remap); + } +} + +// ─── Stage 4: build_mini_document_from_fragment + paste_hwpx_fragment ───── + +const HWPX_NS_DECL: &str = " xmlns:hp=\"http://www.hancom.co.kr/hwpml/2011/paragraph\" \ + xmlns:hh=\"http://www.hancom.co.kr/hwpml/2011/head\" \ + xmlns:hc=\"http://www.hancom.co.kr/hwpml/2011/core\" \ + xmlns:hs=\"http://www.hancom.co.kr/hwpml/2011/section\""; + +/// HWPX fragment(byte-exact) + 4개 카테고리 source 정의 raw XML 을 +/// in-memory mini Document 로 빌드한다. +/// +/// 각 인자는 raw HWPX XML 스니펫 (1개 이상의 `` 또는 ``): +/// - `fragment_xml`: 1개 이상의 `...` 시퀀스 +/// - `char_prs`: 1개 이상의 `...` +/// - `para_prs`: 1개 이상의 `...` +/// - `styles`: 1개 이상의 `` +/// - `border_fills`: 1개 이상의 `...` +/// +/// 빈 문자열은 해당 카테고리에 정의 없음을 의미. +pub(crate) fn build_mini_document_from_fragment( + fragment_xml: &str, + char_prs: &str, + para_prs: &str, + styles: &str, + border_fills: &str, +) -> Result { + use crate::parser::hwpx::{header::parse_hwpx_header, section::parse_hwpx_section}; + + let header_xml = format!( + "{bf}{cs}{ps}{st}", + ns = HWPX_NS_DECL, + bf = border_fills, + cs = char_prs, + ps = para_prs, + st = styles, + ); + let (doc_info, _) = parse_hwpx_header(&header_xml).map_err(HwpError::from)?; + + let section_xml = format!( + "{frag}", + ns = HWPX_NS_DECL, + frag = fragment_xml, + ); + let section = parse_hwpx_section(§ion_xml).map_err(HwpError::from)?; + + Ok(Document { + doc_info, + sections: vec![section], + ..Default::default() + }) +} + +// ─── Stage 3: cross_document_migrate (DocumentCore impl) ─────────────────── + +impl DocumentCore { + /// 외부 Document 의 paragraphs 를 caret 위치로 migrate 한다. + /// DocInfo 정의(charPr/paraPr/style/borderFill)는 destination 에 머지된다. + /// + /// 삽입 패턴은 `paste_html_native` 의 has_controls 분기와 동일: + /// - dst paragraph 를 caret 에서 split → 좌반 + 우반 + /// - 좌반이 비어있으면 첫 cloned paragraph 로 대체, 아니면 그 뒤에 모두 insert + /// - 우반이 비어있지 않으면 마지막 cloned 뒤에 insert + /// - reflow + recompose + paginate 후 `DocumentEvent::FragmentPasted` 발행 + pub fn cross_document_migrate( + &mut self, + src_doc: &Document, + src_section: usize, + src_paras: Range, + dst_section: usize, + dst_para: usize, + dst_offset: usize, + ) -> Result { + // 1. 검증 + if dst_section >= self.document.sections.len() { + return Err(HwpError::RenderError(format!( + "구역 {} 범위 초과", + dst_section + ))); + } + if dst_para >= self.document.sections[dst_section].paragraphs.len() { + return Err(HwpError::RenderError(format!( + "문단 {} 범위 초과", + dst_para + ))); + } + if src_section >= src_doc.sections.len() { + return Err(HwpError::RenderError(format!( + "src 구역 {} 범위 초과", + src_section + ))); + } + let src_total = src_doc.sections[src_section].paragraphs.len(); + if src_paras.end > src_total || src_paras.start > src_paras.end { + return Err(HwpError::RenderError(format!( + "src paragraph 범위 {:?} 가 총 {} 초과", + src_paras, src_total + ))); + } + + // 2. ID 위상 정렬 + 정의 머지 + let remap = remap_definitions(&src_doc.doc_info, &mut self.document.doc_info); + self.document.doc_info.raw_stream_dirty = true; + + // 3. fragment paragraphs deep-clone + walker + let cloned: Vec = src_doc.sections[src_section].paragraphs[src_paras.clone()] + .iter() + .map(|p| { + let mut c = p.clone(); + walk_paragraph(&mut c, &remap); + c + }) + .collect(); + + let inserted_count = cloned.len(); + if inserted_count == 0 { + return Ok(MigrateReport { + inserted_para_count: 0, + last_para_idx: dst_para, + last_char_offset: dst_offset, + id_remap: remap, + }); + } + + // 4. 삽입 (paste_html_native has_controls 패턴) + self.document.sections[dst_section].raw_stream = None; + + let right_half = + self.document.sections[dst_section].paragraphs[dst_para].split_at(dst_offset); + + let left_empty = self.document.sections[dst_section].paragraphs[dst_para] + .text + .is_empty(); + + let insert_idx = if left_empty { + // 좌반이 비어있으면 첫 cloned 로 대체 + self.document.sections[dst_section].paragraphs[dst_para] = cloned[0].clone(); + let idx = dst_para + 1; + for i in 1..inserted_count { + self.document.sections[dst_section] + .paragraphs + .insert(idx + i - 1, cloned[i].clone()); + } + dst_para + inserted_count + } else { + // 좌반에 텍스트 → 그 뒤에 모든 cloned 삽입 + let idx = dst_para + 1; + for i in 0..inserted_count { + self.document.sections[dst_section] + .paragraphs + .insert(idx + i, cloned[i].clone()); + } + dst_para + 1 + inserted_count + }; + + let last_para_idx; + let last_char_offset; + if !right_half.text.is_empty() { + self.document.sections[dst_section] + .paragraphs + .insert(insert_idx, right_half); + last_para_idx = insert_idx; + last_char_offset = 0; + } else { + last_para_idx = insert_idx - 1; + last_char_offset = self.document.sections[dst_section].paragraphs[last_para_idx] + .text + .chars() + .count(); + } + + // 5. reflow + paginate + // 주의: paste_html_native 는 `insert_composed_paragraph` 로 composed 캐시를 + // 점진 갱신하지만, paragraphs를 여러 개 insert 한 후의 composed 캐시 일관성을 + // 신뢰하기 어려우므로 cross-document migration 은 dirty mark 후 paginate 에 위임. + for i in dst_para..=last_para_idx { + self.reflow_paragraph(dst_section, i); + } + self.mark_section_dirty(dst_section); + self.paginate_if_needed(); + + // 6. event log + self.event_log.push(DocumentEvent::FragmentPasted { + section: dst_section, + para: dst_para, + }); + + Ok(MigrateReport { + inserted_para_count: inserted_count, + last_para_idx, + last_char_offset, + id_remap: remap, + }) + } + + /// HWPX fragment + 4개 source 정의를 받아 caret 위치에 paste 한다. + /// `cross_document_migrate` 의 얇은 래퍼. + /// + /// 반환: `{"ok":true,"paraIdx":,"charOffset":,"insertedParaCount":}` + pub fn paste_hwpx_fragment_native( + &mut self, + section_idx: usize, + para_idx: usize, + char_offset: usize, + fragment_xml: &str, + char_prs: &str, + para_prs: &str, + styles: &str, + border_fills: &str, + ) -> Result { + let src = build_mini_document_from_fragment( + fragment_xml, + char_prs, + para_prs, + styles, + border_fills, + )?; + let n = src.sections[0].paragraphs.len(); + let report = + self.cross_document_migrate(&src, 0, 0..n, section_idx, para_idx, char_offset)?; + Ok(format!( + "{{\"ok\":true,\"paraIdx\":{},\"charOffset\":{},\"insertedParaCount\":{}}}", + report.last_para_idx, report.last_char_offset, report.inserted_para_count + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_char_shape(font: u16) -> CharShape { + let mut cs = CharShape::default(); + cs.font_ids = [font; 7]; + cs + } + + fn make_para_shape(margin_left: i32) -> ParaShape { + let mut ps = ParaShape::default(); + ps.margin_left = margin_left; + ps + } + + fn make_border_fill(attr: u16) -> BorderFill { + let mut bf = BorderFill::default(); + bf.attr = attr; + bf + } + + #[test] + fn test_remap_reuse_existing_char_shape() { + // dst에 동일한 charPr 존재 → remap 이 기존 idx 재사용, 길이 변화 없음 + let cs = make_char_shape(7); + + let mut src = DocInfo::default(); + src.char_shapes.push(cs.clone()); + + let mut dst = DocInfo::default(); + dst.char_shapes.push(cs.clone()); + let initial_len = dst.char_shapes.len(); + + let remap = remap_definitions(&src, &mut dst); + + assert_eq!( + dst.char_shapes.len(), + initial_len, + "기존 charShape 재사용, dst 길이 변화 없음" + ); + assert_eq!( + remap.char_shape.get(&0), + Some(&0), + "src[0] → dst[0] remap (기존 idx)" + ); + } + + #[test] + fn test_remap_append_new_border_fill() { + // dst에 없는 borderFill → append, 새 idx 부여 + let mut src = DocInfo::default(); + src.border_fills.push(make_border_fill(42)); + + let mut dst = DocInfo::default(); + let initial_len = dst.border_fills.len(); + + let remap = remap_definitions(&src, &mut dst); + + assert_eq!( + dst.border_fills.len(), + initial_len + 1, + "새 borderFill append (Stage 1: 항상 append)" + ); + assert_eq!( + remap.border_fill.get(&0), + Some(&(initial_len as u16)), + "src[0] → 새 idx" + ); + } + + #[test] + fn test_remap_topological_order_borderfill_first() { + // src.char_shape 가 src.border_fill_id=2 참조. + // dst가 비어있을 때 → border_fill 들이 먼저 append되고 + // char_shape 의 border_fill_id 가 dst의 새 idx로 갱신되어야 함. + let mut src = DocInfo::default(); + src.border_fills.push(make_border_fill(0)); + src.border_fills.push(make_border_fill(1)); + src.border_fills.push(make_border_fill(2)); + + let mut cs = make_char_shape(1); + cs.border_fill_id = 2; // src 기준 idx 2 (bf2) 참조 + src.char_shapes.push(cs); + + let mut dst = DocInfo::default(); + let remap = remap_definitions(&src, &mut dst); + + let new_bf2_idx = *remap.border_fill.get(&2).expect("bf2 remap 존재"); + let new_cs_idx = *remap.char_shape.get(&0).expect("cs0 remap 존재"); + + assert_eq!( + dst.char_shapes[new_cs_idx as usize].border_fill_id, new_bf2_idx, + "char_shape의 border_fill_id 가 위상 정렬 결과에 따라 갱신됨" + ); + } + + #[test] + fn test_remap_para_shape_dedup() { + // 동일 ParaShape 가 dst에 있으면 재사용 + let ps = make_para_shape(1000); + + let mut src = DocInfo::default(); + src.para_shapes.push(ps.clone()); + + let mut dst = DocInfo::default(); + dst.para_shapes.push(ps); + let initial_len = dst.para_shapes.len(); + + let remap = remap_definitions(&src, &mut dst); + + assert_eq!(dst.para_shapes.len(), initial_len, "동일 paraShape 재사용"); + assert_eq!(remap.para_shape.get(&0), Some(&0)); + } + + #[test] + fn test_remap_style_always_appends() { + // Stage 1: Style 은 PartialEq 부재 → 항상 append. + let mut st = Style::default(); + st.local_name = "바탕글".into(); + st.style_type = 0; + + let mut src = DocInfo::default(); + src.styles.push(st.clone()); + + let mut dst = DocInfo::default(); + dst.styles.push(st); + let initial_len = dst.styles.len(); + + let remap = remap_definitions(&src, &mut dst); + + assert_eq!( + dst.styles.len(), + initial_len + 1, + "Stage 1: style 은 항상 append (dedup 비활성)" + ); + assert_eq!( + remap.style.get(&0), + Some(&(initial_len as u8)), + "src[0] → 새 idx (initial_len)" + ); + } + + #[test] + fn test_remap_style_id_refs_updated_after_topological_sort() { + // src.style 이 src.para_shape_id=1 + src.char_shape_id=1 참조. + // dst가 비어있으면 둘 다 append → style 의 ID ref 들이 새 idx 로 갱신 + let mut src = DocInfo::default(); + src.para_shapes.push(make_para_shape(0)); + src.para_shapes.push(make_para_shape(1000)); + src.char_shapes.push(make_char_shape(0)); + src.char_shapes.push(make_char_shape(1)); + + let mut st = Style::default(); + st.para_shape_id = 1; + st.char_shape_id = 1; + src.styles.push(st); + + let mut dst = DocInfo::default(); + let remap = remap_definitions(&src, &mut dst); + + let new_ps1 = *remap.para_shape.get(&1).expect("ps1 remap 존재"); + let new_cs1 = *remap.char_shape.get(&1).expect("cs1 remap 존재"); + let new_st_idx = *remap.style.get(&0).expect("style remap 존재"); + + let new_st = &dst.styles[new_st_idx as usize]; + assert_eq!(new_st.para_shape_id, new_ps1, "style.para_shape_id 갱신됨"); + assert_eq!( + new_st.char_shape_id as u32, new_cs1, + "style.char_shape_id 갱신됨 (u16↔u32 cast)" + ); + } + + #[test] + fn test_remap_empty_source_no_changes() { + let src = DocInfo::default(); + let mut dst = DocInfo::default(); + dst.border_fills.push(make_border_fill(99)); + let initial = dst.border_fills.len(); + + let remap = remap_definitions(&src, &mut dst); + + assert!(remap.border_fill.is_empty()); + assert!(remap.char_shape.is_empty()); + assert!(remap.para_shape.is_empty()); + assert!(remap.style.is_empty()); + assert_eq!( + dst.border_fills.len(), + initial, + "빈 src 는 dst 를 변경하지 않음" + ); + } + + // ─── Stage 2: Walker tests ───────────────────────────────────────────── + + use crate::model::control::CharOverlap; + use crate::model::header_footer::Header; + use crate::model::paragraph::CharShapeRef; + use crate::model::table::{Cell, Table, TableZone}; + + fn remap_with_char_shape(src_id: u32, dst_id: u32) -> IdRemap { + let mut r = IdRemap::default(); + r.char_shape.insert(src_id, dst_id); + r + } + + fn remap_with_border_fill(src_id: u16, dst_id: u16) -> IdRemap { + let mut r = IdRemap::default(); + r.border_fill.insert(src_id, dst_id); + r + } + + fn remap_with_para_shape(src_id: u16, dst_id: u16) -> IdRemap { + let mut r = IdRemap::default(); + r.para_shape.insert(src_id, dst_id); + r + } + + #[test] + fn test_walk_paragraph_text_only() { + // Para with single CharShapeRef(char_shape_id=0); remap 0→5 + let mut p = Paragraph::default(); + p.para_shape_id = 0; + p.style_id = 0; + p.char_shapes.push(CharShapeRef { + start_pos: 0, + char_shape_id: 0, + }); + + let mut remap = remap_with_char_shape(0, 5); + remap.para_shape.insert(0, 9); + remap.style.insert(0, 7); + + walk_paragraph(&mut p, &remap); + + assert_eq!(p.para_shape_id, 9, "para_shape_id remap"); + assert_eq!(p.style_id, 7, "style_id remap"); + assert_eq!( + p.char_shapes[0].char_shape_id, 5, + "char_shape_id (CharShapeRef) remap" + ); + } + + #[test] + fn test_walk_paragraph_no_remap_keeps_id() { + // remap 에 없는 ID 는 그대로 유지 + let mut p = Paragraph::default(); + p.para_shape_id = 42; + p.style_id = 3; + + let remap = IdRemap::default(); // 빈 remap + walk_paragraph(&mut p, &remap); + + assert_eq!(p.para_shape_id, 42); + assert_eq!(p.style_id, 3); + } + + fn make_table_2x2_with_bf(table_bf: u16, cell_bf: u16) -> Table { + let mut t = Table::default(); + t.row_count = 2; + t.col_count = 2; + t.border_fill_id = table_bf; + for r in 0..2_u16 { + for c in 0..2_u16 { + let mut cell = Cell::default(); + cell.col = c; + cell.row = r; + cell.col_span = 1; + cell.row_span = 1; + cell.border_fill_id = cell_bf; + t.cells.push(cell); + } + } + t + } + + #[test] + fn test_walk_table_2x2() { + let mut t = make_table_2x2_with_bf(2, 3); + + let mut remap = remap_with_border_fill(2, 12); + remap.border_fill.insert(3, 13); + + walk_table(&mut t, &remap); + + assert_eq!(t.border_fill_id, 12, "table border_fill_id remap"); + for cell in &t.cells { + assert_eq!(cell.border_fill_id, 13, "cell border_fill_id remap"); + } + assert_eq!(t.cells.len(), 4); + } + + #[test] + fn test_walk_nested_table() { + // outer.cells[0].paragraphs[0].controls[0] = Inner Table + // Inner table.border_fill_id 도 remap 적용되어야 함 + let mut inner = make_table_2x2_with_bf(5, 6); + let mut inner_para = Paragraph::default(); + inner_para.controls.push(Control::Table(Box::new(inner))); + + let mut outer = make_table_2x2_with_bf(2, 3); + outer.cells[0].paragraphs.push(inner_para); + + let mut remap = IdRemap::default(); + remap.border_fill.insert(2, 12); + remap.border_fill.insert(3, 13); + remap.border_fill.insert(5, 15); + remap.border_fill.insert(6, 16); + + walk_table(&mut outer, &remap); + + assert_eq!(outer.border_fill_id, 12, "outer table remap"); + let extracted_inner = match &outer.cells[0].paragraphs[0].controls[0] { + Control::Table(t) => t, + _ => panic!("expected inner Table"), + }; + assert_eq!( + extracted_inner.border_fill_id, 15, + "inner table remap (재귀)" + ); + for cell in &extracted_inner.cells { + assert_eq!(cell.border_fill_id, 16, "inner cell remap"); + } + } + + #[test] + fn test_walk_char_overlap() { + // CharOverlap.char_shape_ids 의 각 element 가 remap 적용 + let mut co = CharOverlap::default(); + co.char_shape_ids = vec![1, 2, 3]; + + let mut remap = IdRemap::default(); + remap.char_shape.insert(1, 10); + remap.char_shape.insert(2, 20); + remap.char_shape.insert(3, 30); + + let mut ctrl = Control::CharOverlap(co); + walk_control(&mut ctrl, &remap); + + if let Control::CharOverlap(co_out) = ctrl { + assert_eq!(co_out.char_shape_ids, vec![10, 20, 30]); + } else { + panic!("expected CharOverlap"); + } + } + + #[test] + fn test_walk_table_zones() { + let mut t = make_table_2x2_with_bf(0, 0); + t.zones.push(TableZone { + start_col: 0, + start_row: 0, + end_col: 1, + end_row: 1, + border_fill_id: 7, + }); + + let mut remap = IdRemap::default(); + remap.border_fill.insert(0, 100); + remap.border_fill.insert(7, 17); + + walk_table(&mut t, &remap); + + assert_eq!( + t.zones[0].border_fill_id, 17, + "TableZone border_fill_id remap" + ); + } + + #[test] + fn test_walk_header_paragraphs() { + // Header.paragraphs 재귀: header 안 paragraph 의 para_shape_id remap + let mut header = Header::default(); + let mut p = Paragraph::default(); + p.para_shape_id = 1; + header.paragraphs.push(p); + + let mut ctrl = Control::Header(Box::new(header)); + let remap = remap_with_para_shape(1, 5); + walk_control(&mut ctrl, &remap); + + if let Control::Header(h_out) = ctrl { + assert_eq!( + h_out.paragraphs[0].para_shape_id, 5, + "Header.paragraphs[0].para_shape_id remap (재귀)" + ); + } else { + panic!("expected Header"); + } + } + + // ─── Stage 3: cross_document_migrate integration tests ──────────────── + + use crate::model::document::{Document, Section}; + use crate::wasm_api::HwpDocument; + + /// blank2010.hwp 템플릿으로 정상 1-section 1-paragraph dst doc 생성. + fn make_blank_doc() -> HwpDocument { + let mut doc = HwpDocument::create_empty(); + doc.create_blank_document_native().expect("blank doc setup"); + doc + } + + /// src 측 mini Document 빌드: paragraphs + doc_info 정의. + fn make_src_doc_with_paragraphs(paragraphs: Vec) -> Document { + let mut doc = Document::default(); + let mut section = Section::default(); + section.paragraphs = paragraphs; + doc.sections.push(section); + doc + } + + fn make_text_paragraph(text: &str, char_shape_id: u32, para_shape_id: u16) -> Paragraph { + let mut p = Paragraph::default(); + p.text = text.to_string(); + p.char_count = text.chars().count() as u32; + p.para_shape_id = para_shape_id; + p.style_id = 0; + p.char_shapes.push(CharShapeRef { + start_pos: 0, + char_shape_id, + }); + p + } + + #[test] + fn test_cross_document_migrate_text_only() { + // src: 단일 텍스트 paragraph (char_shape_id=0, para_shape_id=0) + // src.doc_info: char_shape[0], para_shape[0] + let mut src = make_src_doc_with_paragraphs(vec![make_text_paragraph("안녕", 0, 0)]); + src.doc_info.char_shapes.push(make_char_shape(1)); + src.doc_info.para_shapes.push(make_para_shape(0)); + + let mut doc = make_blank_doc(); + let initial_para_count = doc.document.sections[0].paragraphs.len(); + + let report = doc + .cross_document_migrate(&src, 0, 0..1, 0, 0, 0) + .expect("migrate ok"); + + assert_eq!(report.inserted_para_count, 1, "1 paragraph inserted"); + assert!( + doc.document.sections[0].paragraphs.len() >= initial_para_count, + "dst.paragraphs 길이 증가 또는 유지 (좌반 빈 → 대체 가능)" + ); + // 이벤트 발행 확인 + let last_event = doc.event_log.last().expect("event_log non-empty"); + assert!( + matches!(last_event, DocumentEvent::FragmentPasted { .. }), + "FragmentPasted event 발행" + ); + } + + #[test] + fn test_cross_document_migrate_table_fragment() { + // src: table 1개 가진 paragraph + let table = make_table_2x2_with_bf(2, 3); + let mut src_para = Paragraph::default(); + src_para.text = "\u{0002}".into(); // table marker (rhwp 관례) + src_para.char_count = 1; + src_para.controls.push(Control::Table(Box::new(table))); + + let mut src = make_src_doc_with_paragraphs(vec![src_para]); + src.doc_info.border_fills.push(make_border_fill(2)); + src.doc_info.border_fills.push(make_border_fill(3)); + + let mut doc = make_blank_doc(); + let report = doc + .cross_document_migrate(&src, 0, 0..1, 0, 0, 0) + .expect("migrate ok"); + + assert_eq!(report.inserted_para_count, 1); + // remap: src bf 0 (default) → 0, src bf 1 (default) → 1 (Stage 1: 항상 append) + // 또는 dst doc_info 의 기존 보존된 borderFill 들과 idx 충돌 가능. + // 검증: report.id_remap.border_fill 에 src idx 0, 1 매핑 존재 + assert!(report.id_remap.border_fill.contains_key(&0)); + assert!(report.id_remap.border_fill.contains_key(&1)); + } + + #[test] + fn test_cross_document_migrate_caret_position() { + // dst paragraph 0 에 미리 텍스트가 있고 caret offset > 0 일 때 + // split → 우반 → 마지막 cloned 뒤로 삽입 + let mut doc = make_blank_doc(); + // 빈 문서이므로 텍스트를 채워 caret middle 시뮬레이션 + let _ = doc.insert_text_native(0, 0, 0, "abcde"); + + let src = make_src_doc_with_paragraphs(vec![make_text_paragraph("XYZ", 0, 0)]); + + // caret offset = 2 ("ab" 뒤) + let report = doc + .cross_document_migrate(&src, 0, 0..1, 0, 0, 2) + .expect("migrate ok"); + + assert_eq!(report.inserted_para_count, 1); + // dst 측: 좌반 "ab" + cloned "XYZ" + 우반 "cde" 으로 분리됨 + // 정확한 paragraph 개수는 paste_html_native 패턴과 동일하게 3 (또는 우반 이동 시) + let final_paras = doc.document.sections[0].paragraphs.len(); + assert!(final_paras >= 2, "최소 2 paragraph (split 결과)"); + } + + #[test] + fn test_cross_document_migrate_out_of_range() { + let src = make_src_doc_with_paragraphs(vec![make_text_paragraph("x", 0, 0)]); + let mut doc = make_blank_doc(); + + // dst_section 범위 초과 + assert!(doc.cross_document_migrate(&src, 0, 0..1, 99, 0, 0).is_err()); + // src_section 범위 초과 + assert!(doc.cross_document_migrate(&src, 99, 0..1, 0, 0, 0).is_err()); + // src_paras.end 초과 + assert!(doc.cross_document_migrate(&src, 0, 0..99, 0, 0, 0).is_err()); + } + + // ─── Stage 4: build_mini_document + paste_hwpx_fragment_native tests ── + + const TEST_FRAGMENT: &str = r#"x"#; + + #[test] + fn test_build_mini_document_from_fragment_minimal() { + // 가장 작은 fragment + 빈 정의 카테고리 → mini Document 빌드 성공 + let src = build_mini_document_from_fragment(TEST_FRAGMENT, "", "", "", "") + .expect("build_mini_document ok"); + + assert_eq!(src.sections.len(), 1, "1 section"); + assert_eq!( + src.sections[0].paragraphs.len(), + 1, + "1 paragraph from fragment" + ); + } + + #[test] + fn test_paste_hwpx_fragment_minimal_synthetic() { + // 합성 fragment paste → JSON 응답 형식 검증 + let mut doc = make_blank_doc(); + + let json = doc + .paste_hwpx_fragment_native(0, 0, 0, TEST_FRAGMENT, "", "", "", "") + .expect("paste_hwpx_fragment ok"); + + assert!(json.contains(r#""ok":true"#), "ok flag: {}", json); + assert!(json.contains(r#""paraIdx":"#), "paraIdx field: {}", json); + assert!( + json.contains(r#""charOffset":"#), + "charOffset field: {}", + json + ); + assert!( + json.contains(r#""insertedParaCount":1"#), + "insertedParaCount=1: {}", + json + ); + } + + #[test] + fn test_paste_hwpx_fragment_no_panic_on_malformed() { + // 의미 없는 fragment 도 panic 없이 완료 (Ok 또는 Err 양쪽 허용) + let mut doc = make_blank_doc(); + let _ = doc.paste_hwpx_fragment_native(0, 0, 0, "", "", "", "", ""); + // panic 없으면 통과 + } +} diff --git a/src/document_core/commands/document.rs b/src/document_core/commands/document.rs index 4fffcd57..ef9bd82f 100644 --- a/src/document_core/commands/document.rs +++ b/src/document_core/commands/document.rs @@ -49,8 +49,19 @@ impl DocumentCore { pub fn from_bytes(data: &[u8]) -> Result { let source_format = crate::parser::detect_format(data); - let mut document = crate::parser::parse_document(data) - .map_err(|e| HwpError::InvalidFile(e.to_string()))?; + // HWPX 는 raw XML 까지 함께 보존해야 paste fragment wasm bridge 가 byte-preserving 으로 동작한다. + // HWP 또는 미지원 포맷은 기존 parse_document 경로로 fallback (raw 는 빈 값으로 남는다). + let (mut document, raw_section_xmls, raw_header_xml) = if matches!( + source_format, + crate::parser::FileFormat::Hwpx + ) { + crate::parser::hwpx::parse_hwpx_with_raw_xmls(data) + .map_err(|e| HwpError::InvalidFile(e.to_string()))? + } else { + let doc = crate::parser::parse_document(data) + .map_err(|e| HwpError::InvalidFile(e.to_string()))?; + (doc, Vec::new(), String::new()) + }; let styles = resolve_styles(&document.doc_info, DEFAULT_DPI); @@ -117,6 +128,8 @@ impl DocumentCore { para_offset: Vec::new(), source_format, validation_report, + source_section_xmls: raw_section_xmls, + source_header_xml: raw_header_xml, }; doc.paginate(); @@ -474,7 +487,7 @@ impl DocumentCore { reflowed } - /// 내장 템플릿에서 빈 문서 생성 (네이티브) + /// 내장 템플릿에서 빈 HWP 5.0 문서 생성 (네이티브). 기존 동작 유지. pub fn create_blank_document_native(&mut self) -> Result { const BLANK_TEMPLATE: &[u8] = include_bytes!("../../../saved/blank2010.hwp"); @@ -504,6 +517,43 @@ impl DocumentCore { Ok(self.get_document_info()) } + /// 내장 HWPX 템플릿에서 빈 문서 생성 (네이티브). + /// + /// `paste_hwpx_fragment_in_document` 가 raw section/header XML 보존을 요구하므로, + /// 양식 부품 paste 같은 wasm bridge 기능을 쓰려면 본 함수로 새 문서를 시작해야 한다. + /// 시드: `saved/04-blank_hwpx_empty.hwpx` (1 section, 1 빈 단락, ~7KB). + pub fn create_blank_hwpx_document_native(&mut self) -> Result { + const BLANK_HWPX_TEMPLATE: &[u8] = include_bytes!("../../../saved/04-blank_hwpx_empty.hwpx"); + + let (document, raw_section_xmls, raw_header_xml) = + crate::parser::hwpx::parse_hwpx_with_raw_xmls(BLANK_HWPX_TEMPLATE) + .map_err(|e| HwpError::InvalidFile(e.to_string()))?; + + let styles = resolve_styles(&document.doc_info, self.dpi); + let composed = document.sections.iter().map(|s| compose_section(s)).collect(); + let sec_count = document.sections.len(); + + self.document = document; + self.styles = styles; + self.composed = composed; + self.clipboard = None; + self.dirty_sections = vec![true; sec_count]; + self.measured_tables = Vec::new(); + self.measured_sections = Vec::new(); + self.dirty_paragraphs = Vec::new(); + self.para_column_map = Vec::new(); + self.page_tree_cache.borrow_mut().clear(); + self.snapshot_store.clear(); + self.next_snapshot_id = 0; + self.source_format = crate::parser::FileFormat::Hwpx; + self.source_section_xmls = raw_section_xmls; + self.source_header_xml = raw_header_xml; + + self.paginate(); + + Ok(self.get_document_info()) + } + /// Document IR을 HWP 5.0 CFB 바이너리로 직렬화 (네이티브 에러 타입) pub fn export_hwp_native(&self) -> Result, HwpError> { crate::serializer::serialize_document(&self.document) diff --git a/src/document_core/commands/fragment_paste.rs b/src/document_core/commands/fragment_paste.rs new file mode 100644 index 00000000..59227e24 --- /dev/null +++ b/src/document_core/commands/fragment_paste.rs @@ -0,0 +1,1389 @@ +//! 외부 HWPX fragment paste — Stage 1: ID remap (charPr/paraPr/style/borderFill). +//! +//! 본 모듈은 byte-preserving HWPX 편집 원칙에 따라 +//! lxml/quick-xml 직렬화 일체 사용 금지. 모든 변경은 raw `String`/byte slice에 +//! 대한 `find` + `String::insert_str` / 직접 build로만 수행한다. +//! +//! 단위 테스트는 ID remap 로직을 격리 검증한다. paste 본체는 Stage 2. + +use std::collections::HashMap; + +/// 양식.hwpx 등 외부 HWPX의 raw header 정의 묶음. 각 필드는 +/// `...` 형태의 1개 이상 정의가 +/// 줄바꿈으로 이어붙인 raw XML 문자열이다. +#[derive(Debug, Clone, Default)] +pub struct SourceDefinitions { + pub char_prs: String, + pub para_prs: String, + pub styles: String, + pub border_fills: String, +} + +/// source ID(외부 양식 기준) → target ID(현재 문서 기준) 매핑. +#[derive(Debug, Default)] +pub struct IdRemap { + pub char_pr: HashMap, + pub para_pr: HashMap, + pub style: HashMap, + pub border_fill: HashMap, +} + +/// Stage 1/2 격리 에러 enum. Stage 4 wasm 통합 시 HwpError로 매핑한다. +#[derive(Debug, PartialEq, Eq)] +pub enum FragmentPasteError { + /// source_definitions 안 정의의 id 속성이 없거나 파싱 불가. + MalformedSourceDefinitions(String), + /// header_xml에 동종 refList 닫는 태그가 없어 새 정의를 삽입할 위치가 없음. + HeaderInsertionPointMissing(&'static str), + /// fragment_xml 또는 paste 결과 section_xml이 well-formed가 아님. + MalformedFragment(String), +} + +impl std::fmt::Display for FragmentPasteError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FragmentPasteError::MalformedSourceDefinitions(d) => { + write!(f, "malformed source definitions: {d}") + } + FragmentPasteError::HeaderInsertionPointMissing(tag) => { + write!(f, "header insertion point missing for {tag}") + } + FragmentPasteError::MalformedFragment(d) => { + write!(f, "malformed fragment: {d}") + } + } + } +} +impl std::error::Error for FragmentPasteError {} + +// ───────────────────────────── Internal helpers ───────────────────────────── + +/// `...` 또는 self-closing `` +/// 형태의 정의 1건씩을 raw bytes로 떼어낸다. (id, raw_definition) 시퀀스 반환. +/// 정규식/quick-xml 사용 금지 — 직접 byte scan. +fn split_definitions<'a>( + src: &'a str, + tag: &str, +) -> Result, FragmentPasteError> { + let open_prefix = format!(""); + let mut out = Vec::new(); + let bytes = src.as_bytes(); + let mut pos = 0usize; + while pos < bytes.len() { + let Some(rel) = src[pos..].find(&open_prefix) else { + break; + }; + let start = pos + rel; + // open prefix 다음 문자가 ' ', '/', '>' 중 하나여야 정확히 매칭 + // (예: ') | Some(b'\t') | Some(b'\n') + ) { + pos = start + open_prefix.len(); + continue; + } + // 여는 태그의 닫힘 위치 + let Some(open_close_rel) = src[start..].find('>') else { + return Err(FragmentPasteError::MalformedSourceDefinitions(format!( + "unterminated at offset {start}" + ))); + }; + let open_close = start + open_close_rel; // '>' 위치 + let self_closed = bytes.get(open_close.saturating_sub(1)).copied() == Some(b'/'); + + // id 속성 추출 + let open_attrs = &src[start..=open_close]; + let id = parse_id_attr(open_attrs).ok_or_else(|| { + FragmentPasteError::MalformedSourceDefinitions(format!( + "missing id attr in near offset {start}" + )) + })?; + + let end_excl = if self_closed { + open_close + 1 + } else { + // 닫는 태그까지 포함 + let Some(close_rel) = src[open_close..].find(&close_tag) else { + return Err(FragmentPasteError::MalformedSourceDefinitions(format!( + "unterminated definition" + ))); + }; + open_close + close_rel + close_tag.len() + }; + + out.push((id, &src[start..end_excl])); + pos = end_excl; + } + Ok(out) +} + +/// `` 또는 `` 의 N을 추출. +fn parse_id_attr(open_tag: &str) -> Option { + for needle in [" id=\"", " id='"] { + if let Some(idx) = open_tag.find(needle) { + let after = &open_tag[idx + needle.len()..]; + let quote = needle.as_bytes().last().copied().unwrap(); + let end = after.find(quote as char)?; + return after[..end].parse::().ok(); + } + } + None +} + +/// 동일성 비교: id 속성을 제거한 나머지 raw bytes 문자열 반환. +fn def_body_normalized(def_xml: &str) -> String { + let mut out = String::with_capacity(def_xml.len()); + for needle in [" id=\"", " id='"] { + if let Some(idx) = def_xml.find(needle) { + let quote = needle.as_bytes().last().copied().unwrap() as char; + let after_start = idx + needle.len(); + if let Some(end_rel) = def_xml[after_start..].find(quote) { + out.push_str(&def_xml[..idx]); + out.push_str(&def_xml[after_start + end_rel + 1..]); + return out; + } + } + } + def_xml.to_string() +} + +/// 현재 header에서 사용 중인 최대 id를 찾는다. +fn max_existing_id(header_xml: &str, tag: &str) -> u32 { + let open_prefix = format!("') | Some(b'\t') | Some(b'\n') + ) { + pos = start + open_prefix.len(); + continue; + } + let Some(close_rel) = header_xml[start..].find('>') else { + break; + }; + let close = start + close_rel; + if let Some(id) = parse_id_attr(&header_xml[start..=close]) { + if id > max { + max = id; + } + } + pos = close + 1; + } + max +} + +/// HWPX header.xml 의 ref list 닫는 태그 후보. 두 변형(`*List`, `*Properties`)을 모두 시도. +/// - 일부 양식(예: 양식.hwpx)은 `` 컨테이너 사용 +/// - 표준 샘플은 `` 사용 +fn ref_list_close_tag_candidates(item_tag: &str) -> &'static [&'static str] { + match item_tag { + "charPr" => &["", ""], + "paraPr" => &["", ""], + "style" => &["", ""], + "borderFill" => &["", ""], + _ => &[""], + } +} + +/// 첫 번째 후보 닫는 태그 (대표 — 에러 메시지에 사용). +fn ref_list_close_tag(item_tag: &str) -> &'static str { + ref_list_close_tag_candidates(item_tag)[0] +} + +/// header_xml의 ref list 닫는 태그 직전에 `to_insert`를 삽입. +/// 후보 닫는 태그 중 가장 먼저 발견되는 것을 사용. +fn insert_into_ref_list( + header_xml: &mut String, + item_tag: &str, + to_insert: &str, +) -> Result<(), FragmentPasteError> { + if to_insert.is_empty() { + return Ok(()); + } + let candidates = ref_list_close_tag_candidates(item_tag); + let mut found: Option = None; + for cand in candidates { + if let Some(p) = header_xml.rfind(cand) { + found = Some(p); + break; + } + } + let Some(pos) = found else { + return Err(FragmentPasteError::HeaderInsertionPointMissing( + candidates[0], + )); + }; + header_xml.insert_str(pos, to_insert); + Ok(()) +} + +/// ` String { + for needle in [" id=\"", " id='"] { + if let Some(idx) = def_xml.find(needle) { + let quote = needle.as_bytes().last().copied().unwrap() as char; + let after_start = idx + needle.len(); + if let Some(end_rel) = def_xml[after_start..].find(quote) { + let mut out = String::with_capacity(def_xml.len() + 4); + out.push_str(&def_xml[..after_start]); + out.push_str(&new_id.to_string()); + out.push_str(&def_xml[after_start + end_rel..]); + return out; + } + } + } + def_xml.to_string() +} + +// ───────────────────────────── Public API ───────────────────────────── + +/// header_xml에 source 정의들을 add-or-reuse 한 뒤 source_id → target_id 매핑을 반환. +/// header_xml은 in-place로 갱신된다(byte-preserving: 기존 정의 변경 없음, 닫는 태그 +/// 직전에 새 정의 raw 삽입만). +pub fn build_id_remap( + header_xml: &mut String, + source: &SourceDefinitions, +) -> Result { + let mut out = IdRemap::default(); + process_one_kind(header_xml, "charPr", &source.char_prs, &mut out.char_pr)?; + process_one_kind(header_xml, "paraPr", &source.para_prs, &mut out.para_pr)?; + process_one_kind(header_xml, "style", &source.styles, &mut out.style)?; + process_one_kind( + header_xml, + "borderFill", + &source.border_fills, + &mut out.border_fill, + )?; + Ok(out) +} + +fn process_one_kind( + header_xml: &mut String, + item_tag: &str, + source_xml: &str, + remap: &mut HashMap, +) -> Result<(), FragmentPasteError> { + if source_xml.trim().is_empty() { + return Ok(()); + } + let source_defs = split_definitions(source_xml, item_tag)?; + let existing_defs = split_definitions(header_xml, item_tag)?; + + // 기존 본문(id 제거 후)을 키로 하는 lookup — 동일 정의면 기존 ID 재사용. + let mut body_to_id: HashMap = HashMap::with_capacity(existing_defs.len()); + for (eid, raw) in &existing_defs { + body_to_id.entry(def_body_normalized(raw)).or_insert(*eid); + } + + let mut next_id = max_existing_id(header_xml, item_tag) + 1; + let mut to_append = String::new(); + + for (sid, raw) in &source_defs { + let body = def_body_normalized(raw); + if let Some(&target) = body_to_id.get(&body) { + remap.insert(*sid, target); + } else { + let target = next_id; + next_id += 1; + let rewritten = rewrite_id_in_def(raw, target); + to_append.push('\n'); + to_append.push_str(&rewritten); + body_to_id.insert(body, target); + remap.insert(*sid, target); + } + } + + insert_into_ref_list(header_xml, item_tag, &to_append)?; + Ok(()) +} + +/// fragment_xml의 *IDRef 속성값을 remap 결과로 갈아끼운다. +/// 매칭은 `charPrIDRef="N"` / `charPrIDRef='N'` 양쪽 모두 지원. +pub fn rewrite_id_refs(fragment_xml: &str, remap: &IdRemap) -> String { + let mut out = fragment_xml.to_string(); + out = rewrite_one_attr(out, "charPrIDRef", &remap.char_pr); + out = rewrite_one_attr(out, "paraPrIDRef", &remap.para_pr); + out = rewrite_one_attr(out, "styleIDRef", &remap.style); + out = rewrite_one_attr(out, "borderFillIDRef", &remap.border_fill); + out +} + +fn rewrite_one_attr(input: String, attr: &str, remap: &HashMap) -> String { + if remap.is_empty() { + return input; + } + let dq_needle = format!("{attr}=\""); + let sq_needle = format!("{attr}='"); + let mut out = String::with_capacity(input.len()); + let mut pos = 0usize; + while pos < input.len() { + let dq = input[pos..].find(&dq_needle); + let sq = input[pos..].find(&sq_needle); + let next = match (dq, sq) { + (None, None) => None, + (Some(a), None) => Some((a, '"')), + (None, Some(a)) => Some((a, '\'')), + (Some(a), Some(b)) => Some(if a <= b { (a, '"') } else { (b, '\'') }), + }; + let Some((rel, quote)) = next else { + out.push_str(&input[pos..]); + break; + }; + let attr_start = pos + rel; + // attr 시작 직전 문자가 attribute boundary인지 확인 (다른 속성 prefix 충돌 방지) + let prev = if attr_start == 0 { + b'\0' + } else { + input.as_bytes()[attr_start - 1] + }; + if !matches!(prev, b' ' | b'\t' | b'\n' | b'\r' | b'<' | b'/') { + // boundary 아님 — 이 위치까지 그대로 복사하고 attr_start+1부터 다시 시작 + out.push_str(&input[pos..=attr_start]); + pos = attr_start + 1; + continue; + } + let value_start = attr_start + attr.len() + 2; // attr + '=' + quote + let Some(value_end_rel) = input[value_start..].find(quote) else { + out.push_str(&input[pos..]); + break; + }; + let value_end = value_start + value_end_rel; + let raw_id = &input[value_start..value_end]; + out.push_str(&input[pos..value_start]); + match raw_id + .parse::() + .ok() + .and_then(|sid| remap.get(&sid).copied()) + { + Some(target) => out.push_str(&target.to_string()), + None => out.push_str(raw_id), + } + out.push(quote); + pos = value_end + 1; + } + out +} + +// ───────────────────────────── Stage 2: paragraphs paste ───────────────────────────── + +/// Stage 2 paste 결과. Document 모델 갱신은 Stage 4에서. +#[derive(Debug, Default)] +pub struct ParagraphPasteResult { + pub id_remap: IdRemap, + pub inserted_para_count: usize, + pub new_section_xml: String, +} + +/// section_xml의 top-level `` 시퀀스 중 `after_para_idx` 직후에 fragment를 byte-preserving 삽입. +/// header_xml은 in-place로 갱신된다(ID remap에 따른 새 정의 추가). +/// +/// fragment_xml은 1개 이상의 `...` 또는 self-closing `` 시퀀스여야 한다. +/// 표(``)가 포함된 fragment는 Stage 3에서 처리. +pub fn paste_paragraphs_into_section( + section_xml: &str, + header_xml: &mut String, + after_para_idx: usize, + fragment_xml: &str, + source: &SourceDefinitions, +) -> Result { + // 1. ID remap 사전 단계 (Stage 1 함수 재사용) + let remap = build_id_remap(header_xml, source)?; + let fragment_remapped = rewrite_id_refs(fragment_xml, &remap); + + // 2. 입력 fragment well-formedness 사전 검증 (read-only, 직렬화 금지) + validate_paragraphs_wellformed(&fragment_remapped)?; + + // 3. fragment의 top-level 개수 — 단위 테스트 검증용 + let fragment_p_count = count_top_level_p(&fragment_remapped); + if fragment_p_count == 0 { + return Err(FragmentPasteError::MalformedFragment( + "no top-level found in fragment".into(), + )); + } + + // 4. anchor: section_xml의 top-level 시퀀스에서 after_para_idx 위치를 찾음 + let p_spans = find_top_level_p_spans(section_xml); + if after_para_idx >= p_spans.len() { + return Err(FragmentPasteError::MalformedFragment(format!( + "after_para_idx {after_para_idx} out of range (section has {} top-level paragraphs)", + p_spans.len() + ))); + } + let (_a_start, a_end) = p_spans[after_para_idx]; + + // 5. byte-preserving 삽입: anchor paragraph 닫는 태그 직후에 fragment 삽입 + let mut new_section = String::with_capacity(section_xml.len() + fragment_remapped.len()); + new_section.push_str(§ion_xml[..a_end]); + new_section.push_str(&fragment_remapped); + new_section.push_str(§ion_xml[a_end..]); + + // 6. 출력 well-formedness 검증 (read-only) + validate_section_wellformed(&new_section)?; + + Ok(ParagraphPasteResult { + id_remap: remap, + inserted_para_count: fragment_p_count, + new_section_xml: new_section, + }) +} + +/// section_xml의 top-level `` 시퀀스를 (start, end) byte offset으로 반환. +/// ``/`` 안의 nested paragraph는 제외. +fn find_top_level_p_spans(section_xml: &str) -> Vec<(usize, usize)> { + let bytes = section_xml.as_bytes(); + let mut spans: Vec<(usize, usize)> = Vec::new(); + let mut depth: i32 = 0; + let mut cur_start: Option = None; + let mut pos = 0usize; + while pos < bytes.len() { + // CDATA / comment 안전 스킵. + if section_xml[pos..].starts_with("") + .map(|r| pos + r + 3) + .unwrap_or(bytes.len()); + continue; + } + if section_xml[pos..].starts_with("") + .map(|r| pos + r + 3) + .unwrap_or(bytes.len()); + continue; + } + if section_xml[pos..].starts_with("") { + if depth == 0 { + spans.push((pos, pos + 7)); + } + pos += 7; + continue; + } + if section_xml[pos..].starts_with("") { + depth -= 1; + pos += 7; + if depth == 0 { + if let Some(s) = cur_start.take() { + spans.push((s, pos)); + } + } + continue; + } + if section_xml[pos..].starts_with("") { + depth -= 1; + pos += "".len(); + continue; + } + // 다음 '<' 까지 빠르게 점프 + let nxt = section_xml[pos + 1..].find('<').map(|r| pos + 1 + r); + pos = nxt.unwrap_or(bytes.len()); + } + spans +} + +fn count_top_level_p(fragment_xml: &str) -> usize { + find_top_level_p_spans(fragment_xml).len() +} + +/// fragment에 1개 이상의 top-level `` 가 있고 모두 닫혀 있으면 OK. +/// quick-xml 등 직렬화 라이브러리 사용 금지. 직접 byte scan. +fn validate_paragraphs_wellformed(fragment_xml: &str) -> Result<(), FragmentPasteError> { + let spans = find_top_level_p_spans(fragment_xml); + if spans.is_empty() { + return Err(FragmentPasteError::MalformedFragment( + "no top-level in fragment".into(), + )); + } + // 마지막 span end 이후에 닫히지 않은 ") { + return Err(FragmentPasteError::MalformedFragment( + "unclosed in fragment tail".into(), + )); + } + Ok(()) +} + +/// section 결과에 대해 lightweight well-formedness 검증. +/// top-level `` depth가 paste 전후로 양수→0 으로 일관되는지 확인. +fn validate_section_wellformed(section_xml: &str) -> Result<(), FragmentPasteError> { + // find_top_level_p_spans는 depth 0으로 끝나면 정상 종료. + // 끝까지 못 닫힌 경우 cur_start.take() 호출이 안 되어 spans에 안 들어감 — 그 경우를 별도 검출. + let bytes = section_xml.as_bytes(); + let mut depth: i32 = 0; + let mut pos = 0usize; + while pos < bytes.len() { + if section_xml[pos..].starts_with("") + .map(|r| pos + r + 3) + .unwrap_or(bytes.len()); + continue; + } + if section_xml[pos..].starts_with("") + .map(|r| pos + r + 3) + .unwrap_or(bytes.len()); + continue; + } + if section_xml[pos..].starts_with("") { + pos += 7; + continue; + } + if section_xml[pos..].starts_with("") { + depth -= 1; + if depth < 0 { + return Err(FragmentPasteError::MalformedFragment( + "unbalanced in section".into(), + )); + } + pos += 7; + continue; + } + if section_xml[pos..].starts_with("") { + depth -= 1; + if depth < 0 { + return Err(FragmentPasteError::MalformedFragment( + "unbalanced in section".into(), + )); + } + pos += "".len(); + continue; + } + let nxt = section_xml[pos + 1..].find('<').map(|r| pos + 1 + r); + pos = nxt.unwrap_or(bytes.len()); + } + if depth != 0 { + return Err(FragmentPasteError::MalformedFragment(format!( + "section ended with unbalanced depth={depth}" + ))); + } + Ok(()) +} + +// ───────────────────────────── Stage 3: table addrs + table-aware paste ───────────────────────────── + +/// `` 안 모든 행/셀의 `rowCnt`/`rowAddr`/`colAddr` 를 colSpan + rowSpan 그리드 기준으로 +/// 재계산해 byte-preserving으로 갱신한 fragment_xml 반환. +/// HWPX table coordinate invariants를 직접 보정한다. +pub fn recompute_table_addrs(fragment_xml: &str) -> Result { + let mut out = String::with_capacity(fragment_xml.len()); + let mut pos = 0usize; + while let Some(rel) = fragment_xml[pos..].find("') + let after = fragment_xml.as_bytes().get(tbl_start + 7).copied(); + if !matches!( + after, + Some(b' ') | Some(b'/') | Some(b'>') | Some(b'\t') | Some(b'\n') + ) { + out.push_str(&fragment_xml[pos..tbl_start + 7]); + pos = tbl_start + 7; + continue; + } + // 닫는 까지 (depth tracking — nested tables) + let tbl_end = find_balanced_close(fragment_xml, tbl_start, "") + .ok_or_else(|| { + FragmentPasteError::MalformedFragment(format!( + "unbalanced at offset {tbl_start}" + )) + })?; + out.push_str(&fragment_xml[pos..tbl_start]); + let tbl_slice = &fragment_xml[tbl_start..tbl_end]; + let recomputed = recompute_one_table(tbl_slice)?; + out.push_str(&recomputed); + pos = tbl_end; + } + out.push_str(&fragment_xml[pos..]); + Ok(out) +} + +/// 단일 `...` slice의 메타데이터 재계산. +fn recompute_one_table(tbl_slice: &str) -> Result { + // 1. row spans 추출 — 각 row에 (col_span, row_span)의 셀들 + let rows = parse_rows(tbl_slice)?; + + // 2. row-by-row pass — 같은 row 안 cells는 logical_col 변수로 진행, + // rowSpan>1로 다른 row를 점유하는 경우만 occupied에 마킹. + let mut occupied: Vec> = + vec![std::collections::HashSet::new(); rows.len()]; + let mut replacements: Vec<(usize, usize, String)> = Vec::new(); + + for (r, row) in rows.iter().enumerate() { + let mut logical_col: u32 = 0; + for cell in &row.cells { + while occupied[r].contains(&logical_col) { + logical_col += 1; + } + // colAddr / rowAddr 속성 갱신 + for &(attr_name, new_value) in &[("rowAddr", r as u32), ("colAddr", logical_col)] { + if let Some((vs, ve)) = find_attr_value_span( + tbl_slice, + cell.open_tag_start, + cell.open_tag_end, + attr_name, + ) { + replacements.push((vs, ve, new_value.to_string())); + } + } + // rowSpan>1 만 occupied에 마킹 (자기 row는 logical_col 변수로 처리) + for span_r in 1..cell.row_span { + let target_row = r + span_r as usize; + if target_row >= rows.len() { + break; + } + for span_c in 0..cell.col_span { + occupied[target_row].insert(logical_col + span_c); + } + } + logical_col += cell.col_span; + } + } + + // 3b. 표의 rowCnt — opening 태그 안에서 갱신 + let tbl_open_end = tbl_slice.find('>').ok_or_else(|| { + FragmentPasteError::MalformedFragment("table open tag missing '>'".into()) + })?; + if let Some((vs, ve)) = find_attr_value_span(tbl_slice, 0, tbl_open_end + 1, "rowCnt") { + replacements.push((vs, ve, rows.len().to_string())); + } + + // 4. 역순 적용 (offset 보존) + replacements.sort_by(|a, b| b.0.cmp(&a.0)); + let mut out = tbl_slice.to_string(); + for (start, end, new_val) in replacements { + out.replace_range(start..end, &new_val); + } + + // 5. nested table 재귀 처리: outer open tag와 close tag 사이 본문에 대해 recompute_table_addrs 재귀 + if let (Some(open_end_rel), Some(close_start_rel)) = (out.find('>'), out.rfind("")) { + let open_end = open_end_rel + 1; + if open_end < close_start_rel { + let inner = &out[open_end..close_start_rel]; + let inner_recomputed = recompute_table_addrs(inner)?; + let mut final_out = String::with_capacity(out.len()); + final_out.push_str(&out[..open_end]); + final_out.push_str(&inner_recomputed); + final_out.push_str(&out[close_start_rel..]); + return Ok(final_out); + } + } + Ok(out) +} + +/// 단순 row/cell 추상화 (메타데이터만) +#[derive(Debug)] +struct CellInfo { + open_tag_start: usize, // 의 '<' 위치 (tbl_slice 내부 offset) + open_tag_end: usize, // open tag 의 '>' 직후 위치 + col_span: u32, + row_span: u32, +} + +#[derive(Debug)] +struct RowInfo { + cells: Vec, +} + +/// `` slice 안의 직접 자식 row들과 각 row의 직접 자식 cell들을 추출. +/// nested 표는 무시 (마지막 `` 뒤 메타데이터만 검색). +fn parse_rows(tbl_slice: &str) -> Result, FragmentPasteError> { + let mut rows = Vec::new(); + // 직접 자식 만 찾는다 — depth=1 (table 직속) + let row_spans = find_direct_children(tbl_slice, "", "")?; + for (rs, re) in row_spans { + let row_slice = &tbl_slice[rs..re]; + let cell_spans = + find_direct_children(row_slice, "", "")?; + let mut cells = Vec::new(); + for (cs, ce) in cell_spans { + let cell_slice = &row_slice[cs..ce]; + let open_end_rel = cell_slice.find('>').ok_or_else(|| { + FragmentPasteError::MalformedFragment("cell open tag missing '>'".into()) + })?; + let col_span = extract_span_attr(cell_slice, "colSpan").unwrap_or(1); + let row_span = extract_span_attr(cell_slice, "rowSpan").unwrap_or(1); + cells.push(CellInfo { + open_tag_start: rs + cs, + open_tag_end: rs + cs + open_end_rel + 1, + col_span, + row_span, + }); + } + rows.push(RowInfo { cells }); + } + Ok(rows) +} + +/// `outer_open` ... `outer_close` 안에서 `child_open`/`child_close` 인 직접 자식의 (start, end) 시퀀스 반환. +/// `outer_open`은 입력 slice의 시작이 ` Result, FragmentPasteError> { + let mut spans = Vec::new(); + let mut pos = 0usize; + let mut depth: i32 = 0; + let mut current_child_start: Option = None; + while pos < slice.len() { + if slice[pos..].starts_with(outer_open) { + // outer 자체는 depth 변화 없이 그냥 지나감 (slice 시작에서만 등장 가정) + let after = slice.as_bytes().get(pos + outer_open.len()).copied(); + if matches!( + after, + Some(b' ') | Some(b'/') | Some(b'>') | Some(b'\t') | Some(b'\n') + ) { + pos += outer_open.len(); + continue; + } + } + if slice[pos..].starts_with(outer_close) { + pos += outer_close.len(); + continue; + } + if slice[pos..].starts_with(child_open) { + let after = slice.as_bytes().get(pos + child_open.len()).copied(); + if matches!( + after, + Some(b' ') | Some(b'/') | Some(b'>') | Some(b'\t') | Some(b'\n') + ) { + if depth == 0 && current_child_start.is_none() { + current_child_start = Some(pos); + } + depth += 1; + pos += child_open.len(); + continue; + } + } + if slice[pos..].starts_with(child_close) { + depth -= 1; + pos += child_close.len(); + if depth == 0 { + if let Some(s) = current_child_start.take() { + spans.push((s, pos)); + } + } else if depth < 0 { + return Err(FragmentPasteError::MalformedFragment(format!( + "unbalanced {child_close}" + ))); + } + continue; + } + let nxt = slice[pos + 1..].find('<').map(|r| pos + 1 + r); + pos = nxt.unwrap_or(slice.len()); + } + Ok(spans) +} + +/// cell slice의 open tag 안에서 colSpan/rowSpan 속성 값을 추출. 없으면 None. +/// 마지막 `` 뒤 부분만 검색해 nested table 안 cellSpan과 혼동 방지. +fn extract_span_attr(cell_slice: &str, attr: &str) -> Option { + // open tag만 본다: '<' 부터 첫 '>' 까지 + let open_end = cell_slice.find('>')?; + let open_tag = &cell_slice[..=open_end]; + for q in [&format!(" {attr}=\""), &format!(" {attr}='")] { + if let Some(idx) = open_tag.find(q.as_str()) { + let quote = q.as_bytes().last().copied().unwrap() as char; + let val_start = idx + q.len(); + if let Some(end_rel) = open_tag[val_start..].find(quote) { + return open_tag[val_start..val_start + end_rel].parse().ok(); + } + } + } + None +} + +/// open tag 내부에서 attribute 값의 byte span (인용부호 안쪽) 반환. +/// `tag_start` ~ `tag_end` 는 open tag '<' ... '>' 의 byte range (tag_end는 '>' 다음 위치). +fn find_attr_value_span( + src: &str, + tag_start: usize, + tag_end: usize, + attr: &str, +) -> Option<(usize, usize)> { + let region = &src[tag_start..tag_end]; + for q in [&format!(" {attr}=\""), &format!(" {attr}='")] { + if let Some(idx) = region.find(q.as_str()) { + let quote = q.as_bytes().last().copied().unwrap() as char; + let val_start = tag_start + idx + q.len(); + let after = &src[val_start..tag_end]; + if let Some(end_rel) = after.find(quote) { + return Some((val_start, val_start + end_rel)); + } + } + } + None +} + +/// `outer_open`/`outer_close`로 둘러싸인 nested 구조에서 outer의 닫는 위치를 찾음. +fn find_balanced_close( + src: &str, + start: usize, + open_prefix: &str, + close_tag: &str, +) -> Option { + let mut depth: i32 = 0; + let mut pos = start; + while pos < src.len() { + if src[pos..].starts_with(open_prefix) { + let after = src.as_bytes().get(pos + open_prefix.len()).copied(); + if matches!( + after, + Some(b' ') | Some(b'/') | Some(b'>') | Some(b'\t') | Some(b'\n') + ) { + depth += 1; + pos += open_prefix.len(); + continue; + } + } + if src[pos..].starts_with(close_tag) { + depth -= 1; + pos += close_tag.len(); + if depth == 0 { + return Some(pos); + } + continue; + } + let nxt = src[pos + 1..].find('<').map(|r| pos + 1 + r); + pos = nxt.unwrap_or(src.len()); + } + None +} + +/// table-aware fragment paste — fragment에 `` 포함 시 자동으로 rowCnt/rowAddr/colAddr 보정. +/// paragraphs-only fragment는 paste_paragraphs_into_section과 동일 동작. +pub fn paste_fragment_into_section( + section_xml: &str, + header_xml: &mut String, + after_para_idx: usize, + fragment_xml: &str, + source: &SourceDefinitions, +) -> Result { + let has_table = fragment_xml.contains(" HwpOpenResult { + use std::process::Command; + let bin = std::path::Path::new("/opt/hnc/hoffice11/Bin/hwp"); + if !bin.is_file() { + return HwpOpenResult::Skipped(format!("hwp binary not found at {}", bin.display())); + } + if !path.is_file() { + return HwpOpenResult::Skipped(format!("input not found: {}", path.display())); + } + let out = Command::new("timeout") + .arg(timeout_sec.to_string()) + .arg("env") + .arg("DISPLAY=:0") + .arg(bin) + .arg(path) + .output(); + let Ok(out) = out else { + return HwpOpenResult::Skipped("failed to spawn timeout/hwp".into()); + }; + match out.status.code() { + Some(143) | Some(124) => HwpOpenResult::Accepted, + Some(c) => HwpOpenResult::Rejected(c), + None => HwpOpenResult::Skipped("no exit code".into()), + } +} + +// ───────────────────────────── Tests ───────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn header_with(char_prs: &str, para_prs: &str, styles: &str, border_fills: &str) -> String { + format!( + "\ +{char_prs}\ +{para_prs}\ +{styles}\ +{border_fills}\ +" + ) + } + + #[test] + fn id_remap_existing_definition_reuses_id() { + let mut header = header_with( + "", + "", + "", + "", + ); + let source = SourceDefinitions { + char_prs: "" + .into(), + ..Default::default() + }; + let remap = build_id_remap(&mut header, &source).unwrap(); + // source id=9 가 기존 동일 정의(id=5)를 재사용해야 함 + assert_eq!(remap.char_pr.get(&9), Some(&5)); + // header에 새 정의 append되지 않았어야 함 (기존 1건만) + assert_eq!(header.matches("", "", "", ""); + let source = SourceDefinitions { + char_prs: "".into(), + ..Default::default() + }; + let original_len = header.len(); + let remap = build_id_remap(&mut header, &source).unwrap(); + // 다른 정의이므로 새 ID = max(5)+1 = 6 + assert_eq!(remap.char_pr.get(&9), Some(&6)); + // header에 새 정의가 추가됐고, 닫는 태그 위치가 보존됨 + assert!(header.len() > original_len); + assert!(header.contains("")); + assert!(header.ends_with("")); + assert_eq!(header.matches("", "", "", ""); + let baseline = header.clone(); + let source = SourceDefinitions { + char_prs: "".into(), + ..Default::default() + }; + build_id_remap(&mut header, &source).unwrap(); + // 기존 정의는 그대로 byte-exact 유지되어야 함 + assert!(header.contains("")); + // baseline의 charPropertyList 닫는 태그 직전까지가 byte-exact 보존 + let baseline_close_pos = baseline.find("").unwrap(); + assert_eq!( + &header[..baseline_close_pos], + &baseline[..baseline_close_pos] + ); + } + + #[test] + fn rewrite_id_refs_preserves_unrelated_attrs() { + let mut remap = IdRemap::default(); + remap.char_pr.insert(9, 27); + let frag = r#"x"#; + let out = rewrite_id_refs(frag, &remap); + assert_eq!( + out, + r#"x"# + ); + } + + #[test] + fn rewrite_id_refs_handles_quote_styles() { + let mut remap = IdRemap::default(); + remap.char_pr.insert(9, 27); + remap.para_pr.insert(3, 11); + let frag = r#""#; + let out = rewrite_id_refs(frag, &remap); + assert!(out.contains("paraPrIDRef='11'")); + assert!(out.contains("charPrIDRef=\"27\"")); + } + + #[test] + fn rewrite_id_refs_unknown_ids_left_intact() { + let mut remap = IdRemap::default(); + remap.char_pr.insert(9, 27); + // remap에 없는 12는 그대로 유지 + let frag = r#""#; + let out = rewrite_id_refs(frag, &remap); + assert_eq!(out, r#""#); + } + + #[test] + fn rewrite_id_refs_no_match_for_attr_prefix_collision() { + // "extraCharPrIDRef"는 본 attr가 아니므로 변경되면 안 됨 + let mut remap = IdRemap::default(); + remap.char_pr.insert(9, 27); + let frag = r#""#; + let out = rewrite_id_refs(frag, &remap); + assert_eq!(out, frag); + } + + // ─── Stage 2: paragraphs paste ─── + + fn empty_section() -> String { + // top-level 1개 (anchor용). secPr 같은 부수 요소는 단순화. + "x".to_string() + } + + fn empty_header() -> String { + header_with( + "", + "", + "", + "", + ) + } + + #[test] + fn paste_paragraphs_simple() { + let section = empty_section(); + let mut header = empty_header(); + let fragment = r#"hello"#; + let source = SourceDefinitions { + char_prs: "".into(), + para_prs: "".into(), + ..Default::default() + }; + let result = + paste_paragraphs_into_section(§ion, &mut header, 0, fragment, &source).unwrap(); + assert_eq!(result.inserted_para_count, 1); + // 결과 section의 top-level 가 paste 전(1)에서 +1 = 2 + let final_p_count = find_top_level_p_spans(&result.new_section_xml).len(); + assert_eq!(final_p_count, 2); + // remap이 적용되어 fragment의 charPrIDRef="9"가 새 ID로 갈아끼워짐 + assert!(!result.new_section_xml.contains("charPrIDRef=\"9\"")); + assert!(result.new_section_xml.contains("hello")); + } + + #[test] + fn paste_paragraphs_multi() { + let section = empty_section(); + let mut header = empty_header(); + let fragment = r#"123"#; + let source = SourceDefinitions::default(); + let result = + paste_paragraphs_into_section(§ion, &mut header, 0, fragment, &source).unwrap(); + assert_eq!(result.inserted_para_count, 3); + let final_p_count = find_top_level_p_spans(&result.new_section_xml).len(); + assert_eq!(final_p_count, 4); + } + + #[test] + fn paste_paragraphs_byte_preserving() { + let section = empty_section(); + let baseline = section.clone(); + let mut header = empty_header(); + let fragment = + r#"x"#; + let source = SourceDefinitions::default(); + let result = + paste_paragraphs_into_section(§ion, &mut header, 0, fragment, &source).unwrap(); + // 기존 section의 첫 paragraph가 byte-exact 보존: anchor end까지가 baseline의 anchor end까지와 동일 + let baseline_first_p_end = find_top_level_p_spans(&baseline)[0].1; + assert_eq!( + &result.new_section_xml[..baseline_first_p_end], + &baseline[..baseline_first_p_end] + ); + // 닫는 도 보존 + assert!(result.new_section_xml.ends_with("")); + } + + #[test] + fn paste_paragraphs_id_remapped_into_header() { + let section = empty_section(); + let mut header = empty_header(); + // source의 charPr id=99는 본문이 다른 신규 정의 → 새 ID 부여 + header에 추가됨 + let fragment = + r#"x"#; + let source = SourceDefinitions { + char_prs: "".into(), + ..Default::default() + }; + let result = + paste_paragraphs_into_section(§ion, &mut header, 0, fragment, &source).unwrap(); + // header에 height=5555 새 정의가 추가됨 + assert!(header.contains("height=\"5555\"")); + // remap은 99 → 1 (max 0 + 1) 또는 그 이상의 새 ID + let new_id = result.id_remap.char_pr.get(&99).copied().unwrap(); + assert!(new_id > 0); + // fragment 안 charPrIDRef="99"가 새 ID로 갈아끼워졌어야 함 + assert!(!result.new_section_xml.contains("charPrIDRef=\"99\"")); + assert!(result + .new_section_xml + .contains(&format!("charPrIDRef=\"{new_id}\""))); + } + + #[test] + fn paste_paragraphs_validates_input_unclosed_p() { + let section = empty_section(); + let mut header = empty_header(); + // 닫히지 않은 + let fragment = r#"x"#; + let source = SourceDefinitions::default(); + let err = + paste_paragraphs_into_section(§ion, &mut header, 0, fragment, &source).unwrap_err(); + assert!(matches!(err, FragmentPasteError::MalformedFragment(_))); + } + + #[test] + fn paste_paragraphs_validates_input_no_p() { + let section = empty_section(); + let mut header = empty_header(); + // top-level 가 전혀 없는 fragment + let fragment = r#"x"#; + let source = SourceDefinitions::default(); + let err = + paste_paragraphs_into_section(§ion, &mut header, 0, fragment, &source).unwrap_err(); + assert!(matches!(err, FragmentPasteError::MalformedFragment(_))); + } + + #[test] + fn paste_paragraphs_after_para_idx_out_of_range() { + let section = empty_section(); + let mut header = empty_header(); + let fragment = + r#"x"#; + let source = SourceDefinitions::default(); + let err = + paste_paragraphs_into_section(§ion, &mut header, 5, fragment, &source).unwrap_err(); + assert!(matches!(err, FragmentPasteError::MalformedFragment(_))); + } + + // ─── Stage 3: table addrs ─── + + fn make_simple_table(rows: u32, cols: u32) -> String { + let mut out = format!(""); + for r in 0..rows { + out.push_str(""); + for c in 0..cols { + let _ = c; // placeholder + out.push_str(&format!( + "x" + )); + let _ = r; + } + out.push_str(""); + } + out.push_str(""); + out + } + + #[test] + fn recompute_simple_2x2() { + let tbl = make_simple_table(2, 2); + let out = recompute_table_addrs(&tbl).unwrap(); + // rowCnt=2 (opening tag) + assert!(out.contains("rowCnt=\"2\"")); + // 4개 셀 모두 99 -> 정확한 값으로 + assert!(!out.contains("colAddr=\"99\"")); + assert!(!out.contains("rowAddr=\"99\"")); + // 첫 행: rowAddr=0, colAddr=[0, 1] + assert!(out.contains("rowAddr=\"0\" colAddr=\"0\"")); + assert!(out.contains("rowAddr=\"0\" colAddr=\"1\"")); + // 둘째 행: rowAddr=1, colAddr=[0, 1] + assert!(out.contains("rowAddr=\"1\" colAddr=\"0\"")); + assert!(out.contains("rowAddr=\"1\" colAddr=\"1\"")); + } + + #[test] + fn recompute_with_colspan() { + // colCnt=3, Row0 [colSpan=2, colSpan=1] → colAddr=[0, 2] + let tbl = "\ +\ +\ +\ +"; + let out = recompute_table_addrs(tbl).unwrap(); + // 첫 셀 colAddr=0, 둘째 셀 colAddr=2 + assert!(out.contains("rowAddr=\"0\" colAddr=\"0\" rowSpan=\"1\" colSpan=\"2\"")); + assert!(out.contains("rowAddr=\"0\" colAddr=\"2\" rowSpan=\"1\" colSpan=\"1\"")); + assert!(out.contains("rowCnt=\"1\"")); + } + + #[test] + fn recompute_with_rowspan() { + // colCnt=3, Row0 [rowSpan=2 colSpan=1, _, _] → Row1 cells start from col=1 + let tbl = "\ +\ +\ +\ +\ +\ +\ +\ +\ +\ +"; + let out = recompute_table_addrs(tbl).unwrap(); + // Row0: cells at colAddr 0, 1, 2 + assert!(out.contains("rowAddr=\"0\" colAddr=\"0\" rowSpan=\"2\"")); + assert!(out.contains("rowAddr=\"0\" colAddr=\"1\"")); + assert!(out.contains("rowAddr=\"0\" colAddr=\"2\"")); + // Row1: col=0 점유 → cells at colAddr 1, 2 + assert!(out.contains("rowAddr=\"1\" colAddr=\"1\"")); + assert!(out.contains("rowAddr=\"1\" colAddr=\"2\"")); + assert!(out.contains("rowCnt=\"2\"")); + } + + #[test] + fn recompute_with_both_spans() { + // colCnt=3, Row0 [colSpan=1 rowSpan=2, colSpan=2] → Row1은 col=0 점유 + col=1,2도 colspan으로 점유 안 됨 + // Row0 cells: c0(rs=2,cs=1), c1(rs=1,cs=2) → colAddr [0, 1] + // Row1 cells: 1개만, col=0 점유 → colAddr=1 (cs=1) but col 1과 2는 점유 안 됨, c1만 들어감 colAddr=1, 다음 c2 colAddr=2 + let tbl = "\ +\ +\ +\ +\ +\ +\ +\ +\ +"; + let out = recompute_table_addrs(tbl).unwrap(); + // Row0: colAddr [0, 1] + assert!(out.contains("rowAddr=\"0\" colAddr=\"0\" rowSpan=\"2\" colSpan=\"1\"")); + assert!(out.contains("rowAddr=\"0\" colAddr=\"1\" rowSpan=\"1\" colSpan=\"2\"")); + // Row1: col=0 점유, 다음 cells at colAddr [1, 2] + assert!(out.contains("rowAddr=\"1\" colAddr=\"1\"")); + assert!(out.contains("rowAddr=\"1\" colAddr=\"2\"")); + } + + #[test] + fn recompute_byte_exact_outside_attrs() { + // baseline의 셀 안 본문 텍스트 / 다른 속성 / 공백이 보존되는지 검증 + let tbl = "\ +\ +본문\ +본문2\ +"; + let out = recompute_table_addrs(tbl).unwrap(); + assert!(out.contains("hint=\"keep\"")); + assert!(out.contains("extra=\"X\"")); + assert!(out.contains("본문")); + assert!(out.contains("본문2")); + } + + #[test] + fn recompute_nested_table_safety() { + // 외부 셀의 colSpan/rowSpan만 잡고, 내부 nested cell의 cellSpan은 무시되어야 함 + let tbl = "\ +\ +\ +\ +\ +\ +\ +\ +\ +\ +"; + let out = recompute_table_addrs(tbl).unwrap(); + // 외부 셀 0,0 — 갱신됨 + assert!(out.contains("rowAddr=\"0\" colAddr=\"0\"")); + // nested table도 자체적으로 recompute됨 (재귀적 처리) → "rowAddr=\"55\"" 사라져야 함 + assert!(!out.contains("rowAddr=\"55\"")); + assert!(!out.contains("colAddr=\"55\"")); + // outermost rowCnt=1 + assert!(out.starts_with(" 안에 + let fragment = "\ +\ +\ +\ +"; + let source = SourceDefinitions::default(); + let result = + paste_fragment_into_section(§ion, &mut header, 0, fragment, &source).unwrap(); + // rowCnt 99 → 1로 보정 + assert!(result.new_section_xml.contains("rowCnt=\"1\"")); + assert!(!result.new_section_xml.contains("rowAddr=\"99\"")); + } + + #[test] + fn paste_fragment_paragraphs_only_unchanged() { + // 표가 없는 fragment는 paste_paragraphs_into_section 동작과 동일 + let section = empty_section(); + let mut header = empty_header(); + let fragment = + r#"x"#; + let source = SourceDefinitions::default(); + let result = + paste_fragment_into_section(§ion, &mut header, 0, fragment, &source).unwrap(); + assert_eq!(result.inserted_para_count, 1); + } + + #[test] + fn header_insertion_missing_returns_error() { + // 닫는 charPropertyList 태그가 없는 손상된 header + let mut header = "".to_string(); + let source = SourceDefinitions { + char_prs: "".into(), + ..Default::default() + }; + let err = build_id_remap(&mut header, &source).unwrap_err(); + assert!(matches!( + err, + FragmentPasteError::HeaderInsertionPointMissing(_) + )); + } +} diff --git a/src/document_core/commands/fragment_paste_in_document.rs b/src/document_core/commands/fragment_paste_in_document.rs new file mode 100644 index 00000000..515db911 --- /dev/null +++ b/src/document_core/commands/fragment_paste_in_document.rs @@ -0,0 +1,394 @@ +//! Stage 2 wasm bridge — `paste_hwpx_fragment_in_document` +//! +//! `DocumentCore` 의 보존된 raw section/header XML 을 이용해 paste fragment 를 +//! byte-preserving 으로 적용하고, IR 재파싱·dirty 플래그·이벤트 로그까지 한 번에 처리한다. +//! +//! 5대 원칙 준수: +//! - **Rule 9**: IR→XML 직렬화 회피 (raw 보관본을 사용) +//! - **Rule 10/11/12**: Phase 2 산출물(`paste_fragment_into_section`)에 위임 +//! - **Template-based**: source 정의 raw 그대로 보관·재사용 + +use crate::document_core::commands::fragment_paste::{ + paste_fragment_into_section, FragmentPasteError, IdRemap, SourceDefinitions, +}; +use crate::document_core::DocumentCore; +use crate::model::event::DocumentEvent; +use crate::parser::hwpx::{header::parse_hwpx_header, section::parse_hwpx_section}; +use crate::renderer::composer::compose_section; + +/// `paste_hwpx_fragment_in_document` 결과. +#[derive(Debug, Default)] +pub struct PasteInDocumentResult { + pub id_remap: IdRemap, + pub inserted_para_count: usize, +} + +/// Stage 2 wasm bridge 에러. Stage 1 의 `FragmentPasteError` 에 IR/IO 관련 케이스를 추가. +#[derive(Debug)] +pub enum PasteInDocumentError { + /// HWPX raw XML 미보존 (HWP 로드 또는 빈 문서). + NoSourceXml, + /// section_idx 가 보관된 raw section 범위를 벗어남. + SectionOutOfRange { idx: usize, count: usize }, + /// `paste_fragment_into_section` 의 위임 에러. + Paste(FragmentPasteError), + /// 결과 section/header XML 의 IR 재파싱 실패. + Reparse(String), +} + +impl std::fmt::Display for PasteInDocumentError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PasteInDocumentError::NoSourceXml => { + write!( + f, + "document was not loaded from HWPX (no source XML preserved)" + ) + } + PasteInDocumentError::SectionOutOfRange { idx, count } => { + write!(f, "section_idx {idx} out of range (have {count})") + } + PasteInDocumentError::Paste(e) => write!(f, "paste failed: {e}"), + PasteInDocumentError::Reparse(d) => write!(f, "IR reparse failed: {d}"), + } + } +} +impl std::error::Error for PasteInDocumentError {} + +impl From for PasteInDocumentError { + fn from(e: FragmentPasteError) -> Self { + PasteInDocumentError::Paste(e) + } +} + +impl DocumentCore { + /// Stage 2 통합 paste 함수. + /// + /// 흐름: + /// 1. 보존된 raw section/header 추출 + /// 2. `paste_fragment_into_section` 호출 (Phase 2) + /// 3. 결과 raw 를 보존 슬롯에 저장 + /// 4. IR 재파싱 (`parse_hwpx_section` + `parse_hwpx_header`) → `Document.sections[idx]` / `doc_info` 교체 + /// 5. dirty 플래그 + cache 무효화 + `DocumentEvent::FragmentPasted` 추가 + pub fn paste_hwpx_fragment_in_document_native( + &mut self, + section_idx: usize, + after_para_idx: usize, + fragment_xml: &str, + source: &SourceDefinitions, + ) -> Result { + if !self.has_source_xmls() { + return Err(PasteInDocumentError::NoSourceXml); + } + let section_count = self.source_section_xmls.len(); + if section_idx >= section_count { + return Err(PasteInDocumentError::SectionOutOfRange { + idx: section_idx, + count: section_count, + }); + } + if section_idx >= self.document.sections.len() { + return Err(PasteInDocumentError::Reparse(format!( + "source XML section {section_idx} has no matching IR section (have {})", + self.document.sections.len() + ))); + } + + // 1. raw 추출 (clone — paste_fragment_into_section 가 &mut String header 를 받기 때문) + let section_xml = self.source_section_xmls[section_idx].clone(); + let mut header_xml = self.source_header_xml.clone(); + + // 2. Phase 2 paste 호출 (byte-preserving) + let paste_result = paste_fragment_into_section( + §ion_xml, + &mut header_xml, + after_para_idx, + fragment_xml, + source, + )?; + + // 3. raw 슬롯 갱신 + let new_section_xml = paste_result.new_section_xml.clone(); + self.source_section_xmls[section_idx] = new_section_xml.clone(); + self.source_header_xml = header_xml.clone(); + + // 4. IR 재파싱 (header 먼저, 그 다음 section — id remap 일관성) + let (new_doc_info, _new_doc_props) = parse_hwpx_header(&header_xml) + .map_err(|e| PasteInDocumentError::Reparse(format!("header: {e}")))?; + let new_section = parse_hwpx_section(&new_section_xml) + .map_err(|e| PasteInDocumentError::Reparse(format!("section: {e}")))?; + + // BinData 목록은 기존을 유지 (재파싱한 doc_info 의 빈 bin_data_list 와 합침) + let preserved_bin_data = std::mem::take(&mut self.document.doc_info.bin_data_list); + self.document.doc_info = new_doc_info; + if self.document.doc_info.bin_data_list.is_empty() { + self.document.doc_info.bin_data_list = preserved_bin_data; + } + self.document.sections[section_idx] = new_section; + + // 4-b. composed 동기화 — 영향받은 섹션만 재컴포즈. + // 다른 IR 변경 커맨드(document.rs:444 / 466 / 596–597 등)는 모두 동일한 + // `self.composed = ... compose_section(s) ...` 패턴을 따른다. 이 함수만 + // 누락하면 렌더러가 stale composed 를 참조해 paste 결과가 표시되지 않는다. + // (RCA: task_local_yangsik_paste_composed_refresh, 2026-04-28) + let new_composed = compose_section(&self.document.sections[section_idx]); + if section_idx < self.composed.len() { + self.composed[section_idx] = new_composed; + } else { + self.composed.resize_with(section_idx + 1, Default::default); + self.composed[section_idx] = new_composed; + } + + // 5. dirty 플래그 + cache 무효화 + if section_idx < self.dirty_sections.len() { + self.dirty_sections[section_idx] = true; + } else { + self.dirty_sections.resize(section_idx + 1, true); + self.dirty_sections[section_idx] = true; + } + if section_idx < self.dirty_paragraphs.len() { + self.dirty_paragraphs[section_idx] = None; + } + // 페이지 트리 캐시 전체 무효화 + for slot in self.page_tree_cache.borrow_mut().iter_mut() { + *slot = None; + } + + // 6. 이벤트 로그 + self.event_log.push(DocumentEvent::FragmentPasted { + section: section_idx, + para: after_para_idx, + }); + + Ok(PasteInDocumentResult { + id_remap: paste_result.id_remap, + inserted_para_count: paste_result.inserted_para_count, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture_core_with_minimal_hwpx() -> DocumentCore { + // 최소 HWPX 구조를 직접 inject (수동 mutation 으로 from_bytes 우회). + // 단위 테스트용 — 실제 hwpx 로드는 Stage 3 통합 테스트가 검증. + let mut core = DocumentCore::new_empty(); + core.source_header_xml = String::from( + "\ +\ +\ +\ +\ +", + ); + core.source_section_xmls.push(String::from( + "x", + )); + // dirty_sections 는 from_bytes 가 채우지만 단위 테스트 픽스처는 직접 채움 + core.dirty_sections = vec![false]; + // IR 도 1 섹션 placeholder 채움 + core.document + .sections + .push(crate::model::document::Section::default()); + core + } + + #[test] + fn errors_when_no_source_xmls() { + let mut core = DocumentCore::new_empty(); + let source = SourceDefinitions::default(); + let r = + core.paste_hwpx_fragment_in_document_native(0, 0, "", &source); + assert!(matches!(r, Err(PasteInDocumentError::NoSourceXml))); + } + + #[test] + fn errors_on_section_out_of_range() { + let mut core = fixture_core_with_minimal_hwpx(); + let source = SourceDefinitions::default(); + let r = + core.paste_hwpx_fragment_in_document_native(5, 0, "", &source); + assert!(matches!( + r, + Err(PasteInDocumentError::SectionOutOfRange { idx: 5, count: 1 }) + )); + } + + #[test] + fn paragraph_paste_updates_raw_xml_and_dirty() { + let mut core = fixture_core_with_minimal_hwpx(); + let fragment = r#"integration"#; + let source = SourceDefinitions::default(); + + let result = core + .paste_hwpx_fragment_in_document_native(0, 0, fragment, &source) + .expect("paste ok"); + + assert_eq!(result.inserted_para_count, 1); + // raw 갱신 확인 + let updated = core.get_source_section_xml(0).expect("section preserved"); + assert!(updated.contains("integration")); + // dirty 플래그 set + assert!(core.dirty_sections[0]); + // 이벤트 로그 + assert!(matches!( + core.event_log.last(), + Some(DocumentEvent::FragmentPasted { + section: 0, + para: 0 + }) + )); + } + + #[test] + fn header_definition_reuse_across_two_pastes() { + let mut core = fixture_core_with_minimal_hwpx(); + let fragment = r#"x"#; + let source = SourceDefinitions { + char_prs: "".into(), + para_prs: "".into(), + ..Default::default() + }; + + let r1 = core + .paste_hwpx_fragment_in_document_native(0, 0, fragment, &source) + .expect("first paste"); + let header_after_first = core.get_source_header_xml().len(); + + let r2 = core + .paste_hwpx_fragment_in_document_native(0, 0, fragment, &source) + .expect("second paste"); + let header_after_second = core.get_source_header_xml().len(); + + // ID 재사용 → header 길이 유지 + assert_eq!( + header_after_second, header_after_first, + "header bloated on second paste — ID reuse failed" + ); + assert_eq!( + r1.id_remap.char_pr.get(&9), + r2.id_remap.char_pr.get(&9), + "ID remap diverged across calls" + ); + // 두 번 push 됨 + let pasted_events: Vec<_> = core + .event_log + .iter() + .filter(|e| matches!(e, DocumentEvent::FragmentPasted { .. })) + .collect(); + assert_eq!(pasted_events.len(), 2); + } + + #[test] + fn ir_section_actually_grows_after_paste() { + let mut core = fixture_core_with_minimal_hwpx(); + let fragment = r#"integration"#; + let source = SourceDefinitions::default(); + + let para_before = core.document.sections[0].paragraphs.len(); + core.paste_hwpx_fragment_in_document_native(0, 0, fragment, &source) + .expect("paste ok"); + let para_after = core.document.sections[0].paragraphs.len(); + assert!( + para_after > para_before, + "IR section paragraph count did not grow ({} → {})", + para_before, + para_after + ); + } + + #[test] + fn malformed_fragment_returns_paste_error() { + let mut core = fixture_core_with_minimal_hwpx(); + let source = SourceDefinitions::default(); + // 닫는 태그 없는 fragment → MalformedFragment + let r = core.paste_hwpx_fragment_in_document_native(0, 0, "", &source); + assert!( + matches!(r, Err(PasteInDocumentError::Paste(_))), + "expected Paste error, got {r:?}" + ); + } + + #[test] + fn page_cache_invalidated_after_paste() { + let mut core = fixture_core_with_minimal_hwpx(); + // cache 에 더미 항목 채워서 무효화 검증 + core.page_tree_cache.borrow_mut().push(None); + let fragment = r#"x"#; + let source = SourceDefinitions::default(); + core.paste_hwpx_fragment_in_document_native(0, 0, fragment, &source) + .expect("paste"); + for slot in core.page_tree_cache.borrow().iter() { + assert!(slot.is_none(), "cache slot should be None after paste"); + } + } + + /// 회귀 테스트 — 표 fragment paste 후 `composed[section_idx]` 가 IR 과 + /// 동기화되어야 함. 동기화 누락 시 렌더러가 stale composed 를 사용해 표가 누락된다. + /// (task_local_yangsik_paste_composed_refresh, RCA: 2026-04-28) + /// + /// 시나리오: 표1.fragment.xml(2x2) 1회 paste 후 + /// - IR `Section.paragraphs[*].controls` 에 Table >= 1 + /// - `core.composed[0]` 에 Table inline_control >= 1 + /// 둘 다 존재해야 한다. fixture 시드에 `composed` 가 비어있는 채로 paste 만 진행하면 + /// IR 만 채워지고 composed 는 그대로 비어있어 RED. + #[test] + fn table_paste_syncs_composed() { + let fragment = r#"ABCD"#; + + let source = SourceDefinitions { + char_prs: r##" +"##.into(), + para_prs: r##" +"##.into(), + styles: r##""##.into(), + border_fills: r##" +"##.into(), + }; + + let mut core = fixture_core_with_minimal_hwpx(); + core.paste_hwpx_fragment_in_document_native(0, 0, &fragment, &source) + .expect("paste ok"); + + // 1) IR 검증 — 표가 들어갔는가 + use crate::model::control::Control; + let section = &core.document.sections[0]; + let ir_table_count: usize = section + .paragraphs + .iter() + .map(|p| { + p.controls + .iter() + .filter(|c| matches!(c, Control::Table(_))) + .count() + }) + .sum(); + assert!( + ir_table_count >= 1, + "IR section[0] should have at least 1 Table control after paste, got {ir_table_count}" + ); + + // 2) composed 검증 — 핵심 assertion (현재 코드에선 RED) + use crate::renderer::composer::InlineControlType; + let composed_section = core + .composed + .get(0) + .expect("composed[0] should exist after paste — composed sync 누락 시 빈 Vec"); + let composed_table_count: usize = composed_section + .iter() + .map(|cp| { + cp.inline_controls + .iter() + .filter(|ic| ic.control_type == InlineControlType::Table) + .count() + }) + .sum(); + assert_eq!( + composed_table_count, ir_table_count, + "composed[0] table count ({composed_table_count}) must match IR table count ({ir_table_count}) — \ + paste_hwpx_fragment_in_document_native 가 composed 를 갱신하지 않으면 mismatch (RED)" + ); + } +} diff --git a/src/document_core/commands/mod.rs b/src/document_core/commands/mod.rs index 5a505f8c..474ff79f 100644 --- a/src/document_core/commands/mod.rs +++ b/src/document_core/commands/mod.rs @@ -7,3 +7,6 @@ mod clipboard; mod html_import; mod header_footer_ops; mod footnote_ops; +mod cross_document_migrate; +pub(crate) mod fragment_paste; +pub(crate) mod fragment_paste_in_document; diff --git a/src/document_core/mod.rs b/src/document_core/mod.rs index 3209cc4c..b4baa161 100644 --- a/src/document_core/mod.rs +++ b/src/document_core/mod.rs @@ -9,6 +9,12 @@ pub(crate) use helpers::*; mod commands; pub mod queries; pub mod builders; + +// 외부 HWPX fragment paste API — Stage 1~3 산출물 노출 (Stage 4 wasm 통합용). +pub use commands::fragment_paste::{ + paste_fragment_into_section, FragmentPasteError, IdRemap, ParagraphPasteResult, + SourceDefinitions, +}; pub(crate) mod html_table_import; pub mod table_calc; pub mod validation; @@ -111,6 +117,13 @@ pub struct DocumentCore { /// HWPX 비표준 감지 등 문서 검증 경고. /// `from_bytes` 에서 자동 생성되며, 사용자 고지·선택적 reflow 에 사용 (#177). pub(crate) validation_report: validation::ValidationReport, + /// HWPX 로드 시 보존된 섹션별 raw XML (section_idx → 원본 section{N}.xml 본문). + /// HWP 로드 시에는 빈 Vec. paste fragment wasm bridge 가 byte-preserving paste 의 + /// source/sink 로 사용 (task_local_yangsik_paste_wasm_bridge). + pub(crate) source_section_xmls: Vec, + /// HWPX 로드 시 보존된 raw header.xml. paste 시 새 정의 추가로 갱신됨. + /// HWP 로드 시에는 빈 String. + pub(crate) source_header_xml: String, } /// 활성 필드 위치 정보 @@ -229,6 +242,8 @@ impl DocumentCore { para_offset: Vec::new(), source_format: crate::parser::FileFormat::Hwp, validation_report: validation::ValidationReport::new(), + source_section_xmls: Vec::new(), + source_header_xml: String::new(), } } diff --git a/src/document_core/queries/mod.rs b/src/document_core/queries/mod.rs index f976b8f7..9bfeb8d1 100644 --- a/src/document_core/queries/mod.rs +++ b/src/document_core/queries/mod.rs @@ -6,3 +6,4 @@ pub(crate) mod field_query; mod form_query; mod search_query; mod bookmark_query; +mod raw_xml; diff --git a/src/document_core/queries/raw_xml.rs b/src/document_core/queries/raw_xml.rs new file mode 100644 index 00000000..747394c1 --- /dev/null +++ b/src/document_core/queries/raw_xml.rs @@ -0,0 +1,176 @@ +//! HWPX raw XML 보존본 게터 (paste fragment wasm bridge 용). +//! +//! `DocumentCore::from_bytes` 가 HWPX 로 로드한 경우 `source_section_xmls`/`source_header_xml` +//! 에 원본 `Contents/section{N}.xml` 과 `Contents/header.xml` 문자열이 byte-exact 로 보존된다. +//! HWP 로드 시에는 둘 다 비어있다. +//! +//! Stage 2 의 `paste_hwpx_fragment_in_document` 가 이 게터로 raw XML 을 꺼낸 뒤 +//! `paste_fragment_into_section` 을 호출하고, 결과를 다시 동일 슬롯에 저장한다. + +use crate::document_core::DocumentCore; + +impl DocumentCore { + /// 보존된 섹션 raw XML 을 반환한다. + /// HWPX 로 로드된 경우 인덱스가 유효하면 원본 문자열, 그 외엔 `None`. + pub fn get_source_section_xml(&self, section_idx: usize) -> Option<&str> { + self.source_section_xmls + .get(section_idx) + .map(|s| s.as_str()) + } + + /// 보존된 header.xml raw 문자열을 반환한다. + /// HWPX 로 로드된 경우 원본 문자열, HWP 의 경우 빈 문자열. + pub fn get_source_header_xml(&self) -> &str { + self.source_header_xml.as_str() + } + + /// 보존된 섹션 개수 (`source_section_xmls.len()`). + pub fn source_section_xml_count(&self) -> usize { + self.source_section_xmls.len() + } + + /// HWPX raw XML 보존 여부. + /// `true` 이면 paste fragment wasm bridge 호출이 가능, `false` 이면 NoSourceXml 류 에러. + pub fn has_source_xmls(&self) -> bool { + !self.source_section_xmls.is_empty() && !self.source_header_xml.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 다중 섹션 hwpx 샘플을 hwpx 로 로드 → raw XML byte-exact 일치 검증. + /// 표준 샘플이 없는 경우 단일 섹션 fallback. + fn locate_hwpx_sample() -> Option { + let candidates = [ + "samples/standard.hwpx", + "samples/page_layout.hwpx", + "samples/lineseg.hwpx", + ]; + for c in candidates { + let p = std::path::Path::new(c); + if p.exists() { + return Some(p.to_path_buf()); + } + } + None + } + + #[test] + fn raw_xml_byte_exact_after_hwpx_load() { + let Some(sample) = locate_hwpx_sample() else { + eprintln!("HWPX 샘플 없음 — 테스트 스킵"); + return; + }; + let bytes = std::fs::read(&sample).expect("read sample"); + let core = DocumentCore::from_bytes(&bytes).expect("from_bytes"); + + assert!(core.has_source_xmls(), "HWPX 로드 후 source XML 보존 기대"); + let header = core.get_source_header_xml(); + assert!( + header.contains(" = None; + for c in candidates { + let p = std::path::Path::new(c); + if p.exists() { + found = Some(p.to_path_buf()); + break; + } + } + let Some(sample) = found else { + eprintln!("HWP 샘플 없음 — 테스트 스킵"); + return; + }; + let bytes = std::fs::read(&sample).expect("read sample"); + let core = DocumentCore::from_bytes(&bytes).expect("from_bytes"); + assert!( + !core.has_source_xmls(), + "HWP 로드는 raw XML 보존 안함 (graceful)" + ); + assert_eq!(core.source_section_xml_count(), 0); + } + + #[test] + fn out_of_range_section_returns_none() { + let core = DocumentCore::new_empty(); + assert!(core.get_source_section_xml(0).is_none()); + assert!(core.get_source_section_xml(99).is_none()); + } + + #[test] + fn manual_mutation_reflected_in_getters() { + // 게터가 in-place 갱신을 그대로 반영하는지 검증 (Stage 2 paste 흐름의 사전 보장). + let mut core = DocumentCore::new_empty(); + core.source_section_xmls.push("
".into()); + core.source_header_xml = "
".into(); + assert_eq!(core.get_source_section_xml(0), Some("
")); + assert_eq!(core.get_source_header_xml(), "
"); + assert!(core.has_source_xmls()); + + // 갱신 + core.source_section_xmls[0] = "

".into(); + core.source_header_xml = "
".into(); + assert_eq!( + core.get_source_section_xml(0), + Some("

") + ); + assert_eq!(core.get_source_header_xml(), "
"); + } + + #[test] + fn multi_section_indexing_independent() { + let mut core = DocumentCore::new_empty(); + core.source_section_xmls.push("".into()); + core.source_section_xmls.push("".into()); + core.source_section_xmls.push("".into()); + assert_eq!(core.source_section_xml_count(), 3); + assert_eq!(core.get_source_section_xml(0), Some("")); + assert_eq!(core.get_source_section_xml(1), Some("")); + assert_eq!(core.get_source_section_xml(2), Some("")); + assert!(core.get_source_section_xml(3).is_none()); + } +} diff --git a/src/model/event.rs b/src/model/event.rs index c4769ce2..535f5af8 100644 --- a/src/model/event.rs +++ b/src/model/event.rs @@ -37,6 +37,8 @@ pub enum DocumentEvent { // ── 클립보드/HTML ── ContentPasted { section: usize, para: usize }, HtmlImported { section: usize, para: usize }, + /// 외부 HWPX fragment paste (cross-document migration) + FragmentPasted { section: usize, para: usize }, } impl DocumentEvent { @@ -96,6 +98,8 @@ impl DocumentEvent { format!(r#"{{"type":"ContentPasted","section":{},"para":{}}}"#, section, para), DocumentEvent::HtmlImported { section, para } => format!(r#"{{"type":"HtmlImported","section":{},"para":{}}}"#, section, para), + DocumentEvent::FragmentPasted { section, para } => + format!(r#"{{"type":"FragmentPasted","section":{},"para":{}}}"#, section, para), } } } diff --git a/src/parser/hwpx/mod.rs b/src/parser/hwpx/mod.rs index db398f51..0dab4456 100644 --- a/src/parser/hwpx/mod.rs +++ b/src/parser/hwpx/mod.rs @@ -17,9 +17,7 @@ pub mod section; pub mod utils; use crate::model::bin_data::{BinData, BinDataContent, BinDataType}; -use crate::model::document::{ - Document, FileHeader, HwpVersion, Section, -}; +use crate::model::document::{Document, FileHeader, HwpVersion, Section}; /// HWPX 파싱 에러 #[derive(Debug)] @@ -61,6 +59,15 @@ impl From for HwpxError { /// HWPX 파일 바이트 데이터를 파싱하여 Document IR로 변환 pub fn parse_hwpx(data: &[u8]) -> Result { + parse_hwpx_with_raw_xmls(data).map(|(doc, _, _)| doc) +} + +/// HWPX 파싱 결과 raw XML 보존 변형. paste fragment wasm bridge 용. +/// +/// 반환: (Document IR, section{N}.xml 원본 문자열들, header.xml 원본 문자열). +/// 인덱스는 `Document.sections` 와 1:1 대응한다. +/// 섹션 파싱 실패로 `Section::default()` 가 들어간 경우에도 raw XML 자체는 원본을 유지한다. +pub fn parse_hwpx_with_raw_xmls(data: &[u8]) -> Result<(Document, Vec, String), HwpxError> { // 1. ZIP 컨테이너 열기 let mut reader = reader::HwpxReader::open(data)?; @@ -68,8 +75,9 @@ pub fn parse_hwpx(data: &[u8]) -> Result { let content_xml = reader.read_file("Contents/content.hpf")?; let package_info = content::parse_content_hpf(&content_xml)?; - // 3. header.xml → DocInfo, DocProperties + // 3. header.xml → DocInfo, DocProperties (+raw 보존) let header_xml = reader.read_file("Contents/header.xml")?; + let raw_header_xml = header_xml.clone(); let (mut doc_info, doc_properties) = header::parse_hwpx_header(&header_xml)?; // [Task #554] HWP3 → HWPX 변환본 식별: hwpml 스키마 버전 = "1.4" @@ -98,10 +106,12 @@ pub fn parse_hwpx(data: &[u8]) -> Result { }); } - // 4. section*.xml → Section 변환 + // 4. section*.xml → Section 변환 (+raw 보존) let mut sections = Vec::new(); + let mut raw_section_xmls: Vec = Vec::new(); for section_href in &package_info.section_files { let section_xml = reader.read_file(section_href)?; + raw_section_xmls.push(section_xml.clone()); match section::parse_hwpx_section(§ion_xml) { Ok(section) => sections.push(section), Err(e) => { @@ -115,8 +125,11 @@ pub fn parse_hwpx(data: &[u8]) -> Result { // 모든 SectionDef.page_def 의 margin_bottom 을 1600 HU 줄여 한글97 페이지네이션과 정합. if is_hwp3_origin { for section in sections.iter_mut() { - section.section_def.page_def.margin_bottom = - section.section_def.page_def.margin_bottom.saturating_sub(1600); + section.section_def.page_def.margin_bottom = section + .section_def + .page_def + .margin_bottom + .saturating_sub(1600); } } @@ -161,7 +174,12 @@ pub fn parse_hwpx(data: &[u8]) -> Result { // Document 조립 let model_header = FileHeader { - version: HwpVersion { major: 5, minor: 1, build: 0, revision: 0 }, + version: HwpVersion { + major: 5, + minor: 1, + build: 0, + revision: 0, + }, flags: 0, compressed: false, encrypted: false, @@ -184,7 +202,7 @@ pub fn parse_hwpx(data: &[u8]) -> Result { // dir 영역 basename 매칭 영역 image 영역 자동 load. HWP5 parser 와 동일 처리. super::populate_link_image_paths(&mut doc); - Ok(doc) + Ok((doc, raw_section_xmls, raw_header_xml)) } #[cfg(test)] diff --git a/src/wasm_api.rs b/src/wasm_api.rs index 253a70b7..2643f420 100644 --- a/src/wasm_api.rs +++ b/src/wasm_api.rs @@ -155,6 +155,17 @@ impl HwpDocument { self.create_blank_document_native().map_err(|e| e.into()) } + /// 내장 HWPX 템플릿에서 빈 문서를 생성한다. + /// + /// `paste_hwpx_fragment_in_document` 같은 wasm bridge 기능이 raw section/header XML + /// 보존을 요구하므로, 양식 부품 paste 등을 사용하려면 본 함수로 새 문서를 시작한다. + /// 시드: `saved/04-blank_hwpx_empty.hwpx` (1 section, 1 빈 단락, ~7KB). + #[wasm_bindgen(js_name = createBlankHwpxDocument)] + pub fn create_blank_hwpx_document(&mut self) -> Result { + self.create_blank_hwpx_document_native() + .map_err(|e| e.into()) + } + /// 문단부호(¶) 표시 여부를 설정한다. #[wasm_bindgen(js_name = setShowParagraphMarks)] pub fn set_show_paragraph_marks(&mut self, enabled: bool) { @@ -259,7 +270,9 @@ impl HwpDocument { use crate::renderer::layer_renderer::LayerRenderer; use crate::renderer::web_canvas::WebCanvasRenderer; - let tree = self.build_page_layer_tree(page_num).map_err(JsValue::from)?; + let tree = self + .build_page_layer_tree(page_num) + .map_err(JsValue::from)?; let scale = normalize_canvas_scale(tree.page_width, tree.page_height, scale) .map_err(JsValue::from_str)?; @@ -294,21 +307,25 @@ impl HwpDocument { scale: f64, layer_kind: &str, ) -> Result<(), JsValue> { + use crate::model::shape::TextWrap; use crate::renderer::layer_renderer::LayerRenderer; use crate::renderer::web_canvas::{LayerFilter, WebCanvasRenderer}; - use crate::model::shape::TextWrap; let filter = match layer_kind { "all" => LayerFilter::All, "flow" => LayerFilter::FlowOnly, "behind" => LayerFilter::WrapOnly(TextWrap::BehindText), "front" => LayerFilter::WrapOnly(TextWrap::InFrontOfText), - _ => return Err(JsValue::from_str( - "invalid layer_kind: 'all' | 'flow' | 'behind' | 'front'", - )), + _ => { + return Err(JsValue::from_str( + "invalid layer_kind: 'all' | 'flow' | 'behind' | 'front'", + )) + } }; - let tree = self.build_page_layer_tree(page_num).map_err(JsValue::from)?; + let tree = self + .build_page_layer_tree(page_num) + .map_err(JsValue::from)?; let scale = normalize_canvas_scale(tree.page_width, tree.page_height, scale) .map_err(JsValue::from_str)?; @@ -421,7 +438,11 @@ impl HwpDocument { /// 현재 구역의 다단 설정을 JSON으로 반환한다. #[wasm_bindgen(js_name = getColumnDef)] pub fn get_column_def(&self, section_idx: u32) -> Result { - let sec = self.core.document.sections.get(section_idx as usize) + let sec = self + .core + .document + .sections + .get(section_idx as usize) .ok_or_else(|| JsValue::from_str("구역 인덱스 범위 초과"))?; let col_def = HwpDocument::find_initial_column_def(&sec.paragraphs); let col_type = match col_def.column_type { @@ -2185,10 +2206,18 @@ impl HwpDocument { }; if let Some(ref path) = pic.image_attr.external_path { let id = pic.image_attr.bin_data_id; - let already_loaded = self.document().bin_data_content.iter() + let already_loaded = self + .document() + .bin_data_content + .iter() .any(|c| c.id == id && !c.data.is_empty()); - if already_loaded { continue; } - let basename = path.rsplit(|c| c == '/' || c == '\\').next().unwrap_or(path); + if already_loaded { + continue; + } + let basename = path + .rsplit(|c| c == '/' || c == '\\') + .next() + .unwrap_or(path); names.insert(basename.to_string()); } } @@ -2206,10 +2235,14 @@ impl HwpDocument { /// `basename`: 영역 영역 file 영역 영역 (예: "oracle.gif") /// `data`: 영역 영역 binary 영역 /// `display_path`: dialog 영역 영역 영역 영역 표시 영역 영역 path. 빈 문자열 ("") 영역 - /// 영역 영역 fallback 영역 영역 `/samples/` 영역 사용. 한컴 viewer - /// 정합 영역 영역 OS 영역 절대 경로 영역 영역 (예: "/Users/.../samples/rdb02.gif") + /// 영역 영역 fallback 영역 영역 `/samples/` 영역 사용. #[wasm_bindgen(js_name = injectExternalImage)] - pub fn inject_external_image(&mut self, basename: &str, data: &[u8], display_path: &str) -> u32 { + pub fn inject_external_image( + &mut self, + basename: &str, + data: &[u8], + display_path: &str, + ) -> u32 { use crate::model::control::Control; use crate::model::shape::ShapeObject; @@ -2228,14 +2261,27 @@ impl HwpDocument { _ => continue, }; if let Some(ref path) = pic.image_attr.external_path { - let path_basename = path.rsplit(|c| c == '/' || c == '\\').next().unwrap_or(path); - if path_basename != basename { continue; } + let path_basename = path + .rsplit(|c| c == '/' || c == '\\') + .next() + .unwrap_or(path); + if path_basename != basename { + continue; + } let id = pic.image_attr.bin_data_id; - let already_loaded = self.document().bin_data_content.iter() + let already_loaded = self + .document() + .bin_data_content + .iter() .any(|c| c.id == id && !c.data.is_empty()); - if already_loaded { continue; } + if already_loaded { + continue; + } let ext = std::path::Path::new(basename) - .extension().and_then(|e| e.to_str()).unwrap_or("").to_string(); + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_string(); targets.push((id, ext)); } } @@ -2249,11 +2295,13 @@ impl HwpDocument { self.document_mut().bin_data_content[idx].data = data.to_vec(); self.document_mut().bin_data_content[idx].extension = ext; } else { - self.document_mut().bin_data_content.push( - crate::model::bin_data::BinDataContent { - id, data: data.to_vec(), extension: ext, - } - ); + self.document_mut() + .bin_data_content + .push(crate::model::bin_data::BinDataContent { + id, + data: data.to_vec(), + extension: ext, + }); } injected += 1; @@ -2279,7 +2327,8 @@ impl HwpDocument { _ => continue, }; if pic.image_attr.bin_data_id == id - && pic.image_attr.external_path.is_some() { + && pic.image_attr.external_path.is_some() + { pic.image_attr.external_path = Some(resolved.clone()); } } @@ -3201,7 +3250,8 @@ impl HwpDocument { new_text: &str, case_sensitive: bool, ) -> Result { - self.core.replace_one_native(query, new_text, case_sensitive) + self.core + .replace_one_native(query, new_text, case_sensitive) .map_err(|e| e.into()) } @@ -3917,19 +3967,19 @@ impl HwpDocument { None => "null".to_string(), }; let kind_name = match &w.kind { - crate::document_core::validation::WarningKind::LinesegArrayEmpty => - "LinesegArrayEmpty", - crate::document_core::validation::WarningKind::LinesegUncomputed => - "LinesegUncomputed", - crate::document_core::validation::WarningKind::LinesegTextRunReflow => - "LinesegTextRunReflow", + crate::document_core::validation::WarningKind::LinesegArrayEmpty => { + "LinesegArrayEmpty" + } + crate::document_core::validation::WarningKind::LinesegUncomputed => { + "LinesegUncomputed" + } + crate::document_core::validation::WarningKind::LinesegTextRunReflow => { + "LinesegTextRunReflow" + } }; warning_parts.push(format!( r#"{{"section":{},"paragraph":{},"kind":"{}","cell":{}}}"#, - w.section_idx, - w.paragraph_idx, - kind_name, - cell_part, + w.section_idx, w.paragraph_idx, kind_name, cell_part, )); } @@ -5118,6 +5168,45 @@ impl HwpDocument { self.measure_width_diagnostic_native(section_idx as usize, para_idx as usize) .map_err(|e| e.into()) } + + /// HWPX fragment(byte-exact)를 caret 위치에 paste 한다. + /// + /// 외부 hwpx 파일에서 추출한 단편을 source 정의(charPr/paraPr/style/borderFill)와 + /// 함께 받아, destination DocInfo 에 정의를 머지하고 ID ref 를 remap 한 뒤 + /// caret 위치에 삽입한다. 한컴오피스의 외부-from-paste 와 동등한 동작. + /// + /// 인자 (모두 raw HWPX XML 스니펫, 빈 문자열 허용): + /// - `fragment_xml`: 1개 이상의 `...` + /// - `char_prs`: 1개 이상의 `...` + /// - `para_prs`: 1개 이상의 `...` + /// - `styles`: 1개 이상의 `` + /// - `border_fills`: 1개 이상의 `...` + /// + /// 반환: `{"ok":true,"paraIdx":,"charOffset":,"insertedParaCount":}` + #[wasm_bindgen(js_name = pasteHwpxFragment)] + pub fn paste_hwpx_fragment( + &mut self, + section_idx: u32, + para_idx: u32, + char_offset: u32, + fragment_xml: &str, + char_prs: &str, + para_prs: &str, + styles: &str, + border_fills: &str, + ) -> Result { + self.paste_hwpx_fragment_native( + section_idx as usize, + para_idx as usize, + char_offset as usize, + fragment_xml, + char_prs, + para_prs, + styles, + border_fills, + ) + .map_err(|e| e.into()) + } } pub(crate) mod event; @@ -5285,5 +5374,156 @@ fn base64_encode(data: &[u8]) -> String { base64::engine::general_purpose::STANDARD.encode(data) } +// ───────────────────────────── HWPX fragment paste in Document (wasm bridge) ───────────────────────────── + +#[wasm_bindgen] +impl HwpDocument { + /// 보존된 raw section/header XML 을 사용해 HWPX fragment 를 현재 Document 에 paste 한다. + /// + /// Phase 2 의 `pasteHwpxFragmentRaw` 와 달리 클라이언트가 zip/unzip 라운드트립을 다룰 + /// 필요가 없다. Document IR 도 자동으로 재파싱돼 후속 명령(rendering/edit)이 그대로 사용 가능. + /// + /// 반환 JSON 스키마: + /// `{"inserted_para_count":N,"id_remap_char_pr":{...},"id_remap_para_pr":{...}, + /// "id_remap_style":{...},"id_remap_border_fill":{...}}` + /// + /// 에러: 문서가 HWP 로 로드됐거나 raw XML 보존이 없으면 `NoSourceXml`, + /// section_idx 가 범위 밖이면 `SectionOutOfRange`, + /// fragment 가 well-formed 아니면 `Paste(...)`. + #[wasm_bindgen(js_name = pasteHwpxFragmentInDocument)] + pub fn paste_hwpx_fragment_in_document( + &mut self, + section_idx: u32, + after_para_idx: u32, + fragment_xml: &str, + source_char_prs: &str, + source_para_prs: &str, + source_styles: &str, + source_border_fills: &str, + ) -> Result { + use crate::document_core::SourceDefinitions; + let source = SourceDefinitions { + char_prs: source_char_prs.to_string(), + para_prs: source_para_prs.to_string(), + styles: source_styles.to_string(), + border_fills: source_border_fills.to_string(), + }; + let result = self + .core + .paste_hwpx_fragment_in_document_native( + section_idx as usize, + after_para_idx as usize, + fragment_xml, + &source, + ) + .map_err(|e| JsValue::from_str(&e.to_string()))?; + + // paste 후 layout 재계산 — 새 IR 위에 page count/measured 가 갱신되도록. + // 미호출 시 클라이언트가 refreshPages 해도 pageCount/getPageInfo 가 stale 이라 + // 캔버스에 새 fragment 가 보이지 않는다. + self.core.paginate(); + + let mut json = String::with_capacity(256); + json.push_str("{\"inserted_para_count\":"); + json.push_str(&result.inserted_para_count.to_string()); + json.push_str(",\"id_remap_char_pr\":"); + push_remap_json(&mut json, &result.id_remap.char_pr); + json.push_str(",\"id_remap_para_pr\":"); + push_remap_json(&mut json, &result.id_remap.para_pr); + json.push_str(",\"id_remap_style\":"); + push_remap_json(&mut json, &result.id_remap.style); + json.push_str(",\"id_remap_border_fill\":"); + push_remap_json(&mut json, &result.id_remap.border_fill); + json.push('}'); + Ok(json) + } +} + +// ───────────────────────────── External HWPX fragment paste (Stage 4) ───────────────────────────── + +/// 외부 HWPX fragment(원본 양식의 byte-exact slice)를 caret 위치에 byte-preserving + ID remap + +/// 표 정합성 보존하며 paste 한다. +/// +/// **Document 모델 미사용** — 클라이언트가 hwpx unzip 후 raw section_xml/header_xml을 인자로 +/// 전달하고, 결과 JSON의 `section_xml`/`header_xml`을 받아 zip에 다시 packing 한다. +/// 이 설계는 Document IR 동기화 문제를 회피해 byte-preserving 동작을 보장한다. +#[wasm_bindgen(js_name = pasteHwpxFragmentRaw)] +pub fn paste_hwpx_fragment_raw( + section_xml: &str, + header_xml: &str, + after_para_idx: u32, + fragment_xml: &str, + source_char_prs: &str, + source_para_prs: &str, + source_styles: &str, + source_border_fills: &str, +) -> Result { + use crate::document_core::{paste_fragment_into_section, SourceDefinitions}; + let mut header_mut = header_xml.to_string(); + let source = SourceDefinitions { + char_prs: source_char_prs.to_string(), + para_prs: source_para_prs.to_string(), + styles: source_styles.to_string(), + border_fills: source_border_fills.to_string(), + }; + let result = paste_fragment_into_section( + section_xml, + &mut header_mut, + after_para_idx as usize, + fragment_xml, + &source, + ) + .map_err(|e| JsValue::from_str(&e.to_string()))?; + let mut json = String::with_capacity(result.new_section_xml.len() + header_mut.len() + 256); + json.push_str("{\"section_xml\":"); + push_paste_json_string(&mut json, &result.new_section_xml); + json.push_str(",\"header_xml\":"); + push_paste_json_string(&mut json, &header_mut); + json.push_str(",\"inserted_para_count\":"); + json.push_str(&result.inserted_para_count.to_string()); + json.push_str(",\"id_remap_char_pr\":"); + push_remap_json(&mut json, &result.id_remap.char_pr); + json.push_str(",\"id_remap_para_pr\":"); + push_remap_json(&mut json, &result.id_remap.para_pr); + json.push_str(",\"id_remap_style\":"); + push_remap_json(&mut json, &result.id_remap.style); + json.push_str(",\"id_remap_border_fill\":"); + push_remap_json(&mut json, &result.id_remap.border_fill); + json.push('}'); + Ok(json) +} + +fn push_paste_json_string(out: &mut String, s: &str) { + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); +} + +fn push_remap_json(out: &mut String, remap: &std::collections::HashMap) { + out.push('{'); + let mut first = true; + for (k, v) in remap { + if !first { + out.push(','); + } + first = false; + out.push('"'); + out.push_str(&k.to_string()); + out.push_str("\":"); + out.push_str(&v.to_string()); + } + out.push('}'); +} + #[cfg(test)] mod tests; diff --git a/tests/fragment_paste_in_document.rs b/tests/fragment_paste_in_document.rs new file mode 100644 index 00000000..f5c1c277 --- /dev/null +++ b/tests/fragment_paste_in_document.rs @@ -0,0 +1,123 @@ +//! Stage 2 통합 테스트 — `paste_hwpx_fragment_in_document_native` (wasm bridge underlying) +//! +//! native 통합에서 `HwpDocument::from_bytes` 로 실제 HWPX 를 로드한 뒤 +//! 통합 paste 함수를 호출해 raw XML + IR 의 일관성을 검증한다. +//! +//! `pasteHwpxFragmentInDocument` (wasm-bindgen) 자체는 native 에서도 호출 가능하지만 +//! `JsValue` 의존성을 회피하기 위해 underlying `paste_hwpx_fragment_in_document_native` +//! 를 직접 호출한다. + +use rhwp::document_core::{DocumentCore, SourceDefinitions}; + +fn bundled_hwpx() -> &'static [u8] { + include_bytes!("../saved/04-blank_hwpx_empty.hwpx") +} + +#[test] +fn integration_paste_paragraphs_in_loaded_hwpx() { + let mut doc = DocumentCore::from_bytes(bundled_hwpx()).expect("from_bytes"); + + let raw_section_before_len = doc.get_source_section_xml(0).expect("section 0 raw").len(); + let header_before_len = doc.get_source_header_xml().len(); + + let fragment = r#"integration-marker-A1"#; + let source = SourceDefinitions::default(); + + let result = doc + .paste_hwpx_fragment_in_document_native(0, 0, fragment, &source) + .expect("paste ok"); + + assert_eq!(result.inserted_para_count, 1); + + let raw_after = doc.get_source_section_xml(0).expect("section 0 raw after"); + assert!(raw_after.len() > raw_section_before_len, "section grew"); + assert!( + raw_after.contains("integration-marker-A1"), + "fragment text appears in raw section" + ); + + // header 는 source 가 비어 있으므로 변동 0 (ID remap 없음) + let header_after_len = doc.get_source_header_xml().len(); + assert_eq!( + header_before_len, header_after_len, + "header should not grow when source has no new defs" + ); +} + +#[test] +fn integration_paste_table_in_loaded_hwpx_recomputes_addrs() { + let mut doc = DocumentCore::from_bytes(bundled_hwpx()).expect("from_bytes"); + + // 2x2 표 fragment — recompute_table_addrs 가 colSpan/rowSpan 점유 그리드로 보정 + let fragment = "\ +\ +\ +\ +\ +\ +\ +\ +\ +"; + let source = SourceDefinitions::default(); + + doc.paste_hwpx_fragment_in_document_native(0, 0, fragment, &source) + .expect("paste table ok"); + + let raw_after = doc.get_source_section_xml(0).expect("section 0 raw after"); + assert!(raw_after.contains("rowCnt=\"2\""), "rowCnt 99→2 corrected"); + assert!( + !raw_after.contains("rowAddr=\"99\""), + "all rowAddr=99 corrected" + ); + assert!( + raw_after.contains("rowAddr=\"0\" colAddr=\"0\""), + "(0,0) cell" + ); + assert!( + raw_after.contains("rowAddr=\"1\" colAddr=\"1\""), + "(1,1) cell" + ); +} + +#[test] +fn integration_two_consecutive_pastes_reuse_ids() { + let mut doc = DocumentCore::from_bytes(bundled_hwpx()).expect("from_bytes"); + + let fragment = r#"reuse-marker"#; + let source = SourceDefinitions { + char_prs: "".into(), + para_prs: "".into(), + ..Default::default() + }; + + let header_before = doc.get_source_header_xml().len(); + let r1 = doc + .paste_hwpx_fragment_in_document_native(0, 0, fragment, &source) + .expect("first paste"); + let header_after_first = doc.get_source_header_xml().len(); + + // 첫 paste 는 새 정의 추가 → header 길이 증가 + assert!(header_after_first > header_before); + + let r2 = doc + .paste_hwpx_fragment_in_document_native(0, 0, fragment, &source) + .expect("second paste"); + let header_after_second = doc.get_source_header_xml().len(); + + // 두 번째 paste 는 동일 정의 재사용 → header 길이 변화 없음 (W5 위험 직접 측정) + assert_eq!( + header_after_second, header_after_first, + "header bloated on second paste — ID reuse failed" + ); + assert_eq!( + r1.id_remap.char_pr.get(&9), + r2.id_remap.char_pr.get(&9), + "char_pr ID remap target diverged" + ); + assert_eq!( + r1.id_remap.para_pr.get(&9), + r2.id_remap.para_pr.get(&9), + "para_pr ID remap target diverged" + ); +} diff --git a/tests/fragment_paste_integration.rs b/tests/fragment_paste_integration.rs new file mode 100644 index 00000000..362b87d3 --- /dev/null +++ b/tests/fragment_paste_integration.rs @@ -0,0 +1,109 @@ +//! Stage 4 통합 테스트 — `paste_fragment_into_section` 진입점을 native build에서 +//! 직접 호출해 paragraphs/table 두 형태의 fragment paste를 검증한다. +//! +//! `paste_hwpx_fragment_raw` (wasm_bindgen 함수) 자체는 native에서도 호출 가능하지만 +//! `JsValue` 의존성을 회피하기 위해 native 통합 테스트는 underlying +//! `crate::document_core::paste_fragment_into_section` 을 직접 호출한다. + +use rhwp::document_core::{paste_fragment_into_section, SourceDefinitions}; + +fn empty_section() -> String { + "x" + .to_string() +} + +fn empty_header() -> String { + "\ +\ +\ +\ +\ +" + .to_string() +} + +#[test] +fn integration_paste_paragraphs_fragment() { + let section = empty_section(); + let mut header = empty_header(); + let fragment = r#"integration paragraph"#; + let source = SourceDefinitions { + char_prs: "".into(), + para_prs: "".into(), + ..Default::default() + }; + let result = + paste_fragment_into_section(§ion, &mut header, 0, fragment, &source).unwrap(); + assert_eq!(result.inserted_para_count, 1); + assert!(result.new_section_xml.contains("integration paragraph")); + let new_char_pr = result.id_remap.char_pr.get(&9).copied().unwrap(); + assert_ne!(new_char_pr, 9); + assert!(header.contains("height=\"3000\"")); + assert!(!result.new_section_xml.contains("charPrIDRef=\"9\"")); + assert!(result + .new_section_xml + .contains(&format!("charPrIDRef=\"{new_char_pr}\""))); +} + +#[test] +fn integration_paste_table_fragment_recomputes_addrs() { + let section = empty_section(); + let mut header = empty_header(); + let fragment = "\ +\ +\ +\ +\ +\ +\ +\ +\ +"; + let source = SourceDefinitions::default(); + let result = + paste_fragment_into_section(§ion, &mut header, 0, fragment, &source).unwrap(); + assert_eq!(result.inserted_para_count, 1); + assert!(result.new_section_xml.contains("rowCnt=\"2\"")); + assert!(!result.new_section_xml.contains("rowAddr=\"99\"")); + assert!(!result.new_section_xml.contains("colAddr=\"99\"")); + assert!(result + .new_section_xml + .contains("rowAddr=\"0\" colAddr=\"0\"")); + assert!(result + .new_section_xml + .contains("rowAddr=\"0\" colAddr=\"1\"")); + assert!(result + .new_section_xml + .contains("rowAddr=\"1\" colAddr=\"0\"")); + assert!(result + .new_section_xml + .contains("rowAddr=\"1\" colAddr=\"1\"")); +} + +#[test] +fn integration_paste_same_fragment_twice_reuses_ids() { + let section = empty_section(); + let mut header = empty_header(); + let fragment = r#"x"#; + let source = SourceDefinitions { + char_prs: "".into(), + ..Default::default() + }; + let header_baseline_len = header.len(); + let r1 = paste_fragment_into_section(§ion, &mut header, 0, fragment, &source).unwrap(); + let header_after_first = header.len(); + let r2 = paste_fragment_into_section(&r1.new_section_xml, &mut header, 0, fragment, &source) + .unwrap(); + let header_after_second = header.len(); + + assert!(header_after_first > header_baseline_len); + assert_eq!( + header_after_second, header_after_first, + "header bloated on second paste — ID reuse failed" + ); + assert_eq!( + r1.id_remap.char_pr.get(&9), + r2.id_remap.char_pr.get(&9), + "ID remap diverged across calls" + ); +}