diff --git a/rhwp-studio/index.html b/rhwp-studio/index.html
index e6fc1b4a8..787d5b0d9 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 f99c3593d..660dc8e45 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 6793ca356..9a0d6e913 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 000000000..247a387b6
--- /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 000000000..0d9a31f40
--- /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 24cbdb63d..34c6ba982 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 000000000..1ca849f3f
Binary files /dev/null and b/saved/04-blank_hwpx_empty.hwpx differ
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 000000000..2377c4145
--- /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 4fffcd57d..ef9bd82fd 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 000000000..59227e244
--- /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("